diff --git a/.env.example b/.env.example index 5463787..4777fac 100644 --- a/.env.example +++ b/.env.example @@ -46,3 +46,12 @@ GITEA_TOKEN_SOURCE=GITEA_TOKEN # profile's values. Leave unset for pure env-based configuration. GITEA_MCP_CONFIG=/Users/jasonwalker/.config/gitea-tools/profiles.json GITEA_MCP_PROFILE=prgs + +# Namespace-scoped active task workspaces (#510). Each MCP namespace uses only +# its own role env var; foreign bindings (e.g. GITEA_AUTHOR_WORKTREE in a +# merger process) are ignored. +# GITEA_AUTHOR_WORKTREE=/path/to/repo/branches/issue-123-work +# GITEA_REVIEWER_WORKTREE=/path/to/repo/branches/review-pr456 +# GITEA_MERGER_WORKTREE=/path/to/repo/branches/merge-pr456 +# GITEA_RECONCILER_WORKTREE=/path/to/repo/branches/reconcile-pr456 +# GITEA_ACTIVE_WORKTREE=/path/to/repo/branches/session-override diff --git a/audit_reconciliation_mode.py b/audit_reconciliation_mode.py new file mode 100644 index 0000000..52d0244 --- /dev/null +++ b/audit_reconciliation_mode.py @@ -0,0 +1,344 @@ +"""Audit vs cleanup phase gates for reconciliation workflows (#419). + +Audit/reconciliation tasks are read-only unless a separate cleanup phase is +explicitly authorized with exact capability proof, safety proof, and +before/after snapshots. Cleanup mutations must be classified in final reports; +audit reports must not claim ``no mutations`` when cleanup occurred. +""" + +from __future__ import annotations + +import re +from typing import Any + +RECONCILE_WORKFLOW_PATH = "workflows/reconcile-landed-pr.md" + +PHASE_AUDIT = "audit" +PHASE_CLEANUP = "cleanup" + +# Tasks that enter audit phase on capability resolution (read-only default). +AUDIT_PHASE_TASKS = frozenset({ + "reconcile-landed-pr", + "reconcile_landed_pr", + "reconcile_issue_claims", + "reconcile_merged_cleanups", +}) + +# Mutation tasks forbidden during audit phase (fail closed). +AUDIT_FORBIDDEN_TASKS = frozenset({ + "delete_branch", + "create_branch", + "push_branch", + "create_pr", + "commit_files", + "gitea_commit_files", + "mark_issue", + "lock_issue", + "claim_issue", + "close_pr", + "close_issue", + "create_issue", + "merge_pr", + "review_pr", + "submit_pr_review", + "comment_pr", + "comment_issue", + "set_issue_labels", +}) + +# Shell/git commands audit phase must not run. +AUDIT_FORBIDDEN_COMMAND_RE = re.compile( + r"(?:^|\s)(?:git\s+(?:push|branch\s+-D|worktree\s+remove)|" + r"gitea_delete_branch|delete_remote_branch)", + re.IGNORECASE, +) + +_NO_MUTATIONS_RE = re.compile( + r"(?:no\s+mutations|mutations\s*:\s*none|no\s+unsafe\s+mutation)", + re.IGNORECASE, +) +_CLEANUP_OCCURRED_RE = re.compile( + r"(?:delete_remote_branch|remove_local_worktree|git\s+branch\s+-D|" + r"git\s+worktree\s+remove|remote branch.*deleted|worktree.*removed|" + r"cleanup\s+phase\s*:\s*(?!none\b)\S)", + re.IGNORECASE, +) +_EXTERNAL_STATE_RE = re.compile( + r"^\s*[-*]?\s*external[- ]state mutations\s*:", + re.IGNORECASE | re.MULTILINE, +) +_GIT_REF_RE = re.compile( + r"^\s*[-*]?\s*git ref mutations\s*:", + re.IGNORECASE | re.MULTILINE, +) +_CLEANUP_MUTATIONS_RE = re.compile( + r"^\s*[-*]?\s*cleanup mutations\s*:", + re.IGNORECASE | re.MULTILINE, +) +_CLEANUP_PHASE_AUTH_RE = re.compile( + r"^\s*[-*]?\s*cleanup phase (?:authorized|authorization)\s*:\s*true", + re.IGNORECASE | re.MULTILINE, +) +_DELETE_CAPABILITY_RE = re.compile( + r"^\s*[-*]?\s*delete.?branch capability(?: proven)?\s*:\s*true", + re.IGNORECASE | re.MULTILINE, +) +_BEFORE_AFTER_RE = re.compile( + r"^\s*[-*]?\s*before/after (?:state )?snapshot\s*:", + re.IGNORECASE | re.MULTILINE, +) +_SAFETY_PROOF_RE = re.compile( + r"^\s*[-*]?\s*(?:branch|worktree) safe to remove\s*:\s*true", + re.IGNORECASE | re.MULTILINE, +) + +_session: dict[str, Any] | None = None + + +def _blank_session() -> dict[str, Any]: + return { + "phase": PHASE_AUDIT, + "entered_from_task": None, + "cleanup_authorized": False, + "cleanup_authorization": {}, + } + + +def current_phase() -> str | None: + """Return active reconciliation phase or None when unset.""" + if not _session: + return None + return _session.get("phase") + + +def active_record() -> dict[str, Any] | None: + """Return a copy of the session record, if any.""" + return dict(_session) if _session else None + + +def clear_phase() -> None: + """Clear reconciliation phase state.""" + global _session + _session = None + + +def enter_audit_phase(task: str) -> dict[str, Any]: + """Enter read-only audit phase for a reconciliation task.""" + global _session + normalized = (task or "").strip().lower() + _session = _blank_session() + _session["entered_from_task"] = normalized + return dict(_session) + + +def authorize_cleanup_phase( + *, + operator_approved: bool = False, + workflow_authorized: bool = False, + delete_capability_proven: bool = False, + safety_proof: dict[str, Any] | None = None, + before_after_snapshot: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Authorize cleanup phase after explicit approval and safety proofs.""" + reasons: list[str] = [] + if not (operator_approved or workflow_authorized): + reasons.append( + "cleanup phase requires operator approval or explicit workflow " + "authorization" + ) + if not delete_capability_proven: + reasons.append( + "cleanup phase requires exact delete_branch capability proof " + "(gitea.branch.delete)" + ) + safety = dict(safety_proof or {}) + if not safety.get("safe_to_delete_remote") and not safety.get( + "safe_to_remove_worktree" + ): + reasons.append( + "cleanup phase requires proof that branch/worktree is safe to remove" + ) + snapshot = dict(before_after_snapshot or {}) + if not snapshot.get("before") or not snapshot.get("after"): + reasons.append( + "cleanup phase requires before/after state snapshot" + ) + + if reasons: + return { + "authorized": False, + "phase": current_phase() or PHASE_AUDIT, + "reasons": reasons, + "safe_next_action": ( + "remain in audit-only mode or supply operator approval, " + "delete_branch capability proof, safety proof, and " + "before/after snapshot before cleanup" + ), + } + + global _session + if _session is None: + _session = _blank_session() + _session["phase"] = PHASE_CLEANUP + _session["cleanup_authorized"] = True + _session["cleanup_authorization"] = { + "operator_approved": operator_approved, + "workflow_authorized": workflow_authorized, + "delete_capability_proven": delete_capability_proven, + "safety_proof": safety, + "before_after_snapshot": snapshot, + } + return { + "authorized": True, + "phase": PHASE_CLEANUP, + "reasons": [], + "cleanup_authorization": dict(_session["cleanup_authorization"]), + "safe_next_action": "proceed with authorized cleanup mutations only", + } + + +def check_audit_task_enters_phase(task: str) -> bool: + """Return whether resolving *task* should enter audit phase.""" + return (task or "").strip().lower() in AUDIT_PHASE_TASKS + + +def check_audit_mutation_allowed(task: str) -> tuple[bool, list[str]]: + """Fail closed when a mutation task runs during audit phase.""" + normalized = (task or "").strip().lower() + phase = current_phase() + if phase != PHASE_AUDIT: + return True, [] + if normalized in AUDIT_FORBIDDEN_TASKS: + return False, [ + f"task '{normalized}' is forbidden in audit-only reconciliation " + "mode: switch to an explicit cleanup phase with operator approval " + "and exact delete_branch capability proof before cleanup mutations" + ] + return True, [] + + +def check_cleanup_execution_allowed() -> tuple[bool, list[str]]: + """Fail closed when cleanup execution is attempted without authorization.""" + phase = current_phase() + if phase == PHASE_CLEANUP and (_session or {}).get("cleanup_authorized"): + return True, [] + if phase is None: + return False, [ + "cleanup execution requires an active reconciliation session; " + "resolve a reconciliation audit task first" + ] + return False, [ + "cleanup execution forbidden in audit-only reconciliation mode; " + "call gitea_authorize_reconciliation_cleanup_phase with operator " + "approval, delete_branch capability proof, safety proof, and " + "before/after snapshot" + ] + + +def classify_cleanup_mutation(action: str) -> str: + """Map a cleanup action to the required mutation ledger category (#419).""" + normalized = (action or "").strip().lower() + if "delete_remote" in normalized or normalized in { + "delete_branch", + "gitea_delete_branch", + }: + return "external-state" + if "branch" in normalized and "delete" in normalized: + return "git-ref" + if "worktree" in normalized or "remove_local" in normalized: + return "cleanup" + return "cleanup" + + +def assess_audit_reconciliation_report(report_text: str) -> dict[str, Any]: + """Validate audit/cleanup reconciliation reports (fail closed).""" + text = report_text or "" + reasons: list[str] = [] + + cleanup_occurred = bool(_CLEANUP_OCCURRED_RE.search(text)) + claims_no_mutations = bool(_NO_MUTATIONS_RE.search(text)) + + if cleanup_occurred and claims_no_mutations: + reasons.append( + "report claims no mutations but documents cleanup mutations; " + "audit-only reports must not perform cleanup and cleanup reports " + "must not claim no mutations" + ) + + if cleanup_occurred: + if not _CLEANUP_PHASE_AUTH_RE.search(text): + reasons.append( + "cleanup mutations reported without " + "'Cleanup phase authorized: true'" + ) + if not _DELETE_CAPABILITY_RE.search(text): + reasons.append( + "cleanup mutations reported without delete_branch capability " + "proof" + ) + if not _BEFORE_AFTER_RE.search(text): + reasons.append( + "cleanup mutations reported without before/after state snapshot" + ) + if not _SAFETY_PROOF_RE.search(text): + reasons.append( + "cleanup mutations reported without branch/worktree safety proof" + ) + + if re.search(r"delete_remote|remote branch.*delet", text, re.I): + if not _EXTERNAL_STATE_RE.search(text): + reasons.append( + "remote branch deletion must be classified under " + "External-state mutations" + ) + if re.search(r"git\s+branch\s+-D|local branch.*delet", text, re.I): + if not _GIT_REF_RE.search(text): + reasons.append( + "local branch deletion must be classified under " + "Git ref mutations" + ) + if re.search(r"worktree.*remov|remove_local_worktree", text, re.I): + if not _CLEANUP_MUTATIONS_RE.search(text): + reasons.append( + "worktree removal must be classified under Cleanup mutations" + ) + + if ( + RECONCILE_WORKFLOW_PATH.replace("workflows/", "") in text + or "reconcile-landed-pr" in text.lower() + ): + if cleanup_occurred and "audit phase" in text.lower(): + if "cleanup phase" not in text.lower(): + reasons.append( + "report mixes audit phase with cleanup mutations without " + "documenting cleanup phase transition" + ) + + proven = not reasons + return { + "proven": proven, + "block": not proven, + "reasons": reasons, + "cleanup_occurred": cleanup_occurred, + "claims_no_mutations": claims_no_mutations, + "safe_next_action": ( + "proceed" + if proven + else "fix audit/cleanup report: separate audit from cleanup phase, " + "classify mutations, and do not claim no mutations after cleanup" + ), + } + + +def assess_audit_command_allowed(command: str) -> tuple[bool, list[str]]: + """Block shell commands that perform cleanup during audit phase.""" + phase = current_phase() + if phase != PHASE_AUDIT: + return True, [] + cmd = (command or "").strip() + if AUDIT_FORBIDDEN_COMMAND_RE.search(cmd): + return False, [ + f"command forbidden in audit-only reconciliation mode: {cmd!r}; " + "authorize cleanup phase before branch/worktree deletion or push" + ] + return True, [] \ No newline at end of file diff --git a/author_mutation_worktree.py b/author_mutation_worktree.py index 04b6933..dccb94a 100644 --- a/author_mutation_worktree.py +++ b/author_mutation_worktree.py @@ -7,8 +7,13 @@ project's ``branches/`` directory, never from the stable control checkout. from __future__ import annotations import os +import subprocess BASE_BRANCHES = frozenset({"master", "main", "dev"}) +ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE" +AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE" +# Author-only: reviewer/merger/reconciler namespaces use role-specific env vars +# via namespace_workspace_binding (#510). def _normalize_path(path: str) -> str: @@ -48,6 +53,130 @@ def resolve_mutation_workspace( return os.path.realpath(project_root) +def _realpath_git_common_dir(workspace_path: str, common_dir: str) -> str: + """Resolve ``git rev-parse --git-common-dir`` relative to *workspace_path*.""" + raw = (common_dir or "").strip() + if not raw: + return raw + if os.path.isabs(raw): + return os.path.realpath(raw) + return os.path.realpath(os.path.join(workspace_path, raw)) + + +def resolve_canonical_repo_root(workspace_path: str, fallback_project_root: str) -> str: + """Return the stable repository root for *workspace_path* via git metadata (#460).""" + path = (workspace_path or "").strip() + fallback = os.path.realpath(fallback_project_root) + if not path: + return fallback + try: + res = subprocess.run( + ["git", "-C", path, "rev-parse", "--git-common-dir"], + capture_output=True, + text=True, + check=True, + ) + common = _realpath_git_common_dir(path, res.stdout) + except Exception: + return fallback + if common.endswith(f"{os.sep}.git"): + return os.path.dirname(common) + if os.path.basename(common) == ".git": + return os.path.dirname(common) + return fallback + + +def resolve_author_mutation_context( + worktree_path: str | None, + process_project_root: str, + *, + active_worktree_env: str | None = None, + author_worktree_env: str | None = None, +) -> dict: + """Shared workspace resolution for runtime_context and mutation guards (#460).""" + workspace = resolve_mutation_workspace( + worktree_path, + process_project_root, + active_worktree_env=active_worktree_env, + author_worktree_env=author_worktree_env, + ) + process_root = os.path.realpath(process_project_root) + # Canonical repository identity comes from the MCP process checkout (#460), + # not from the declared task workspace being validated. + canonical_root = resolve_canonical_repo_root(process_root, process_root) + return { + "workspace_path": workspace, + "process_project_root": process_root, + "canonical_repo_root": canonical_root, + "roots_aligned": canonical_root == process_root, + } + + +def assess_workspace_repo_membership( + *, + workspace_path: str, + canonical_repo_root: str, +) -> dict: + """Fail closed when *workspace_path* is not a git worktree of *canonical_repo_root*.""" + workspace = os.path.realpath(workspace_path) + root = os.path.realpath(canonical_repo_root) + reasons: list[str] = [] + + if not os.path.exists(workspace): + reasons.append(f"worktree path '{workspace}' does not exist") + return _membership_assessment(False, reasons, workspace, root, None) + + if not os.path.isdir(workspace): + reasons.append(f"worktree path '{workspace}' is not a directory") + return _membership_assessment(False, reasons, workspace, root, None) + + try: + res = subprocess.run( + ["git", "-C", workspace, "rev-parse", "--git-common-dir"], + capture_output=True, + text=True, + check=True, + ) + common_dir = _realpath_git_common_dir(workspace, res.stdout) + except Exception: + reasons.append(f"worktree '{workspace}' is not a valid git repository") + return _membership_assessment(False, reasons, workspace, root, None) + + expected_dir = os.path.realpath(os.path.join(root, ".git")) + if common_dir != expected_dir: + reasons.append( + f"worktree '{workspace}' does not belong to the target repository '{root}'" + ) + return _membership_assessment(not reasons, reasons, workspace, root, common_dir) + + +def _membership_assessment( + proven: bool, + reasons: list[str], + workspace: str, + root: str, + common_dir: str | None, +) -> dict: + return { + "proven": proven, + "block": not proven, + "reasons": reasons, + "workspace_path": workspace, + "canonical_repo_root": root, + "git_common_dir": common_dir, + } + + +def format_workspace_repo_membership_error(assessment: dict) -> str: + workspace = assessment.get("workspace_path") or "(unknown)" + root = assessment.get("canonical_repo_root") or "(unknown)" + reasons = "; ".join(assessment.get("reasons") or ["unknown repository membership violation"]) + return ( + f"Branches-only mutation guard (#274): {reasons} (fail closed). " + f"canonical repository root: {root}; workspace: {workspace}." + ) + + def assess_author_mutation_worktree( *, workspace_path: str, diff --git a/docs/developer-testing-guidelines.md b/docs/developer-testing-guidelines.md index 291ff5d..d85c670 100644 --- a/docs/developer-testing-guidelines.md +++ b/docs/developer-testing-guidelines.md @@ -13,6 +13,25 @@ credentials.** Every test mocks the HTTP client and the keychain/auth lookup. ## 1. Standard test commands +### Canonical runner: `./run-tests.sh` + +The canonical full-validation command is the root-level runner. It invokes the +project virtualenv interpreter and passes any extra arguments straight through +to `pytest`: + +```bash +# Full validation +./run-tests.sh + +# Focused validation (extra args forward to pytest) +./run-tests.sh tests/test_mcp_server.py -q +``` + +`run-tests.sh` runs `venv/bin/python -m pytest "$@"` and fails with a clear +setup message if the virtualenv Python is missing (so a session never silently +falls back to the wrong interpreter). The explicit `venv/bin/python -m pytest` +forms below remain valid and equivalent. + The test suite needs the project virtualenv (it provides the MCP SDK): ```bash diff --git a/docs/issue-acceptance-gate.md b/docs/issue-acceptance-gate.md new file mode 100644 index 0000000..522825b --- /dev/null +++ b/docs/issue-acceptance-gate.md @@ -0,0 +1,52 @@ +# Controller Issue-Acceptance Gate + +A merged PR does not automatically prove an issue is fully satisfied. After +merge, a controller must audit the linked issue against its acceptance criteria +and post a durable handoff before the issue is treated as complete. + +## Workflow position + +1. Author implements the issue and opens a PR. +2. Reviewer reviews the PR. +3. Merger merges the approved PR. +4. **Controller performs issue-acceptance audit.** +5. Controller posts a `## Controller Issue Acceptance` comment with either: + - `STATE: accepted` and checked criteria, or + - a rejection path (`more-work-required`, `needs-tests`, `needs-docs`, etc.) + with `MISSING_WORK` and a paste-ready `NEXT_PROMPT`. + +Gitea may auto-close an issue via `Closes #N` in the PR body. That closure is +merge mechanics only. Controller acceptance is still required before any final +report or queue controller treats the issue as complete. + +## Template + +Use `issue_acceptance_gate.render_controller_acceptance_template()` or the +copy in +[`skills/llm-project-workflow/templates/controller-issue-acceptance.md`](../skills/llm-project-workflow/templates/controller-issue-acceptance.md). + +## Final-report rules + +Final reports must not claim `issue complete` solely because a PR merged. +Either: + +- include a valid `## Controller Issue Acceptance` block with + `STATE: accepted`, or +- explicitly state `controller acceptance pending` and identify the controller + as the next actor. + +`final_report_validator` enforces this through +`issue_acceptance_gate.validate_final_report_issue_acceptance()`. + +## Role boundaries + +- Authors must not mark their own issues accepted. +- Reviewers must not mark issue acceptance unless acting under controller + capability. +- Mergers merge PRs; they do not substitute for controller acceptance. + +## Related + +- #495 — canonical next-action comment templates +- #496 — fail-closed canonical comment validation before posting +- #303 — controller handoff schema for reconciliation workflows \ No newline at end of file diff --git a/docs/llm-workflow-runbooks.md b/docs/llm-workflow-runbooks.md index f3d82e3..d0e472d 100644 --- a/docs/llm-workflow-runbooks.md +++ b/docs/llm-workflow-runbooks.md @@ -274,12 +274,72 @@ is proven abandoned and the takeover is recorded. Gitea-Tools lease gates: `gitea_lock_issue` (fail-closed before author mutations), `status:in-progress`, and claim comments. `gitea_lock_issue` -records an `author_issue_work` lease in the issue-lock payload with issue -number, optional PR number, branch, worktree path, claimant identity/profile, -created timestamp, expiry timestamp, and last heartbeat timestamp. An active -same-issue/same-operation lease blocks duplicate work. An expired lease still -blocks takeover until a recovery review records why the prior work is abandoned, -completed, or unsafe to continue. +records an `author_issue_work` lease in a keyed lock file under +`GITEA_ISSUE_LOCK_DIR` (default `~/.cache/gitea-tools/issue-locks`), one file +per `remote` + `org` + `repo` + `issue_number`. The current MCP session binds +its active lock through a per-process pointer so concurrent repos/issues never +share one overwrite-prone slot (#443). + +Each lock payload includes issue number, optional PR number, branch, worktree +path, claimant identity/profile, created timestamp, expiry timestamp, and last +heartbeat timestamp. An active same-issue/same-operation lease blocks duplicate +work. An expired lease still blocks takeover until a recovery review records why +the prior work is abandoned, completed, or unsafe to continue. + +**Stacked PRs (#484).** By default the lock worktree must be base-equivalent to +`master`/`main`/`dev` — ordinary work is unchanged. A *stacked* PR (deliberately +based on another unmerged PR's branch) is an explicit, opt-in path: pass +`stacked_base_branch` **and** `stacked_base_pr` to `gitea_lock_issue`. The lock +fails closed unless that branch is owned by a live **open** PR whose number +matches `stacked_base_pr`, so arbitrary or stale branches cannot be used as +bases. When approved, the lock payload records +`approved_stacked_base = {branch, pr_number, verified_open}` and the worktree may +be base-equivalent to that branch instead of master. `gitea_create_pr` then +allows `base = ` only when it matches the recorded approval, the +dependency PR is **still open**, and the PR body documents the stack: + +- `Stacked on PR # / issue #` +- `Base branch: ` +- `Head branch: ` +- `Do not merge before PR #` (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 @@ -317,6 +377,37 @@ explicit control-checkout repair. Portable wording: [`skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md). +### Root checkout guard (#475) + +The MCP server enforces a fail-closed **root checkout guard** before author, +reviewer, and merger mutations when the active workspace is not an isolated +`branches/...` worktree (reconciler close paths remain exempt per #468). + +The guard blocks when the **control checkout** (repository root) is: + +- on a non-stable branch (`master` / `main` / `dev` expected), +- detached HEAD, +- dirty (tracked edits), +- or its `HEAD` does not match `prgs/master` when that ref is available. + +**Remediation (never auto-reset or stash):** + +> Root checkout is not on master. Preserve state, switch root back to master, +> and use `scripts/worktree-review` or the sanctioned issue worktree flow. + +**Recovery after root hijack:** + +1. Preserve any in-progress edits (copy paths, note branch name, or commit on a + rescue branch from a `branches/...` worktree). +2. From the repository root: `git checkout master` (or `main` / `dev` per repo + policy) and `git fetch prgs && git merge --ff-only prgs/master` when safe. +3. Confirm `git status` is clean and `git branch --show-current` is `master`. +4. Resume work only inside `branches/issue--` via `gitea_lock_issue` / + `git worktree add`. + +`branches/...` directories are disposable role worktrees; the root checkout is +the stable orchestration surface only. + ## Shell Spawn Hard-Stop Rule Symptom: a shell tool call returns `exit_code: -1` with empty stdout/stderr. @@ -581,6 +672,33 @@ loop and do **not** substitute WebFetch/Playwright/manual base64. - **Prompt:** `Use any eligible merger profile to merge PR #N if checks pass and it is mergeable. Confirm with "MERGE PR N". Do not force-merge.` +#### Merger lease adoption (#536) + +When review and merge run in **separate sessions**, the merger must **not** +manually seed `reviewer_pr_lease._SESSION_LEASE` or equivalent in-process state. +That ad hoc pattern was used incidentally for PR #493 and PR #421; it is not +canonical proof and is rejected by mutation gates. + +**Canonical merger handoff:** + +1. Confirm a reviewer session holds an active PR lease and posted **APPROVED** + at the current live head (`gitea_get_pr_review_feedback`). +2. In a clean merger worktree under `branches/`, call + `gitea_adopt_merger_pr_lease` with `worktree`, `expected_head_sha`, and + optional `issue_number`. +3. The tool posts durable adoption proof on the PR thread (``) + recording actor, profiles, adopted-from session/comment, adoption reason, and + timestamp, then records sanctioned in-session provenance. +4. Call `gitea_merge_pr` with the same pinned `expected_head_sha`. + +**Forbidden:** Python one-liners or scripts that call `record_session_lease()` +without provenance from `gitea_acquire_reviewer_pr_lease`, +`gitea_adopt_merger_pr_lease`, or `gitea_heartbeat_reviewer_pr_lease`. + +**Same-session review+merge:** the reviewer session may use +`gitea_acquire_reviewer_pr_lease` directly; adoption is only for cross-session +merger handoff. + ### Close the issue after merge / Reconciliation - **Profile:** issue-manager or merger. @@ -763,6 +881,45 @@ scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push ``` +## Namespace workspace binding (#510) + +Each MCP namespace resolves its **own** active task workspace. Foreign role +worktree environment variables must not poison another namespace's purity +checks. + +| Namespace | Workspace env vars (in priority under `GITEA_ACTIVE_WORKTREE`) | Allowed roots | +|-----------|------------------------------------------------------------------|---------------| +| author | `GITEA_AUTHOR_WORKTREE` | `branches/` worktree only (#274) | +| reviewer | `GITEA_REVIEWER_WORKTREE` | clean `branches/` worktree | +| merger | `GITEA_MERGER_WORKTREE` | clean `branches/` worktree **or** clean control checkout | +| reconciler | `GITEA_RECONCILER_WORKTREE` | clean `branches/` worktree **or** clean control checkout | + +`GITEA_AUTHOR_WORKTREE` is **author-only**. Reviewer, merger, and reconciler +MCP processes ignore it even when it points at a dirty author WIP tree. + +### Safe reconnect / rebind procedure + +When a mutation blocks on workspace binding: + +1. Read the error — it names the **resolved workspace path**, **role + namespace**, and **binding source** (tool arg, env var, or process root). +2. Reconnect or relaunch the correct namespace MCP server from the intended + workspace (or set the role-specific env var before launch). +3. Pass `worktree_path` on reviewer/merger mutation tools when the active + branches/ worktree differs from the MCP process root. +4. **Do not** clean, reset, or discard foreign role worktrees to unblock your + own namespace — that destroys another agent's WIP. + +### CTH guidance for workspace binding blockers + +When posting a Canonical Thread Handoff after a binding blocker: + +- State which namespace was active (author / reviewer / merger / reconciler). +- Quote the resolved workspace path and binding source from the error. +- Name the safe reconnect action (relaunch MCP from `branches/...`, set + `GITEA_*_WORKTREE`, or pass `worktree_path`). +- Explicitly note that foreign worktrees must not be cleaned to unblock. + ## Safety notes - Never place raw tokens or passwords in any LLM MCP config; reference secrets @@ -772,6 +929,7 @@ scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push ## Related documents +- [`issue-acceptance-gate.md`](issue-acceptance-gate.md) — controller issue-acceptance audit after PR merge (#500). - [`../skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable cross-project LLM workflow skill. - [`gitea-execution-profiles.md`](gitea-execution-profiles.md) — the profile model. - [`gitea-dual-namespace-deployment.md`](gitea-dual-namespace-deployment.md) — static author/reviewer namespace deployment (#139 decision). diff --git a/docs/mcp-menu.md b/docs/mcp-menu.md new file mode 100644 index 0000000..0b028f9 --- /dev/null +++ b/docs/mcp-menu.md @@ -0,0 +1,67 @@ +# MCP operator shell menu + +## Purpose + +`./mcp-menu.sh` is a repository-root terminal menu for onboarding and operating +the Gitea-Tools MCP/Gitea workflow without memorizing every prompt, script path, +or runbook section. + +It is intentionally **safe by default**: status checks and copy-paste workflow +prompts. It does not delete branches, force-push, edit lock files, or bypass +sanctioned MCP tools. + +## How to run + +From the repository root: + +```bash +./mcp-menu.sh +``` + +The script must be executable (`chmod +x mcp-menu.sh`). It uses bash with +`set -euo pipefail`. + +## Safety rules + +- **Read-only by default** — root checkout health is inspection only. +- **No destructive git** — no `git push --force`, branch deletion, or + `--delete` refspecs. +- **No lock-file editing** — issue locks are acquired only through + `gitea_lock_issue`. +- **No raw API bypass** — prompts direct operators to sanctioned MCP tools. +- **Remote mutations require confirmation** — any future menu action that would + mutate remote or server state must be clearly labeled and require explicit + operator confirmation before running. +- **Author work stays under `branches/`** — the root checkout is a stable + control checkout on `master` / `prgs/master`. + +## Menu options + +| Option | Description | +|--------|-------------| +| Project status / root checkout health | Shows cwd, branch, `git status --short --branch`, HEAD SHA, `prgs/master` SHA, and warnings when the root checkout is dirty or off `master`. | +| Author workflow prompts | Ready-to-copy prompts for issue work, conflict-fix sessions, and root checkout recovery. | +| Reviewer workflow prompts | PR review prompt (review-only; no merge). | +| Merger workflow prompts | PR merge prompt (merge gates and explicit approval). | +| Reconciler workflow prompts | Already-landed / closed PR reconciliation prompt. | +| Onboarding new project | Checklist prompt for adding a repository to the MCP workflow. | +| Proxmox deployment placeholder | **Not implemented** — informational message only. | +| Create Proxmox LXC placeholder | **Not implemented** — informational message only. | +| Run tests | Runs `./run-tests.sh` when present; otherwise `venv/bin/python -m pytest`; otherwise fails closed with a clear error. | +| Exit | Quit the menu. | + +## Placeholder-only entries + +**Proxmox deployment** and **Create Proxmox LXC** are placeholders until +dedicated issues implement sanctioned automation. The menu prints a clear +message and does not invoke deploy scripts. + +## Related documentation + +- [`docs/llm-workflow-runbooks.md`](llm-workflow-runbooks.md) — Gitea-specific workflow runbooks +- [`skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md) — portable workflow skill +- [`skills/llm-project-workflow/workflows/`](../skills/llm-project-workflow/workflows/) — canonical task workflows + +## Tests + +Hermetic coverage lives in `tests/test_mcp_menu_script.py`. \ No newline at end of file diff --git a/docs/webui-local-dev.md b/docs/webui-local-dev.md index 92e10b8..f7a1e27 100644 --- a/docs/webui-local-dev.md +++ b/docs/webui-local-dev.md @@ -50,6 +50,8 @@ Optional environment variables: | `/actions` | Gated write-action registry — all disabled in MVP (#434) | | `/api/actions` | JSON action registry with capability metadata | | `/api/actions/{id}/preview` | Mutation ledger preview (GET, read-only) | +| `/leases` | Lease and collision visibility (#433) | +| `/api/leases` | JSON lease/collision export | All routes are GET-only except registered POST handlers, which still return `405` with `read-only-mvp` until write paths ship. @@ -93,9 +95,17 @@ permission, and profile role from `task_capability_map.py` — aligned with `gitea_resolve_task_capability`. Buttons are disabled; previews always render a mutation ledger. Direct `attempt_action` calls fail closed without invoking MCP tools. +## Lease visibility (#433) + +`/leases` surfaces read-only lease and collision state: local issue lock file, +in-progress claim inventory (#268), reviewer PR lease comments when present +(``, #407), duplicate open PRs per issue (#400), +and duplicate local branches per issue. Links to collision-history backend +issues (#267, #268, #400, #407) are included. No lease acquire/release from UI. ## Tests ```bash pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_gated_actions.py -q +pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py -q ``` \ No newline at end of file diff --git a/docs/wiki/MCP-Tools.md b/docs/wiki/MCP-Tools.md index 38af50e..cd46012 100644 --- a/docs/wiki/MCP-Tools.md +++ b/docs/wiki/MCP-Tools.md @@ -24,6 +24,8 @@ - `gitea_dry_run_pr_review` — validation-phase review mechanics. - `gitea_mark_final_review_decision` — mark validation complete. - `gitea_submit_pr_review` / `gitea_review_pr` — gated live review. +- `gitea_acquire_reviewer_pr_lease` / `gitea_heartbeat_reviewer_pr_lease` — per-PR reviewer lease (#407). +- `gitea_adopt_merger_pr_lease` — guarded cross-session merger lease adoption (#536). - `gitea_merge_pr` — gated merge (only merge path). ## Read tools diff --git a/final_report_validator.py b/final_report_validator.py index 3fbac0f..a807ff9 100644 --- a/final_report_validator.py +++ b/final_report_validator.py @@ -11,6 +11,10 @@ import inspect import re from typing import Any, Callable +import issue_acceptance_gate +import issue_lock_provenance +from mcp_native_cleanup_proof import assess_mcp_native_cleanup_proof +from post_merge_cleanup_proof import assess_post_merge_cleanup_proof from review_proofs import ( HANDOFF_HEADING, assess_controller_handoff, @@ -20,6 +24,7 @@ from review_proofs import ( assess_review_mutation_final_report, assess_validation_report, ) +from validation_status_vocabulary import assess_validation_status_vocabulary FINAL_REPORT_TASK_KINDS = frozenset({ "review_pr", @@ -115,6 +120,22 @@ _TARGET_BRANCH_SHA_RE = re.compile( r"target branch sha\s*:\s*[0-9a-f]{40}", re.IGNORECASE, ) +_WORKFLOW_LOAD_HELPER_RE = re.compile( + r"workflow[- ]load helper result\s*:", + re.IGNORECASE, +) +_WORKFLOW_LOAD_HASH_RE = re.compile( + r"workflow[- ]load helper result[\s\S]{0,400}?workflow[_ ]hash\s*:\s*[0-9a-f]{12}", + re.IGNORECASE, +) +_WORKFLOW_LOAD_BOUNDARY_RE = re.compile( + r"workflow[- ]load helper result[\s\S]{0,400}?boundary[_ ]status\s*:\s*(?:clean|violation)", + re.IGNORECASE, +) +_WORKFLOW_FILE_VIEW_NARRATIVE_RE = re.compile( + r"(?:read|viewed|loaded)\s+(?:the\s+)?(?:canonical\s+)?(?:workflow|review-merge-pr\.md)", + re.IGNORECASE, +) _FULL_SHA_RE = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE) _RECONCILE_STALE_FIELDS = ( "pr number opened", @@ -490,6 +511,102 @@ def _rule_reviewer_validation_failure_history( ] +def _rule_reviewer_validation_cwd_proof( + report_text: str, + *, + validation_session: dict | None = None, +) -> list[dict[str, str]]: + from reviewer_validation_cwd_proof import assess_validation_cwd_proof_report + + session = validation_session or {} + claims = ( + session.get("validation_ran") + or session.get("command") + or session.get("baseline_validation_ran") + ) + if not claims and "validation command:" not in (report_text or "").lower(): + return [] + + result = assess_validation_cwd_proof_report( + report_text, + validation_session=session, + ) + if result.get("proven") or not result.get("claims_validation"): + return [] + severity = "block" if result.get("violations") else "downgrade" + return [ + validator_finding( + "reviewer.validation_cwd_proof", + severity, + "Validation cwd/HEAD proof", + reason, + result.get("safe_next_action") + or "document pwd, HEAD SHA, and explicit cwd before validation", + ) + for reason in (result.get("violations") or result.get("reasons") or ["incomplete"]) + ] + + +def _rule_reviewer_stale_head_proof(report_text: str) -> list[dict[str, str]]: + from pr_work_lease import assess_reviewer_stale_head_final_report + + result = assess_reviewer_stale_head_final_report(report_text) + if result.get("proven"): + return [] + return _findings_from_reasons( + "reviewer.stale_head_proof", + result.get("reasons") or [], + field="Stale-head proof", + severity="block", + safe_next_action=( + "state reviewed head SHA, live head before approval/merge, and " + "whether any push occurred during validation" + ), + ) + + +def _rule_conflict_fix_push_proof(report_text: str) -> list[dict[str, str]]: + from pr_work_lease import assess_conflict_fix_final_report + + text = report_text or "" + if "conflict-fix" not in text.lower() and "conflict fix" not in text.lower(): + return [] + result = assess_conflict_fix_final_report(text) + if result.get("proven"): + return [] + return _findings_from_reasons( + "author.conflict_fix_push_proof", + result.get("reasons") or [], + field="Conflict-fix push proof", + severity="block", + safe_next_action=( + "state branch head before/after push, reviewer lease status, " + "fast-forward status, and whether any reviewer was active" + ), + ) + + +def _rule_worktree_cleanup_audit_proof(report_text: str) -> list[dict[str, str]]: + from worktree_cleanup_audit import assess_cleanup_audit_final_report + + text = report_text or "" + if "cleanup audit" not in text.lower() and "reconciliation table" not in text.lower(): + return [] + result = assess_cleanup_audit_final_report(text) + if result.get("proven"): + return [] + return _findings_from_reasons( + "author.worktree_cleanup_audit_proof", + result.get("reasons") or [], + field="Worktree cleanup audit", + severity="block", + safe_next_action=( + "include reconciliation table counts, disposition rows, and " + "final git worktree list proof" + ), + ) + + def _rule_reviewer_validation_command(report_text: str) -> list[dict[str, str]]: text = report_text or "" if not _BARE_PYTEST_RE.search(text): @@ -598,6 +715,34 @@ def _rule_reviewer_main_checkout_baseline(report_text: str) -> list[dict[str, st ] +def _rule_reviewer_validation_status_vocabulary( + report_text: str, + *, + action_log: list[dict] | None = None, +) -> list[dict[str, str]]: + text = report_text or "" + if not re.search( + r"validation status|pr-head validation status|official validation status", + text, + re.IGNORECASE, + ): + return [] + result = assess_validation_status_vocabulary( + text, + command_log=action_log, + ) + if not result.get("block"): + return [] + return _findings_from_reasons( + "reviewer.validation_status_vocabulary", + result.get("reasons") or [], + field="Validation status", + severity="block", + safe_next_action=result.get("safe_next_action") + or "use a validation status that matches the proof path executed", + ) + + def _rule_reviewer_main_checkout_path(report_text: str) -> list[dict[str, str]]: text = report_text or "" if "baseline worktree path" not in text.lower(): @@ -816,6 +961,69 @@ def _rule_reconcile_linked_issue_live( return [] +_PR_CLOSE_NEGATIVE = frozenset({"", "none", "n/a", "na", "0", "no", "not closed", "not performed"}) +_ANCESTOR_AFFIRMATIVE_RE = re.compile( + r"ancestor|passed|true|verified|confirmed", re.IGNORECASE +) + + +def _reconciler_pr_close_performed(fields: dict[str, str], lock: dict) -> bool: + """True when the report/session indicates a reconciler PR close happened.""" + if lock.get("pr_closed") is True: + return True + value = (fields.get("prs closed", "") or "").strip().lower() + if value in _PR_CLOSE_NEGATIVE: + return False + # A closed PR is reported by number (e.g. "#99") or an affirmative result. + return bool(re.search(r"#\s*\d+|\b(?:closed|success|done)\b", value)) + + +def _rule_reconcile_close_proof( + report_text: str, + *, + reconciler_close_lock: dict | None = None, +) -> list[dict[str, str]]: + """#306: a reconciler PR close must carry exact proof fields. + + Read-only/comment-only reconciliations are untouched. Once a PR close is + reported (via the ``PRs closed`` field or a session close lock), the + handoff must prove the close capability, ancestor landing, PR close + result, and the linked-issue result — narrative alone fails closed. + """ + fields = _handoff_fields(report_text) + lock = reconciler_close_lock or {} + if not _reconciler_pr_close_performed(fields, lock): + return [] + + missing: list[str] = [] + capabilities = fields.get("capabilities proven", "") + if "gitea.pr.close" not in capabilities.lower(): + missing.append("close capability proof (gitea.pr.close)") + ancestor = fields.get("ancestor proof", "") + if not _ANCESTOR_AFFIRMATIVE_RE.search(ancestor): + missing.append("ancestor proof") + prs_closed = (fields.get("prs closed", "") or "").strip().lower() + if prs_closed in _PR_CLOSE_NEGATIVE and lock.get("pr_closed") is True: + missing.append("PR close result") + linked = fields.get("linked issue live status", "") or fields.get("issues closed", "") + if not linked.strip(): + missing.append("linked issue result") + + if not missing: + return [] + return [ + validator_finding( + "reconcile.close_proof_fields", + "block", + "Reconciler close proof", + "reconciler PR close reported without required proof field(s): " + + ", ".join(missing), + "include close capability proof, ancestor proof, PR close result, " + "and linked issue result in the handoff", + ) + ] + + def _rule_reconcile_pagination_proof(report_text: str) -> list[dict[str, str]]: text = report_text or "" if not _INVENTORY_COMPLETE_RE.search(text): @@ -870,6 +1078,134 @@ 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_issue_acceptance_gate(report_text: str) -> list[dict[str, str]]: + result = issue_acceptance_gate.validate_final_report_issue_acceptance(report_text) + if not result.get("applicable") or result.get("valid"): + return [] + return _findings_from_reasons( + "shared.issue_acceptance_gate", + result.get("reasons") or [], + field="Controller acceptance", + severity="block", + safe_next_action=( + "add Controller Issue Acceptance proof or state that controller " + "acceptance is pending; do not claim issue complete from PR merge alone" + ), + ) + + +def _rule_shared_author_reviewer_same_run(report_text: str) -> list[dict[str, str]]: + result = issue_lock_provenance.assess_author_reviewer_same_run_report(report_text) + if result.get("proven"): + 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_workflow_load_boundary(report_text: str) -> list[dict[str, str]]: + """#403: require structured workflow-load helper result, not file-view narrative.""" + if not report_text.strip(): + return [] + findings: list[dict[str, str]] = [] + has_helper = bool(_WORKFLOW_LOAD_HELPER_RE.search(report_text)) + has_hash = bool(_WORKFLOW_LOAD_HASH_RE.search(report_text)) + has_boundary = bool(_WORKFLOW_LOAD_BOUNDARY_RE.search(report_text)) + has_narrative_only = bool(_WORKFLOW_FILE_VIEW_NARRATIVE_RE.search(report_text)) + + if has_narrative_only and not has_helper: + findings.append(validator_finding( + "reviewer.workflow_load_boundary", + "block", + "Workflow-load helper result", + ( + "canonical workflow file-view narrative without structured " + "gitea_load_review_workflow helper result" + ), + ( + "include Workflow-load helper result with workflow_hash and " + "boundary_status from gitea_load_review_workflow" + ), + )) + return findings + + if has_helper and (not has_hash or not has_boundary): + missing = [] + if not has_hash: + missing.append("workflow_hash") + if not has_boundary: + missing.append("boundary_status") + findings.append(validator_finding( + "reviewer.workflow_load_boundary", + "block", + "Workflow-load helper result", + ( + "workflow-load helper result incomplete; missing " + + ", ".join(missing) + ), + ( + "copy workflow_load_helper_result fields from " + "gitea_load_review_workflow into the final report" + ), + )) + return findings + + +def _rule_audit_reconciliation_boundary(report_text: str) -> list[dict[str, str]]: + from audit_reconciliation_mode import assess_audit_reconciliation_report + + result = assess_audit_reconciliation_report(report_text) + if result.get("proven"): + return [] + return _findings_from_reasons( + "reconcile.audit_cleanup_boundary", + result.get("reasons") or [], + field="Audit/cleanup phase", + severity="block", + safe_next_action=result.get("safe_next_action") + or "separate audit from authorized cleanup and classify mutations", + ) + + def _rule_reviewer_review_mutation( report_text: str, *, @@ -889,60 +1225,134 @@ def _rule_reviewer_review_mutation( ) +def _rule_reviewer_mutation_capability_proof(report_text: str) -> list[dict[str, str]]: + from reviewer_mutation_capability_proof import assess_mutation_capability_proof + + result = assess_mutation_capability_proof(report_text) + if not result.get("block"): + return [] + return _findings_from_reasons( + "reviewer.mutation_capability_proof", + result.get("reasons") or [], + field="Capabilities proven", + severity="block", + safe_next_action=result.get("safe_next_action") + or "document exact per-mutation capability proof before each mutation", + ) + + +def _rule_reviewer_post_merge_cleanup_proof(report_text: str) -> list[dict[str, str]]: + result = assess_post_merge_cleanup_proof(report_text) + if not result.get("block"): + return [] + return _findings_from_reasons( + "reviewer.post_merge_cleanup_proof", + result.get("reasons") or [], + field="Cleanup status", + severity="block", + safe_next_action=result.get("safe_next_action") + or "report CLEANUP_SKIPPED with blocker or full cleanup checklist", + ) + + +def _rule_shared_mcp_native_cleanup_proof(report_text: str) -> list[dict[str, str]]: + result = assess_mcp_native_cleanup_proof(report_text) + if not result.get("block"): + return [] + return _findings_from_reasons( + "shared.mcp_native_cleanup_proof", + result.get("reasons") or [], + field="Cleanup mutations", + severity="block", + safe_next_action=result.get("safe_next_action") + or "use authorized reconciler MCP cleanup tools; never raw scripts", + ) + + +_SHARED_ISSUE_LOCK_RULES = ( + _rule_shared_issue_lock_external_state, + _rule_shared_manual_lock_pr_override, + _rule_shared_author_reviewer_same_run, +) + +_SHARED_CLEANUP_PROOF_RULES = ( + _rule_shared_mcp_native_cleanup_proof, +) + _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_workflow_load_boundary, _rule_reviewer_mutation_ledger, _rule_reviewer_review_mutation, + _rule_reviewer_mutation_capability_proof, + _rule_reviewer_post_merge_cleanup_proof, + *_SHARED_CLEANUP_PROOF_RULES, + _rule_reviewer_stale_head_proof, ], "reconcile_already_landed": [ _rule_reconcile_controller_handoff, _rule_shared_email_disclosure, + *_SHARED_ISSUE_LOCK_RULES, + *_SHARED_CLEANUP_PROOF_RULES, _rule_reconcile_stale_author_fields, _rule_reconcile_eligible_reviewed, _rule_reconcile_linked_issue_live, + _rule_reconcile_close_proof, _rule_reconcile_pagination_proof, _rule_reviewer_git_fetch_readonly, _rule_reviewer_legacy_workspace_mutations, _rule_reviewer_vague_mutations_none, + _rule_audit_reconciliation_boundary, ], "author_issue": [ _rule_shared_controller_handoff, _rule_shared_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_shared_issue_acceptance_gate, _rule_reviewer_vague_mutations_none, + _rule_conflict_fix_push_proof, + _rule_worktree_cleanup_audit_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, ], } @@ -1006,6 +1416,7 @@ def assess_final_report_validator( issue_filing_lock: dict | None = None, session_pr_opened: bool = False, validation_session: dict | None = None, + reconciler_close_lock: dict | None = None, ) -> dict[str, Any]: """Validate final-report text against task-specific proof rules (#327). @@ -1062,6 +1473,7 @@ def assess_final_report_validator( "local_edits": local_edits, "session_pr_opened": session_pr_opened, "validation_session": validation_session, + "reconciler_close_lock": reconciler_close_lock, } for rule in _RULES_BY_TASK.get(normalized_kind, ()): diff --git a/gitea_mcp_server.py b/gitea_mcp_server.py index beb6084..ed650b5 100644 --- a/gitea_mcp_server.py +++ b/gitea_mcp_server.py @@ -165,6 +165,7 @@ _preflight_capability_called = False _preflight_whoami_violation = False _preflight_capability_violation = False _preflight_resolved_role = None +_preflight_resolved_task: str | None = None _process_start_porcelain: str | None = None _preflight_whoami_baseline_porcelain: str | None = None _preflight_capability_baseline_porcelain: str | None = None @@ -174,12 +175,98 @@ _preflight_reviewer_violation_files: list[str] = [] ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE" AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE" +REVIEWER_WORKTREE_ENV = "GITEA_REVIEWER_WORKTREE" +MERGER_WORKTREE_ENV = "GITEA_MERGER_WORKTREE" +RECONCILER_WORKTREE_ENV = "GITEA_RECONCILER_WORKTREE" + +import namespace_workspace_binding as nwb # noqa: E402 def _preflight_in_test_mode() -> bool: return "pytest" in sys.modules or "unittest" in sys.modules +def _reviewer_session_worktree() -> str | None: + import reviewer_pr_lease as _reviewer_pr_lease + + session = _reviewer_pr_lease.get_session_lease() + if not session: + return None + worktree = (session.get("worktree") or "").strip() + return worktree or None + + +def _effective_workspace_role() -> str: + """Resolve the namespace key used for workspace binding (#510).""" + profile = get_profile() + role = _preflight_resolved_role + if not role: + role = _role_kind( + profile.get("allowed_operations") or [], + profile.get("forbidden_operations") or [], + ) + return nwb.normalize_role_kind( + role, + profile_name=profile.get("profile_name"), + ) + + +def _actual_profile_role() -> str: + """Resolve the workspace role from the ACTIVE PROFILE alone (#540). + + Unlike :func:`_effective_workspace_role`, this never consults + ``_preflight_resolved_role``. A task capability whose ``required_role_kind`` + is ``author`` (e.g. ``comment_issue``) stamps ``_preflight_resolved_role = + "author"``; the #274/#475 role exemptions must key off the real profile + identity so that stamp cannot poison a genuine reviewer/merger/reconciler + session into being treated as an author. Author profiles still classify as + ``author`` here, so author blocking is preserved. + """ + profile = get_profile() + role = _role_kind( + profile.get("allowed_operations") or [], + profile.get("forbidden_operations") or [], + ) + return nwb.normalize_role_kind( + role, + profile_name=profile.get("profile_name"), + ) + + +def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str: + """Resolve the namespace-scoped workspace root inspected by pre-flight guards.""" + role = _effective_workspace_role() + workspace, _source = nwb.resolve_namespace_workspace( + role_kind=role, + worktree_path=worktree_path, + process_project_root=PROJECT_ROOT, + session_lease_worktree=( + _reviewer_session_worktree() if role in {"reviewer", "merger"} else None + ), + profile_name=get_profile().get("profile_name"), + ) + return workspace + + +def _resolve_namespace_mutation_context(worktree_path: str | None = None) -> dict: + """Canonical namespace workspace + repository root for guards (#460/#510).""" + role = _effective_workspace_role() + return nwb.resolve_namespace_mutation_context( + role_kind=role, + worktree_path=worktree_path, + process_project_root=PROJECT_ROOT, + session_lease_worktree=( + _reviewer_session_worktree() if role in {"reviewer", "merger"} else None + ), + profile_name=get_profile().get("profile_name"), + ) + + +def _resolve_author_mutation_context(worktree_path: str | None = None) -> dict: + """Backward-compatible alias for namespace workspace context.""" + return _resolve_namespace_mutation_context(worktree_path) + + def _ensure_process_start_porcelain() -> str: """Capture the shared-worktree baseline once per MCP process (#252).""" global _process_start_porcelain @@ -188,18 +275,6 @@ def _ensure_process_start_porcelain() -> str: return _process_start_porcelain -def _resolve_preflight_workspace_path(worktree_path: str | None = None) -> str: - """Resolve the workspace root inspected by pre-flight guards.""" - path = (worktree_path or "").strip() - if not path: - path = (os.environ.get(ACTIVE_WORKTREE_ENV) or "").strip() - if not path: - path = (os.environ.get(AUTHOR_WORKTREE_ENV) or "").strip() - if not path: - path = PROJECT_ROOT - return os.path.realpath(os.path.abspath(path)) - - def _get_git_root(path: str) -> str | None: try: res = subprocess.run( @@ -267,31 +342,50 @@ def _format_preflight_files(files: list[str]) -> str: def _preflight_workspace_details(worktree_path: str | None, dirty_files: list[str]) -> dict: - workspace = _resolve_preflight_workspace_path(worktree_path) + ctx = _resolve_namespace_mutation_context(worktree_path) + workspace = ctx["workspace_path"] inspected_root = _get_git_root(workspace) - control_root = os.path.realpath(PROJECT_ROOT) + process_root = ctx["process_project_root"] + canonical_root = ctx["canonical_repo_root"] active_root = os.path.realpath(inspected_root or workspace) - if active_root == control_root: + if active_root == canonical_root: dirty_scope = "control checkout" else: dirty_scope = "active task workspace" - return { - "mcp_server_process_root": control_root, + details = { + "mcp_server_process_root": process_root, + "canonical_repository_root": canonical_root, "active_task_workspace_root": active_root, "inspected_git_root": inspected_root, "dirty_files": list(dirty_files), "dirty_scope": dirty_scope, + "workspace_roots_aligned": ctx["roots_aligned"], + "workspace_role_kind": ctx.get("workspace_role_kind"), + "workspace_binding_source": ctx.get("workspace_binding_source"), + "ignored_bindings": list(ctx.get("ignored_bindings") or []), } + if not ctx["roots_aligned"]: + details["workspace_root_mismatch"] = ( + "runtime_context and mutation guard use canonical repository root " + f"'{canonical_root}' instead of MCP process root '{process_root}'" + ) + return details def _format_preflight_workspace_details(details: dict) -> str: - return ( - f"MCP server process root: {details.get('mcp_server_process_root')}; " - f"active task workspace root: {details.get('active_task_workspace_root')}; " - f"inspected git root: {details.get('inspected_git_root')}; " - f"dirty files: {_format_preflight_files(details.get('dirty_files') or [])}; " - f"dirty scope: {details.get('dirty_scope')}" - ) + parts = [ + f"MCP server process root: {details.get('mcp_server_process_root')}", + f"active task workspace root: {details.get('active_task_workspace_root')}", + f"workspace role: {details.get('workspace_role_kind')}", + f"binding source: {details.get('workspace_binding_source')}", + f"inspected git root: {details.get('inspected_git_root')}", + f"dirty files: {_format_preflight_files(details.get('dirty_files') or [])}", + f"dirty scope: {details.get('dirty_scope')}", + ] + ignored = details.get("ignored_bindings") or [] + if ignored: + parts.append(f"ignored foreign bindings: {'; '.join(ignored)}") + return "; ".join(parts) def assess_preflight_status(worktree_path: str | None = None) -> dict: @@ -314,6 +408,25 @@ def assess_preflight_status(worktree_path: str | None = None) -> dict: "Active task workspace has tracked file edits before mutation " f"({_format_preflight_workspace_details(workspace_details)})" ) + role = _effective_workspace_role() + if role in nwb.NON_AUTHOR_ROLES: + binding = nwb.assess_metadata_only_worktree_binding( + role_kind=role, + declared_worktree_path=worktree_path, + mutation_workspace=_resolve_preflight_workspace_path(worktree_path), + process_project_root=PROJECT_ROOT, + profile_name=get_profile().get("profile_name"), + ) + if binding.get("block"): + reasons.append(binding["reasons"][0]) + reasons.append( + nwb.format_namespace_workspace_binding_error( + role_kind=role, + workspace_path=binding["mutation_workspace"], + binding_source="MCP server process root (default)", + reasons=binding.get("reasons"), + ) + ) return { "preflight_ready": not reasons, "preflight_block_reasons": reasons, @@ -361,11 +474,30 @@ def assess_preflight_status(worktree_path: str | None = None) -> dict: } -def record_preflight_check(type_name: str, resolved_role: str | None = None): +def _clear_preflight_capability_state() -> None: + """Drop resolved capability proof (consumed by a mutation or fresh resolve).""" + global _preflight_capability_called, _preflight_capability_violation + global _preflight_resolved_task + global _preflight_capability_baseline_porcelain, _preflight_capability_violation_files + global _preflight_reviewer_violation_files + + _preflight_capability_called = False + _preflight_capability_violation = False + _preflight_capability_violation_files = [] + _preflight_capability_baseline_porcelain = None + _preflight_resolved_task = None + _preflight_reviewer_violation_files = [] + + +def record_preflight_check( + type_name: str, + resolved_role: str | None = None, + resolved_task: str | None = None, +): """Record a pre-flight check (whoami or capability) with session-scoped deltas.""" global _preflight_whoami_called, _preflight_capability_called global _preflight_whoami_violation, _preflight_capability_violation - global _preflight_resolved_role + global _preflight_resolved_role, _preflight_resolved_task global _preflight_whoami_baseline_porcelain, _preflight_capability_baseline_porcelain global _preflight_whoami_violation_files, _preflight_capability_violation_files global _preflight_reviewer_violation_files @@ -373,13 +505,24 @@ def record_preflight_check(type_name: str, resolved_role: str | None = None): current = _get_workspace_porcelain() if type_name == "whoami": - # Fresh whoami restarts the capability step and re-evaluates violations - # instead of replaying a sticky record (#252). - _preflight_capability_called = False - _preflight_capability_violation = False - _preflight_capability_violation_files = [] - _preflight_capability_baseline_porcelain = None - _preflight_reviewer_violation_files = [] + # Re-evaluate whoami violations instead of replaying sticky state (#252). + # Interleaved read-only whoami must not clear a valid capability proof (#469). + saved_capability = None + preserve_capability = ( + _preflight_capability_called + and _preflight_whoami_called + and not _preflight_whoami_violation + ) + if preserve_capability: + saved_capability = ( + _preflight_capability_violation, + list(_preflight_capability_violation_files), + _preflight_capability_baseline_porcelain, + _preflight_resolved_role, + _preflight_resolved_task, + ) + else: + _clear_preflight_capability_state() process_start = _ensure_process_start_porcelain() whoami_delta = _new_tracked_changes_since(process_start, current) @@ -387,6 +530,20 @@ def record_preflight_check(type_name: str, resolved_role: str | None = None): _preflight_whoami_violation_files = whoami_delta _preflight_whoami_baseline_porcelain = current _preflight_whoami_called = True + + if ( + preserve_capability + and saved_capability is not None + and not whoami_delta + ): + ( + _preflight_capability_violation, + _preflight_capability_violation_files, + _preflight_capability_baseline_porcelain, + _preflight_resolved_role, + _preflight_resolved_task, + ) = saved_capability + _preflight_capability_called = True elif type_name == "capability": baseline = _preflight_whoami_baseline_porcelain or "" capability_delta = _new_tracked_changes_since(baseline, current) @@ -396,22 +553,36 @@ def record_preflight_check(type_name: str, resolved_role: str | None = None): _preflight_capability_called = True if resolved_role: _preflight_resolved_role = resolved_role + if resolved_task: + _preflight_resolved_task = resolved_task def _enforce_branches_only_author_mutation(worktree_path: str | None = None) -> None: - """#274: author mutations must run from a branches/ session worktree.""" - if _preflight_resolved_role == "reviewer": + """#274: author file/branch mutations must run from a branches/ worktree. + + Reviewer, merger, and reconciler roles are exempt: reconciler ``close_pr`` + is a Gitea metadata mutation and must not require ``GITEA_AUTHOR_WORKTREE`` + (#468). Non-author namespaces use dedicated workspace env vars (#510). + + The exemption honours BOTH the effective workspace role and the actual + profile role (#540). ``comment_issue`` preflight stamps + ``_preflight_resolved_role = "author"`` (its ``required_role_kind``), which + would otherwise poison :func:`_effective_workspace_role` into classifying a + genuine reconciler as an author and defeat this exemption. Keying off the + real profile role as well preserves the exemption without weakening author + blocking — an actual author profile classifies as ``author`` in both. + """ + if ( + _effective_workspace_role() in nwb.NON_AUTHOR_ROLES + or _actual_profile_role() in nwb.NON_AUTHOR_ROLES + ): return - workspace = author_mutation_worktree.resolve_mutation_workspace( - worktree_path, - PROJECT_ROOT, - active_worktree_env=os.environ.get(ACTIVE_WORKTREE_ENV), - author_worktree_env=os.environ.get(AUTHOR_WORKTREE_ENV), - ) + ctx = _resolve_namespace_mutation_context(worktree_path) + workspace = ctx["workspace_path"] git_state = issue_lock_worktree.read_worktree_git_state(workspace) assessment = author_mutation_worktree.assess_author_mutation_worktree( workspace_path=workspace, - project_root=PROJECT_ROOT, + project_root=ctx["canonical_repo_root"], current_branch=git_state.get("current_branch"), ) if assessment["block"]: @@ -420,7 +591,11 @@ def _enforce_branches_only_author_mutation(worktree_path: str | None = None) -> ) -def verify_preflight_purity(remote: str | None = None, worktree_path: str | None = None): +def verify_preflight_purity( + remote: str | None = None, + worktree_path: str | None = None, + task: str | None = None, +): """Verify that identity and capability were verified prior to session edits.""" global _preflight_reviewer_violation_files @@ -439,53 +614,48 @@ def verify_preflight_purity(remote: str | None = None, worktree_path: str | None raise RuntimeError( "Pre-flight order violation: Task capability (gitea_resolve_task_capability) has not been resolved (fail closed)" ) + if ( + task is not None + and _preflight_resolved_task is not None + and task != _preflight_resolved_task + ): + raise RuntimeError( + "Pre-flight task mismatch: " + f"resolved '{_preflight_resolved_task}' but mutation requires " + f"'{task}' (fail closed)" + ) - workspace = author_mutation_worktree.resolve_mutation_workspace( - worktree_path, - PROJECT_ROOT, - active_worktree_env=os.environ.get(ACTIVE_WORKTREE_ENV), - author_worktree_env=os.environ.get(AUTHOR_WORKTREE_ENV), - ) + ctx = _resolve_namespace_mutation_context(worktree_path) + workspace = ctx["workspace_path"] + canonical_root = ctx["canonical_repo_root"] + process_root = ctx["process_project_root"] real_workspace = os.path.realpath(workspace) - real_root = os.path.realpath(PROJECT_ROOT) + role = ctx.get("workspace_role_kind") or _effective_workspace_role() - if real_workspace != real_root: + if real_workspace != process_root: if not _preflight_in_test_mode(): - if not os.path.exists(real_workspace): + membership = author_mutation_worktree.assess_workspace_repo_membership( + workspace_path=workspace, + canonical_repo_root=canonical_root, + ) + if membership["block"]: raise RuntimeError( - f"Branches-only mutation guard (#274): worktree path '{workspace}' does not exist (fail closed)" - ) - if not os.path.isdir(real_workspace): - raise RuntimeError( - f"Branches-only mutation guard (#274): worktree path '{workspace}' is not a directory (fail closed)" - ) - try: - res = subprocess.run( - ["git", "-C", real_workspace, "rev-parse", "--git-common-dir"], - capture_output=True, - text=True, - check=True, - ) - common_dir = os.path.realpath(res.stdout.strip()) - expected_dir = os.path.realpath(os.path.join(real_root, ".git")) - if common_dir != expected_dir: - raise RuntimeError( - f"Branches-only mutation guard (#274): worktree '{workspace}' does not belong to the target repository '{PROJECT_ROOT}' (fail closed)" + author_mutation_worktree.format_workspace_repo_membership_error( + membership ) - except Exception as e: - if isinstance(e, RuntimeError): - raise e - raise RuntimeError( - f"Branches-only mutation guard (#274): worktree '{workspace}' is not a valid git repository (fail closed)" ) dirty_files = sorted(_parse_porcelain_entries(_get_workspace_porcelain(workspace))) if dirty_files: - details = _preflight_workspace_details(workspace, dirty_files) raise RuntimeError( - "Pre-flight order violation: Active task workspace has tracked " - "file edits before mutation (fail closed). " - f"{_format_preflight_workspace_details(details)}" + nwb.format_namespace_workspace_binding_error( + role_kind=role, + workspace_path=workspace, + binding_source=ctx.get("workspace_binding_source") + or "unknown binding source", + dirty_files=dirty_files, + ignored_bindings=ctx.get("ignored_bindings"), + ) ) else: if _preflight_whoami_violation: @@ -501,19 +671,115 @@ def verify_preflight_purity(remote: str | None = None, worktree_path: str | None f"{_format_preflight_files(_preflight_capability_violation_files)}" ) - if _preflight_resolved_role == "reviewer": + if role in {"reviewer", "merger"}: current = _get_workspace_porcelain() baseline = _preflight_capability_baseline_porcelain or "" reviewer_delta = _new_tracked_changes_since(baseline, current) _preflight_reviewer_violation_files = reviewer_delta if reviewer_delta: raise RuntimeError( - "Reviewer role violation: Reviewer profile is forbidden from modifying " + f"{role.title()} role violation: profile is forbidden from modifying " "tracked workspace files (fail closed). Offending files: " f"{_format_preflight_files(reviewer_delta)}" ) + _enforce_root_checkout_guard(worktree_path) _enforce_branches_only_author_mutation(worktree_path) + _clear_preflight_capability_state() + + +def _verify_role_mutation_workspace( + remote: str | None = None, + *, + worktree_path: str | None = None, + worktree: str | None = None, + task: str | None = None, +) -> str: + """Bind reviewer/merger mutations to the active namespace workspace (#510).""" + # Check running runtimes to prevent stale mutations + try: + if "PYTEST_CURRENT_TEST" not in os.environ or "GITEA_FORCE_MCP_RUNTIME_CHECK" in os.environ: + config = gitea_config.load_config() + required_permission = task_capability_map.required_permission(task) if task else None + matching_profiles = [] + if required_permission and config and "profiles" in config: + for p_name, p_data in config["profiles"].items(): + p_allowed = p_data.get("allowed_operations") or [] + p_forbidden = p_data.get("forbidden_operations") or [] + p_allowed_n = [] + for op in p_allowed: + try: + p_allowed_n.append(gitea_config.normalize_operation(op)) + except Exception: + pass + p_forbidden_n = [] + for op in p_forbidden: + try: + p_forbidden_n.append(gitea_config.normalize_operation(op)) + except Exception: + pass + ok, _ = gitea_config.check_operation(required_permission, p_allowed_n, p_forbidden_n) + if ok: + matching_profiles.append(p_name) + runtime_reasons = _check_mcp_runtimes_diagnostics(task or "unknown", matching_profiles) + if runtime_reasons: + raise RuntimeError("; ".join(runtime_reasons)) + except Exception as exc: + if "stale-runtime:" in str(exc): + raise RuntimeError(str(exc)) + pass + + role = _effective_workspace_role() + git_state = issue_lock_worktree.read_worktree_git_state( + _resolve_preflight_workspace_path(worktree_path) + ) + assessment = nwb.assess_namespace_mutation_workspace( + role_kind=role, + worktree_path=worktree_path, + worktree=worktree, + process_project_root=PROJECT_ROOT, + session_lease_worktree=( + _reviewer_session_worktree() if role in {"reviewer", "merger"} else None + ), + profile_name=get_profile().get("profile_name"), + current_branch=git_state.get("current_branch"), + ) + if assessment["block"]: + raise RuntimeError( + nwb.format_namespace_workspace_binding_error( + role_kind=role, + workspace_path=assessment["mutation_workspace"], + binding_source=assessment.get("workspace_binding_source") + or "unknown binding source", + reasons=assessment.get("reasons"), + ignored_bindings=assessment.get("ignored_bindings"), + ) + ) + resolved = assessment["mutation_workspace"] + verify_preflight_purity(remote, worktree_path=resolved, task=task) + return resolved + + +def _enforce_root_checkout_guard(worktree_path: str | None = None) -> None: + """#475: fail closed when the stable control checkout is contaminated.""" + ctx = _resolve_author_mutation_context(worktree_path) + canonical_root = ctx["canonical_repo_root"] + workspace = ctx["workspace_path"] + git_state = issue_lock_worktree.read_worktree_git_state(canonical_root) + remote_master_sha = root_checkout_guard.resolve_remote_master_sha(canonical_root) + assessment = root_checkout_guard.assess_root_checkout_guard( + workspace_path=workspace, + canonical_repo_root=canonical_root, + current_branch=git_state.get("current_branch"), + head_sha=git_state.get("head_sha"), + porcelain_status=git_state.get("porcelain_status") or "", + remote_master_sha=remote_master_sha, + resolved_role=_preflight_resolved_role, + actual_role=_actual_profile_role(), + ) + if assessment["block"]: + raise RuntimeError(root_checkout_guard.format_root_checkout_guard_error(assessment)) + from mcp.server.fastmcp import FastMCP # noqa: E402 @@ -536,20 +802,38 @@ import role_session_router # noqa: E402 import role_namespace_gate # noqa: E402 import task_capability_map # noqa: E402 import review_proofs # noqa: E402 +import review_workflow_boundary # noqa: E402 +import review_workflow_load # noqa: E402 +import mcp_session_state # noqa: E402 import agent_temp_artifacts import issue_lock_worktree # noqa: E402 +import issue_lock_provenance # noqa: E402 +import issue_lock_store # noqa: E402 +import issue_lock_adoption # noqa: E402 +import stacked_pr_support # noqa: E402 +import merge_approval_gate # noqa: E402 import already_landed_reconcile # noqa: E402 import author_mutation_worktree # noqa: E402 +import root_checkout_guard # noqa: E402 +import remote_repo_guard # noqa: E402 import issue_claim_heartbeat # noqa: E402 +import issue_work_duplicate_gate # noqa: E402 +import reviewer_pr_lease # noqa: E402 +import merger_lease_adoption # noqa: E402 import merged_cleanup_reconcile # noqa: E402 +import worktree_cleanup_audit # noqa: E402 import reconciler_profile # noqa: E402 import reconciliation_workflow # noqa: E402 +import audit_reconciliation_mode # noqa: E402 import review_merge_state_machine # noqa: E402 +import pr_work_lease # noqa: E402 import native_mcp_preference # noqa: E402 +import worktree_cleanup_audit # noqa: E402 -# Fail-closed exact-issue-lock file (#204): written by gitea_lock_issue, -# consumed by gitea_create_pr and scripts/worktree-start. +# Keyed issue-lock storage (#443): per remote/org/repo/issue files under +# GITEA_ISSUE_LOCK_DIR, bound to the current MCP session via a per-PID pointer. +# Legacy global path retained only for test/doc references — do not seed manually. ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json" WORK_LEASE_TTL_HOURS = 4 AUTHOR_ISSUE_WORK_LEASE = "author_issue_work" @@ -581,15 +865,59 @@ def _parse_work_lease_timestamp(value: str | None) -> datetime | None: return None -def _load_existing_issue_lock() -> dict | None: - if not os.path.exists(ISSUE_LOCK_FILE): - return None +def _load_existing_issue_lock( + *, + remote: str | None = None, + org: str | None = None, + repo: str | None = None, + issue_number: int | None = None, +) -> dict | None: + if remote and org and repo and issue_number is not None: + return issue_lock_store.load_issue_lock( + remote=remote, + org=org, + repo=repo, + issue_number=issue_number, + ) + return issue_lock_store.read_session_issue_lock() + + +def _resolve_issue_lock_for_pr( + *, + remote: str, + org: str, + repo: str, + head: str, +) -> dict: + lock_data = issue_lock_store.read_session_issue_lock() + if not lock_data: + lock_data = issue_lock_store.find_lock_for_branch( + remote=remote, + org=org, + repo=repo, + branch_name=head, + ) + if not lock_data: + raise RuntimeError( + "Issue lock is missing (fail closed). Call gitea_lock_issue first." + ) + return lock_data + + +def _save_issue_lock(data: dict) -> str: + existing = issue_lock_store.load_issue_lock( + remote=str(data.get("remote") or ""), + org=str(data.get("org") or ""), + repo=str(data.get("repo") or ""), + issue_number=int(data.get("issue_number") or 0), + ) + overwrite_block = issue_lock_store.assess_foreign_lock_overwrite(existing, data) + if overwrite_block: + raise RuntimeError(overwrite_block) try: - with open(ISSUE_LOCK_FILE, encoding="utf-8") as f: - data = json.load(f) - return data if isinstance(data, dict) else None - except Exception: - return None + return issue_lock_store.bind_session_lock(data) + except Exception as e: + raise RuntimeError(f"Could not write issue lock file: {e}") from e def _work_lease_claimant(host: str | None) -> dict: @@ -679,6 +1007,132 @@ def _branch_entry_name(branch: dict | str) -> str: return str(branch.get("name") or branch.get("ref") or "") +def _live_fetch_issue_duplicate_context( + h: str, + o: str, + r: str, + auth: str, + issue_number: int, +) -> tuple[list[dict], list[str], dict]: + """Live open PRs, remote branch names, and claim state for one issue.""" + base = repo_api_url(h, o, r) + open_prs = api_get_all(f"{base}/pulls?state=open", auth) + branches = api_get_all(f"{base}/branches", auth) + branch_names = [_branch_entry_name(b) for b in branches] + issue = api_request("GET", f"{base}/issues/{issue_number}", auth) or {} + comments = api_request( + "GET", f"{base}/issues/{issue_number}/comments", auth + ) or [] + claim_entry = issue_claim_heartbeat.classify_issue_claim( + issue=issue, + comments=comments, + open_prs=open_prs, + branch_names=branch_names, + ) + return open_prs, branch_names, claim_entry + + +# Injectable duplicate-work context fetcher (#400). Production uses the live +# Gitea API path above; unit tests patch this symbol instead of hitting the +# network. +issue_duplicate_context_fetcher = _live_fetch_issue_duplicate_context + + +def _collect_issue_duplicate_context( + h: str, + o: str, + r: str, + auth: str, + issue_number: int, +) -> tuple[list[dict], list[str], dict]: + return issue_duplicate_context_fetcher(h, o, r, auth, issue_number) + + +def _assess_issue_duplicate_gate( + issue_number: int, + *, + h: str, + o: str, + r: str, + auth: str, + locked_branch: str | None = None, + phase: str, +) -> dict: + open_prs, branch_names, claim_entry = _collect_issue_duplicate_context( + h, o, r, auth, issue_number + ) + return issue_work_duplicate_gate.assess_work_issue_duplicate_gate( + issue_number, + open_prs=open_prs, + branch_names=branch_names, + claim_entry=claim_entry, + locked_branch=locked_branch, + phase=phase, + ) + + +def _duplicate_gate_block_response(gate: dict, **extra) -> dict: + out = { + "success": False, + "performed": False, + "reasons": list(gate.get("reasons") or []), + "duplicate_gate": gate, + "safe_next_action": gate.get("safe_next_action"), + } + out.update(extra) + return out + + +def _enforce_locked_issue_duplicate_recheck( + remote: str, + phase: str, + *, + host: str | None = None, + org: str | None = None, + repo: str | None = None, +) -> dict | None: + """Re-check duplicate-work gates for the locked issue (#400).""" + lock_data = _load_existing_issue_lock() + if not lock_data: + return None + issue_number = int(lock_data.get("issue_number") or 0) + locked_branch = lock_data.get("branch_name") + if not issue_number: + return None + h, o, r = _resolve( + remote or lock_data.get("remote") or "dadeschools", + host or lock_data.get("host"), + org or lock_data.get("org"), + repo or lock_data.get("repo"), + ) + auth = _auth(h) + gate = _assess_issue_duplicate_gate( + issue_number, + h=h, + o=o, + r=r, + auth=auth, + locked_branch=locked_branch, + phase=phase, + ) + if gate.get("block"): + return gate + return None + + +def _branch_entry_commit_sha(branch: dict | str) -> str | None: + """Best-effort head SHA for a Gitea branch entry (None when absent).""" + if not isinstance(branch, dict): + return None + commit = branch.get("commit") + if isinstance(commit, dict): + sha = commit.get("id") or commit.get("sha") + if sha: + return str(sha) + sha = branch.get("commit_sha") + return str(sha) if sha else None + + def _reveal_endpoints() -> bool: """Admin/debug opt-in (#120): include endpoint URLs and token source names in tool output. Off by default so normal LLM-facing responses @@ -776,11 +1230,49 @@ def _resolve(remote: str, host: str | None, org: str | None, repo: str | None): if remote not in REMOTES: raise ValueError(f"Unknown remote '{remote}'. Choose from: {list(REMOTES)}") profile = REMOTES[remote] - return ( - host or profile["host"], - org or profile["org"], - repo or profile["repo"], + resolved_host = host or profile["host"] + resolved_org = org or profile["org"] + resolved_repo = repo or profile["repo"] + _enforce_remote_repo_guard( + remote, + resolved_org, + resolved_repo, + org_explicit=org is not None, + repo_explicit=repo is not None, ) + return (resolved_host, resolved_org, resolved_repo) + + +def _enforce_remote_repo_guard( + remote: str, + resolved_org: str, + resolved_repo: str, + *, + org_explicit: bool, + repo_explicit: bool, +) -> None: + """Fail closed on a remote/repo mismatch vs. the local git remote (#530). + + Best-effort: bypassed under pytest unless ``GITEA_FORCE_REMOTE_REPO_CHECK`` is + set, so the unit suite (which calls tools with bare remotes against mocked APIs) + is unaffected. In production it protects every read/lookup/mutation tool because + they all resolve targets through :func:`_resolve`. + """ + if "pytest" in sys.modules and not os.environ.get( + "GITEA_FORCE_REMOTE_REPO_CHECK" + ): + return + local_remote_url = _local_git_remote_url(remote) + assessment = remote_repo_guard.assess_remote_repo_match( + remote=remote, + resolved_org=resolved_org, + resolved_repo=resolved_repo, + local_remote_url=local_remote_url, + org_explicit=org_explicit, + repo_explicit=repo_explicit, + ) + if assessment["block"]: + raise RuntimeError(remote_repo_guard.format_remote_repo_guard_error(assessment)) def _auth(host: str) -> str: @@ -1050,7 +1542,7 @@ def gitea_create_issue( ) if blocked: return blocked - verify_preflight_purity(remote, worktree_path=worktree_path) + verify_preflight_purity(remote, worktree_path=worktree_path, task="create_issue") base = repo_api_url(h, o, r) open_issues = api_get_all(f"{base}/issues?state=open&type=issues", auth) closed_issues = api_get_all( @@ -1089,6 +1581,16 @@ def gitea_create_issue( return _with_optional_url({"number": data["number"]}, data.get("html_url")) +def _list_open_pulls(h: str, o: str, r: str, auth: str) -> list[dict]: + """Fetch all OPEN pull requests for a repo (used for stacked-base proof, #484).""" + try: + return api_get_all(f"{repo_api_url(h, o, r)}/pulls?state=open", auth) or [] + except Exception as exc: # fail closed: no proof of an open dependency PR + raise RuntimeError( + f"Could not list open pull requests to verify stacked base: {exc}" + ) + + @mcp.tool() def gitea_lock_issue( issue_number: int, @@ -1098,6 +1600,8 @@ def gitea_lock_issue( org: str | None = None, repo: str | None = None, worktree_path: str | None = None, + stacked_base_branch: str | None = None, + stacked_base_pr: int | None = None, ) -> dict: """Lock exactly one Gitea issue and its branch name to ensure durable tracking. @@ -1110,6 +1614,15 @@ def gitea_lock_issue( repo: Override Repo. worktree_path: Author scratch-clone path to validate (defaults to GITEA_AUTHOR_WORKTREE or the MCP server project root). + stacked_base_branch: Opt-in. Declare a non-master base branch for a + *stacked* PR (a PR based on another unmerged PR's branch). Normal work + leaves this ``None`` and stays master-equivalent. When set, the + worktree may be base-equivalent to this branch instead of + master/main/dev, and the approved base is recorded on the lock (#484). + stacked_base_pr: Required when ``stacked_base_branch`` is set. The number + of the OPEN pull request that owns the stacked base branch. The lock + fails closed unless this open PR exists and owns that branch, so + arbitrary or stale branches cannot be used as stacked bases. """ # 1. Enforce branch name includes issue number expected_pattern = f"issue-{issue_number}" @@ -1126,8 +1639,9 @@ def gitea_lock_issue( resolved_worktree = issue_lock_worktree.resolve_author_worktree_path( worktree_path, PROJECT_ROOT ) - active_lease_block = _active_work_lease_block( - _load_existing_issue_lock(), + h, o, r = _resolve(remote, host, org, repo) + active_lease_block = issue_lock_store.assess_same_issue_lease_conflict( + _load_existing_issue_lock(remote=remote, org=o, repo=r, issue_number=issue_number), issue_number=issue_number, branch_name=branch_name, worktree_path=resolved_worktree, @@ -1136,8 +1650,32 @@ def gitea_lock_issue( if active_lease_block: raise RuntimeError(active_lease_block) - git_state = issue_lock_worktree.read_worktree_git_state(resolved_worktree) - verify_preflight_purity(remote, worktree_path=resolved_worktree) + # ── Stacked-PR base declaration (opt-in, #484) ── + # Normal work leaves stacked_base_branch None → master-equivalent path. + # A declared stacked base must be proven to own an OPEN PR before it can + # anchor base-equivalence; this never bypasses the lock. + stacked_extra_bases: tuple[str, ...] = () + stacked_approved: dict | None = None + if stacked_base_branch: + stacked_assessment = stacked_pr_support.assess_stacked_base_declaration( + stacked_base_branch=stacked_base_branch, + stacked_base_pr=stacked_base_pr, + open_prs=_list_open_pulls(h, o, r, _auth(h)), + ) + if stacked_assessment["block"]: + raise RuntimeError( + "; ".join(stacked_assessment["reasons"]) + " (fail closed)" + ) + stacked_approved = stacked_assessment["approved"] + stacked_extra_bases = (stacked_approved["branch"],) + + if stacked_extra_bases: + git_state = issue_lock_worktree.read_worktree_git_state( + resolved_worktree, extra_bases=stacked_extra_bases + ) + else: + git_state = issue_lock_worktree.read_worktree_git_state(resolved_worktree) + verify_preflight_purity(remote, worktree_path=resolved_worktree, task="lock_issue") lock_assessment = issue_lock_worktree.assess_issue_lock_worktree( worktree_path=resolved_worktree, current_branch=git_state.get("current_branch"), @@ -1151,48 +1689,44 @@ def gitea_lock_issue( issue_lock_worktree.format_issue_lock_worktree_error(lock_assessment) ) - # 2. Check if the issue already has an open PR (reuse protection) - h, o, r = _resolve(remote, host, org, repo) auth = _auth(h) - url = f"{repo_api_url(h, o, r)}/pulls?state=open" - - try: - prs = api_get_all(url, auth) - except Exception as e: - raise RuntimeError(f"Could not list open PRs to verify issue lock: {e}") - - for pr in prs: - pr_head = pr.get("head", {}).get("ref", "") - pr_title = pr.get("title", "") - pr_body = pr.get("body", "") - - if expected_pattern in pr_head: - raise ValueError( - f"Issue #{issue_number} is already tied to an open PR (PR #{pr.get('number')}, branch '{pr_head}') (fail closed)" - ) - - patterns = [ - f"closes #{issue_number}", - f"fixes #{issue_number}", - ] - text_to_check = f"{pr_title} {pr_body}".lower() - if any(p in text_to_check for p in patterns): - raise ValueError( - f"Issue #{issue_number} is already tied to an open PR (PR #{pr.get('number')}) via Closes/Fixes reference (fail closed)" - ) + duplicate_gate = _assess_issue_duplicate_gate( + issue_number, + h=h, + o=o, + r=r, + auth=auth, + locked_branch=branch_name, + phase=issue_work_duplicate_gate.PHASE_LOCK, + ) + if duplicate_gate.get("block"): + raise ValueError("; ".join(duplicate_gate.get("reasons") or [ + f"duplicate work gate blocked issue #{issue_number} (fail closed)" + ])) branch_url = f"{repo_api_url(h, o, r)}/branches" try: branches = api_get_all(branch_url, auth) except Exception as e: raise RuntimeError(f"Could not list branches to verify issue lock: {e}") - for branch in branches: - name = _branch_entry_name(branch) - if expected_pattern in name: - raise ValueError( - f"Issue #{issue_number} already has matching branch '{name}' " - "(fail closed)" - ) + existing_branch_entries = [ + { + "name": _branch_entry_name(branch), + "commit_sha": _branch_entry_commit_sha(branch), + } + for branch in branches + ] + adoption = issue_lock_adoption.assess_own_branch_adoption( + issue_number=issue_number, + requested_branch=branch_name, + existing_branches=existing_branch_entries, + ) + if adoption["block"]: + competing = ", ".join(adoption["competing_branches"]) + raise ValueError( + f"Issue #{issue_number} already has matching branch '{competing}' " + "that is not the requested branch (fail closed)" + ) work_lease = _build_author_issue_work_lease( issue_number=issue_number, @@ -1208,13 +1742,28 @@ def gitea_lock_issue( "repo": r, "worktree_path": resolved_worktree, "work_lease": work_lease, + "lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance( + tool="gitea_lock_issue", + claimant=work_lease.get("claimant"), + ), } + if stacked_approved: + data["approved_stacked_base"] = stacked_approved - try: - with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f: - json.dump(data, f) - except Exception as e: - raise RuntimeError(f"Could not write issue lock file: {e}") + lock_file_path = _save_issue_lock(data) + lock_record = issue_lock_store.read_lock_file(lock_file_path) or data + freshness = issue_lock_store.assess_lock_freshness(lock_record) + competing = [ + entry + for entry in issue_lock_store.list_live_locks() + if entry.get("issue_number") != issue_number + ] + lock_proof = issue_lock_store.format_lock_proof( + lock_record, + freshness=freshness, + competing_live_locks=competing, + released=False, + ) agent_artifacts = agent_temp_artifacts.find_agent_temp_artifacts_from_porcelain( git_state.get("porcelain_status") or "" @@ -1229,7 +1778,39 @@ def gitea_lock_issue( "branch_name": branch_name, "worktree_path": resolved_worktree, "work_lease": work_lease, + "lock_file_path": lock_file_path, + "lock_freshness": freshness, + "lock_proof": lock_proof, } + if stacked_approved: + result["approved_stacked_base"] = stacked_approved + result["message"] = ( + f"Successfully locked issue #{issue_number} to branch '{branch_name}' " + f"as a STACKED PR on base '{stacked_approved['branch']}' " + f"(open PR #{stacked_approved['pr_number']}); fail-closed check complete." + ) + if adoption["adopt"]: + result["adoption"] = issue_lock_adoption.build_adoption_proof( + issue_number=issue_number, + branch_name=branch_name, + assessment=adoption, + open_pr_checked=True, + competing_lock_checked=True, + lock_file_path=lock_file_path, + lock_file_status="written", + ) + result["message"] = ( + f"Adopted existing branch '{branch_name}' and locked issue " + f"#{issue_number} for recovery (fail-closed check complete)." + ) + else: + # #477 AC2: normal (no-adoption) lock responses carry explicit, + # adoption-free proof metadata so they stay clear and cannot be + # misread as claiming a branch was adopted. + result["adoption_check"] = issue_lock_adoption.build_non_adoption_lock_proof( + issue_number=issue_number, + branch_name=branch_name, + ) if agent_artifacts: result["warnings"] = [ "Agent temp artifacts at repo root (delete before implementation): " @@ -1238,6 +1819,39 @@ def gitea_lock_issue( return result +@mcp.tool() +def gitea_assess_work_issue_duplicate( + issue_number: int, + branch_name: str | None = None, + phase: str = issue_work_duplicate_gate.PHASE_LOCK, + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, +) -> dict: + """Read-only duplicate-work gate for author sessions before mutations (#400).""" + read_block = _profile_operation_gate("gitea.read") + if read_block: + return { + "success": False, + "performed": False, + "reasons": read_block, + "permission_report": _permission_block_report("gitea.read"), + } + h, o, r = _resolve(remote, host, org, repo) + auth = _auth(h) + gate = _assess_issue_duplicate_gate( + issue_number, + h=h, + o=o, + r=r, + auth=auth, + locked_branch=branch_name, + phase=phase, + ) + return {"success": not gate.get("block"), **gate} + + @mcp.tool() def gitea_create_pr( title: str, @@ -1285,18 +1899,19 @@ def gitea_create_pr( ) if blocked: return blocked - verify_preflight_purity(remote, worktree_path=worktree_path) + verify_preflight_purity(remote, worktree_path=worktree_path, task="create_pr") h, o, r = _resolve(remote, host, org, repo) - # ── Issue Lock Validation (Issue #194 / #196) ── - if not os.path.exists(ISSUE_LOCK_FILE): - raise RuntimeError("Issue lock is missing (fail closed). Call gitea_lock_issue first.") + # ── Issue Lock Validation (Issue #194 / #196 / #443) ── + lock_data = _resolve_issue_lock_for_pr(remote=remote, org=o, repo=r, head=head) - try: - with open(ISSUE_LOCK_FILE, "r", encoding="utf-8") as f: - lock_data = json.load(f) - except Exception as e: - raise RuntimeError(f"Could not read issue lock file: {e} (fail closed)") + lock_provenance_check = issue_lock_provenance.assess_lock_file_for_create_pr( + lock_data + ) + if lock_provenance_check["block"]: + raise RuntimeError( + issue_lock_provenance.format_lock_provenance_error(lock_provenance_check) + ) locked_issue = lock_data.get("issue_number") locked_branch = lock_data.get("branch_name") @@ -1313,6 +1928,15 @@ def gitea_create_pr( f"PR head branch '{head}' does not match locked branch '{locked_branch}' (fail closed)" ) + ownership = issue_lock_store.verify_lock_for_mutation( + lock_data, + issue_number=locked_issue, + branch_name=head, + worktree_path=worktree_path, + ) + if ownership["block"]: + raise ValueError(ownership["reasons"][0]) + # Check for forbidden terms anywhere in title/body forbidden_terms = ["equivalent", "related", "same as"] text_to_check = f"{title} {body}".lower() @@ -1329,6 +1953,39 @@ def gitea_create_pr( f"PR title or body must contain 'Closes #{locked_issue}' or 'Fixes #{locked_issue}' exactly to ensure durable tracking (fail closed)" ) + # ── Stacked-PR base validation (#484) ── + # Normal base branches (master/main/dev) pass unchanged. A non-base target 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. This never bypasses the lock. + base_open_prs = ( + [] + if stacked_pr_support.is_base_branch(base) + else _list_open_pulls(h, o, r, _auth(h)) + ) + base_check = stacked_pr_support.assess_create_pr_base( + base=base, + approved_stacked_base=lock_data.get("approved_stacked_base"), + body=body, + open_prs=base_open_prs, + ) + if base_check["block"]: + raise ValueError("; ".join(base_check["reasons"]) + " (fail closed)") + + duplicate_block = _enforce_locked_issue_duplicate_recheck( + remote, + issue_work_duplicate_gate.PHASE_CREATE_PR, + host=host, + org=org, + repo=repo, + ) + if duplicate_block: + return _duplicate_gate_block_response( + duplicate_block, + number=None, + issue_number=locked_issue, + branch_name=locked_branch, + ) + auth = _auth(h) url = f"{repo_api_url(h, o, r)}/pulls" payload = {"title": title, "body": body, "head": head, "base": base} @@ -1824,56 +2481,144 @@ _REVIEW_ACTIONS = { _TERMINAL_REVIEW_ACTIONS = frozenset({"approve", "request_changes"}) -# In-process only (#211): never persist to /tmp — host-global files are -# spoofable by any local process and go stale across sessions. +# Durable across MCP daemon process pools (#559), but never under /tmp (#211): +# host-global temp files are spoofable. Persistence uses the user-private +# cache directory from mcp_session_state (mode 0o700 / files 0o600), keyed by +# remote + profile identity with TTL. _REVIEW_DECISION_LOCK: dict | None = None +def _decision_lock_binding(lock: dict | None = None) -> dict: + """Resolve key fields for durable decision-lock storage.""" + profile = get_profile() + profile_name = (profile.get("profile_name") or "").strip() + env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip() + stored_lock = ((lock or {}).get("session_profile_lock") or "").strip() + session_lock = env_lock or stored_lock or profile_name + remote = ((lock or {}).get("remote") or "").strip() or None + org = ((lock or {}).get("org") or (lock or {}).get("ready_org") or "").strip() or None + repo = ((lock or {}).get("repo") or (lock or {}).get("ready_repo") or "").strip() or None + return { + "session_profile": profile_name, + "session_profile_lock": session_lock, + "profile_identity": mcp_session_state.current_profile_identity( + profile_name=profile_name, + session_profile_lock=session_lock, + ), + "remote": remote, + "org": org, + "repo": repo, + } + + def _load_review_decision_lock(): + """Load decision lock from memory, falling back to durable session state.""" global _REVIEW_DECISION_LOCK + if _REVIEW_DECISION_LOCK is not None: + return _REVIEW_DECISION_LOCK + binding = _decision_lock_binding() + # Profile-keyed durable file; remote/org/repo validated from payload (#559). + durable = mcp_session_state.load_state( + kind=mcp_session_state.KIND_DECISION_LOCK, + profile_identity=binding.get("profile_identity"), + ) + if durable is not None: + _REVIEW_DECISION_LOCK = dict(durable) return _REVIEW_DECISION_LOCK def _save_review_decision_lock(data): + """Persist decision lock to memory + durable shared state (#559).""" global _REVIEW_DECISION_LOCK - _REVIEW_DECISION_LOCK = data + if data is None: + binding = _decision_lock_binding(_REVIEW_DECISION_LOCK) + mcp_session_state.clear_state( + kind=mcp_session_state.KIND_DECISION_LOCK, + profile_identity=binding.get("profile_identity"), + ) + _REVIEW_DECISION_LOCK = None + return + payload = dict(data) + binding = _decision_lock_binding(payload) + payload.setdefault("session_pid", os.getpid()) + payload["writer_pid"] = os.getpid() + payload["session_profile"] = binding["session_profile"] or payload.get( + "session_profile" + ) + payload["session_profile_lock"] = binding["session_profile_lock"] + payload["profile_identity"] = binding["profile_identity"] + if binding.get("remote") and not payload.get("remote"): + payload["remote"] = binding["remote"] + persisted = mcp_session_state.save_state( + kind=mcp_session_state.KIND_DECISION_LOCK, + payload=payload, + remote=payload.get("remote"), + org=payload.get("org") or payload.get("ready_org"), + repo=payload.get("repo") or payload.get("ready_repo"), + profile_identity=payload.get("profile_identity"), + ) + _REVIEW_DECISION_LOCK = dict(persisted or payload) def _review_decision_session_reasons(lock: dict | None) -> list[str]: - """Reject locks that do not belong to this MCP process/session.""" + """Reject locks that do not belong to this MCP session identity (#559). + + Different daemon PIDs in the same IDE session pool are allowed when the + profile identity and remote match. Spoofed/stale locks still fail closed. + """ if lock is None: return [] reasons = [] - if lock.get("session_pid") != os.getpid(): - reasons.append( - "review decision lock was created in a different process " - "(fail closed)" - ) env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip() - stored_lock = (lock.get("session_profile_lock") or "").strip() + stored_lock = (lock.get("session_profile_lock") or lock.get("profile_identity") or "").strip() if env_lock and stored_lock and env_lock != stored_lock: reasons.append( "review decision lock session profile lock mismatch (fail closed)" ) + # TTL / identity checks from durable envelope fields. + for reason in mcp_session_state.identity_match_reasons( + lock, + remote=lock.get("remote"), + org=lock.get("org") or lock.get("ready_org"), + repo=lock.get("repo") or lock.get("ready_repo"), + profile_identity=env_lock or stored_lock, + ): + if "profile identity mismatch" in reason or "expired" in reason or "future" in reason or "missing recorded_at" in reason: + reasons.append(reason) return reasons -def init_review_decision_lock(remote: str | None, task: str | None): +def init_review_decision_lock(remote: str | None, task: str | None, force: bool = True): """Seed read-only-until-ready state for reviewer PR review tasks.""" if task != "review_pr": return + if not force: + lock = _load_review_decision_lock() + if lock is not None: + env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip() + stored_lock = (lock.get("session_profile_lock") or "").strip() + same_remote = lock.get("remote") == remote + same_profile = (not env_lock or not stored_lock or env_lock == stored_lock) + if same_remote and same_profile and not _review_decision_session_reasons(lock): + return + review_workflow_load.clear_review_workflow_load() profile = get_profile() profile_name = (profile.get("profile_name") or "").strip() session_lock = ( (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip() or profile_name ) + reviewer_pr_lease.clear_session_lease() _save_review_decision_lock({ "task": task, "remote": remote, "session_pid": os.getpid(), "session_profile": profile_name, "session_profile_lock": session_lock, + "profile_identity": mcp_session_state.current_profile_identity( + profile_name=profile_name, + session_profile_lock=session_lock, + ), "final_review_decision_ready": False, "ready_pr_number": None, "ready_action": None, @@ -1887,6 +2632,11 @@ def init_review_decision_lock(remote: str | None, task: str | None): }) +def _review_workflow_load_gate_reasons() -> list[str]: + """Fail closed when canonical review workflow was not loaded (#389).""" + return review_workflow_load.review_workflow_load_blockers(PROJECT_ROOT) + + def check_review_decision_gate( pr_number: int, action: str, @@ -1897,7 +2647,10 @@ def check_review_decision_gate( repo: str | None = None, ) -> list[str]: """Fail closed unless validation completed and the final decision is ready.""" - reasons = [] + reasons = list(_review_workflow_load_gate_reasons()) + if reasons: + reasons.extend(review_workflow_load.recovery_handoff_without_replay()) + return reasons lock = _load_review_decision_lock() if lock is None: reasons.append( @@ -2216,6 +2969,10 @@ def gitea_get_pr_review_feedback( e for e in latest_by_reviewer.values() if e["verdict"] == "APPROVED" and not e["dismissed"] ] + approval_head = merge_approval_gate.assess_merge_approval_head( + current_head_sha=current_head, + latest_by_reviewer=latest_by_reviewer, + ) return { "success": True, "pr_number": pr_number, @@ -2226,6 +2983,9 @@ def gitea_get_pr_review_feedback( login: e["verdict"] for login, e in latest_by_reviewer.items()}, "has_blocking_change_requests": bool(blocking), "approval_visible": bool(approvals), + "approval_at_current_head": approval_head["approval_at_current_head"], + "latest_approved_head_sha": approval_head["latest_approved_head_sha"], + "stale_approval_block_reason": approval_head["stale_approval_block_reason"], "latest_reviewed_head_sha": latest_reviewed_head, "review_feedback_stale": bool( latest_reviewed_head and current_head @@ -2237,6 +2997,142 @@ def gitea_get_pr_review_feedback( } +def _fetch_pr_lease_comments_safe( + pr_number: int, + *, + remote: str, + host: str | None, + org: str | None, + repo: str | None, + limit: int = 100, + require_open: bool = False, +) -> dict: + """Fetch PR thread comments with structured fail-closed errors (#519).""" + h, o, r = _resolve(remote, host, org, repo) + auth = _auth(h) + resolved_repo = f"{o}/{r}" + pr_url = f"{repo_api_url(h, o, r)}/pulls/{pr_number}" + try: + pr = api_request("GET", pr_url, auth) + except RuntimeError as exc: + return { + "success": False, + "comments": [], + "reasons": [ + "PR lookup failed before conflict-fix push assessment " + f"(pr_number={pr_number}, repo={resolved_repo}, remote={remote}): " + f"{_redact(str(exc))}" + ], + "pr_lookup": "failed", + "resolved_repo": resolved_repo, + "remote": remote, + "pr_number": pr_number, + } + pr_state = (pr.get("state") or "").strip().lower() + if require_open and pr_state != "open": + return { + "success": False, + "comments": [], + "reasons": [ + f"PR #{pr_number} on {resolved_repo} is not open " + f"(state={pr_state or 'unknown'})" + ], + "pr_lookup": "not_open", + "resolved_repo": resolved_repo, + "remote": remote, + "pr_number": pr_number, + "head_sha": (pr.get("head") or {}).get("sha"), + } + api = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments" + try: + comments = api_request("GET", api, auth) + except RuntimeError as exc: + return { + "success": False, + "comments": [], + "reasons": [ + "PR comment fetch failed during conflict-fix push assessment " + f"(pr_number={pr_number}, issue_index={pr_number}, " + f"repo={resolved_repo}, remote={remote}): " + f"{_redact(str(exc))}" + ], + "pr_lookup": "ok", + "resolved_repo": resolved_repo, + "remote": remote, + "pr_number": pr_number, + "head_sha": (pr.get("head") or {}).get("sha"), + } + if not isinstance(comments, list): + comments = [] + return { + "success": True, + "comments": list(comments[:limit]), + "reasons": [], + "pr_lookup": "ok", + "resolved_repo": resolved_repo, + "remote": remote, + "pr_number": pr_number, + "head_sha": (pr.get("head") or {}).get("sha"), + } + + +def _list_pr_lease_comments( + pr_number: int, + *, + remote: str, + host: str | None, + org: str | None, + repo: str | None, + limit: int = 100, +) -> list[dict]: + """Fetch PR/issue thread comments used for reviewer/conflict-fix leases. + + Intentionally does **not** pre-fetch the PR via GET /pulls/{n}. Shared + callers (merge approval feedback, reviewer lease gates) depend on a single + comments GET so mock sequences and fail-open #485 non-list handling stay + stable. Structured PR-lookup failures belong only to + :func:`_fetch_pr_lease_comments_safe` used by conflict-fix push assessment + (#519). + """ + h, o, r = _resolve(remote, host, org, repo) + auth = _auth(h) + api = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments" + comments = api_request("GET", api, auth) + # Fail safe to no lease comments when the API returns a non-list payload + # (e.g. an error object such as an HTTP 401 body): lease state can only be + # proven from real comment entries, never inferred from an error shape (#485). + if not isinstance(comments, list): + return [] + return list(comments[:limit]) + + +def _pr_work_lease_reviewer_block( + *, + pr_number: int, + reviewed_head_sha: str | None, + live_head_sha: str | None, + mutation: str, + remote: str, + host: str | None, + org: str | None, + repo: str | None, +) -> dict: + comments = _list_pr_lease_comments( + pr_number, + remote=remote, + host=host, + org=org, + repo=repo, + ) + return pr_work_lease.assess_reviewer_mutation_blocked( + pr_number=pr_number, + comments=comments, + reviewed_head_sha=reviewed_head_sha, + live_head_sha=live_head_sha, + mutation=mutation, + ) + + def _evaluate_pr_review_submission( pr_number: int, action: str, @@ -2249,10 +3145,14 @@ def _evaluate_pr_review_submission( *, live: bool, final_review_decision_ready: bool = False, + worktree_path: str | None = None, ) -> dict: """Shared gate chain for live submit and dry-run review tools.""" - verify_preflight_purity(remote) + _verify_role_mutation_workspace( + remote, worktree_path=worktree_path, task="review_pr" + ) action = (action or "").strip().lower() + workflow_blockers = _review_workflow_load_gate_reasons() if live else [] result = { "requested_action": action, "performed": False, @@ -2268,6 +3168,10 @@ def _evaluate_pr_review_submission( "reasons": [], } reasons = result["reasons"] + if workflow_blockers: + reasons.extend(workflow_blockers) + reasons.extend(review_workflow_load.recovery_handoff_without_replay()) + return result if action not in _REVIEW_ACTIONS: reasons.append( @@ -2310,6 +3214,20 @@ def _evaluate_pr_review_submission( result["permission_report"] = elig["permission_report"] return result + if live: + reasons.extend(_reviewer_pr_lease_gate( + pr_number=pr_number, + remote=remote, + host=host, + org=org, + repo=repo, + mutation=action, + live_head_sha=result.get("head_sha"), + pinned_head_sha=expected_head_sha, + )) + if reasons: + return result + auth_user = result["authenticated_user"] pr_author = result["pr_author"] if action == "approve" and auth_user and pr_author and auth_user == pr_author: @@ -2321,6 +3239,11 @@ def _evaluate_pr_review_submission( lock = _load_review_decision_lock() or {} if live and lock.get("ready_expected_head_sha"): pinned_sha = lock.get("ready_expected_head_sha") + if live and not pinned_sha: + reasons.append( + "reviewed head SHA required before live review mutation (fail closed, #399)" + ) + return result if pinned_sha and actual_sha and pinned_sha != actual_sha: reasons.append( "expected head SHA does not match current PR head (fail closed)" @@ -2330,6 +3253,21 @@ def _evaluate_pr_review_submission( reasons.append("PR head SHA unavailable (fail closed)") return result + lease_block = _pr_work_lease_reviewer_block( + pr_number=pr_number, + reviewed_head_sha=pinned_sha, + live_head_sha=actual_sha, + mutation=action, + remote=remote, + host=host, + org=org, + repo=repo, + ) + if lease_block.get("block"): + reasons.extend(lease_block.get("reasons") or []) + result["pr_work_lease"] = lease_block + return result + result["would_perform"] = True if not live: reasons.append( @@ -2452,6 +3390,13 @@ def gitea_mark_final_review_decision( } org = resolved_org repo = resolved_repo + workflow_blockers = _review_workflow_load_gate_reasons() + if workflow_blockers: + return { + "marked_ready": False, + "reasons": workflow_blockers + ( + review_workflow_load.recovery_handoff_without_replay()), + } hard_stop = terminal_review_hard_stop_reasons(pr_number, "mark_ready") if hard_stop: return {"marked_ready": False, "reasons": hard_stop} @@ -2472,6 +3417,39 @@ def gitea_mark_final_review_decision( f"{sorted(_REVIEW_ACTIONS)}" ], } + if not (expected_head_sha or "").strip(): + return { + "marked_ready": False, + "reasons": [ + "expected_head_sha required before marking final review " + "decision (fail closed, #399)" + ], + } + elig = gitea_check_pr_eligibility( + pr_number=pr_number, + action="review", + remote=remote, + host=None, + org=org, + repo=repo, + ) + live_head = elig.get("head_sha") + lease_block = _pr_work_lease_reviewer_block( + pr_number=pr_number, + reviewed_head_sha=expected_head_sha, + live_head_sha=live_head, + mutation="mark_ready", + remote=remote, + host=None, + org=org, + repo=repo, + ) + if lease_block.get("block"): + return { + "marked_ready": False, + "reasons": lease_block.get("reasons") or [], + "pr_work_lease": lease_block, + } if action == "request_changes": # Duplicate request-changes suppression (#332): an unresolved # REQUEST_CHANGES at the current head must not be duplicated. @@ -2584,6 +3562,7 @@ def gitea_dry_run_pr_review( host: str | None = None, org: str | None = None, repo: str | None = None, + worktree_path: str | None = None, ) -> dict: """Validate review submission mechanics without a live PR mutation.""" return _evaluate_pr_review_submission( @@ -2596,6 +3575,7 @@ def gitea_dry_run_pr_review( org=org, repo=repo, live=False, + worktree_path=worktree_path, ) @@ -2611,6 +3591,7 @@ def gitea_submit_pr_review( org: str | None = None, repo: str | None = None, final_review_decision_ready: bool = False, + worktree_path: str | None = None, ) -> dict: """Gated PR review mutation: comment findings, request changes, or approve. @@ -2629,6 +3610,7 @@ def gitea_submit_pr_review( repo=repo, live=True, final_review_decision_ready=final_review_decision_ready, + worktree_path=worktree_path, ) @@ -2691,12 +3673,12 @@ def gitea_edit_pr( if not payload: raise ValueError("At least one field to edit (title, body, state, base) must be provided.") - verify_preflight_purity(remote) + closing = payload.get("state") == "closed" + verify_preflight_purity(remote, task="close_pr" if closing else None) # PR closure is a first-class capability, distinct from retitling or # rebasing edits (#216). Gate BEFORE auth/API setup so a blocked close # never touches the network. - closing = payload.get("state") == "closed" if closing: gate_reasons = _profile_operation_gate("gitea.pr.close") if gate_reasons: @@ -2792,13 +3774,7 @@ def _prepare_commit_payload_files(files: list[dict]) -> tuple[list[dict], list[d processed_files = [] source_proofs = [] - lock_data = {} - if os.path.exists(ISSUE_LOCK_FILE): - try: - with open(ISSUE_LOCK_FILE, "r", encoding="utf-8") as f: - lock_data = json.load(f) - except Exception: - pass + lock_data = issue_lock_store.read_session_issue_lock() or {} locked_worktree = lock_data.get("worktree_path") if locked_worktree: @@ -2933,7 +3909,21 @@ def gitea_commit_files( if blocked: return blocked - verify_preflight_purity(remote) + duplicate_block = _enforce_locked_issue_duplicate_recheck( + remote, + issue_work_duplicate_gate.PHASE_COMMIT, + host=host, + org=org, + repo=repo, + ) + if duplicate_block: + return _duplicate_gate_block_response( + duplicate_block, + commit="", + branch="", + ) + + verify_preflight_purity(remote, task="commit_files") processed_files, source_proofs = _prepare_commit_payload_files(files) h, o, r = _resolve(remote, host, org, repo) @@ -2981,6 +3971,7 @@ def gitea_merge_pr( host: str | None = None, org: str | None = None, repo: str | None = None, + worktree_path: str | None = None, ) -> dict: """Gated merge of a Gitea pull request (#16). @@ -3027,6 +4018,10 @@ def gitea_merge_pr( host: Override the Gitea host. org: Override the owner/organization. repo: Override the repository name. + worktree_path: Merger workspace path under ``branches/`` or clean + control checkout; defaults to ``GITEA_MERGER_WORKTREE``, + ``GITEA_ACTIVE_WORKTREE``, or the MCP server process root. Ignores + foreign ``GITEA_AUTHOR_WORKTREE`` bindings (#510). Returns: dict describing the attempt: performed, authenticated user, profile @@ -3034,7 +4029,10 @@ def gitea_merge_pr( reasons/gates passed or blocked, and merge result / merge commit if available. Never secrets. """ - verify_preflight_purity(remote) + _verify_role_mutation_workspace( + remote, worktree_path=worktree_path, task="merge_pr" + ) + workflow_blockers = _review_workflow_load_gate_reasons() do = (do or "").strip().lower() result = { "performed": False, @@ -3052,6 +4050,10 @@ def gitea_merge_pr( "reasons": [], } reasons = result["reasons"] + if workflow_blockers: + reasons.extend(workflow_blockers) + reasons.extend(review_workflow_load.recovery_handoff_without_replay()) + return result # Gate 1 — valid merge method (no API call on a bad method). if do not in _MERGE_METHODS: @@ -3095,8 +4097,40 @@ def gitea_merge_pr( result["permission_report"] = elig["permission_report"] return result - # Gate 4 — head SHA must match if the caller pinned a reviewed SHA. + reasons.extend(_reviewer_pr_lease_gate( + pr_number=pr_number, + remote=remote, + host=host, + org=org, + repo=repo, + mutation="merge", + live_head_sha=result.get("head_sha"), + pinned_head_sha=expected_head_sha, + )) + if reasons: + return result + + # Gate 4 — reviewed head SHA is mandatory and must match live PR head (#399). actual_sha = result["head_sha"] + if not (expected_head_sha or "").strip(): + reasons.append( + "expected_head_sha required before merge (fail closed, #399)" + ) + return result + lease_block = _pr_work_lease_reviewer_block( + pr_number=pr_number, + reviewed_head_sha=expected_head_sha, + live_head_sha=actual_sha, + mutation="merge", + remote=remote, + host=host, + org=org, + repo=repo, + ) + if lease_block.get("block"): + reasons.extend(lease_block.get("reasons") or []) + result["pr_work_lease"] = lease_block + return result if expected_head_sha and actual_sha and expected_head_sha != actual_sha: reasons.append( "expected head SHA does not match current PR head (fail closed)" @@ -3151,6 +4185,9 @@ def gitea_merge_pr( result["permission_report"] = feedback["permission_report"] return result result["approval_visible"] = feedback.get("approval_visible") + result["approval_at_current_head"] = feedback.get("approval_at_current_head") + result["latest_approved_head_sha"] = feedback.get("latest_approved_head_sha") + result["review_feedback_stale"] = feedback.get("review_feedback_stale") result["has_blocking_change_requests"] = feedback.get( "has_blocking_change_requests") if feedback.get("has_blocking_change_requests"): @@ -3164,6 +4201,16 @@ def gitea_merge_pr( "completed before merge (fail closed)" ) return result + if not feedback.get("approval_at_current_head"): + reasons.append( + feedback.get("stale_approval_block_reason") + or ( + "approval does not apply to current PR head SHA " + "(fail closed); required next action: re-review PR at " + "current head before merge" + ) + ) + return result # Gate 8 — in-process mutation authority (#199): the last check before # the merge mutation, using the identity the eligibility gate proved. @@ -3518,7 +4565,19 @@ def gitea_delete_branch( "permission_report": _permission_block_report("gitea.branch.delete"), } - verify_preflight_purity(remote) + audit_allowed, audit_reasons = ( + audit_reconciliation_mode.check_audit_mutation_allowed("delete_branch") + ) + if not audit_allowed: + return { + "success": False, + "performed": False, + "required_permission": "gitea.branch.delete", + "reasons": audit_reasons, + "audit_phase": audit_reconciliation_mode.current_phase(), + } + + verify_preflight_purity(remote, task="delete_branch") h, o, r = _resolve(remote, host, org, repo) auth = _auth(h) import urllib.parse @@ -3549,6 +4608,59 @@ def _remote_branch_exists(h: str, o: str, r: str, auth: str, branch: str) -> boo raise +@mcp.tool() +def gitea_capture_branches_worktree_snapshot( + open_pr_branches: list[str] | None = None, + active_lock_branch: str | None = None, + leased_paths: list[str] | None = None, + worktree_path: str | None = None, +) -> dict: + """Read-only: capture ``branches/`` and worktree audit snapshot (#404).""" + read_block = _profile_operation_gate("gitea.read") + if read_block: + return { + "success": False, + "reasons": read_block, + "permission_report": _permission_block_report("gitea.read"), + } + root = PROJECT_ROOT + if worktree_path: + root = os.path.realpath(os.path.abspath(worktree_path)) + git_root = _get_git_root(root) + if git_root: + root = git_root + snapshot = worktree_cleanup_audit.capture_branches_worktree_snapshot( + root, + open_pr_branches=open_pr_branches, + active_lock_branch=active_lock_branch, + leased_paths=leased_paths, + ) + return {"success": True, "snapshot": snapshot} + + +@mcp.tool() +def gitea_assess_worktree_cleanup_integrity( + before_snapshot: dict, + after_snapshot: dict, + removals: list[dict] | None = None, + explained_missing: dict[str, str] | None = None, +) -> dict: + """Read-only: reconcile cleanup before/after snapshots (#404).""" + read_block = _profile_operation_gate("gitea.read") + if read_block: + return { + "integrity_passed": False, + "reasons": read_block, + "permission_report": _permission_block_report("gitea.read"), + } + return worktree_cleanup_audit.assess_worktree_cleanup_integrity( + before=before_snapshot, + after=after_snapshot, + removals=removals, + explained_missing=explained_missing, + ) + + @mcp.tool() def gitea_reconcile_merged_cleanups( dry_run: bool = True, @@ -3587,6 +4699,18 @@ def gitea_reconcile_merged_cleanups( "execute_confirmed must be True when dry_run=False (fail closed)" ) + if not dry_run: + exec_allowed, exec_reasons = ( + audit_reconciliation_mode.check_cleanup_execution_allowed() + ) + if not exec_allowed: + return { + "success": False, + "performed": False, + "reasons": exec_reasons, + "audit_phase": audit_reconciliation_mode.current_phase(), + } + h, o, r = _resolve(remote, host, org, repo) auth = _auth(h) base = repo_api_url(h, o, r) @@ -3627,7 +4751,7 @@ def gitea_reconcile_merged_cleanups( report["executed"] = False return {"success": True, "performed": False, **report} - verify_preflight_purity(remote) + verify_preflight_purity(remote, task="reconcile_merged_cleanups") actions: list[dict] = [] for entry in report.get("entries") or []: head_branch = entry.get("head_branch") or "" @@ -3669,6 +4793,53 @@ def gitea_reconcile_merged_cleanups( return {"success": True, "performed": True, **report} +@mcp.tool() +def gitea_authorize_reconciliation_cleanup_phase( + operator_approved: bool = False, + workflow_authorized: bool = False, + delete_capability_proven: bool = False, + safe_to_delete_remote: bool = False, + safe_to_remove_worktree: bool = False, + before_state: str = "", + after_state: str = "", +) -> dict: + """Authorize cleanup phase after audit-only reconciliation (#419). + + Requires operator or workflow approval, exact delete_branch capability proof, + branch/worktree safety proof, and before/after state snapshots. Audit phase + forbids branch deletion, worktree removal, pushes, and issue/PR mutations. + """ + delete_gate = _profile_operation_gate("gitea.branch.delete") + capability_ok = not bool(delete_gate) + if delete_capability_proven and delete_gate: + return { + "authorized": False, + "performed": False, + "delete_capability_verified": False, + "reasons": [ + "delete_capability_proven=true but active profile lacks " + "gitea.branch.delete", + ] + delete_gate, + "audit_phase": audit_reconciliation_mode.current_phase(), + } + result = audit_reconciliation_mode.authorize_cleanup_phase( + operator_approved=operator_approved, + workflow_authorized=workflow_authorized, + delete_capability_proven=delete_capability_proven and capability_ok, + safety_proof={ + "safe_to_delete_remote": safe_to_delete_remote, + "safe_to_remove_worktree": safe_to_remove_worktree, + }, + before_after_snapshot={ + "before": (before_state or "").strip(), + "after": (after_state or "").strip(), + }, + ) + result["performed"] = bool(result.get("authorized")) + result["delete_capability_verified"] = capability_ok + return result + + @mcp.tool() def gitea_assess_already_landed_reconciliation( pr_number: int, @@ -3841,6 +5012,90 @@ def gitea_scan_already_landed_open_prs( } +@mcp.tool() +def gitea_audit_worktree_cleanup( + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, + ttl_hours: float = worktree_cleanup_audit.DEFAULT_TTL_HOURS, +) -> dict: + """Read-only: classify every session-owned worktree under ``branches/`` (#401). + + Audits the local ``branches/`` directory, classifying each worktree as + active open PR, active issue work, dirty local, clean stale removable, + detached review leftover, or unsafe/unknown. Open PR branch heads are + fetched live so a worktree tied to an open PR is never marked removable; + the active issue-lock branch is read from the local lock file and treated + as active work. Deletes nothing and mutates no Gitea state. + + Fails closed if the live open-PR list cannot be fetched: without it, + removability cannot be proven, so no candidates are returned. + + Args: + remote: Known instance — 'dadeschools' or 'prgs'. + host: Override the Gitea host. + org: Override the owner/organization. + repo: Override the repository name. + ttl_hours: Age (hours) after which a clean issue/conflict-fix + worktree becomes stale-removable (default from + GITEA_WORKTREE_TTL_HOURS). + + Returns: + dict with per-worktree classifications, counts, removable + candidates, and the ``git worktree list`` verification proof. + """ + read_block = _profile_operation_gate("gitea.read") + if read_block: + return { + "success": False, + "performed": False, + "reasons": read_block, + "permission_report": _permission_block_report("gitea.read"), + } + + try: + h, o, r = _resolve(remote, host, org, repo) + auth = _auth(h) + open_prs = api_get_all(f"{repo_api_url(h, o, r)}/pulls?state=open", auth) + except Exception as exc: + return { + "success": False, + "performed": False, + "open_pr_state_verified": False, + "reasons": [ + "could not fetch live open PRs; removability unverified " + f"(fail closed): {_redact(str(exc))}" + ], + } + + open_pr_branches = { + str((pr.get("head") or {}).get("ref")) + for pr in open_prs + if (pr.get("head") or {}).get("ref") + } + + active_issue_branches: set[str] = set() + lock = merged_cleanup_reconcile.read_issue_lock(ISSUE_LOCK_FILE) + if lock and lock.get("branch_name"): + active_issue_branches.add(str(lock["branch_name"]).strip()) + + report = worktree_cleanup_audit.audit_branches_directory( + PROJECT_ROOT, + open_pr_branches=open_pr_branches, + active_issue_branches=active_issue_branches, + now=datetime.now(timezone.utc), + ttl_hours=ttl_hours, + ) + return { + "success": True, + "performed": False, + "open_pr_state_verified": True, + "task_mode": "work-issue", + **report, + } + + @mcp.tool() def gitea_reconcile_already_landed_pr( pr_number: int, @@ -3941,7 +5196,7 @@ def gitea_reconcile_already_landed_pr( ) return result - verify_preflight_purity(remote) + verify_preflight_purity(remote, task="reconcile_already_landed_pr") if post_comment and comment_body.strip(): comment_block = _profile_operation_gate("gitea.pr.comment") @@ -4044,7 +5299,7 @@ def gitea_close_issue( task_capability_map.required_permission("close_issue")) if blocked: return blocked - verify_preflight_purity(remote) + verify_preflight_purity(remote, task="close_issue") h, o, r = _resolve(remote, host, org, repo) auth = _auth(h) url = f"{repo_api_url(h, o, r)}/issues/{issue_number}" @@ -4394,6 +5649,686 @@ def _namespace_mutation_block(mutation_task: str, **extra_fields) -> dict | None return blocked +def _fetch_pr_comments( + pr_number: int, + *, + remote: str, + host: str | None, + org: str | None, + repo: str | None, +) -> list[dict]: + h, o, r = _resolve(remote, host, org, repo) + auth = _auth(h) + api = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments" + return api_request("GET", api, auth) or [] + + +def _reviewer_pr_lease_gate( + *, + pr_number: int, + remote: str, + host: str | None, + org: str | None, + repo: str | None, + mutation: str, + live_head_sha: str | None, + pinned_head_sha: str | None, +) -> list[str]: + """Return block reasons when the session lacks an owned PR reviewer lease.""" + session = reviewer_pr_lease.get_session_lease() + session_id = (session or {}).get("session_id") + identity = _authenticated_username(remote) or "" + try: + comments = _fetch_pr_comments( + pr_number, remote=remote, host=host, org=org, repo=repo) + except Exception as exc: + return [f"cannot fetch PR comments for lease gate: {_redact(str(exc))}"] + assessment = reviewer_pr_lease.assess_mutation_lease_gate( + pr_number=pr_number, + comments=comments, + reviewer_identity=identity, + session_id=session_id, + mutation=mutation, + live_head_sha=live_head_sha, + pinned_head_sha=pinned_head_sha, + ) + return list(assessment.get("reasons") or []) if assessment.get("block") else [] + + +@mcp.tool() +def gitea_acquire_reviewer_pr_lease( + pr_number: int, + worktree: str, + candidate_head: str | None = None, + target_branch: str = "master", + target_branch_sha: str | None = None, + issue_number: int | None = None, + session_id: str | None = None, + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, +) -> dict: + """Acquire a per-PR reviewer lease before review/merge mutations (#407).""" + read_block = _profile_operation_gate("gitea.read") + if read_block: + return { + "success": False, + "acquired": False, + "reasons": read_block, + "permission_report": _permission_block_report("gitea.read"), + } + comment_block = _profile_operation_gate("gitea.pr.comment") + if comment_block: + return { + "success": False, + "acquired": False, + "reasons": comment_block, + "permission_report": _permission_block_report("gitea.pr.comment"), + } + + _verify_role_mutation_workspace(remote, worktree=worktree, task="review_pr") + h, o, r = _resolve(remote, host, org, repo) + auth = _auth(h) + profile = get_profile() + identity = _authenticated_username(remote) or profile.get("username") or "" + sid = (session_id or "").strip() or reviewer_pr_lease.new_session_id() + repo_label = f"{o}/{r}" + + comments = _fetch_pr_comments( + pr_number, remote=remote, host=host, org=org, repo=repo) + # Refuse merge-oriented lease acquisition/adoption on an already-merged or + # closed PR: the lease is moot and adopting it for merge work is unsafe (#515). + pr_live = api_request( + "GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth) or {} + pr_merged_or_closed = bool( + pr_live.get("merged") or pr_live.get("merged_at") + ) or (str(pr_live.get("state") or "").strip().lower() == "closed") + assessment = reviewer_pr_lease.assess_acquire_lease( + comments, + pr_number=pr_number, + reviewer_identity=identity, + profile=profile.get("profile_name") or "unknown", + session_id=sid, + repo=repo_label, + issue_number=issue_number, + worktree=worktree, + candidate_head=candidate_head, + target_branch=target_branch, + target_branch_sha=target_branch_sha, + pr_merged_or_closed=pr_merged_or_closed, + ) + if not assessment.get("acquire_allowed"): + return { + "success": False, + "acquired": False, + "reasons": assessment.get("reasons") or [], + "existing_lease": assessment.get("existing_lease"), + "post_merge_moot": assessment.get("post_merge_moot", False), + } + + body = assessment["lease_body"] + comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments" + with _audited( + "comment_pr", + host=h, + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + request_metadata={"source": "acquire_reviewer_pr_lease"}, + ): + posted = api_request("POST", comment_url, auth, {"body": body}) + + session_lease = reviewer_pr_lease.record_session_lease({ + "pr_number": pr_number, + "issue_number": issue_number, + "session_id": sid, + "reviewer_identity": identity, + "profile": profile.get("profile_name"), + "worktree": worktree, + "phase": "claimed", + "candidate_head": candidate_head, + "target_branch": target_branch, + "target_branch_sha": target_branch_sha, + "repo": repo_label, + "comment_id": posted.get("id"), + }, lease_provenance=merger_lease_adoption.build_lease_provenance( + source=merger_lease_adoption.SOURCE_ACQUIRE, + comment_id=posted.get("id"), + )) + return { + "success": True, + "acquired": True, + "pr_number": pr_number, + "session_id": sid, + "comment_id": posted.get("id"), + "session_lease": session_lease, + "reasons": [], + } + + +@mcp.tool() +def gitea_adopt_merger_pr_lease( + pr_number: int, + worktree: str, + expected_head_sha: str, + issue_number: int | None = None, + session_id: str | None = None, + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, +) -> dict: + """Adopt an active reviewer PR lease for a merger session (#536). + + Merger sessions must not manually seed ``reviewer_pr_lease._SESSION_LEASE``. + This tool posts durable adoption proof on the PR thread and records + sanctioned in-session lease provenance so ``gitea_merge_pr`` can proceed + after a separate reviewer session held the lease. + + Args: + pr_number: Open PR to adopt. + worktree: Merger workspace under ``branches/``. + expected_head_sha: Approved head the merger will pin during merge. + issue_number: Optional linked issue for the adoption record. + session_id: Optional merger session id (generated when omitted). + remote: Known instance — 'dadeschools' or 'prgs'. + host: Override the Gitea host. + org: Override the owner/organization. + repo: Override the repository name. + + Returns: + dict with adoption proof, session lease, and block reasons when the + guarded preconditions are not met. + """ + read_block = _profile_operation_gate("gitea.read") + if read_block: + return { + "success": False, + "adopted": False, + "reasons": read_block, + "permission_report": _permission_block_report("gitea.read"), + } + comment_block = _profile_operation_gate("gitea.pr.comment") + if comment_block: + return { + "success": False, + "adopted": False, + "reasons": comment_block, + "permission_report": _permission_block_report("gitea.pr.comment"), + } + merge_block = _profile_operation_gate("gitea.pr.merge") + if merge_block: + return { + "success": False, + "adopted": False, + "reasons": merge_block, + "permission_report": _permission_block_report("gitea.pr.merge"), + } + + _verify_role_mutation_workspace( + remote, worktree=worktree, task="adopt_merger_pr_lease" + ) + h, o, r = _resolve(remote, host, org, repo) + auth = _auth(h) + profile = get_profile() + profile_name = profile.get("profile_name") or "unknown" + identity = _authenticated_username(remote) or profile.get("username") or "" + sid = (session_id or "").strip() or reviewer_pr_lease.new_session_id() + repo_label = f"{o}/{r}" + + pr_live = api_request( + "GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth) or {} + pr_state = (pr_live.get("state") or "").strip().lower() + pr_open = pr_state == "open" and not pr_live.get("merged") + live_head = (pr_live.get("head", {}) or {}).get("sha") or pr_live.get( + "head_sha" + ) + + feedback = gitea_get_pr_review_feedback( + pr_number=pr_number, + remote=remote, + host=host, + org=org, + repo=repo, + ) + approval_at_head = bool(feedback.get("approval_at_current_head")) + + comments = _fetch_pr_comments( + pr_number, remote=remote, host=host, org=org, repo=repo) + assessment = merger_lease_adoption.assess_adopt_merger_lease( + comments, + pr_number=pr_number, + adopter_identity=identity, + adopter_profile=profile_name, + adopter_session_id=sid, + repo=repo_label, + issue_number=issue_number, + worktree=worktree, + expected_head_sha=expected_head_sha, + live_head_sha=live_head, + approval_at_head=approval_at_head, + pr_open=pr_open, + ) + if not assessment.get("adopt_allowed"): + return { + "success": False, + "adopted": False, + "pr_number": pr_number, + "reasons": assessment.get("reasons") or [], + "active_lease": assessment.get("active_lease"), + "expected_head_sha": expected_head_sha, + "live_head_sha": live_head, + } + + active = assessment.get("active_lease") or {} + body = assessment["adoption_body"] + comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments" + with _audited( + "comment_pr", + host=h, + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + request_metadata={"source": "adopt_merger_pr_lease"}, + ): + posted = api_request("POST", comment_url, auth, {"body": body}) + + provenance = merger_lease_adoption.build_lease_provenance( + source=merger_lease_adoption.SOURCE_ADOPT, + comment_id=posted.get("id"), + adopted_from_session_id=active.get("session_id"), + adopted_from_profile=active.get("profile"), + adopted_from_reviewer_identity=active.get("reviewer_identity"), + adoption_reason=merger_lease_adoption.DEFAULT_ADOPTION_REASON, + ) + session_lease = reviewer_pr_lease.record_session_lease({ + "pr_number": pr_number, + "issue_number": issue_number or active.get("issue_number"), + "session_id": sid, + "reviewer_identity": identity, + "profile": profile_name, + "worktree": worktree, + "phase": "adopted", + "candidate_head": live_head, + "target_branch": active.get("target_branch") or "master", + "target_branch_sha": active.get("target_branch_sha"), + "repo": repo_label, + "comment_id": posted.get("id"), + }, lease_provenance=provenance) + + return { + "success": True, + "adopted": True, + "pr_number": pr_number, + "session_id": sid, + "adoption_comment_id": posted.get("id"), + "adopted_from_session_id": active.get("session_id"), + "adopted_from_profile": active.get("profile"), + "adopted_from_reviewer_identity": active.get("reviewer_identity"), + "adoption_reason": merger_lease_adoption.DEFAULT_ADOPTION_REASON, + "expected_head_sha": expected_head_sha, + "live_head_sha": live_head, + "session_lease": session_lease, + "lease_provenance": provenance, + "reasons": [], + } + + +@mcp.tool() +def gitea_heartbeat_reviewer_pr_lease( + pr_number: int, + phase: str, + worktree: str | None = None, + candidate_head: str | None = None, + target_branch_sha: str | None = None, + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, +) -> dict: + """Post a reviewer lease heartbeat / phase update on the PR thread (#407).""" + comment_block = _profile_operation_gate("gitea.pr.comment") + if comment_block: + return { + "success": False, + "posted": False, + "reasons": comment_block, + "permission_report": _permission_block_report("gitea.pr.comment"), + } + session = reviewer_pr_lease.get_session_lease() + if not session or session.get("pr_number") != pr_number: + return { + "success": False, + "posted": False, + "reasons": [ + f"no in-session lease for PR #{pr_number}; acquire first " + "(fail closed)" + ], + } + + verify_preflight_purity(remote, task="review_pr") + h, o, r = _resolve(remote, host, org, repo) + auth = _auth(h) + body = reviewer_pr_lease.format_lease_body( + repo=f"{o}/{r}", + pr_number=pr_number, + issue_number=session.get("issue_number"), + reviewer_identity=session.get("reviewer_identity") or "", + profile=session.get("profile") or "unknown", + session_id=session.get("session_id") or reviewer_pr_lease.new_session_id(), + worktree=worktree or session.get("worktree") or "", + phase=phase, + candidate_head=candidate_head or session.get("candidate_head"), + target_branch=session.get("target_branch") or "master", + target_branch_sha=target_branch_sha or session.get("target_branch_sha"), + ) + comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments" + with _audited( + "comment_pr", + host=h, + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + request_metadata={"source": "heartbeat_reviewer_pr_lease", "phase": phase}, + ): + posted = api_request("POST", comment_url, auth, {"body": body}) + + prior_provenance = (session.get("lease_provenance") or {}).copy() + heartbeat_provenance = merger_lease_adoption.build_lease_provenance( + source=merger_lease_adoption.SOURCE_HEARTBEAT, + comment_id=posted.get("id"), + adopted_from_session_id=prior_provenance.get("adopted_from_session_id"), + adopted_from_profile=prior_provenance.get("adopted_from_profile"), + adopted_from_reviewer_identity=prior_provenance.get( + "adopted_from_reviewer_identity" + ), + adoption_reason=prior_provenance.get("adoption_reason"), + ) + updated = reviewer_pr_lease.record_session_lease({ + **session, + "phase": phase, + "worktree": worktree or session.get("worktree"), + "candidate_head": candidate_head or session.get("candidate_head"), + "target_branch_sha": target_branch_sha or session.get("target_branch_sha"), + "last_comment_id": posted.get("id"), + "comment_id": posted.get("id"), + }, lease_provenance=heartbeat_provenance) + return { + "success": True, + "posted": True, + "pr_number": pr_number, + "phase": phase, + "comment_id": posted.get("id"), + "session_lease": updated, + "reasons": [], + } + + +@mcp.tool() +def gitea_assess_reviewer_pr_lease( + pr_number: int, + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, +) -> dict: + """Read-only: assess active reviewer lease state for a PR (#407).""" + read_block = _profile_operation_gate("gitea.read") + if read_block: + return { + "success": False, + "reasons": read_block, + "permission_report": _permission_block_report("gitea.read"), + } + comments = _fetch_pr_comments( + pr_number, remote=remote, host=host, org=org, repo=repo) + active = reviewer_pr_lease.find_active_reviewer_lease( + comments, pr_number=pr_number) + return { + "success": True, + "pr_number": pr_number, + "active_lease": active, + "session_lease": reviewer_pr_lease.get_session_lease(), + "reasons": [], + } + + +@mcp.tool() +def gitea_cleanup_post_merge_moot_lease( + pr_number: int, + apply: bool = False, + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, +) -> dict: + """Safely resolve a moot reviewer lease left on an ALREADY-MERGED/closed PR (#515). + + Read-first and fail-safe. This tool never merges and never adopts a lease. + It only acts when the live PR state is merged/closed, and it never steals or + force-cleans an active *foreign* lease while the PR is still open. When + ``apply`` is true and a lease is still active on a merged/closed PR, it posts + a terminal ``phase: released`` lease marker (``blocker: post-merge-moot``) — + an append-only comment that neutralises the moot lease without deleting any + other session's comment. + + Args: + pr_number: The PR whose lingering lease to assess/clean. + apply: When false (default) report only (read-only). When true, post the + terminal released marker if — and only if — cleanup is allowed. + remote: Known instance — 'dadeschools' or 'prgs'. + host: Override the Gitea host. + org: Override the owner/organization. + repo: Override the repository name. + + Returns: + dict reporting PR merged/closed state, merge_commit_sha, linked-issue + closure state, whether the lease is moot, whether cleanup was performed + or skipped (and why), and ``no_merge_or_adoption`` True — this path never + merges or adopts. + """ + read_block = _profile_operation_gate("gitea.read") + if read_block: + return { + "success": False, + "cleanup_performed": False, + "no_merge_or_adoption": True, + "reasons": read_block, + "permission_report": _permission_block_report("gitea.read"), + } + h, o, r = _resolve(remote, host, org, repo) + auth = _auth(h) + pr_live = api_request( + "GET", f"{repo_api_url(h, o, r)}/pulls/{pr_number}", auth) or {} + comments = _fetch_pr_comments( + pr_number, remote=remote, host=host, org=org, repo=repo) + assessment = reviewer_pr_lease.assess_post_merge_moot_lease( + comments, + pr_number=pr_number, + pr_merged=bool(pr_live.get("merged") or pr_live.get("merged_at")), + pr_state=pr_live.get("state"), + merge_commit_sha=pr_live.get("merge_commit_sha"), + ) + + # Best-effort linked-issue closure state for the report. + active = assessment.get("active_lease") or {} + issue_no = active.get("issue_number") + linked_issue_state = None + if issue_no: + try: + issue = api_request( + "GET", f"{repo_api_url(h, o, r)}/issues/{issue_no}", auth) or {} + linked_issue_state = issue.get("state") + except Exception: + linked_issue_state = None + + report = { + "success": True, + "pr_number": pr_number, + "pr_state": assessment.get("pr_state"), + "pr_merged_or_closed": assessment.get("pr_merged_or_closed"), + "merge_commit_sha": assessment.get("merge_commit_sha"), + "linked_issue_number": issue_no, + "linked_issue_state": linked_issue_state, + "lease_moot": assessment.get("is_moot"), + "active_lease": assessment.get("active_lease"), + "cleanup_allowed": assessment.get("cleanup_allowed"), + "cleanup_performed": False, + "no_merge_or_adoption": True, + "mode": "apply" if apply else "read_only", + "reasons": assessment.get("reasons") or [], + } + + if not apply: + return report + if not assessment.get("cleanup_allowed"): + report["cleanup_skipped_reason"] = ( + assessment.get("reasons") or ["cleanup not allowed"] + ) + return report + + comment_block = _profile_operation_gate("gitea.pr.comment") + if comment_block: + report["success"] = False + report["reasons"] = comment_block + report["permission_report"] = _permission_block_report("gitea.pr.comment") + return report + + verify_preflight_purity(remote) + body = assessment["release_body"] + comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments" + with _audited( + "comment_pr", + host=h, + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + request_metadata={"source": "cleanup_post_merge_moot_lease"}, + ): + posted = api_request("POST", comment_url, auth, {"body": body}) + + report["cleanup_performed"] = True + report["released_comment_id"] = posted.get("id") + return report + + +@mcp.tool() +def gitea_release_reviewer_pr_lease( + pr_number: int, + worktree: str | None = None, + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, +) -> dict: + """Safely release an active reviewer lease owned by the current session on an open PR. + + This provides a canonical way to clean up reviewer leases when a review fails + before submission. + + Args: + pr_number: The PR number whose reviewer lease to release. + worktree: Optional worktree path (resolves automatically if not supplied). + remote: Known instance — 'dadeschools' or 'prgs'. + host: Override the Gitea host. + org: Override the owner/organization. + repo: Override the repository name. + + Returns: + dict reporting release status. + """ + _verify_role_mutation_workspace( + remote, worktree=worktree, task="review_pr" + ) + h, o, r = _resolve(remote, host, org, repo) + auth = _auth(h) + + comments = _fetch_pr_comments( + pr_number, remote=remote, host=host, org=org, repo=repo + ) + active = reviewer_pr_lease.find_active_reviewer_lease( + comments, pr_number=pr_number + ) + + if not active: + return { + "success": True, + "released": False, + "reasons": ["no active reviewer lease found on PR"], + } + + identity = _authenticated_username(remote) or "" + session = reviewer_pr_lease.get_session_lease() or {} + session_id = session.get("session_id") + + owner_identity = active.get("reviewer_identity") + owner_session_id = active.get("session_id") + + # Authorize release: identity matches, session_id matches, or lease is expired/reclaimable + authorized = False + reasons = [] + if owner_identity and identity and owner_identity == identity: + authorized = True + elif owner_session_id and session_id and owner_session_id == session_id: + authorized = True + else: + freshness = reviewer_pr_lease.classify_lease_freshness(active) + if freshness in {"expired", "reclaimable"}: + authorized = True + + if not authorized: + return { + "success": False, + "released": False, + "reasons": [ + f"unauthorized to release active lease: owned by {owner_identity} " + f"(session {owner_session_id})" + ], + } + + # Format and post release comment + body = reviewer_pr_lease.format_lease_body( + repo=active.get("repo") or f"{o}/{r}", + pr_number=pr_number, + issue_number=active.get("issue_number"), + reviewer_identity=owner_identity or identity, + profile=active.get("profile") or "reviewer", + session_id=owner_session_id or session_id or "unknown", + worktree=active.get("worktree") or "", + phase="released", + candidate_head=active.get("candidate_head"), + target_branch=active.get("target_branch") or "master", + target_branch_sha=active.get("target_branch_sha"), + blocker="manual-release", + ) + + comment_url = f"{repo_api_url(h, o, r)}/issues/{pr_number}/comments" + with _audited( + "comment_pr", + host=h, + remote=remote, + org=o, + repo=r, + pr_number=pr_number, + request_metadata={"source": "release_reviewer_pr_lease"}, + ): + posted = api_request("POST", comment_url, auth, {"body": body}) + + reviewer_pr_lease.clear_session_lease() + + return { + "success": True, + "released": True, + "comment_id": posted.get("id"), + "reasons": [], + } + + @mcp.tool() def gitea_list_issue_comments( issue_number: int, @@ -4435,7 +6370,12 @@ def gitea_list_issue_comments( h, o, r = _resolve(remote, host, org, repo) auth = _auth(h) api = f"{repo_api_url(h, o, r)}/issues/{issue_number}/comments" - comments = api_request("GET", api, auth) or [] + comments = api_request("GET", api, auth) + # Fail safe to no comments when the API returns a non-list payload (e.g. an + # error object such as an HTTP 401 body) so listing never crashes on a + # malformed response (#485). + if not isinstance(comments, list): + comments = [] reveal = _reveal_endpoints() out = [] for c in comments[:limit]: @@ -4460,6 +6400,7 @@ def gitea_create_issue_comment( host: str | None = None, org: str | None = None, repo: str | None = None, + worktree_path: str | None = None, ) -> dict: """Post a markdown comment to a Gitea issue's discussion thread. @@ -4481,6 +6422,7 @@ def gitea_create_issue_comment( host: Override the Gitea host. org: Override the owner/organization. repo: Override the repository name. + worktree_path: Optional path to verify branches-only guard. Returns: dict with 'success', 'comment_id', and 'issue_number' ('url' only @@ -4489,7 +6431,7 @@ def gitea_create_issue_comment( (permission blocks also carry a structured 'permission_report', #142). """ - verify_preflight_purity(remote) + verify_preflight_purity(remote, worktree_path=worktree_path, task="comment_issue") gate_reasons = _profile_operation_gate("gitea.issue.comment") reasons = list(gate_reasons) if not (body or "").strip(): @@ -4788,6 +6730,8 @@ _PROJECT_SKILLS = { "steps": [ "Resolve task first: gitea_resolve_task_capability(task='review_pr') " "to confirm reviewer namespace and avoid author-profile blocks.", + "Load canonical workflow proof with gitea_load_review_workflow " + "before any review/merge mutation (#389).", "Verify reviewer identity with gitea_whoami; the PR author " "must be a different user.", "Reconcile live queue state FIRST (do not trust prior handoffs): " @@ -5711,6 +7655,8 @@ def gitea_get_runtime_context( ), "role_kind": _role_kind(allowed, forbidden), "shell_health": native_mcp_preference.shell_health_status(), + "workflow_load_proof": review_workflow_load.workflow_load_status( + PROJECT_ROOT), } if reveal and h: @@ -5719,6 +7665,80 @@ def gitea_get_runtime_context( return result +@mcp.tool() +def gitea_record_pre_review_command( + command: str, + cwd: str | None = None, + classification: str | None = None, +) -> dict: + """Classify and record a command executed before workflow load (#403). + + Read-only with respect to Gitea API. Pre-review inventory/diagnostic commands + may be recorded as allowed; boundary violations block reviewer mutations. + """ + recorded = review_workflow_boundary.record_pre_review_command( + command, + cwd=cwd, + project_root=PROJECT_ROOT, + classification=classification, + ) + boundary_state = review_workflow_boundary.assess_boundary_status(PROJECT_ROOT) + return { + "success": True, + "recorded": recorded, + "boundary_status": boundary_state.get("boundary_status"), + "boundary_clean": boundary_state.get("boundary_clean"), + "reasons": list(boundary_state.get("reasons") or []), + } + + +@mcp.tool() +def gitea_load_review_workflow( + prompt_text: str | None = None, +) -> dict: + """Load and record canonical review-merge workflow proof for this session (#389, #403). + + Read-only with respect to Gitea API; records in-process workflow source/hash + proof and session boundary state required before reviewer review or merge + mutations. + """ + try: + recorded = review_workflow_load.record_review_workflow_load( + PROJECT_ROOT, prompt_text=prompt_text) + except OSError as exc: + return { + "success": False, + "loaded": False, + "reasons": [str(exc)], + "recovery_handoff": review_workflow_load.recovery_handoff_without_replay(), + } + boundary_reasons = review_workflow_boundary.boundary_blockers(PROJECT_ROOT) + helper = review_workflow_boundary.workflow_load_helper_result( + recorded, PROJECT_ROOT) + return { + "success": not boundary_reasons, + "loaded": True, + "workflow_source": recorded["workflow_source"], + "task_mode": recorded["task_mode"], + "workflow_hash": recorded["workflow_hash"], + "workflow_version": recorded["workflow_version"], + "final_report_schema_path": recorded["final_report_schema_path"], + "final_report_schema_hash": recorded["final_report_schema_hash"], + "prompt_conflicts_with_workflow": recorded[ + "prompt_conflicts_with_workflow"], + "prompt_conflict_reasons": recorded.get("prompt_conflict_reasons") or [], + "workflow_load_proof_present": True, + "boundary_status": recorded.get("boundary_status"), + "boundary_clean": recorded.get("boundary_clean"), + "workflow_load_helper_result": helper, + "reasons": boundary_reasons, + "recovery_handoff": ( + review_workflow_load.recovery_handoff_without_replay() + if boundary_reasons else [] + ), + } + + @mcp.tool() def gitea_list_profiles() -> dict: """Read-only: list all Gitea MCP profiles with redacted metadata. @@ -6023,7 +8043,7 @@ def gitea_mark_issue( task_capability_map.required_permission("mark_issue")) if blocked: return blocked - verify_preflight_purity(remote, worktree_path=worktree_path) + verify_preflight_purity(remote, worktree_path=worktree_path, task="mark_issue") h, o, r = _resolve(remote, host, org, repo) auth = _auth(h) base = repo_api_url(h, o, r) @@ -6101,7 +8121,7 @@ def gitea_post_heartbeat( task_capability_map.required_permission("post_heartbeat")) if blocked: return blocked - verify_preflight_purity(remote) + verify_preflight_purity(remote, task="post_heartbeat") active_profile = profile or get_profile().get("profile_name") body = issue_claim_heartbeat.format_heartbeat_body( kind="progress", @@ -6124,6 +8144,127 @@ def gitea_post_heartbeat( ) +@mcp.tool() +def gitea_acquire_conflict_fix_lease( + pr_number: int, + branch: str, + worktree_path: str, + head_before: str, + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, +) -> dict: + """Acquire a conflict-fix lease on a PR branch before pushing (#399).""" + blocked = _profile_permission_block( + task_capability_map.required_permission("comment_issue")) + if blocked: + return blocked + verify_preflight_purity(remote, worktree_path=worktree_path) + comments = _list_pr_lease_comments( + pr_number, + remote=remote, + host=host, + org=org, + repo=repo, + ) + reviewer_lease = pr_work_lease.find_active_reviewer_lease( + comments, pr_number=pr_number) + if reviewer_lease: + return { + "acquired": False, + "reasons": [ + f"active reviewer lease on PR #{pr_number}; cannot acquire " + "conflict-fix lease (fail closed)" + ], + "active_reviewer_lease": reviewer_lease, + } + profile_name = get_profile().get("profile_name") or "unknown" + body = pr_work_lease.format_conflict_fix_lease_body( + pr_number=pr_number, + branch=branch, + worktree=worktree_path, + profile=profile_name, + head_before=head_before, + reviewer_active=bool(reviewer_lease), + ) + posted = _post_structured_issue_comment( + issue_number=pr_number, + body=body, + remote=remote, + host=host, + org=org, + repo=repo, + audit_op="conflict_fix_lease_acquire", + ) + return { + "acquired": posted.get("success", False), + "pr_number": pr_number, + "branch": branch, + "worktree_path": worktree_path, + "head_before": head_before, + "comment_id": posted.get("comment_id"), + "active_reviewer_lease": reviewer_lease, + "reasons": [] if posted.get("success") else ["lease comment post failed"], + } + + +@mcp.tool() +def gitea_assess_conflict_fix_push( + pr_number: int, + branch_head_before: str, + branch_head_after: str, + worktree_path: str, + push_cwd: str, + is_fast_forward: bool = True, + remote: str = "dadeschools", + host: str | None = None, + org: str | None = None, + repo: str | None = None, +) -> dict: + """Read-only pre-push gate for author conflict-fix sessions (#399).""" + read_block = _profile_operation_gate("gitea.read") + if read_block: + return { + "push_allowed": False, + "reasons": read_block, + "permission_report": _permission_block_report("gitea.read"), + } + fetched = _fetch_pr_lease_comments_safe( + pr_number, + remote=remote, + host=host, + org=org, + repo=repo, + require_open=True, + ) + if not fetched["success"]: + return { + "push_allowed": False, + "block": True, + "reasons": fetched["reasons"], + "pr_lookup": fetched.get("pr_lookup"), + "resolved_repo": fetched.get("resolved_repo"), + "remote": fetched.get("remote"), + "pr_number": pr_number, + "assessment_failed": True, + } + assessment = pr_work_lease.assess_conflict_fix_push( + pr_number=pr_number, + comments=fetched["comments"], + branch_head_before=branch_head_before, + branch_head_after=branch_head_after, + worktree_path=worktree_path, + push_cwd=push_cwd, + is_fast_forward=is_fast_forward, + ) + assessment["pr_lookup"] = fetched.get("pr_lookup") + assessment["resolved_repo"] = fetched.get("resolved_repo") + assessment["remote"] = fetched.get("remote") + assessment["live_pr_head_sha"] = fetched.get("head_sha") + return assessment + + @mcp.tool() def gitea_reconcile_issue_claims( state: str = "open", @@ -6169,6 +8310,15 @@ def gitea_reconcile_issue_claims( heartbeat_lease_minutes=heartbeat_lease_minutes, reclaim_after_minutes=reclaim_after_minutes, ) + live_locks = issue_lock_store.list_live_locks() + inventory["live_issue_locks"] = live_locks + inventory["live_issue_lock_numbers"] = sorted( + { + int(entry["issue_number"]) + for entry in live_locks + if entry.get("issue_number") is not None + } + ) inventory["cleanup_plan"] = issue_claim_heartbeat.build_cleanup_plan(inventory) inventory["success"] = True inventory["performed"] = False @@ -6231,7 +8381,7 @@ def gitea_cleanup_stale_claims( task_capability_map.required_permission("cleanup_stale_claims")) if blocked: return blocked - verify_preflight_purity(remote) + verify_preflight_purity(remote, task="cleanup_stale_claims") h, o, r = _resolve(remote, host, org, repo) auth = _auth(h) @@ -6385,7 +8535,7 @@ def gitea_set_issue_labels( task_capability_map.required_permission("set_issue_labels")) if blocked: return blocked - verify_preflight_purity(remote) + verify_preflight_purity(remote, task="set_issue_labels") h, o, r = _resolve(remote, host, org, repo) auth = _auth(h) base = repo_api_url(h, o, r) @@ -6563,6 +8713,102 @@ def gitea_route_task_session( ) +def _check_mcp_runtimes_diagnostics(task: str, matching_profiles: list[str]) -> list[str]: + """Check running runtimes and return errors if they are missing or stale.""" + import subprocess + import re + from datetime import datetime + + reasons = [] + code_path = os.path.join(PROJECT_ROOT, "gitea_mcp_server.py") + if not os.path.exists(code_path): + return reasons + + code_mtime = datetime.fromtimestamp(os.path.getmtime(code_path)) + + try: + proc = subprocess.run( + ["ps", "-o", "pid,lstart,command", "-ax"], + capture_output=True, text=True, check=True + ) + except Exception as exc: + return [f"stale-runtime: failed to list running processes: {exc}"] + + self_pid = os.getpid() + self_stale = False + + running_profiles = {} + for line in proc.stdout.splitlines()[1:]: + line = line.strip() + if not line or "mcp_server.py" not in line: + continue + + parts = line.split(None, 6) + if len(parts) < 7: + continue + pid_str = parts[0] + lstart_str = " ".join(parts[1:6]) + + try: + pid = int(pid_str) + start_time = datetime.strptime(lstart_str, "%a %b %d %H:%M:%S %Y") + except Exception: + continue + + try: + env_proc = subprocess.run( + ["ps", "eww", str(pid)], + capture_output=True, text=True, check=True + ) + env_out = env_proc.stdout + except Exception: + continue + + profile = "gitea-default" + match = re.search(r'\bGITEA_MCP_PROFILE=([^\s]+)', env_out) + if match: + profile = match.group(1) + + is_stale = start_time < code_mtime + if pid == self_pid and is_stale: + self_stale = True + + if profile not in running_profiles or start_time > running_profiles[profile]["start_time"]: + running_profiles[profile] = { + "pid": pid, + "start_time": start_time, + "is_stale": is_stale + } + + if self_stale: + reasons.append( + "stale-runtime: The active Gitea MCP server process is stale (running code from before changes were merged). " + "Please fully restart the Gitea MCP server (e.g. touch /Users/jasonwalker/.gemini/config/mcp_config.json) and retry." + ) + + if matching_profiles: + any_running = False + any_fresh = False + for mp in matching_profiles: + if mp in running_profiles: + any_running = True + if not running_profiles[mp]["is_stale"]: + any_fresh = True + break + if not any_running: + reasons.append( + f"stale-runtime: None of the matching profiles for task '{task}' ({matching_profiles}) are running in the OS. " + "Please restart the MCP server to ensure they are spawned." + ) + elif not any_fresh: + reasons.append( + f"stale-runtime: All matching profiles for task '{task}' ({matching_profiles}) are running but stale. " + "Please fully restart the Gitea MCP server and retry." + ) + + return reasons + + @mcp.tool() def gitea_resolve_task_capability( task: str, @@ -6622,7 +8868,7 @@ def gitea_resolve_task_capability( "exact_safe_next_action": next_safe_action, } - record_preflight_check("capability", required_role) + record_preflight_check("capability", required_role, resolved_task=task) # Try automatic dispatch switching _ensure_matching_profile(required_permission, required_role, remote, host) @@ -6673,6 +8919,16 @@ def gitea_resolve_task_capability( configured = len(matching_profiles) > 0 available_in_session = allowed_in_current_session + if "PYTEST_CURRENT_TEST" not in os.environ or "GITEA_FORCE_MCP_RUNTIME_CHECK" in os.environ: + runtime_reasons = _check_mcp_runtimes_diagnostics(task, matching_profiles) + if runtime_reasons: + restart_required = True + reason_msg = "; ".join(runtime_reasons) + next_safe_action = ( + "stale-runtime: Gitea MCP runtime conflict or missing process detected. " + "Please fully restart the Gitea MCP server and retry." + ) + if not allowed_in_current_session: if configured and switching: restart_required = True @@ -6750,6 +9006,7 @@ def gitea_resolve_task_capability( init_review_decision_lock( remote if remote in REMOTES else None, task, + force=False, ) record_mutation_authority(profile["profile_name"], username, remote if remote in REMOTES else None, task) @@ -6774,7 +9031,20 @@ def gitea_resolve_task_capability( } if reason_msg: result["reason"] = reason_msg + if task in ("review_pr", "merge_pr"): + result["workflow_load_proof"] = review_workflow_load.workflow_load_status( + PROJECT_ROOT) + if not result["workflow_load_proof"].get("workflow_load_valid"): + guidance = ( + "Call gitea_load_review_workflow before any reviewer review " + "or merge mutation." + ) + if guidance not in task_role_guidance: + task_role_guidance.append(guidance) role_session_router.sync_route_from_capability(result) + if audit_reconciliation_mode.check_audit_task_enters_phase(task): + phase_record = audit_reconciliation_mode.enter_audit_phase(task) + result["reconciliation_phase"] = phase_record.get("phase") was_terminal = capability_stop_terminal.is_active() terminal = capability_stop_terminal.sync_from_capability_result(result) if terminal: diff --git a/issue_acceptance_gate.py b/issue_acceptance_gate.py new file mode 100644 index 0000000..cd731c2 --- /dev/null +++ b/issue_acceptance_gate.py @@ -0,0 +1,300 @@ +"""Controller issue-acceptance gate helpers (#500). + +Pure validation for controller acceptance comments and final-report claims +that an issue is complete. Does not post comments or close issues. +""" + +from __future__ import annotations + +import re + +ACCEPTANCE_HEADING = "controller issue acceptance" + +REQUIRED_FIELDS = ( + "STATE", + "WHO_IS_NEXT", + "NEXT_ACTION", + "NEXT_PROMPT", + "ISSUE", + "MERGED_PR", + "MERGE_COMMIT", + "ACCEPTANCE_CRITERIA_CHECKED", + "VALIDATION_REVIEWED", + "CONTROLLER_DECISION", + "WHY", +) + +ACCEPTED_STATES = frozenset({"accepted"}) +REJECTION_STATES = frozenset({ + "more-work-required", + "more_work_required", + "needs-tests", + "needs_tests", + "needs-docs", + "needs_docs", + "needs-feature-enhancement", + "needs_feature_enhancement", + "needs-follow-up-issue", + "needs_follow_up_issue", + "blocked", +}) + +ALLOWED_NEXT_ACTORS = frozenset({ + "controller", + "author", + "reviewer", + "merger", + "reconciler", + "user", +}) + +_FIELD_RE = re.compile(r"^\s*(?:[-*]\s*)?([A-Z][A-Z0-9_ ]+)\s*:\s*(.*)$") +_FULL_SHA_RE = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE) +_ISSUE_REF_RE = re.compile(r"#\d+") +_PR_REF_RE = re.compile(r"#\d+") +_CHECKED_ITEM_RE = re.compile(r"\[[xX]\]") +_UNCHECKED_ITEM_RE = re.compile(r"\[[\s]\]") + +_CLAIMS_ISSUE_COMPLETE_RE = re.compile( + r"\bissue\s+(?:is\s+)?(?:complete|completed|accepted|closed\s+as\s+complete|fully\s+satisfied)\b|" + r"\bissue\s+acceptance\s*:\s*accepted\b|" + r"\bcontroller\s+acceptance\s*:\s*(?:accepted|complete)\b", + re.IGNORECASE, +) +_MERGE_ONLY_COMPLETE_RE = re.compile( + r"(?:pr\s+merged|merged\s+pr|merge\s+result\s*:\s*merged).{0,120}" + r"(?:issue\s+(?:is\s+)?(?:complete|closed|accepted)|issue\s+complete)", + re.IGNORECASE | re.DOTALL, +) +_CLAIMS_CONTROLLER_ACCEPTANCE_RE = re.compile( + r"controller\s+issue\s+acceptance|controller\s+acceptance\s+(?:posted|complete|pending)", + re.IGNORECASE, +) +_PENDING_ACCEPTANCE_RE = re.compile( + r"controller\s+acceptance\s+(?:pending|required|not\s+(?:yet\s+)?(?:performed|complete))", + re.IGNORECASE, +) + + +def render_controller_acceptance_template() -> str: + """Return the canonical controller issue-acceptance comment template.""" + return """## Controller Issue Acceptance + +STATE: + + +WHO_IS_NEXT: + + +NEXT_ACTION: + + +NEXT_PROMPT: + + +ISSUE: +#... + +MERGED_PR: +#... + +MERGE_COMMIT: +<40-character SHA> + +ACCEPTANCE_CRITERIA_CHECKED: +- [x] ... +- [ ] ... + +VALIDATION_REVIEWED: + + +CONTROLLER_DECISION: + + +WHY: + + +MISSING_WORK: + + +FOLLOW_UP_ISSUES: + + +BLOCKERS: + + +LAST_UPDATED_BY: + +""" + + +def extract_acceptance_fields(text: str | None) -> dict[str, str]: + """Return upper-case labeled fields from a controller acceptance block.""" + fields: dict[str, str] = {} + current_key: str | None = None + for line in (text or "").splitlines(): + match = _FIELD_RE.match(line) + if match: + current_key = match.group(1).strip().upper().replace(" ", "_") + fields[current_key] = match.group(2).strip() + continue + stripped = line.strip() + if current_key and stripped: + existing = fields.get(current_key, "") + fields[current_key] = ( + f"{existing}\n{stripped}" if existing else stripped + ) + return fields + + +def contains_acceptance_block(text: str | None) -> bool: + return ACCEPTANCE_HEADING in (text or "").lower() + + +def _empty_or_placeholder(value: str | None) -> bool: + value = (value or "").strip().lower() + return not value or value in {"none", "n/a", "unknown", "tbd", "<...>", "..."} + + +def _normalize_state(value: str | None) -> str: + return (value or "").strip().lower().replace(" ", "_").replace("-", "_") + + +def validate_controller_acceptance_comment(text: str | None) -> dict: + """Validate a controller issue-acceptance comment.""" + body = text or "" + if not contains_acceptance_block(body): + return { + "valid": False, + "fields": {}, + "reasons": ["missing Controller Issue Acceptance heading"], + } + + fields = extract_acceptance_fields(body) + reasons: list[str] = [] + + for field in REQUIRED_FIELDS: + if _empty_or_placeholder(fields.get(field)): + reasons.append(f"missing required controller acceptance field: {field}") + + state = _normalize_state(fields.get("STATE")) + if state and state not in ACCEPTED_STATES and state not in REJECTION_STATES: + reasons.append( + "STATE must be accepted or a rejection path " + "(more-work-required, needs-tests, needs-docs, " + "needs-feature-enhancement, needs-follow-up-issue, blocked)" + ) + + actor = (fields.get("WHO_IS_NEXT") or "").strip().lower() + if actor and actor not in ALLOWED_NEXT_ACTORS: + reasons.append( + "WHO_IS_NEXT must be one of: " + + ", ".join(sorted(ALLOWED_NEXT_ACTORS)) + ) + + if not _ISSUE_REF_RE.search(fields.get("ISSUE") or ""): + reasons.append("ISSUE must cite an issue number (#N)") + if not _PR_REF_RE.search(fields.get("MERGED_PR") or ""): + reasons.append("MERGED_PR must cite a merged PR number (#N)") + if not _FULL_SHA_RE.search(fields.get("MERGE_COMMIT") or ""): + reasons.append("MERGE_COMMIT must include a full 40-character SHA") + + criteria = fields.get("ACCEPTANCE_CRITERIA_CHECKED") or "" + if not _CHECKED_ITEM_RE.search(criteria) and not _UNCHECKED_ITEM_RE.search(criteria): + reasons.append( + "ACCEPTANCE_CRITERIA_CHECKED must list checked/unchecked criteria items" + ) + + decision = (fields.get("CONTROLLER_DECISION") or "").strip().lower() + if state in ACCEPTED_STATES: + if decision not in {"accepted", "accept"}: + reasons.append("accepted STATE requires CONTROLLER_DECISION: accepted") + if not _CHECKED_ITEM_RE.search(criteria): + reasons.append( + "accepted STATE requires at least one checked acceptance criterion" + ) + if _empty_or_placeholder(fields.get("WHY")): + reasons.append("accepted STATE requires WHY with acceptance rationale") + elif state in REJECTION_STATES: + if decision not in {"rejected", "reject", "more_work_required"}: + reasons.append( + "rejection STATE requires CONTROLLER_DECISION: rejected" + ) + if _empty_or_placeholder(fields.get("NEXT_PROMPT")): + reasons.append( + "rejection STATE requires a paste-ready NEXT_PROMPT for the next actor" + ) + missing = fields.get("MISSING_WORK") or "" + if _empty_or_placeholder(missing): + reasons.append( + "rejection STATE requires MISSING_WORK describing what is still needed" + ) + + return { + "valid": not reasons, + "fields": fields, + "reasons": reasons, + } + + +def claims_issue_complete(text: str | None) -> bool: + """Return True when text claims an issue is complete/accepted.""" + return bool(_CLAIMS_ISSUE_COMPLETE_RE.search(text or "")) + + +def claims_merge_only_issue_complete(text: str | None) -> bool: + """Return True when text treats PR merge as issue completion.""" + return bool(_MERGE_ONLY_COMPLETE_RE.search(text or "")) + + +def claims_controller_acceptance_update(text: str | None) -> bool: + return bool(_CLAIMS_CONTROLLER_ACCEPTANCE_RE.search(text or "")) + + +def notes_controller_acceptance_pending(text: str | None) -> bool: + return bool(_PENDING_ACCEPTANCE_RE.search(text or "")) + + +def validate_final_report_issue_acceptance(report_text: str | None) -> dict: + """Validate issue-completion and controller-acceptance claims in final reports.""" + text = report_text or "" + reasons: list[str] = [] + + complete_claim = claims_issue_complete(text) + merge_only = claims_merge_only_issue_complete(text) + acceptance_claim = claims_controller_acceptance_update(text) + pending_noted = notes_controller_acceptance_pending(text) + has_block = contains_acceptance_block(text) + + if merge_only and not (has_block and validate_controller_acceptance_comment(text)["valid"]): + reasons.append( + "final report treats PR merge as issue completion without controller acceptance proof" + ) + + if complete_claim and not pending_noted: + if not has_block: + reasons.append( + "final report claims issue complete but includes no Controller Issue Acceptance block" + ) + else: + result = validate_controller_acceptance_comment(text) + if not result["valid"]: + reasons.extend(result["reasons"]) + else: + state = _normalize_state(result["fields"].get("STATE")) + if state not in ACCEPTED_STATES: + reasons.append( + "final report claims issue complete but controller STATE is not accepted" + ) + + if acceptance_claim and has_block: + result = validate_controller_acceptance_comment(text) + if not result["valid"]: + reasons.extend(result["reasons"]) + + applicable = complete_claim or merge_only or acceptance_claim or pending_noted + return { + "applicable": applicable, + "valid": not reasons, + "reasons": reasons, + } \ No newline at end of file diff --git a/issue_lock_adoption.py b/issue_lock_adoption.py new file mode 100644 index 0000000..81a5a92 --- /dev/null +++ b/issue_lock_adoption.py @@ -0,0 +1,272 @@ +"""Own-branch lock adoption / recovery for ``gitea_lock_issue`` (#442 / #443). + +When an issue's own already-pushed branch exists, lock reacquisition must be +allowed (adoption) instead of being treated as #400 duplicate competing work. +This module isolates the pure decision so it can be unit-tested apart from the +MCP server's live Gitea calls. + +Adoption is granted only for the issue's *exact* requested branch. Any other +branch that merely contains the same ``issue-`` 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), + } \ No newline at end of file diff --git a/issue_lock_provenance.py b/issue_lock_provenance.py new file mode 100644 index 0000000..87ee38f --- /dev/null +++ b/issue_lock_provenance.py @@ -0,0 +1,269 @@ +"""Issue-lock provenance and external-state disclosure (#447). + +Sanctioned locks are written only by ``gitea_lock_issue`` (or adoption recovery +#442). Manual seeding of ``/tmp/gitea_issue_lock.json`` is unsafe and must be +blocked at PR creation unless explicit operator override proof is recorded. +""" + +from __future__ import annotations + +import os +import re +from datetime import datetime, timezone + +ISSUE_LOCK_FILE = os.environ.get("GITEA_ISSUE_LOCK_FILE", "/tmp/gitea_issue_lock.json") + +SOURCE_LOCK_ISSUE = "gitea_lock_issue" +SOURCE_LOCK_ADOPTION = "gitea_lock_issue_adoption" +SOURCE_OPERATOR_OVERRIDE = "operator_override" + +SANCTIONED_LOCK_SOURCES = frozenset({ + SOURCE_LOCK_ISSUE, + SOURCE_LOCK_ADOPTION, + SOURCE_OPERATOR_OVERRIDE, +}) + +_OPERATOR_OVERRIDE_ENV = "GITEA_ISSUE_LOCK_OPERATOR_OVERRIDE" + +_ISSUE_LOCK_PATH_RE = re.compile( + r"(?:/tmp/)?gitea_issue_lock\.json", + re.IGNORECASE, +) +_LOCK_SEED_RE = re.compile( + r"(?:seed(?:ed|ing)?|restor(?:e|ed|ing)|wrote|written|write|programmatically|" + r"hand[- ]forg|manual(?:ly)?).{0,80}gitea_issue_lock", + re.IGNORECASE | re.DOTALL, +) +_LOCK_REMOVE_RE = re.compile( + r"(?:\brm\b|remove|deleted?|unlink).{0,80}gitea_issue_lock", + re.IGNORECASE | re.DOTALL, +) +_LOCK_READ_RE = re.compile( + r"(?:read|loaded?|parsed?).{0,80}gitea_issue_lock", + re.IGNORECASE | re.DOTALL, +) +_EXTERNAL_NONE_RE = re.compile( + r"external[- ]state mutations\s*:\s*none\b", + re.IGNORECASE, +) +_EXTERNAL_FIELD_RE = re.compile( + r"external[- ]state mutations\s*:\s*(.+)$", + re.IGNORECASE | re.MULTILINE, +) +_CLEANUP_ONLY_RE = re.compile( + r"cleanup mutations\s*:\s*(?:none|lock removed|removed issue lock)", + re.IGNORECASE, +) +_PR_CREATED_RE = re.compile( + r"(?:\bgitea_create_pr\b|PR\s*#\s*\d+\s+created|created\s+PR\s*#|opened\s+PR\s*#|" + r"PR\s+creation\s+(?:succeeded|complete))", + re.IGNORECASE, +) +_REVIEW_APPROVE_RE = re.compile( + r"(?:submitted\s+(?:['\"]approve['\"]|approve\s+review)|" + r"review decision\s*:\s*approve|approved\s+PR\s*#|gitea_review_pr.*approve)", + re.IGNORECASE, +) +_OVERRIDE_PROOF_RE = re.compile( + r"operator[- ]override\s+proof\s*:\s*(.+)$", + re.IGNORECASE | re.MULTILINE, +) + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def build_sanctioned_lock_provenance( + *, + tool: str, + source: str = SOURCE_LOCK_ISSUE, + claimant: dict | None = None, + adoption: dict | None = None, +) -> dict: + """Return provenance metadata stored with a sanctioned lock write.""" + entry = { + "source": source, + "written_at": _utc_now_iso(), + "written_by_tool": tool, + "lock_file_path": ISSUE_LOCK_FILE, + } + if claimant: + entry["claimant"] = claimant + if adoption: + entry["adoption"] = adoption + return entry + + +def operator_override_requested() -> bool: + return os.environ.get(_OPERATOR_OVERRIDE_ENV, "").strip().lower() in { + "1", + "true", + "yes", + } + + +def build_operator_override_provenance(*, reason: str, claimant: dict | None = None) -> dict: + text = (reason or "").strip() + if not text: + raise ValueError( + "operator override requires a non-empty override reason (fail closed)" + ) + entry = build_sanctioned_lock_provenance( + tool="operator_override", + source=SOURCE_OPERATOR_OVERRIDE, + claimant=claimant, + ) + entry["override_reason"] = text + return entry + + +def assess_lock_file_for_create_pr(lock_data: dict | None) -> dict: + """Fail closed when lock file lacks sanctioned provenance (#447).""" + data = lock_data if isinstance(lock_data, dict) else {} + reasons: list[str] = [] + provenance = data.get("lock_provenance") + if not isinstance(provenance, dict): + reasons.append( + "issue lock file lacks sanctioned lock_provenance; manual seeding is " + "not a normal recovery path — call gitea_lock_issue or use #442 adoption" + ) + return _provenance_result(False, reasons, provenance) + + source = str(provenance.get("source") or "").strip() + if source not in SANCTIONED_LOCK_SOURCES: + reasons.append( + f"issue lock provenance source '{source or '(missing)'}' is not sanctioned" + ) + + if source == SOURCE_OPERATOR_OVERRIDE and not str( + provenance.get("override_reason") or "" + ).strip(): + reasons.append( + "operator_override lock provenance requires override_reason proof" + ) + + if not data.get("work_lease"): + reasons.append("issue lock file missing work_lease metadata") + + if not str(provenance.get("written_by_tool") or "").strip(): + reasons.append("issue lock provenance missing written_by_tool") + + proven = not reasons + return _provenance_result(proven, reasons, provenance) + + +def _provenance_result(proven: bool, reasons: list[str], provenance: dict | None) -> dict: + return { + "proven": proven, + "block": not proven, + "reasons": reasons, + "lock_provenance": provenance, + } + + +def format_lock_provenance_error(assessment: dict) -> str: + reasons = "; ".join(assessment.get("reasons") or ["unknown lock provenance violation"]) + return f"Issue lock provenance guard (#447): {reasons} (fail closed)" + + +def _lock_activity_detected(text: str) -> dict[str, bool]: + body = text or "" + return { + "seed_or_restore": bool(_LOCK_SEED_RE.search(body)), + "remove": bool(_LOCK_REMOVE_RE.search(body)), + "read": bool(_LOCK_READ_RE.search(body)), + } + + +def _external_state_discloses_lock(text: str) -> bool: + match = _EXTERNAL_FIELD_RE.search(text or "") + if not match: + return False + value = (match.group(1) or "").strip().lower() + if value in {"", "none", "n/a"}: + return False + return "lock" in value or "gitea_issue_lock" in value or "issue-lock" in value + + +def assess_issue_lock_external_state_report(report_text: str) -> dict: + """Require explicit external-state disclosure for issue-lock mutations (#447).""" + text = report_text or "" + activity = _lock_activity_detected(text) + if not any(activity.values()): + return {"proven": True, "block": False, "reasons": [], "activity": activity} + + reasons: list[str] = [] + disclosed = _external_state_discloses_lock(text) + + if activity["seed_or_restore"] and _EXTERNAL_NONE_RE.search(text): + reasons.append( + "report mentions seeding/restoring gitea_issue_lock.json but claims " + "External-state mutations: none" + ) + elif activity["seed_or_restore"] and not disclosed: + reasons.append( + "report mentions issue-lock file activity but External-state mutations " + "does not disclose read/write of gitea_issue_lock.json" + ) + + if activity["remove"]: + if _EXTERNAL_NONE_RE.search(text): + reasons.append( + "report mentions removing gitea_issue_lock.json but claims " + "External-state mutations: none" + ) + elif not disclosed and _CLEANUP_ONLY_RE.search(text): + reasons.append( + "report removes issue lock but classifies it as cleanup only; " + "record under External-state mutations" + ) + elif not disclosed: + reasons.append( + "report mentions deleting issue lock without External-state " + "mutation disclosure" + ) + + proven = not reasons + return { + "proven": proven, + "block": not proven, + "reasons": reasons, + "activity": activity, + } + + +def assess_manual_lock_pr_without_override(report_text: str) -> dict: + """Block reports that created a PR via manual lock seed without override proof.""" + text = report_text or "" + seeded = bool(_LOCK_SEED_RE.search(text)) + created = bool(_PR_CREATED_RE.search(text)) + if not (seeded and created): + return {"proven": True, "block": False, "reasons": []} + + if _OVERRIDE_PROOF_RE.search(text): + return {"proven": True, "block": False, "reasons": []} + + return { + "proven": False, + "block": True, + "reasons": [ + "report created/opened a PR after manual issue-lock seeding without " + "operator override proof" + ], + } + + +def assess_author_reviewer_same_run_report(report_text: str) -> dict: + """Reviewer handoff must not create and approve the same PR in one run (#447).""" + text = report_text or "" + if not (_PR_CREATED_RE.search(text) and _REVIEW_APPROVE_RE.search(text)): + return {"proven": True, "block": False, "reasons": []} + return { + "proven": False, + "block": True, + "reasons": [ + "report mixes author-side PR creation and reviewer approval in one " + "final handoff; split author and reviewer sessions" + ], + } \ No newline at end of file diff --git a/issue_lock_store.py b/issue_lock_store.py new file mode 100644 index 0000000..6a7b4d7 --- /dev/null +++ b/issue_lock_store.py @@ -0,0 +1,612 @@ +"""Keyed, persistent issue-lock storage (#443) with flock hardening (#438). + +Replaces the single global ``/tmp/gitea_issue_lock.json`` slot with per-issue +lock files under ``GITEA_ISSUE_LOCK_DIR`` (default +``~/.cache/gitea-tools/issue-locks``). Each MCP session binds its active lock +via a per-process pointer file so concurrent repos/issues never clobber each +other. Acquisition is serialized per issue with ``fcntl.flock``. +""" + +from __future__ import annotations + +import errno +import fcntl +import json +import os +import re +import tempfile +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from typing import Any + +LOCK_DIR_ENV = "GITEA_ISSUE_LOCK_DIR" +DEFAULT_LOCK_DIR = os.path.expanduser("~/.cache/gitea-tools/issue-locks") +WORK_LEASE_TTL_HOURS = 4 +AUTHOR_ISSUE_WORK_LEASE = "author_issue_work" + +_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+") + + +class LockContentionError(RuntimeError): + """Raised when an exclusive per-issue lock cannot be acquired.""" + + +def default_lock_dir() -> str: + raw = (os.environ.get(LOCK_DIR_ENV) or DEFAULT_LOCK_DIR).strip() + return raw or DEFAULT_LOCK_DIR + + +def _sanitize_segment(value: str) -> str: + text = (value or "").strip() + if not text: + return "_" + return _SAFE_SEGMENT_RE.sub("_", text) + + +def lock_key( + *, + remote: str, + org: str, + repo: str, + issue_number: int, +) -> str: + return "-".join( + _sanitize_segment(part) + for part in (remote, org, repo, str(issue_number)) + ) + + +def lock_file_path( + *, + remote: str, + org: str, + repo: str, + issue_number: int, + lock_dir: str | None = None, +) -> str: + root = (lock_dir or default_lock_dir()).strip() + return os.path.join(root, f"{lock_key(remote=remote, org=org, repo=repo, issue_number=issue_number)}.json") + + +def session_pointer_path(lock_dir: str | None = None) -> str: + root = (lock_dir or default_lock_dir()).strip() + return os.path.join(root, f"session-{os.getpid()}.json") + + +def _ensure_lock_dir(lock_dir: str | None = None) -> str: + root = (lock_dir or default_lock_dir()).strip() + os.makedirs(root, mode=0o700, exist_ok=True) + return root + + +def flock_path(json_path: str) -> str: + return f"{json_path}.lock" + + +def is_process_alive(pid: int | None) -> bool: + if not pid or pid <= 0: + return False + try: + os.kill(int(pid), 0) + return True + except OSError as exc: + return exc.errno != errno.ESRCH + except (TypeError, ValueError): + return False + + +@contextmanager +def _exclusive_file_lock(lock_path: str): + os.makedirs(os.path.dirname(lock_path) or ".", exist_ok=True) + fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600) + try: + try: + fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as exc: + raise LockContentionError( + f"could not acquire exclusive lock on '{lock_path}'" + ) from exc + yield fd + finally: + try: + fcntl.flock(fd, fcntl.LOCK_UN) + finally: + os.close(fd) + + +def read_lock_file(path: str) -> dict[str, Any] | None: + lock_path = (path or "").strip() + if not lock_path or not os.path.exists(lock_path): + return None + try: + with open(lock_path, encoding="utf-8") as handle: + data = json.load(handle) + except (OSError, json.JSONDecodeError): + return None + return data if isinstance(data, dict) else None + + +def save_lock_file(path: str, data: dict[str, Any]) -> None: + lock_path = (path or "").strip() + if not lock_path: + raise ValueError("lock path is required (fail closed)") + parent = os.path.dirname(lock_path) or "." + os.makedirs(parent, mode=0o700, exist_ok=True) + payload = json.dumps(data, indent=2, sort_keys=True) + "\n" + fd, temp_path = tempfile.mkstemp(prefix=".lock-", suffix=".json", dir=parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temp_path, lock_path) + finally: + if os.path.exists(temp_path): + try: + os.remove(temp_path) + except OSError: + pass + + +def bind_session_lock(lock_data: dict[str, Any], lock_dir: str | None = None) -> str: + """Persist a keyed lock and bind it to the current process session.""" + remote = str(lock_data.get("remote") or "") + org = str(lock_data.get("org") or "") + repo = str(lock_data.get("repo") or "") + issue_number = int(lock_data.get("issue_number") or 0) + if not remote or not org or not repo or issue_number <= 0: + raise ValueError("lock record must include remote, org, repo, and issue_number") + + root = _ensure_lock_dir(lock_dir) + path = lock_file_path( + remote=remote, + org=org, + repo=repo, + issue_number=issue_number, + lock_dir=root, + ) + record = dict(lock_data) + record["lock_file_path"] = path + record["session_pid"] = os.getpid() + record.setdefault("pid", os.getpid()) + + pointer = { + "pid": os.getpid(), + "lock_file_path": path, + "issue_number": issue_number, + "branch_name": record.get("branch_name"), + "remote": remote, + "org": org, + "repo": repo, + } + sentinel = flock_path(path) + try: + with _exclusive_file_lock(sentinel): + existing = read_lock_file(path) + overwrite_block = assess_foreign_lock_overwrite(existing, record) + if overwrite_block: + raise RuntimeError(overwrite_block) + lease_block = assess_same_issue_lease_conflict( + existing, + issue_number=issue_number, + branch_name=str(record.get("branch_name") or ""), + worktree_path=str(record.get("worktree_path") or ""), + ) + if lease_block: + raise RuntimeError(lease_block) + save_lock_file(path, record) + save_lock_file(session_pointer_path(root), pointer) + except LockContentionError as exc: + competing = read_lock_file(path) + if competing: + owner_pid = competing.get("session_pid") or competing.get("pid") + raise RuntimeError( + f"Issue #{issue_number} lock contention: {exc}; competing owner " + f"pid={owner_pid} (fail closed)" + ) from exc + raise RuntimeError(f"Issue #{issue_number} lock contention: {exc} (fail closed)") from exc + return path + + +def read_session_issue_lock(lock_dir: str | None = None) -> dict[str, Any] | None: + root = (lock_dir or default_lock_dir()).strip() + pointer = read_lock_file(session_pointer_path(root)) + if not pointer: + return None + lock_path = str(pointer.get("lock_file_path") or "").strip() + if not lock_path: + return None + return read_lock_file(lock_path) + + +def load_issue_lock( + *, + remote: str, + org: str, + repo: str, + issue_number: int, + lock_dir: str | None = None, +) -> dict[str, Any] | None: + return read_lock_file( + lock_file_path( + remote=remote, + org=org, + repo=repo, + issue_number=issue_number, + lock_dir=lock_dir, + ) + ) + + +def iter_lock_files(lock_dir: str | None = None) -> list[str]: + root = (lock_dir or default_lock_dir()).strip() + if not os.path.isdir(root): + return [] + paths: list[str] = [] + for name in os.listdir(root): + if not name.endswith(".json") or name.startswith("session-"): + continue + paths.append(os.path.join(root, name)) + return sorted(paths) + + +def find_lock_for_branch( + *, + remote: str, + org: str, + repo: str, + branch_name: str, + lock_dir: str | None = None, +) -> dict[str, Any] | None: + target = (branch_name or "").strip() + if not target: + return None + for path in iter_lock_files(lock_dir): + lock = read_lock_file(path) + if not lock: + continue + if ( + str(lock.get("remote") or "") == remote + and str(lock.get("org") or "") == org + and str(lock.get("repo") or "") == repo + and str(lock.get("branch_name") or "").strip() == target + ): + lock = dict(lock) + lock.setdefault("lock_file_path", path) + return lock + return None + + +def _lease_now(now: datetime | None = None) -> datetime: + return now or datetime.now(timezone.utc) + + +def _parse_lease_timestamp(value: str | None) -> datetime | None: + text = (value or "").strip() + if not text: + return None + try: + return datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone(timezone.utc) + except ValueError: + return None + + +def lease_expires_at(lock: dict[str, Any] | None) -> datetime | None: + if not lock: + return None + lease = lock.get("work_lease") + if not isinstance(lease, dict): + return None + return _parse_lease_timestamp(lease.get("expires_at")) + + +def is_lease_expired(lock: dict[str, Any] | None, *, now: datetime | None = None) -> bool: + expires = lease_expires_at(lock) + if expires is None: + return False + return expires <= _lease_now(now) + + +def is_lease_live(lock: dict[str, Any] | None, *, now: datetime | None = None) -> bool: + return assess_lock_freshness(lock, now=now)["live"] + + +def assess_lock_freshness( + lock_data: dict[str, Any] | None, + *, + now: datetime | None = None, +) -> dict[str, Any]: + """Classify a lock as live, expired, stale, or absent.""" + current = _lease_now(now) + if not lock_data: + return { + "status": "absent", + "live": False, + "stale": False, + "reason": "no lock record", + } + + expires_at = lease_expires_at(lock_data) + lease = lock_data.get("work_lease") + heartbeat_at = _parse_lease_timestamp(lock_data.get("last_heartbeat_at")) + if heartbeat_at is None and isinstance(lease, dict): + heartbeat_at = _parse_lease_timestamp(lease.get("last_heartbeat_at")) + + pid = lock_data.get("session_pid") + if pid is None: + pid = lock_data.get("pid") + pid_alive = is_process_alive(pid) if pid is not None else False + + if expires_at and expires_at <= current: + return { + "status": "expired", + "live": False, + "stale": True, + "reason": f"lease expired at {expires_at.isoformat()}", + "pid_alive": pid_alive, + } + + if pid is not None and not pid_alive: + return { + "status": "stale", + "live": False, + "stale": True, + "reason": f"owner pid {pid} is not alive", + "pid_alive": False, + } + + return { + "status": "live", + "live": True, + "stale": False, + "reason": "lock heartbeat and lease are fresh", + "pid_alive": pid_alive, + "heartbeat_at": heartbeat_at.isoformat() if heartbeat_at else None, + "expires_at": expires_at.isoformat() if expires_at else None, + } + + +def _same_realpath(left: str | None, right: str | None) -> bool: + if not left or not right: + return False + try: + return os.path.realpath(left) == os.path.realpath(right) + except OSError: + return left == right + + +def assess_same_issue_lease_conflict( + existing_lock: dict[str, Any] | None, + *, + issue_number: int, + branch_name: str, + worktree_path: str, + operation_type: str = AUTHOR_ISSUE_WORK_LEASE, + now: datetime | None = None, +) -> str | None: + """Return a fail-closed error when a competing live lease blocks acquisition.""" + if not existing_lock: + return None + + existing_issue = existing_lock.get("issue_number") + lease = existing_lock.get("work_lease") + existing_operation = ( + lease.get("operation_type") + if isinstance(lease, dict) + else AUTHOR_ISSUE_WORK_LEASE + ) + if existing_issue != issue_number or existing_operation != operation_type: + return None + + existing_branch = existing_lock.get("branch_name") + existing_worktree = existing_lock.get("worktree_path") + same_owner = ( + existing_branch == branch_name + and _same_realpath(str(existing_worktree or ""), worktree_path) + ) + if is_lease_expired(existing_lock, now=now): + return ( + f"Issue #{issue_number} has an expired {operation_type} lease on " + f"branch '{existing_branch}' from worktree '{existing_worktree}'. " + "Recovery review is required before takeover (fail closed)" + ) + if same_owner: + return None + return ( + f"Issue #{issue_number} already has an active {operation_type} lease on " + f"branch '{existing_branch}' from worktree '{existing_worktree}' " + "(fail closed)" + ) + + +def assess_foreign_lock_overwrite( + existing_lock: dict[str, Any] | None, + incoming_lock: dict[str, Any], + *, + now: datetime | None = None, +) -> str | None: + """Block writes that would clobber an unrelated live lease on the same key.""" + if not existing_lock: + return None + + same_issue = existing_lock.get("issue_number") == incoming_lock.get("issue_number") + same_branch = existing_lock.get("branch_name") == incoming_lock.get("branch_name") + same_worktree = _same_realpath( + str(existing_lock.get("worktree_path") or ""), + str(incoming_lock.get("worktree_path") or ""), + ) + if same_issue and same_branch and same_worktree: + return None + if not is_lease_live(existing_lock, now=now): + return None + return ( + "Refusing to overwrite a live foreign issue lock " + f"(issue #{existing_lock.get('issue_number')}, " + f"branch '{existing_lock.get('branch_name')}', " + f"worktree '{existing_lock.get('worktree_path')}') (fail closed)" + ) + + +def find_live_lock_for_branch( + branch_name: str, + lock_dir: str | None = None, +) -> dict[str, Any] | None: + target = (branch_name or "").strip() + if not target: + return None + for path in iter_lock_files(lock_dir): + lock = read_lock_file(path) + if not lock: + continue + if str(lock.get("branch_name") or "").strip() != target: + continue + if not is_lease_live(lock): + continue + record = dict(lock) + record.setdefault("lock_file_path", path) + return record + return None + + +def resolve_locked_branch_for_session( + branch_name: str | None = None, + lock_dir: str | None = None, +) -> str: + if branch_name: + lock = find_live_lock_for_branch(branch_name, lock_dir) + if lock: + return str(lock.get("branch_name") or "") + lock = read_session_issue_lock(lock_dir) + return str((lock or {}).get("branch_name") or "") + + +def has_active_issue_lock( + branch: str, + *, + lock_dir: str | None = None, +) -> bool: + target = (branch or "").strip() + if not target: + return False + for path in iter_lock_files(lock_dir): + lock = read_lock_file(path) + if not lock: + continue + if str(lock.get("branch_name") or "").strip() != target: + continue + if is_lease_live(lock): + return True + return False + + +def verify_lock_for_mutation( + lock_data: dict[str, Any] | None, + *, + issue_number: int | None = None, + branch_name: str | None = None, + worktree_path: str | None = None, +) -> dict[str, Any]: + """Re-check lock ownership immediately before a mutation (#438).""" + reasons: list[str] = [] + if not lock_data: + return {"proven": False, "block": True, "reasons": ["issue lock is missing (fail closed)"]} + + freshness = assess_lock_freshness(lock_data) + if not freshness["live"]: + reasons.append(f"issue lock is not live: {freshness['reason']} (fail closed)") + + if issue_number is not None and lock_data.get("issue_number") != issue_number: + reasons.append( + f"issue lock targets #{lock_data.get('issue_number')}, expected #{issue_number} (fail closed)" + ) + + if branch_name is not None and lock_data.get("branch_name") != branch_name: + reasons.append( + f"issue lock branch '{lock_data.get('branch_name')}' does not match " + f"'{branch_name}' (fail closed)" + ) + + if worktree_path is not None: + locked = os.path.realpath(str(lock_data.get("worktree_path") or "")) + declared = os.path.realpath(worktree_path) + if locked != declared: + reasons.append( + f"issue lock worktree '{locked}' does not match declared '{declared}' (fail closed)" + ) + + return { + "proven": not reasons, + "block": bool(reasons), + "reasons": reasons, + "freshness": freshness, + "lock_proof": format_lock_proof(lock_data, freshness=freshness), + } + + +def list_live_locks( + *, + lock_dir: str | None = None, + now: datetime | None = None, +) -> list[dict[str, Any]]: + """Return live per-issue locks for queue visibility.""" + live: list[dict[str, Any]] = [] + for path in iter_lock_files(lock_dir): + record = read_lock_file(path) + if not record: + continue + freshness = assess_lock_freshness(record, now=now) + if not freshness["live"]: + continue + live.append( + { + "issue_number": record.get("issue_number"), + "branch_name": record.get("branch_name"), + "remote": record.get("remote"), + "org": record.get("org"), + "repo": record.get("repo"), + "worktree_path": record.get("worktree_path"), + "pid": record.get("session_pid") or record.get("pid"), + "claimant": ( + record.get("claimant") + or (record.get("work_lease") or {}).get("claimant") + ), + "freshness": freshness, + "lock_path": record.get("lock_file_path") or path, + } + ) + return live + + +def format_lock_proof( + lock_data: dict[str, Any] | None, + *, + freshness: dict[str, Any] | None = None, + competing_live_locks: list[dict[str, Any]] | None = None, + released: bool | None = None, +) -> str: + """Canonical issue-lock proof string for final reports.""" + if not lock_data: + return "issue lock proof: not acquired" + fresh = freshness or assess_lock_freshness(lock_data) + owner = lock_data.get("claimant") or {} + if not owner and isinstance(lock_data.get("work_lease"), dict): + owner = lock_data["work_lease"].get("claimant") or {} + parts = [ + "issue lock proof:", + f"acquired issue #{lock_data.get('issue_number')}", + f"branch {lock_data.get('branch_name')}", + f"owner {owner.get('profile') or 'unknown'}", + f"pid {lock_data.get('session_pid') or lock_data.get('pid')}", + f"freshness {fresh.get('status')}", + ] + if competing_live_locks is not None: + parts.append( + "no competing live lock" + if not competing_live_locks + else f"competing live locks {len(competing_live_locks)}" + ) + if released is True: + parts.append("lock released") + elif released is False: + parts.append("lock retained") + return "; ".join(parts) \ No newline at end of file diff --git a/issue_lock_worktree.py b/issue_lock_worktree.py index 5d9dc4a..3c23134 100644 --- a/issue_lock_worktree.py +++ b/issue_lock_worktree.py @@ -30,8 +30,16 @@ def resolve_author_worktree_path( return os.path.realpath(os.path.abspath(path)) -def read_worktree_git_state(worktree_path: str) -> dict: - """Read branch name and porcelain status from a git worktree.""" +def read_worktree_git_state( + worktree_path: str, + extra_bases: tuple[str, ...] | list[str] = (), +) -> dict: + """Read branch name and porcelain status from a git worktree. + + ``extra_bases`` names additional branches (e.g. an approved stacked base) + that may anchor base-equivalence in addition to master/main/dev. When empty + (the default), only the normal base branches are considered. + """ path = (worktree_path or "").strip() if not path: return {"current_branch": None, "porcelain_status": ""} @@ -63,7 +71,7 @@ def read_worktree_git_state(worktree_path: str) -> dict: check=False, ) head_sha = (head_res.stdout or "").strip() if head_res.returncode == 0 else None - base_branch, base_sha = _find_matching_base_ref(path, head_sha) + base_branch, base_sha = _find_matching_base_ref(path, head_sha, extra_bases) return { "current_branch": current_branch, "porcelain_status": status_res.stdout or "", @@ -203,13 +211,26 @@ def _assessment( } -def _find_matching_base_ref(path: str, head_sha: str | None) -> tuple[str | None, str | None]: - """Return the stable branch ref whose commit matches HEAD, if any.""" +def _find_matching_base_ref( + path: str, + head_sha: str | None, + extra_bases: tuple[str, ...] | list[str] = (), +) -> tuple[str | None, str | None]: + """Return the stable branch ref whose commit matches HEAD, if any. + + Normal base branches (master/main/dev) are always considered. ``extra_bases`` + adds explicitly-approved stacked bases; each is checked as a local ref and via + the ``prgs``/``origin`` remotes. + """ if not head_sha: return None, None candidates: list[str] = [] for branch in sorted(BASE_BRANCHES): candidates.extend((f"origin/{branch}", branch)) + for branch in extra_bases: + name = (branch or "").strip() + if name: + candidates.extend((f"prgs/{name}", f"origin/{name}", name)) for ref in candidates: res = subprocess.run( ["git", "-C", path, "rev-parse", "--verify", ref], diff --git a/issue_work_duplicate_gate.py b/issue_work_duplicate_gate.py new file mode 100644 index 0000000..fc70437 --- /dev/null +++ b/issue_work_duplicate_gate.py @@ -0,0 +1,180 @@ +"""Early duplicate-work detection for author work-issue sessions (#400).""" + +from __future__ import annotations + +from typing import Any + +import issue_claim_heartbeat as claim_hb + +PHASE_LOCK = "lock_issue" +PHASE_COMMIT = "commit" +PHASE_PUSH = "push" +PHASE_CREATE_PR = "create_pr" + +OUTCOME_DUPLICATE_PR_PREVENTED = "duplicate_pr_prevented" +OUTCOME_DUPLICATE_BRANCH_PREVENTED = "duplicate_branch_prevented" +OUTCOME_DUPLICATE_COMMIT_PREVENTED = "duplicate_commit_prevented" +OUTCOME_DUPLICATE_WORK_NOT_PREVENTED = "duplicate_work_not_prevented" + +_ACTIVE_CLAIM_STATUSES = frozenset({"active", "awaiting_review"}) + + +def _issue_pattern(issue_number: int) -> str: + return f"issue-{int(issue_number)}" + + +def _linked_open_pr(issue_number: int, open_prs: list[dict]) -> dict | None: + return claim_hb._linked_open_pr(issue_number, open_prs) + + +def _matching_branches( + issue_number: int, + branch_names: list[str], + *, + locked_branch: str | None = None, +) -> list[str]: + pattern = _issue_pattern(issue_number) + matches = [ + name for name in (branch_names or []) + if pattern in (name or "").lower() + ] + if locked_branch: + locked = locked_branch.strip() + matches = [name for name in matches if name != locked] + return matches + + +def assess_work_issue_duplicate_gate( + issue_number: int, + *, + open_prs: list[dict] | None = None, + branch_names: list[str] | None = None, + claim_entry: dict | None = None, + locked_branch: str | None = None, + phase: str = PHASE_LOCK, +) -> dict[str, Any]: + """Fail closed when duplicate work is already in flight for an issue.""" + reasons: list[str] = [] + outcome = OUTCOME_DUPLICATE_WORK_NOT_PREVENTED + prs = list(open_prs or []) + branches = list(branch_names or []) + pattern = _issue_pattern(issue_number) + + linked = _linked_open_pr(issue_number, prs) + if linked: + reasons.append( + f"open PR #{linked.get('number')} already covers issue " + f"#{issue_number} (fail closed)" + ) + outcome = OUTCOME_DUPLICATE_PR_PREVENTED + + conflicting_branches = _matching_branches( + issue_number, branches, locked_branch=locked_branch + ) + if conflicting_branches: + names = ", ".join(conflicting_branches[:5]) + reasons.append( + f"remote branch(es) already match issue pattern '{pattern}': " + f"{names} (fail closed)" + ) + if outcome == OUTCOME_DUPLICATE_WORK_NOT_PREVENTED: + outcome = OUTCOME_DUPLICATE_BRANCH_PREVENTED + + entry = claim_entry or {} + if entry.get("linked_open_pr") and not linked: + reasons.append( + f"claim inventory reports open PR #{entry['linked_open_pr']} " + f"for issue #{issue_number} (fail closed)" + ) + outcome = OUTCOME_DUPLICATE_PR_PREVENTED + + status = (entry.get("status") or "").strip().lower() + if status in _ACTIVE_CLAIM_STATUSES and not linked: + heartbeat = entry.get("latest_heartbeat") or {} + claim_branch = (heartbeat.get("branch") or "").strip() + if locked_branch and claim_branch and claim_branch != locked_branch: + reasons.append( + f"active claim lease on branch '{claim_branch}' blocks " + f"work on '{locked_branch}' for issue #{issue_number} " + "(fail closed)" + ) + if outcome == OUTCOME_DUPLICATE_WORK_NOT_PREVENTED: + outcome = OUTCOME_DUPLICATE_BRANCH_PREVENTED + elif not locked_branch and status == "active": + reasons.append( + f"issue #{issue_number} has an active claim lease " + "(fail closed)" + ) + if outcome == OUTCOME_DUPLICATE_WORK_NOT_PREVENTED: + outcome = OUTCOME_DUPLICATE_BRANCH_PREVENTED + + if phase in {PHASE_COMMIT, PHASE_PUSH} and reasons: + if outcome == OUTCOME_DUPLICATE_PR_PREVENTED: + outcome = OUTCOME_DUPLICATE_COMMIT_PREVENTED + elif outcome == OUTCOME_DUPLICATE_BRANCH_PREVENTED: + outcome = OUTCOME_DUPLICATE_COMMIT_PREVENTED + + block = bool(reasons) + return { + "block": block, + "performed": not block, + "issue_number": issue_number, + "phase": phase, + "outcome": outcome, + "linked_open_pr": linked.get("number") if linked else entry.get("linked_open_pr"), + "conflicting_branches": conflicting_branches, + "claim_status": status or None, + "reasons": reasons, + "safe_next_action": ( + "stop before mutating; preserve local work and produce a " + "reconciliation handoff if a concurrent PR appeared after push" + if block and phase == PHASE_CREATE_PR + else "stop before mutating; do not commit or push duplicate work" + if block + else "proceed" + ), + } + + +def assess_work_issue_duplicate_report(report_text: str) -> dict[str, Any]: + """Require explicit duplicate-work outcome wording in work-issue reports.""" + text = (report_text or "").lower() + markers = { + OUTCOME_DUPLICATE_PR_PREVENTED: ( + "duplicate pr prevented", + "duplicate_pr_prevented", + ), + OUTCOME_DUPLICATE_BRANCH_PREVENTED: ( + "duplicate branch prevented", + "duplicate_branch_prevented", + ), + OUTCOME_DUPLICATE_COMMIT_PREVENTED: ( + "duplicate commit prevented", + "duplicate_commit_prevented", + ), + OUTCOME_DUPLICATE_WORK_NOT_PREVENTED: ( + "duplicate work not prevented", + "duplicate_work_not_prevented", + "no duplicate work", + ), + } + matched = [ + key for key, phrases in markers.items() + if any(phrase in text for phrase in phrases) + ] + if len(matched) != 1: + return { + "complete": False, + "downgraded": True, + "reasons": [ + "work-issue report must state exactly one duplicate-work " + "outcome (duplicate PR/branch/commit prevented, or " + "duplicate work not prevented)" + ], + } + return { + "complete": True, + "downgraded": False, + "outcome": matched[0], + "reasons": [], + } \ No newline at end of file diff --git a/mcp-menu.sh b/mcp-menu.sh new file mode 100755 index 0000000..4d124f2 --- /dev/null +++ b/mcp-menu.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +# mcp-menu.sh — Repository-root operator menu for MCP/Gitea workflow onboarding. +# +# Safe by default: read-only status and copy-paste prompts unless an action is +# explicitly labeled and confirmed. No branch deletion, force-push, lock-file +# editing, or raw API bypass. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$SCRIPT_DIR" + +pause() { + read -r -p "Press Enter to return to the menu..." +} + +print_banner() { + printf '\n=== Gitea-Tools MCP Operator Menu ===\n' + printf 'Repository: %s\n' "$REPO_ROOT" + printf 'Safe by default — destructive actions require explicit confirmation.\n\n' +} + +show_root_checkout_health() { + printf '\n--- Project status / root checkout health ---\n\n' + printf 'Current directory: %s\n' "$(pwd)" + local branch head_sha prgs_master_sha dirty + branch="$(git -C "$REPO_ROOT" branch --show-current 2>/dev/null || true)" + if [[ -z "$branch" ]]; then + branch="(detached HEAD)" + fi + printf 'Current branch: %s\n' "$branch" + printf '\nGit status (short, branch):\n' + git -C "$REPO_ROOT" status --short --branch || true + head_sha="$(git -C "$REPO_ROOT" rev-parse HEAD 2>/dev/null || echo 'unknown')" + printf '\nHEAD SHA: %s\n' "$head_sha" + if git -C "$REPO_ROOT" rev-parse --verify prgs/master >/dev/null 2>&1; then + prgs_master_sha="$(git -C "$REPO_ROOT" rev-parse prgs/master)" + printf 'prgs/master SHA: %s\n' "$prgs_master_sha" + if [[ "$head_sha" != "$prgs_master_sha" ]]; then + printf '\nWARNING: root checkout HEAD does not match prgs/master.\n' + printf 'Keep the stable control checkout on master/prgs/master.\n' + fi + else + printf 'prgs/master SHA: unavailable (remote ref not fetched)\n' + fi + if [[ -n "$(git -C "$REPO_ROOT" status --porcelain 2>/dev/null || true)" ]]; then + printf '\nWARNING: root checkout has uncommitted changes (dirty).\n' + printf 'Author mutations belong in a session worktree under branches/.\n' + fi + if [[ "$branch" != "master" && "$branch" != "main" && "$branch" != "dev" ]]; then + printf '\nWARNING: root checkout is not on a stable base branch (master/main/dev).\n' + printf 'Return to master before using the control checkout.\n' + fi + pause +} + +print_prompt_block() { + local title="$1" + local body="$2" + printf '\n--- %s ---\n\n' "$title" + printf '%s\n' "$body" + printf '\n(Copy the prompt above into your LLM session.)\n' + pause +} + +show_author_prompts() { + while true; do + printf '\n--- Author workflow prompts ---\n' + printf ' 1) Work issue (author/coder)\n' + printf ' 2) Conflict-fix author session\n' + printf ' 3) Root checkout recovery session\n' + printf ' 0) Back\n' + read -r -p 'Choice: ' choice + case "$choice" in + 1) + print_prompt_block "Author — work issue" \ +"You are the AUTHOR session for /. + +Goal: implement issue # only. + +Workflow: +1. Preflight: prove identity, work_issue/create_pr capability, clean session worktree under branches/. +2. gitea_lock_issue for issue # and branch feat/issue--. +3. Implement in the locked worktree only — never mutate the root control checkout. +4. Validate, commit, push, gitea_create_pr. Final report with issue, branch, SHA, PR, tests, mutation ledger." + ;; + 2) + print_prompt_block "Author — conflict-fix session" \ +"You are the AUTHOR session for / in conflict-fix mode. + +Goal: resolve merge conflicts on PR #

/ branch only. + +Workflow: +1. Preflight: prove author identity and exact push/commit capability for the locked PR branch. +2. Confirm conflict-fix lease and stale-head protection before pushing. +3. Work only in the session-owned worktree under branches/ — never the root checkout. +4. Rebase or merge target branch, run tests, push, update PR. No force-push without explicit operator approval. +5. Final report: conflict resolution proof, new HEAD SHA, tests, mutation ledger." + ;; + 3) + print_prompt_block "Author — root checkout recovery" \ +"You are a RECOVERY session for /. + +Goal: restore the stable root control checkout to clean master/prgs/master. + +Workflow: +1. Inspect root checkout: branch, git status --short --branch, HEAD vs prgs/master. +2. Do not implement features from the root checkout. Stash or move work to branches/ first. +3. Return root to master (or main/dev per project policy) matching prgs/master with no dirty tracked files. +4. Report before/after branch, SHA, dirty state, and safe next action for author worktree creation." + ;; + 0) return ;; + *) printf 'Invalid choice.\n' ;; + esac + done +} + +show_reviewer_prompts() { + print_prompt_block "Reviewer — PR review" \ +"You are the REVIEWER session for /. + +Goal: review PR #

only — do not merge unless explicitly switched to merger mode. + +Workflow: +1. Load canonical workflow: skills/llm-project-workflow/workflows/review-merge-pr.md +2. Preflight: prove reviewer identity, review_pr capability, clean review worktree. +3. gitea_view_pr, validate scope, run required checks in the correct worktree. +4. gitea_review_pr with approve, request-changes, or comment as warranted. +5. Final report: PR head SHA, verdict, validation evidence, mutation ledger. No merge in reviewer-only runs." +} + +show_merger_prompts() { + print_prompt_block "Merger — PR merge" \ +"You are the MERGER session for /. + +Goal: merge PR #

only after every gate passes. + +Workflow: +1. Load canonical workflow: skills/llm-project-workflow/workflows/review-merge-pr.md +2. Preflight: prove merger identity and exact merge_pr capability for the current PR head SHA. +3. Confirm approval pins the current head SHA; re-validate if the branch moved. +4. gitea_merge_pr only on explicit operator approval after all gates pass. +5. Final report: merged SHA, cleanup handoff, mutation ledger." +} + +show_reconciler_prompts() { + print_prompt_block "Reconciler — already-landed / closed PR cleanup" \ +"You are the RECONCILER session for /. + +Goal: reconcile already-landed open PRs — close or comment only when exact capability is proven. + +Workflow: +1. Load canonical workflow: skills/llm-project-workflow/workflows/reconcile-landed-pr.md +2. Preflight: prove reconciler identity and gitea.pr.close (or authorized close) capability. +3. Do not review, merge, implement code, or create branches. +4. gitea_scan_already_landed_open_prs / gitea_reconcile_already_landed_pr as appropriate. +5. Final report: PR numbers handled, close proof, mutation ledger." +} + +show_onboarding_prompt() { + print_prompt_block "Onboarding — new project to MCP workflow" \ +"Onboard / into the MCP Control Plane workflow. + +Checklist: +1. Prove identity and task capability via gitea_whoami and gitea_resolve_task_capability. +2. Configure separate MCP namespaces/profiles: author, reviewer, merger/reconciler as needed. +3. Register gitea-tools (and jenkins-mcp / glitchtip-mcp if applicable) in the client MCP config. +4. Copy skills/llm-project-workflow/SKILL.md guidance into the target repo or ECC install. +5. Verify gitea_get_runtime_context, gitea_lock_issue, and worktree rules under branches/. +6. Run ./mcp-menu.sh for day-to-day prompts; use docs/mcp-menu.md and docs/llm-workflow-runbooks.md. + +Canonical router: skills/llm-project-workflow/SKILL.md" +} + +show_proxmox_placeholder() { + printf '\n--- Proxmox deployment (placeholder) ---\n\n' + printf 'Push this project to Proxmox — TODO / issue-backed\n' + printf 'Create Proxmox LXC — TODO / issue-backed\n\n' + printf 'These actions are NOT implemented yet.\n' + printf 'Track deployment automation in dedicated Gitea issues before enabling here.\n' + printf 'This menu will not run deploy scripts until sanctioned tooling exists.\n' + pause +} + +run_tests() { + printf '\n--- Run tests ---\n\n' + if [[ -x "$REPO_ROOT/run-tests.sh" ]]; then + printf 'Running ./run-tests.sh ...\n\n' + (cd "$REPO_ROOT" && ./run-tests.sh) + pause + return + fi + if [[ -x "$REPO_ROOT/venv/bin/python" ]]; then + printf 'run-tests.sh not found; falling back to venv/bin/python -m pytest\n\n' + (cd "$REPO_ROOT" && ./venv/bin/python -m pytest) + pause + return + fi + printf 'ERROR: No test runner available (fail closed).\n' >&2 + printf 'Expected ./run-tests.sh or venv/bin/python for pytest fallback.\n' >&2 + exit 1 +} + +main_menu() { + while true; do + print_banner + printf ' 1) Project status / root checkout health\n' + printf ' 2) Author workflow prompts\n' + printf ' 3) Reviewer workflow prompts\n' + printf ' 4) Merger workflow prompts\n' + printf ' 5) Reconciler workflow prompts\n' + printf ' 6) Onboarding new project to this MCP workflow\n' + printf ' 7) Proxmox deployment menu placeholder\n' + printf ' 8) Create Proxmox LXC placeholder\n' + printf ' 9) Run tests\n' + printf ' 0) Exit\n' + read -r -p 'Choice: ' choice + case "$choice" in + 1) show_root_checkout_health ;; + 2) show_author_prompts ;; + 3) show_reviewer_prompts ;; + 4) show_merger_prompts ;; + 5) show_reconciler_prompts ;; + 6) show_onboarding_prompt ;; + 7|8) show_proxmox_placeholder ;; + 9) run_tests ;; + 0) printf 'Goodbye.\n'; exit 0 ;; + *) printf 'Invalid choice.\n'; pause ;; + esac + done +} + +main_menu \ No newline at end of file diff --git a/mcp_native_cleanup_proof.py b/mcp_native_cleanup_proof.py new file mode 100644 index 0000000..70f2a5e --- /dev/null +++ b/mcp_native_cleanup_proof.py @@ -0,0 +1,359 @@ +"""MCP-native post-merge cleanup proof verifier (#517). + +Post-merge cleanup of leases, comments, branches, and worktrees must be +proven through explicit MCP tools (or approved helpers), never raw scripts or +ad hoc git/API commands. Merger/reviewer sessions must hand cleanup to a +reconciler profile with the right capability proof. +""" + +from __future__ import annotations + +import re +from typing import Any + +AUTHORIZED_CLEANUP_TOOLS = frozenset({ + "gitea_cleanup_post_merge_moot_lease", + "gitea_reconcile_merged_cleanups", + "gitea_delete_branch", + "gitea_cleanup_merged_pr_branch", + "gitea_audit_worktree_cleanup", + "gitea_capture_branches_worktree_snapshot", + "gitea_assess_worktree_cleanup_integrity", + "gitea_authorize_reconciliation_cleanup_phase", +}) + +_RAW_BRANCH_DELETE_PATTERNS = ( + re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+branch\s+-[dD]\b[^\n\r]*", re.I), + re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s--delete\b[^\n\r]*", re.I), + re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+push\b[^\n\r]*\s:[^\s`]+", re.I), +) + +_RAW_COMMENT_DELETE_PATTERNS = ( + re.compile( + r"(?:curl|wget|httpie)\b[^\n\r]*\b(?:DELETE|delete)\b[^\n\r]*" + r"(?:/comments/|issues/\d+/comments)", + re.I, + ), + re.compile( + r"\bDELETE\b[^\n\r]*/repos/[^\n\r]*/issues/\d+/comments/\d+", + re.I, + ), + re.compile( + r"\b(?:delete_issue_comment|remove_issue_comment|purge_comments?)\b", + re.I, + ), + re.compile( + r"\b(?:raw|ad hoc|adhoc)\b[^\n\r]{0,40}\bcomment\b[^\n\r]{0,40}\bdelete", + re.I, + ), +) + +_CLEANUP_MUTATIONS_RE = re.compile( + r"^\s*[-*]?\s*cleanup mutations\s*:\s*(.+)$", + re.IGNORECASE | re.MULTILINE, +) +_GIT_REF_MUTATIONS_RE = re.compile( + r"^\s*[-*]?\s*git ref mutations\s*:\s*(.+)$", + re.IGNORECASE | re.MULTILINE, +) +_WORKTREE_MUTATIONS_RE = re.compile( + r"^\s*[-*]?\s*worktree mutations\s*:\s*(.+)$", + re.IGNORECASE | re.MULTILINE, +) +_MERGE_MUTATIONS_RE = re.compile( + r"^\s*[-*]?\s*merge mutations\s*:\s*(.+)$", + re.IGNORECASE | re.MULTILINE, +) +_NONE_LEDGER_VALUES = frozenset({"", "none", "n/a"}) +_MUTATION_LEDGER_PATTERNS = ( + _CLEANUP_MUTATIONS_RE, + _GIT_REF_MUTATIONS_RE, + _WORKTREE_MUTATIONS_RE, +) +_RAW_WORKTREE_REMOVE_PATTERNS = ( + re.compile(r"\bgit(?:\s+-C\s+\S+)?\s+worktree\s+remove\b", re.I), +) +_AUTHORIZED_TOOL_RE = re.compile( + r"\b(" + "|".join(re.escape(t) for t in sorted(AUTHORIZED_CLEANUP_TOOLS)) + r")\b", + re.IGNORECASE, +) +_RECONCILER_CAPABILITY_RE = re.compile( + r"(?:reconciler|gitea\.branch\.delete|delete_branch|reconcile_merged_cleanups)", + re.IGNORECASE, +) +_MERGER_CLEANUP_ROLE_RE = re.compile( + r"(?:merger|reviewer).{0,80}(?:deleted|removed|cleaned).{0,80}" + r"(?:branch|worktree|comment|lease)", + re.IGNORECASE, +) +_HANDOFF_TO_RECONCILER_RE = re.compile( + r"(?:hand(?:ed)? off|defer(?:red)?|next actor).{0,60}reconciler", + re.IGNORECASE, +) + + +def _ledger_field_body(text: str, pattern: re.Pattern[str]) -> str | None: + match = pattern.search(text) + if not match: + return None + return (match.group(1) or "").strip() + + +def _is_none_ledger_value(value: str) -> bool: + return value.strip().lower() in _NONE_LEDGER_VALUES + + +def scoped_cleanup_mutation_ledger_text(text: str | None) -> str: + """Return mutation-ledger bodies scoped to cleanup enforcement (#517).""" + text = text or "" + parts: list[str] = [] + for pattern in _MUTATION_LEDGER_PATTERNS: + body = _ledger_field_body(text, pattern) + if body is not None and not _is_none_ledger_value(body): + parts.append(body) + return "\n".join(parts) + + +def _git_ref_cleanup_claimed(body: str) -> bool: + return bool(raw_branch_delete_commands(body)) + + +def _worktree_cleanup_claimed(body: str) -> bool: + for pattern in _RAW_WORKTREE_REMOVE_PATTERNS: + if pattern.search(body): + return True + return bool( + re.search( + r"\b(?:removed|deleted)\b[^\n]{0,40}\bworktree\b", + body, + re.IGNORECASE, + ) + ) + + +def _cleanup_claiming_ledger_fields(text: str) -> list[tuple[str, str]]: + claiming: list[tuple[str, str]] = [] + cleanup_body = _ledger_field_body(text, _CLEANUP_MUTATIONS_RE) + if cleanup_body is not None and not _is_none_ledger_value(cleanup_body): + claiming.append(("Cleanup mutations", cleanup_body)) + + git_ref_body = _ledger_field_body(text, _GIT_REF_MUTATIONS_RE) + if git_ref_body is not None and not _is_none_ledger_value(git_ref_body): + if _git_ref_cleanup_claimed(git_ref_body): + claiming.append(("Git ref mutations", git_ref_body)) + + worktree_body = _ledger_field_body(text, _WORKTREE_MUTATIONS_RE) + if worktree_body is not None and not _is_none_ledger_value(worktree_body): + if _worktree_cleanup_claimed(worktree_body): + claiming.append(("Worktree mutations", worktree_body)) + return claiming + + +def raw_branch_delete_commands(text: str | None) -> list[str]: + """Return raw git branch-delete commands cited in *text*.""" + if not text: + return [] + commands: list[str] = [] + for pattern in _RAW_BRANCH_DELETE_PATTERNS: + commands.extend(match.group(0).strip("` ") for match in pattern.finditer(text)) + return list(dict.fromkeys(commands)) + + +def raw_comment_delete_commands(text: str | None) -> list[str]: + """Return raw comment-deletion commands/scripts cited in *text*.""" + if not text: + return [] + commands: list[str] = [] + for pattern in _RAW_COMMENT_DELETE_PATTERNS: + commands.extend(match.group(0).strip("` ") for match in pattern.finditer(text)) + return list(dict.fromkeys(commands)) + + +def assess_raw_branch_delete_report(text: str | None) -> dict[str, Any]: + """Fail closed when mutation ledgers cite raw git branch deletion.""" + commands = raw_branch_delete_commands(scoped_cleanup_mutation_ledger_text(text)) + reasons = [ + ( + "raw git branch deletion bypasses MCP branch.delete cleanup gates: " + f"{command}" + ) + for command in commands + ] + return { + "proven": not reasons, + "block": bool(reasons), + "commands": commands, + "reasons": reasons, + "safe_next_action": ( + "use gitea_delete_branch, gitea_reconcile_merged_cleanups, or another " + "approved MCP cleanup helper with explicit branch.delete capability" + if reasons + else "proceed" + ), + } + + +def assess_raw_comment_delete_report(text: str | None) -> dict[str, Any]: + """Fail closed when mutation ledgers cite raw comment deletion.""" + commands = raw_comment_delete_commands(scoped_cleanup_mutation_ledger_text(text)) + reasons = [ + ( + "raw comment deletion bypasses MCP lease cleanup gates: " + f"{command}" + ) + for command in commands + ] + return { + "proven": not reasons, + "block": bool(reasons), + "commands": commands, + "reasons": reasons, + "safe_next_action": ( + "use gitea_cleanup_post_merge_moot_lease (append-only release comment) " + "or hand cleanup to a reconciler session; never delete lease comments" + if reasons + else "proceed" + ), + } + + +def assess_merge_cleanup_mutation_separation(text: str | None) -> dict[str, Any]: + """Require merge mutations and cleanup mutations in separate ledger fields.""" + text = text or "" + merge_match = _MERGE_MUTATIONS_RE.search(text) + cleanup_match = _CLEANUP_MUTATIONS_RE.search(text) + reasons: list[str] = [] + + if cleanup_match and not merge_match: + combined = re.search( + r"merge.{0,40}cleanup mutations|cleanup.{0,40}merge mutations", + text, + re.IGNORECASE, + ) + if combined: + reasons.append( + "merge and cleanup mutations must use separate 'Merge mutations' " + "and 'Cleanup mutations' ledger fields" + ) + + if merge_match and cleanup_match: + merge_val = (merge_match.group(1) or "").strip().lower() + cleanup_val = (cleanup_match.group(1) or "").strip().lower() + if merge_val == cleanup_val and merge_val not in {"", "none"}: + reasons.append( + "merge mutations and cleanup mutations must not duplicate the " + "same ledger entry" + ) + + return { + "proven": not reasons, + "block": bool(reasons), + "reasons": reasons, + "safe_next_action": ( + "split merge mutations (gitea_merge_pr) from cleanup mutations " + "(reconciler MCP tools) in the controller handoff" + if reasons + else "proceed" + ), + } + + +def assess_authorized_reconciler_cleanup_path(text: str | None) -> dict[str, Any]: + """Validate cleanup claims cite authorized MCP tools and reconciler capability.""" + text = text or "" + claiming = _cleanup_claiming_ledger_fields(text) + if not claiming: + return { + "proven": True, + "block": False, + "reasons": [], + "safe_next_action": "proceed", + } + + reasons: list[str] = [] + for field_name, body in claiming: + if not _AUTHORIZED_TOOL_RE.search(body): + reasons.append( + f"{field_name} cleanup must name an authorized MCP cleanup tool " + f"({', '.join(sorted(AUTHORIZED_CLEANUP_TOOLS))})" + ) + if not _RECONCILER_CAPABILITY_RE.search(text): + reasons.append( + "post-merge cleanup requires reconciler capability proof " + "(reconciler profile, gitea.branch.delete, or reconcile_merged_cleanups)" + ) + + return { + "proven": not reasons, + "block": bool(reasons), + "reasons": reasons, + "safe_next_action": ( + "hand cleanup to a prgs-reconciler session and cite the exact MCP tool " + "plus delete_branch/reconcile_merged_cleanups capability proof" + if reasons + else "proceed" + ), + } + + +def assess_merger_cleanup_handoff_guidance(text: str | None) -> dict[str, Any]: + """Merger sessions that performed cleanup must hand off to reconciler.""" + text = text or "" + if not _MERGER_CLEANUP_ROLE_RE.search(text): + return { + "proven": True, + "block": False, + "reasons": [], + "safe_next_action": "proceed", + } + if _HANDOFF_TO_RECONCILER_RE.search(text): + return { + "proven": True, + "block": False, + "reasons": [], + "safe_next_action": "proceed", + } + return { + "proven": False, + "block": True, + "reasons": [ + "merger/reviewer session performed cleanup but did not hand off to " + "reconciler for MCP-native cleanup" + ], + "safe_next_action": ( + "merger sessions must end with cleanup handed to prgs-reconciler; " + "do not perform ad hoc branch/comment/worktree cleanup as merger" + ), + } + + +def assess_mcp_native_cleanup_proof(report_text: str | None) -> dict[str, Any]: + """Composite #517 verifier for MCP-native post-merge cleanup proof.""" + text = report_text or "" + checks = ( + assess_raw_branch_delete_report(text), + assess_raw_comment_delete_report(text), + assess_merge_cleanup_mutation_separation(text), + assess_authorized_reconciler_cleanup_path(text), + assess_merger_cleanup_handoff_guidance(text), + ) + reasons: list[str] = [] + safe_next = "proceed" + for result in checks: + reasons.extend(result.get("reasons") or []) + if result.get("block") and result.get("safe_next_action"): + safe_next = result["safe_next_action"] + + block = bool(reasons) + return { + "proven": not block, + "block": block, + "reasons": reasons, + "safe_next_action": safe_next, + "raw_branch_commands": raw_branch_delete_commands( + scoped_cleanup_mutation_ledger_text(text) + ), + "raw_comment_commands": raw_comment_delete_commands( + scoped_cleanup_mutation_ledger_text(text) + ), + } \ No newline at end of file diff --git a/mcp_session_state.py b/mcp_session_state.py new file mode 100644 index 0000000..3f93784 --- /dev/null +++ b/mcp_session_state.py @@ -0,0 +1,391 @@ +"""Durable MCP session validation state shared across daemon process pools (#559). + +The IDE often routes sequential MCP tool calls to different daemon processes. +Session-scoped proofs (workflow load, review decision lock) must therefore +survive process boundaries while remaining fail-closed against spoofing. + +Security notes (extends #211): +- Never store under host-global ``/tmp`` (world-writable, spoofable). +- Default root is ``~/.cache/gitea-tools/session-state`` (mode ``0o700``). +- Files are written atomically with mode ``0o600``. +- Records are keyed by remote + org + repo + profile identity, not by PID. +- TTL prevents indefinitely stale reuse across unrelated sessions. +""" + +from __future__ import annotations + +import fcntl +import json +import os +import re +import tempfile +from contextlib import contextmanager +from datetime import datetime, timedelta, timezone +from typing import Any + +STATE_DIR_ENV = "GITEA_MCP_SESSION_STATE_DIR" +DEFAULT_STATE_DIR = os.path.expanduser("~/.cache/gitea-tools/session-state") +TTL_HOURS_ENV = "GITEA_MCP_SESSION_STATE_TTL_HOURS" +DEFAULT_TTL_HOURS = 4.0 + +KIND_WORKFLOW_LOAD = "review_workflow_load" +KIND_DECISION_LOCK = "review_decision_lock" + +_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+") +SESSION_PROFILE_LOCK_ENV = "GITEA_SESSION_PROFILE_LOCK" + + +def default_state_dir() -> str: + raw = (os.environ.get(STATE_DIR_ENV) or DEFAULT_STATE_DIR).strip() + return raw or DEFAULT_STATE_DIR + + +def ttl_hours() -> float: + raw = (os.environ.get(TTL_HOURS_ENV) or "").strip() + if not raw: + return DEFAULT_TTL_HOURS + try: + value = float(raw) + except ValueError: + return DEFAULT_TTL_HOURS + return value if value > 0 else DEFAULT_TTL_HOURS + + +def _sanitize_segment(value: str) -> str: + text = (value or "").strip() + if not text: + return "_" + return _SAFE_SEGMENT_RE.sub("_", text) + + +def current_profile_identity( + profile_name: str | None = None, + session_profile_lock: str | None = None, + profile_identity: str | None = None, +) -> str: + """Resolve the session profile identity used as the durable key.""" + env_lock = (os.environ.get(SESSION_PROFILE_LOCK_ENV) or "").strip() + explicit = (profile_identity or session_profile_lock or "").strip() + lock = (explicit or env_lock or "").strip() + name = (profile_name or "").strip() + return lock or name or "unknown-profile" + + +def state_key( + *, + kind: str, + remote: str | None = None, + org: str | None = None, + repo: str | None = None, + profile_identity: str | None = None, +) -> str: + """Build durable filename key. + + Session proofs are one-active-per-profile (workflow load / decision lock), + so the primary key is kind + profile identity. Remote/org/repo are stored + inside the payload and validated on load (#559), which lets a later daemon + process recover state without already knowing the remote argument. + """ + # Keep remote/org/repo parameters for API stability / future kinds; they are + # intentionally not part of the filename for session-scoped proofs. + _ = (remote, org, repo) + return "-".join( + _sanitize_segment(part) + for part in ( + kind, + profile_identity or "unknown-profile", + ) + ) + + +def state_file_path( + *, + kind: str, + remote: str | None = None, + org: str | None = None, + repo: str | None = None, + profile_identity: str | None = None, + state_dir: str | None = None, +) -> str: + root = (state_dir or default_state_dir()).strip() + name = state_key( + kind=kind, + remote=remote, + org=org, + repo=repo, + profile_identity=profile_identity, + ) + return os.path.join(root, f"{name}.json") + + +def _ensure_state_dir(state_dir: str | None = None) -> str: + root = (state_dir or default_state_dir()).strip() + os.makedirs(root, mode=0o700, exist_ok=True) + return root + + +def _now_utc() -> datetime: + return datetime.now(timezone.utc) + + +def _parse_iso(value: str | None) -> datetime | None: + text = (value or "").strip() + if not text: + return None + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +@contextmanager +def _exclusive_file_lock(lock_path: str): + os.makedirs(os.path.dirname(lock_path) or ".", exist_ok=True) + fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600) + try: + fcntl.flock(fd, fcntl.LOCK_EX) + yield fd + finally: + try: + fcntl.flock(fd, fcntl.LOCK_UN) + finally: + os.close(fd) + + +def _read_json(path: str) -> dict[str, Any] | None: + if not path or not os.path.exists(path): + return None + try: + with open(path, encoding="utf-8") as handle: + data = json.load(handle) + except (OSError, json.JSONDecodeError): + return None + return data if isinstance(data, dict) else None + + +def _write_json(path: str, data: dict[str, Any]) -> None: + parent = os.path.dirname(path) or "." + os.makedirs(parent, mode=0o700, exist_ok=True) + payload = json.dumps(data, indent=2, sort_keys=True) + "\n" + fd, temp_path = tempfile.mkstemp(prefix=".session-", suffix=".json", dir=parent) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + os.chmod(temp_path, 0o600) + os.replace(temp_path, path) + try: + os.chmod(path, 0o600) + except OSError: + pass + finally: + if os.path.exists(temp_path): + try: + os.remove(temp_path) + except OSError: + pass + + +def identity_match_reasons( + record: dict[str, Any] | None, + *, + remote: str | None = None, + org: str | None = None, + repo: str | None = None, + profile_identity: str | None = None, +) -> list[str]: + """Return fail-closed reasons when durable record identity does not match.""" + if record is None: + return [] + reasons: list[str] = [] + expected_profile = current_profile_identity(profile_identity=profile_identity) + stored_profile = ( + (record.get("session_profile_lock") or record.get("profile_identity") or "") + .strip() + ) + if stored_profile and expected_profile and stored_profile != expected_profile: + if expected_profile != "unknown-profile": + reasons.append( + "session state profile identity mismatch " + f"(stored={stored_profile!r}, active={expected_profile!r}; fail closed)" + ) + + for field, expected in ( + ("remote", remote), + ("org", org), + ("repo", repo), + ): + want = (expected or "").strip() + have = (str(record.get(field) or "")).strip() + if want and have and want != have: + reasons.append( + f"session state {field} mismatch " + f"(stored={have!r}, expected={want!r}; fail closed)" + ) + + recorded_at = _parse_iso(record.get("recorded_at") or record.get("updated_at")) + if recorded_at is None: + reasons.append("session state missing recorded_at timestamp (fail closed)") + else: + age = _now_utc() - recorded_at + if age > timedelta(hours=ttl_hours()): + reasons.append( + f"session state expired after {ttl_hours():g}h (fail closed)" + ) + if age < timedelta(0): + reasons.append("session state recorded_at is in the future (fail closed)") + return reasons + + +def load_state( + *, + kind: str, + remote: str | None = None, + org: str | None = None, + repo: str | None = None, + profile_identity: str | None = None, + state_dir: str | None = None, +) -> dict[str, Any] | None: + """Load durable state payload when identity checks pass.""" + profile = current_profile_identity(profile_identity=profile_identity) + path = state_file_path( + kind=kind, + remote=remote, + org=org, + repo=repo, + profile_identity=profile, + state_dir=state_dir, + ) + lock_path = f"{path}.lock" + with _exclusive_file_lock(lock_path): + envelope = _read_json(path) + if not envelope: + return None + payload = envelope.get("payload") + if not isinstance(payload, dict): + return None + # Identity fields live on both envelope and payload for convenience. + merged = dict(payload) + for key in ( + "kind", + "remote", + "org", + "repo", + "profile_identity", + "session_profile_lock", + "recorded_at", + "updated_at", + "writer_pid", + ): + if key in envelope and key not in merged: + merged[key] = envelope[key] + reasons = identity_match_reasons( + merged, + remote=remote, + org=org, + repo=repo, + profile_identity=profile, + ) + if reasons: + return None + return merged + + +def save_state( + *, + kind: str, + payload: dict[str, Any] | None, + remote: str | None = None, + org: str | None = None, + repo: str | None = None, + profile_identity: str | None = None, + state_dir: str | None = None, +) -> dict[str, Any] | None: + """Persist or clear durable state for the given session identity key.""" + profile = current_profile_identity( + profile_name=payload.get("session_profile") if payload else None, + session_profile_lock=( + (payload or {}).get("session_profile_lock") or profile_identity + ), + ) + # Prefer explicit args over payload fields for key location. + key_remote = remote if remote is not None else (payload or {}).get("remote") + key_org = org if org is not None else (payload or {}).get("org") + key_repo = repo if repo is not None else (payload or {}).get("repo") + + root = _ensure_state_dir(state_dir) + path = state_file_path( + kind=kind, + remote=key_remote, + org=key_org, + repo=key_repo, + profile_identity=profile, + state_dir=root, + ) + lock_path = f"{path}.lock" + with _exclusive_file_lock(lock_path): + if payload is None: + for candidate in (path, lock_path): + if os.path.exists(candidate): + try: + os.remove(candidate) + except OSError: + pass + return None + + now = _now_utc().isoformat().replace("+00:00", "Z") + body = dict(payload) + body.setdefault("session_pid", os.getpid()) + body["writer_pid"] = os.getpid() + body["profile_identity"] = profile + if not (body.get("session_profile_lock") or "").strip(): + body["session_profile_lock"] = profile + body["recorded_at"] = body.get("recorded_at") or now + body["updated_at"] = now + if key_remote is not None: + body["remote"] = key_remote + if key_org is not None: + body["org"] = key_org + if key_repo is not None: + body["repo"] = key_repo + + envelope = { + "kind": kind, + "remote": key_remote, + "org": key_org, + "repo": key_repo, + "profile_identity": profile, + "session_profile_lock": body.get("session_profile_lock"), + "recorded_at": body["recorded_at"], + "updated_at": body["updated_at"], + "writer_pid": body["writer_pid"], + "payload": body, + } + _write_json(path, envelope) + return dict(body) + + +def clear_state( + *, + kind: str, + remote: str | None = None, + org: str | None = None, + repo: str | None = None, + profile_identity: str | None = None, + state_dir: str | None = None, +) -> None: + save_state( + kind=kind, + payload=None, + remote=remote, + org=org, + repo=repo, + profile_identity=profile_identity, + state_dir=state_dir, + ) diff --git a/merge_approval_gate.py b/merge_approval_gate.py new file mode 100644 index 0000000..08fc8ad --- /dev/null +++ b/merge_approval_gate.py @@ -0,0 +1,61 @@ +"""Merge approval must pin the current PR head SHA (#471). + +Formal APPROVED reviews that predate the live PR head must not satisfy +``gitea_merge_pr`` eligibility. Pure assessment helpers are isolated here +for hermetic unit tests apart from MCP HTTP calls. +""" + +from __future__ import annotations + + +def assess_merge_approval_head( + *, + current_head_sha: str | None, + latest_by_reviewer: dict, +) -> dict: + """Return whether a visible approval applies to the live PR head. + + Args: + current_head_sha: Current PR head commit SHA. + latest_by_reviewer: Map of reviewer login → review entry dicts with + ``verdict``, ``dismissed``, and ``reviewed_head_sha`` keys. + + Returns: + dict with ``approval_at_current_head``, ``latest_approved_head_sha``, + and ``stale_approval_block_reason`` (set when merge must fail closed). + """ + current = (current_head_sha or "").strip() + approved_entries = [ + entry + for entry in (latest_by_reviewer or {}).values() + if (entry.get("verdict") or "").upper() == "APPROVED" + and not entry.get("dismissed") + ] + at_current = any( + (entry.get("reviewed_head_sha") or "").strip() == current + for entry in approved_entries + if current + ) + latest_approved = None + if approved_entries: + latest_entry = sorted( + approved_entries, + key=lambda entry: ( + entry.get("submitted_at") or "", + entry.get("reviewed_head_sha") or "", + ), + )[-1] + latest_approved = (latest_entry.get("reviewed_head_sha") or "").strip() or None + reason = None + if approved_entries and not at_current: + reason = ( + f"stale approval: approved SHA '{latest_approved}' does not match " + f"current live PR head SHA '{current or '(unknown)'}' (fail closed); " + "required next action: re-review PR at current head before merge" + ) + + return { + "approval_at_current_head": at_current, + "latest_approved_head_sha": latest_approved, + "stale_approval_block_reason": reason, + } \ No newline at end of file diff --git a/merged_cleanup_reconcile.py b/merged_cleanup_reconcile.py index 60204f6..bf81258 100644 --- a/merged_cleanup_reconcile.py +++ b/merged_cleanup_reconcile.py @@ -13,9 +13,9 @@ import subprocess from typing import Any from reviewer_worktree import parse_dirty_tracked_files +import issue_lock_store PROTECTED_BRANCHES = frozenset({"master", "main", "dev"}) -ISSUE_LOCK_FILE = os.environ.get("GITEA_ISSUE_LOCK_FILE", "/tmp/gitea_issue_lock.json") CLOSES_FIXES_RE = re.compile(r"\b(?:closes|fixes)\s+#(\d+)\b", re.IGNORECASE) @@ -37,22 +37,18 @@ def resolve_worktree_path(project_root: str, branch: str) -> str: def read_issue_lock(path: str | None = None) -> dict[str, Any] | None: - lock_path = (path or ISSUE_LOCK_FILE).strip() - if not lock_path or not os.path.exists(lock_path): - return None - try: - with open(lock_path, encoding="utf-8") as handle: - data = json.load(handle) - except (OSError, json.JSONDecodeError): - return None - return data if isinstance(data, dict) else None + if path: + return issue_lock_store.read_lock_file(path.strip()) + return issue_lock_store.read_session_issue_lock() def has_active_issue_lock(branch: str, lock_path: str | None = None) -> bool: - lock = read_issue_lock(lock_path) - if not lock: - return False - return (lock.get("branch_name") or "").strip() == (branch or "").strip() + if lock_path: + lock = issue_lock_store.read_lock_file(lock_path.strip()) + if not lock: + return False + return (lock.get("branch_name") or "").strip() == (branch or "").strip() + return issue_lock_store.has_active_issue_lock(branch) def collect_open_pr_heads(open_prs: list[dict[str, Any]]) -> set[str]: diff --git a/merger_lease_adoption.py b/merger_lease_adoption.py new file mode 100644 index 0000000..8c05de1 --- /dev/null +++ b/merger_lease_adoption.py @@ -0,0 +1,253 @@ +"""Guarded merger adoption of an existing reviewer PR lease (#536). + +Replaces manual in-process ``_SESSION_LEASE`` seeding with an auditable, +comment-backed adoption path for merger sessions that inherit a reviewer lease +after formal approval at the current PR head. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +import reviewer_pr_lease as leases + +ADOPTION_MARKER = "" +DEFAULT_ADOPTION_REASON = "merger-handoff-approved-head" + +SOURCE_ADOPT = "gitea_adopt_merger_pr_lease" +SOURCE_ACQUIRE = "gitea_acquire_reviewer_pr_lease" +SOURCE_HEARTBEAT = "gitea_heartbeat_reviewer_pr_lease" + +SANCTIONED_PROVENANCE_SOURCES = frozenset({ + SOURCE_ADOPT, + SOURCE_ACQUIRE, + SOURCE_HEARTBEAT, +}) + +_MERGER_ADOPTABLE_FRESHNESS = frozenset({"active", "stale_warning"}) + + +def format_adoption_body( + *, + repo: str, + pr_number: int, + issue_number: int | None, + adopter_identity: str, + adopter_profile: str, + adopter_session_id: str, + worktree: str, + candidate_head: str | None, + target_branch: str, + target_branch_sha: str | None, + adopted_from_session_id: str, + adopted_from_profile: str, + adopted_from_reviewer_identity: str, + adopted_from_comment_id: int | None, + adoption_reason: str = DEFAULT_ADOPTION_REASON, + adopted_at: datetime | None = None, +) -> str: + """Render a durable merger adoption proof comment.""" + adopted_at = adopted_at or datetime.now(timezone.utc) + adopted_text = adopted_at.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace( + "+00:00", "Z" + ) + lease_body = leases.format_lease_body( + repo=repo, + pr_number=pr_number, + issue_number=issue_number, + reviewer_identity=adopter_identity, + profile=adopter_profile, + session_id=adopter_session_id, + worktree=worktree, + phase="adopted", + candidate_head=candidate_head, + target_branch=target_branch, + target_branch_sha=target_branch_sha, + last_activity=adopted_at, + blocker="none", + ) + lines = [ + ADOPTION_MARKER, + f"adopted_at: {adopted_text}", + f"adopted_by_identity: {adopter_identity}", + f"adopted_by_profile: {adopter_profile}", + f"adopted_from_session_id: {adopted_from_session_id}", + f"adopted_from_profile: {adopted_from_profile}", + f"adopted_from_reviewer_identity: {adopted_from_reviewer_identity}", + f"adopted_from_comment_id: {adopted_from_comment_id or 'none'}", + f"adoption_reason: {adoption_reason}", + lease_body, + ] + return "\n".join(lines) + + +def is_adoption_comment(body: str) -> bool: + return ADOPTION_MARKER in (body or "") + + +def build_lease_provenance( + *, + source: str, + comment_id: int | None = None, + adopted_from_session_id: str | None = None, + adopted_from_profile: str | None = None, + adopted_from_reviewer_identity: str | None = None, + adoption_reason: str | None = None, + recorded_at: datetime | None = None, +) -> dict[str, Any]: + recorded_at = recorded_at or datetime.now(timezone.utc) + recorded_text = recorded_at.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace( + "+00:00", "Z" + ) + proof = { + "source": source, + "recorded_at": recorded_text, + } + if comment_id is not None: + proof["comment_id"] = comment_id + if adopted_from_session_id: + proof["adopted_from_session_id"] = adopted_from_session_id + if adopted_from_profile: + proof["adopted_from_profile"] = adopted_from_profile + if adopted_from_reviewer_identity: + proof["adopted_from_reviewer_identity"] = adopted_from_reviewer_identity + if adoption_reason: + proof["adoption_reason"] = adoption_reason + return proof + + +def is_sanctioned_session_lease(session: dict[str, Any] | None) -> bool: + if not session: + return False + provenance = session.get("lease_provenance") or {} + source = (provenance.get("source") or "").strip() + if source not in SANCTIONED_PROVENANCE_SOURCES: + return False + if source == SOURCE_ADOPT: + return bool(provenance.get("comment_id")) and bool( + provenance.get("adopted_from_session_id") + ) + if source in {SOURCE_ACQUIRE, SOURCE_HEARTBEAT}: + return bool(session.get("comment_id") or provenance.get("comment_id")) + return False + + +def assess_adopt_merger_lease( + comments: list[dict], + *, + pr_number: int, + adopter_identity: str, + adopter_profile: str, + adopter_session_id: str, + repo: str, + issue_number: int | None, + worktree: str, + expected_head_sha: str | None, + live_head_sha: str | None, + approval_at_head: bool, + pr_open: bool = True, + now: datetime | None = None, +) -> dict[str, Any]: + """Decide whether a merger may adopt the active reviewer lease (#536).""" + now = now or datetime.now(timezone.utc) + reasons: list[str] = [] + pinned = leases._normalize_sha(expected_head_sha) + live = leases._normalize_sha(live_head_sha) + + if not pr_open: + reasons.append( + f"PR #{pr_number} is not open; merger lease adoption is only for open PRs" + ) + if not approval_at_head: + reasons.append( + "formal APPROVED review at the current PR head is required before " + "merger lease adoption (fail closed)" + ) + if not pinned: + reasons.append("expected_head_sha is required for merger lease adoption") + if not live: + reasons.append("live PR head SHA unavailable (fail closed)") + elif pinned and live and pinned != live: + reasons.append( + "expected_head_sha does not match live PR head; refresh approval before adoption" + ) + + active = leases.find_active_reviewer_lease( + comments, pr_number=pr_number, now=now + ) + if not active: + reasons.append( + f"no active reviewer lease on PR #{pr_number} to adopt; reviewer " + "must acquire first" + ) + else: + owner_session = (active.get("session_id") or "").strip() + freshness = active.get("freshness") or leases.classify_lease_freshness( + active, now=now + ) + if freshness not in _MERGER_ADOPTABLE_FRESHNESS: + reasons.append( + f"active reviewer lease freshness is '{freshness}'; explicit " + "reclaim is not implemented (fail closed)" + ) + lease_head = active.get("candidate_head") + if lease_head and live and lease_head != live: + reasons.append( + "active reviewer lease candidate_head differs from live PR head" + ) + if owner_session and owner_session == adopter_session_id: + # Same session already holds the thread lease — allow idempotent adopt + # only when the newest entry is not yet an adoption by this session. + entries = leases._lease_entries(comments, pr_number=pr_number) + newest = entries[-1] if entries else None + if newest and (newest.get("phase") or "") == "adopted": + reasons.append( + "merger session already recorded an adoption lease on this PR" + ) + elif owner_session and owner_session != adopter_session_id: + # Cross-session merger handoff: adopt the foreign reviewer lease. + pass + + if not (adopter_identity or "").strip(): + reasons.append("adopter identity required") + if not (adopter_session_id or "").strip(): + reasons.append("adopter session_id required") + if not (worktree or "").strip(): + reasons.append("merger worktree path required") + if "merger" not in (adopter_profile or "").lower(): + reasons.append( + f"profile '{adopter_profile}' is not a merger profile; adoption is " + "merger-only (fail closed)" + ) + + adopt_allowed = not reasons + adoption_body = None + if adopt_allowed and active: + adoption_body = format_adoption_body( + repo=repo, + pr_number=pr_number, + issue_number=issue_number or active.get("issue_number"), + adopter_identity=adopter_identity, + adopter_profile=adopter_profile, + adopter_session_id=adopter_session_id, + worktree=worktree, + candidate_head=live, + target_branch=active.get("target_branch") or "master", + target_branch_sha=active.get("target_branch_sha"), + adopted_from_session_id=active.get("session_id") or "", + adopted_from_profile=active.get("profile") or "unknown", + adopted_from_reviewer_identity=active.get("reviewer_identity") or "", + adopted_from_comment_id=active.get("comment_id"), + adopted_at=now, + ) + + return { + "adopt_allowed": adopt_allowed, + "reasons": reasons, + "active_lease": active, + "adoption_body": adoption_body, + "adopter_session_id": adopter_session_id, + "expected_head_sha": pinned, + "live_head_sha": live, + } \ No newline at end of file diff --git a/namespace_workspace_binding.py b/namespace_workspace_binding.py new file mode 100644 index 0000000..4831907 --- /dev/null +++ b/namespace_workspace_binding.py @@ -0,0 +1,307 @@ +"""Namespace-scoped MCP workspace binding (#510). + +Each role namespace (author, reviewer, merger, reconciler) resolves its own +active task workspace. Foreign role worktree environment variables must not +poison workspace purity checks in another namespace. +""" + +from __future__ import annotations + +import os + +import author_mutation_worktree as amw + +ACTIVE_WORKTREE_ENV = amw.ACTIVE_WORKTREE_ENV +AUTHOR_WORKTREE_ENV = amw.AUTHOR_WORKTREE_ENV +REVIEWER_WORKTREE_ENV = "GITEA_REVIEWER_WORKTREE" +MERGER_WORKTREE_ENV = "GITEA_MERGER_WORKTREE" +RECONCILER_WORKTREE_ENV = "GITEA_RECONCILER_WORKTREE" + +ROLE_WORKTREE_ENVS: dict[str, str] = { + "author": AUTHOR_WORKTREE_ENV, + "reviewer": REVIEWER_WORKTREE_ENV, + "merger": MERGER_WORKTREE_ENV, + "reconciler": RECONCILER_WORKTREE_ENV, +} + +NON_AUTHOR_ROLES = frozenset({"reviewer", "merger", "reconciler"}) + + +def normalize_role_kind( + role_kind: str | None, + *, + profile_name: str | None = None, +) -> str: + """Map profile/task role to a workspace namespace key.""" + role = (role_kind or "author").strip().lower() + profile = (profile_name or "").strip().lower() + if role == "reviewer" and "merger" in profile: + return "merger" + if role in ROLE_WORKTREE_ENVS: + return role + return "author" + + +def _env_value(env: dict[str, str] | os._Environ, key: str) -> str | None: + text = (env.get(key) or "").strip() + return text or None + + +def resolve_namespace_workspace( + *, + role_kind: str, + worktree_path: str | None = None, + worktree: str | None = None, + process_project_root: str, + env: dict[str, str] | os._Environ | None = None, + session_lease_worktree: str | None = None, + profile_name: str | None = None, +) -> tuple[str, str]: + """Return ``(resolved_path, binding_source)`` for *role_kind*.""" + env_map = env if env is not None else os.environ + role = normalize_role_kind(role_kind, profile_name=profile_name) + role_env_key = ROLE_WORKTREE_ENVS[role] + + for candidate, source in ( + (worktree_path, "worktree_path argument"), + (worktree, "worktree argument"), + (_env_value(env_map, ACTIVE_WORKTREE_ENV), f"{ACTIVE_WORKTREE_ENV} environment variable"), + (_env_value(env_map, role_env_key), f"{role_env_key} environment variable"), + (session_lease_worktree if role in {"reviewer", "merger"} else None, + "reviewer PR lease worktree"), + ): + text = (candidate or "").strip() + if text: + return os.path.realpath(os.path.abspath(text)), source + + return os.path.realpath(process_project_root), "MCP server process root (default)" + + +def resolve_namespace_mutation_context( + *, + role_kind: str, + worktree_path: str | None, + process_project_root: str, + env: dict[str, str] | os._Environ | None = None, + session_lease_worktree: str | None = None, + worktree: str | None = None, + profile_name: str | None = None, +) -> dict: + """Shared workspace resolution for runtime_context and mutation guards.""" + workspace, binding_source = resolve_namespace_workspace( + role_kind=role_kind, + worktree_path=worktree_path, + worktree=worktree, + process_project_root=process_project_root, + env=env, + session_lease_worktree=session_lease_worktree, + profile_name=profile_name, + ) + process_root = os.path.realpath(process_project_root) + role = normalize_role_kind(role_kind, profile_name=profile_name) + pollution = assess_foreign_role_worktree_pollution( + role_kind=role, + resolved_workspace=workspace, + binding_source=binding_source, + env=env, + profile_name=profile_name, + ) + canonical_root = amw.resolve_canonical_repo_root(process_root, process_root) + return { + "workspace_path": workspace, + "workspace_binding_source": binding_source, + "workspace_role_kind": role, + "ignored_bindings": pollution.get("ignored_bindings") or [], + "process_project_root": process_root, + "canonical_repo_root": canonical_root, + "roots_aligned": canonical_root == process_root, + } + + +def assess_foreign_role_worktree_pollution( + *, + role_kind: str, + resolved_workspace: str, + binding_source: str, + env: dict[str, str] | os._Environ | None = None, + profile_name: str | None = None, +) -> dict: + """Detect when a foreign role env would have hijacked workspace binding.""" + env_map = env if env is not None else os.environ + role = normalize_role_kind(role_kind, profile_name=profile_name) + if role == "author": + return {"would_pollute": False, "ignored_bindings": []} + + ignored: list[str] = [] + author_path = _env_value(env_map, AUTHOR_WORKTREE_ENV) + if author_path: + author_real = os.path.realpath(os.path.abspath(author_path)) + resolved_real = os.path.realpath(resolved_workspace) + if author_real != resolved_real and binding_source != f"{AUTHOR_WORKTREE_ENV} environment variable": + ignored.append( + f"{AUTHOR_WORKTREE_ENV}={author_real} (ignored for {role} namespace)" + ) + return { + "would_pollute": bool(ignored), + "ignored_bindings": ignored, + } + + +def assess_metadata_only_worktree_binding( + *, + role_kind: str, + declared_worktree_path: str | None, + mutation_workspace: str, + process_project_root: str, + profile_name: str | None = None, +) -> dict: + """Fail closed when declared worktree_path would not redirect mutations.""" + declared = (declared_worktree_path or "").strip() + process_root = os.path.realpath(process_project_root) + mutation_root = os.path.realpath(mutation_workspace) + role = normalize_role_kind(role_kind, profile_name=profile_name) + if not declared: + return {"block": False, "reasons": [], "metadata_only": False} + + declared_root = os.path.realpath(os.path.abspath(declared)) + if declared_root == mutation_root: + return {"block": False, "reasons": [], "metadata_only": False} + + if declared_root != process_root and mutation_root == process_root: + return { + "block": True, + "metadata_only": True, + "reasons": [ + f"worktree_path is metadata-only for {role} mutations: preflight " + f"inspected '{declared_root}' but mutation tools would still " + f"validate MCP server process root '{process_root}'" + ], + "declared_worktree_path": declared_root, + "mutation_workspace": mutation_root, + "process_project_root": process_root, + } + + return {"block": False, "reasons": [], "metadata_only": False} + + +def format_namespace_workspace_binding_error( + *, + role_kind: str, + workspace_path: str, + binding_source: str, + reasons: list[str] | None = None, + ignored_bindings: list[str] | None = None, + dirty_files: list[str] | None = None, +) -> str: + """Canonical error when namespace workspace binding blocks mutations.""" + role = normalize_role_kind(role_kind) + workspace = os.path.realpath(workspace_path) + parts = [ + f"Namespace workspace binding blocked ({role} namespace, #510): " + f"resolved workspace '{workspace}' via {binding_source}." + ] + if ignored_bindings: + parts.append( + "Foreign role bindings ignored: " + "; ".join(ignored_bindings) + "." + ) + if dirty_files: + parts.append( + "Dirty tracked files in active task workspace: " + + ", ".join(dirty_files) + + "." + ) + if reasons: + parts.append("Details: " + "; ".join(reasons) + ".") + parts.append( + "Remediation: reconnect or relaunch the MCP server from a clean dedicated " + f"branches/ {role} worktree, set " + f"{ROLE_WORKTREE_ENVS.get(role, ACTIVE_WORKTREE_ENV)} or {ACTIVE_WORKTREE_ENV} " + "to that path, or pass worktree_path on mutation tools. Do not clean or " + "reset foreign role worktrees to unblock this namespace." + ) + return " ".join(parts) + + +def assess_namespace_mutation_workspace( + *, + role_kind: str, + worktree_path: str | None, + worktree: str | None, + process_project_root: str, + env: dict[str, str] | os._Environ | None = None, + session_lease_worktree: str | None = None, + profile_name: str | None = None, + current_branch: str | None = None, +) -> dict: + """Evaluate namespace workspace binding before preflight/mutation.""" + ctx = resolve_namespace_mutation_context( + role_kind=role_kind, + worktree_path=worktree_path, + worktree=worktree, + process_project_root=process_project_root, + env=env, + session_lease_worktree=session_lease_worktree, + profile_name=profile_name, + ) + mutation_workspace = ctx["workspace_path"] + binding_source = ctx["workspace_binding_source"] + role = ctx["workspace_role_kind"] + process_root = ctx["process_project_root"] + + metadata = assess_metadata_only_worktree_binding( + role_kind=role, + declared_worktree_path=worktree_path, + mutation_workspace=mutation_workspace, + process_project_root=process_root, + profile_name=profile_name, + ) + pollution = assess_foreign_role_worktree_pollution( + role_kind=role, + resolved_workspace=mutation_workspace, + binding_source=binding_source, + env=env, + profile_name=profile_name, + ) + + reasons = list(metadata.get("reasons") or []) + if role == "author": + branches = amw.assess_author_mutation_worktree( + workspace_path=mutation_workspace, + project_root=ctx["canonical_repo_root"], + current_branch=current_branch, + ) + if branches["block"]: + reasons.extend(branches["reasons"]) + elif ( + role == "reviewer" + and mutation_workspace == process_root + and not amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"]) + ): + reasons.append( + f"{role} mutation blocked: workspace is the stable control checkout; " + f"create or reconnect to a session-owned worktree under branches/ " + f"or set {ROLE_WORKTREE_ENVS[role]} / {ACTIVE_WORKTREE_ENV}" + ) + elif ( + role in {"reviewer", "merger"} + and mutation_workspace != process_root + and not amw.is_path_under_branches(mutation_workspace, ctx["canonical_repo_root"]) + ): + reasons.append( + f"{role} mutation blocked: workspace '{mutation_workspace}' is not under " + f"'{ctx['canonical_repo_root']}/branches/'" + ) + + block = bool(reasons) + return { + "block": block, + "reasons": reasons, + "mutation_workspace": mutation_workspace, + "workspace_binding_source": binding_source, + "workspace_role_kind": role, + "process_project_root": process_root, + "canonical_repo_root": ctx["canonical_repo_root"], + "metadata_only": metadata.get("metadata_only", False), + "declared_worktree_path": metadata.get("declared_worktree_path"), + "ignored_bindings": pollution.get("ignored_bindings") or [], + } \ No newline at end of file diff --git a/post_merge_cleanup_proof.py b/post_merge_cleanup_proof.py new file mode 100644 index 0000000..6562a04 --- /dev/null +++ b/post_merge_cleanup_proof.py @@ -0,0 +1,239 @@ +"""Post-merge cleanup proof verifier for reviewer final reports (#402).""" + +from __future__ import annotations + +import re +from typing import Any + +CLEANUP_SKIPPED = "CLEANUP_SKIPPED" +CLEANUP_PERFORMED = "CLEANUP_PERFORMED" + +_CLEANUP_SECTION_HINT = re.compile( + r"(?:cleanup (?:status|result|mutations)|post-merge cleanup|" + r"gitea_delete_branch|remote branch.*deleted|worktree remove)", + re.IGNORECASE, +) +_CLEANUP_SKIPPED_RE = re.compile(r"\bCLEANUP_SKIPPED\b", re.IGNORECASE) +_CLEANUP_BLOCKER_RE = re.compile( + r"(?:cleanup blocker|cleanup skip(?:ped)? reason)\s*:\s*(.+)", + re.IGNORECASE, +) +_REMOTE_DELETE_CLAIM_RE = re.compile( + r"(?:gitea_delete_branch|remote (?:head )?branch (?:was )?deleted|" + r"deleted remote branch|delete_branch)", + re.IGNORECASE, +) +_WORKTREE_REMOVE_CLAIM_RE = re.compile( + r"(?:git worktree remove|worktree (?:was )?removed|removed (?:local )?worktree|" + r"worktree cleanup performed)", + re.IGNORECASE, +) +_DELETE_CAPABILITY_RE = re.compile( + r"(?:delete[- ]branch capability resolved|gitea\.branch\.delete)\s*:\s*" + r".*(?:gitea\.branch\.delete|delete_branch).*(?:resolved|allowed|proven)|" + r"gitea\.branch\.delete\s+(?:resolved|allowed|proven)", + re.IGNORECASE, +) +_DELETE_TASK_RE = re.compile( + r"(?:delete_branch|cleanup_branch|reconcile_merged_cleanups)", + re.IGNORECASE, +) +_MERGE_RESULT_RE = re.compile( + r"merge result\s*:\s*(?:merged|success|performed)", + re.IGNORECASE, +) +_MERGE_COMMIT_SHA_RE = re.compile( + r"(?:merge commit sha|merged commit sha|merge commit)\s*:\s*([0-9a-f]{7,40})", + re.IGNORECASE, +) +_PR_HEAD_BRANCH_RE = re.compile( + r"(?:merged pr head branch|pr head branch|deleted branch)\s*:\s*(\S+)", + re.IGNORECASE, +) +_BRANCH_NOT_PROTECTED_RE = re.compile( + r"branch (?:is )?not protected|branch protection\s*:\s*(?:none|false|no)", + re.IGNORECASE, +) +_OPEN_PR_INVENTORY_RE = re.compile( + r"(?:no other open pr(?:\s+references)?(?:\s+\S+)?|open pr inventory proof|" + r"open pr references).*(?:none|zero|0|clear|inventory complete)|" + r"no other open pr references branch", + re.IGNORECASE, +) +_ACTIVE_CLAIM_LEASE_RE = re.compile( + r"(?:no active (?:heartbeat|claim|lease)|" + r"(?:active )?(?:heartbeat|claim|lease)(?:/(?:claim|lease))*\s*:\s*none)", + re.IGNORECASE, +) +_SESSION_OWNED_WORKTREE_RE = re.compile( + r"(?:removed worktree path|cleanup worktree path|session-owned worktree)\s*:\s*(\S+)", + re.IGNORECASE, +) +_BRANCHES_PATH_RE = re.compile(r"\bbranches/", re.IGNORECASE) +_CLEAN_TRACKED_RE = re.compile( + r"(?:pre-removal tracked state|tracked state before removal)\s*:\s*clean", + re.IGNORECASE, +) +_CLEAN_UNTRACKED_RE = re.compile( + r"(?:pre-removal untracked state|untracked state before removal)\s*:\s*clean", + re.IGNORECASE, +) +_WORKTREE_LIST_AFTER_RE = re.compile( + r"(?:git worktree list after|post-removal worktree list|worktree list after)", + re.IGNORECASE, +) +_WRONG_BRANCH_RE = re.compile( + r"deleted branch (?:does not match|!=|differs from) (?:merged )?pr head", + re.IGNORECASE, +) + + +def _claims_remote_delete(text: str) -> bool: + return bool(_REMOTE_DELETE_CLAIM_RE.search(text)) + + +def _claims_worktree_remove(text: str) -> bool: + return bool(_WORKTREE_REMOVE_CLAIM_RE.search(text)) + + +def _branch_safety_fields_present(text: str) -> list[str]: + missing: list[str] = [] + if not _DELETE_CAPABILITY_RE.search(text): + missing.append("delete-branch capability resolved (gitea.branch.delete)") + if not _DELETE_TASK_RE.search(text): + missing.append("delete-branch task named (delete_branch or cleanup)") + if not _MERGE_RESULT_RE.search(text): + missing.append("merge result: merged") + if not _MERGE_COMMIT_SHA_RE.search(text): + missing.append("merge commit SHA") + if not _PR_HEAD_BRANCH_RE.search(text): + missing.append("merged PR head branch / deleted branch name") + if not _BRANCH_NOT_PROTECTED_RE.search(text): + missing.append("branch not protected proof") + if not _OPEN_PR_INVENTORY_RE.search(text): + missing.append("open PR inventory proof (no other PR references branch)") + if not _ACTIVE_CLAIM_LEASE_RE.search(text): + missing.append("no active heartbeat/claim/lease proof") + return missing + + +def _worktree_cleanup_fields_present(text: str) -> list[str]: + missing: list[str] = [] + match = _SESSION_OWNED_WORKTREE_RE.search(text) + path = match.group(1).strip() if match else "" + if not path: + missing.append("session-owned worktree path") + elif not _BRANCHES_PATH_RE.search(path.replace("\\", "/")): + missing.append("worktree path under branches/") + if not _CLEAN_TRACKED_RE.search(text): + missing.append("pre-removal tracked state: clean") + if not _CLEAN_UNTRACKED_RE.search(text): + missing.append("pre-removal untracked state: clean") + if not _WORKTREE_LIST_AFTER_RE.search(text): + missing.append("git worktree list after removal") + return missing + + +def assess_post_merge_cleanup_proof( + report_text: str, + *, + cleanup_session: dict | None = None, +) -> dict[str, Any]: + """Validate post-merge cleanup claims carry safety-gate proof (#402).""" + text = report_text or "" + session = dict(cleanup_session or {}) + reasons: list[str] = [] + + if _CLEANUP_SKIPPED_RE.search(text) or session.get("outcome") == CLEANUP_SKIPPED: + blocker = (session.get("blocker") or "").strip() + if not blocker: + match = _CLEANUP_BLOCKER_RE.search(text) + blocker = match.group(1).strip() if match else "" + if blocker.upper() == CLEANUP_SKIPPED: + blocker = "" + if not blocker: + reasons.append( + "CLEANUP_SKIPPED requires exact cleanup blocker reason (#402)" + ) + return { + "block": bool(reasons), + "proven": not reasons, + "outcome": CLEANUP_SKIPPED, + "remote_delete_claimed": False, + "worktree_remove_claimed": False, + "reasons": reasons, + "safe_next_action": ( + "report CLEANUP_SKIPPED with exact blocker; do not claim performed cleanup" + if reasons + else "proceed" + ), + } + + if not _CLEANUP_SECTION_HINT.search(text) and not session.get("cleanup_claimed"): + return { + "block": False, + "proven": True, + "outcome": None, + "remote_delete_claimed": False, + "worktree_remove_claimed": False, + "reasons": [], + "safe_next_action": "proceed", + } + + remote_delete = bool( + session.get("remote_delete_claimed") or _claims_remote_delete(text) + ) + worktree_remove = bool( + session.get("worktree_remove_claimed") or _claims_worktree_remove(text) + ) + + if _WRONG_BRANCH_RE.search(text): + reasons.append( + "cleanup report claims deleted branch that is not the merged PR head branch" + ) + + if remote_delete: + reasons.extend( + f"remote branch deletion missing {field}" + for field in _branch_safety_fields_present(text) + ) + + if worktree_remove: + reasons.extend( + f"worktree removal missing {field}" + for field in _worktree_cleanup_fields_present(text) + ) + + if (remote_delete or worktree_remove) and not (remote_delete or worktree_remove): + pass + + if not remote_delete and not worktree_remove: + cleanup_mutations = re.search( + r"cleanup mutations\s*:\s*(?!none\b)\S", + text, + re.IGNORECASE, + ) + if cleanup_mutations: + reasons.append( + "cleanup mutations reported without post-merge cleanup proof checklist" + ) + + outcome = CLEANUP_PERFORMED if (remote_delete or worktree_remove) and not reasons else None + if remote_delete or worktree_remove: + outcome = CLEANUP_PERFORMED if not reasons else "CLEANUP_CLAIMED_UNPROVEN" + + block = bool(reasons) + return { + "block": block, + "proven": not block, + "outcome": outcome, + "remote_delete_claimed": remote_delete, + "worktree_remove_claimed": worktree_remove, + "reasons": reasons, + "safe_next_action": ( + "report CLEANUP_SKIPPED with exact blocker or include the full cleanup " + "checklist before claiming remote delete or worktree removal" + if reasons + else "proceed" + ), + } \ No newline at end of file diff --git a/pr_work_lease.py b/pr_work_lease.py new file mode 100644 index 0000000..e2b3f21 --- /dev/null +++ b/pr_work_lease.py @@ -0,0 +1,482 @@ +"""Conflict-fix and reviewer PR work leases (#399, #407 reader). + +Structured PR/issue comments prove exclusive phases so author conflict-fix +pushes cannot race reviewer validation/approval/merge on the same head. +""" + +from __future__ import annotations + +import re +from datetime import datetime, timedelta, timezone +from typing import Any + +REVIEWER_LEASE_MARKER = "" +CONFLICT_FIX_LEASE_MARKER = "" + +_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, + } \ No newline at end of file diff --git a/remote_repo_guard.py b/remote_repo_guard.py new file mode 100644 index 0000000..f4938bd --- /dev/null +++ b/remote_repo_guard.py @@ -0,0 +1,98 @@ +"""Remote/repo mismatch guard (#530). + +Bare ``remote`` names resolve to a default ``org``/``repo`` via the ``REMOTES`` +table in :mod:`gitea_auth`. For the ``prgs`` instance the hardcoded default repo +is ``Timesheet``, but the tools in this project operate on +``Scaled-Tech-Consulting/Gitea-Tools``. When a session runs inside a Gitea-Tools +worktree and calls a tool with a bare ``remote=prgs`` (no explicit ``org``/``repo``), +the resolved target silently points at the wrong repository, producing false 404s +and risking mutation of a different repo. + +This module provides a pure assessment that compares the MCP-resolved ``org/repo`` +against the local git remote URL and fails closed on a genuine mismatch, unless the +caller supplied explicit ``org``/``repo`` (in which case their intent is authoritative) +or the local remote URL is unavailable (best-effort corroboration only). +""" + +from __future__ import annotations + +REMEDIATION = ( + "Pass explicit org= and repo= matching the local git remote, " + "e.g. org=Scaled-Tech-Consulting repo=Gitea-Tools." +) + + +def assess_remote_repo_match( + *, + remote: str, + resolved_org: str, + resolved_repo: str, + local_remote_url: str | None, + org_explicit: bool, + repo_explicit: bool, +) -> dict: + """Fail closed when the resolved org/repo disagrees with the local git remote. + + The guard is intentionally conservative: + + * When the caller passed both ``org`` and ``repo`` explicitly, their intent is + authoritative and the guard never blocks. + * When the local git remote URL is unavailable (``None``/empty), corroboration + is impossible, so the guard does not block (best-effort only). + * Otherwise, the resolved ``org/repo`` slug must appear in the local remote URL + (case-insensitive); if it does not, the guard blocks. + """ + reasons: list[str] = [] + + if org_explicit and repo_explicit: + return _assessment(True, reasons, remote, resolved_org, resolved_repo, local_remote_url) + + url = (local_remote_url or "").strip() + if not url: + return _assessment(True, reasons, remote, resolved_org, resolved_repo, local_remote_url) + + expected_slug = f"{resolved_org}/{resolved_repo}".lower() + if expected_slug in url.lower(): + return _assessment(True, reasons, remote, resolved_org, resolved_repo, local_remote_url) + + reasons.append( + f"MCP-resolved repository '{resolved_org}/{resolved_repo}' for remote " + f"'{remote}' does not match the local git remote URL '{url}'" + ) + return _assessment(False, reasons, remote, resolved_org, resolved_repo, local_remote_url) + + +def format_remote_repo_guard_error(assessment: dict) -> str: + """Single RuntimeError message for the MCP resolver gate.""" + reasons = "; ".join( + assessment.get("reasons") or ["remote/repo resolution mismatch"] + ) + resolved = ( + f"{assessment.get('resolved_org')}/{assessment.get('resolved_repo')}" + ) + local = assessment.get("local_remote_url") or "(unknown)" + return ( + f"Remote/repo guard (#530): {reasons}. " + f"Resolved target: {resolved}; local git remote: {local}. " + f"{REMEDIATION}" + ) + + +def _assessment( + proven: bool, + reasons: list[str], + remote: str, + resolved_org: str, + resolved_repo: str, + local_remote_url: str | None, +) -> dict: + return { + "proven": proven, + "block": not proven, + "reasons": reasons, + "remote": remote, + "resolved_org": resolved_org, + "resolved_repo": resolved_repo, + "local_remote_url": local_remote_url, + "remediation": REMEDIATION, + } diff --git a/review_proofs.py b/review_proofs.py index 20cf89b..194ce9e 100644 --- a/review_proofs.py +++ b/review_proofs.py @@ -3624,9 +3624,12 @@ def assess_work_issue_mode_isolation(report_text: str) -> dict: def assess_work_issue_final_report(report_text: str) -> dict: """#139: composite verifier for work-issue final reports.""" + from issue_work_duplicate_gate import assess_work_issue_duplicate_report + checks = { "workflow_source": assess_work_issue_workflow_source(report_text), "mode_isolation": assess_work_issue_mode_isolation(report_text), + "duplicate_work_outcome": assess_work_issue_duplicate_report(report_text), } reasons = [] @@ -5112,6 +5115,15 @@ def assess_pr_queue_cleanup_report(report_text: str | None) -> dict: return _assess(report_text or "") +def assess_audit_reconciliation_report(report_text: str | None) -> dict: + """#419: validate audit vs cleanup reconciliation report boundaries.""" + from audit_reconciliation_mode import ( + assess_audit_reconciliation_report as _assess, + ) + + return _assess(report_text or "") + + _GATE_PASSED_VALUE = re.compile(r"\bpassed\b", re.I) _NOT_APPLICABLE_VALUE = re.compile( @@ -5512,6 +5524,13 @@ def assess_validation_failure_history_report(report_text, **kwargs): return _assess(report_text, **kwargs) +def assess_validation_cwd_proof_report(report_text, **kwargs): + """#398: validation commands require explicit worktree cwd and HEAD proof.""" + from reviewer_validation_cwd_proof import assess_validation_cwd_proof_report as _assess + + return _assess(report_text, **kwargs) + + def assess_already_landed_classification_report(report_text, **kwargs): """#295: already-landed PRs are reconciliation-only, not review eligible.""" from reviewer_already_landed_classification import ( @@ -5598,3 +5617,12 @@ def assess_proof_backed_handoff_report(report_text, **kwargs): from reviewer_proof_backed_handoff import assess_proof_backed_handoff_report as _assess return _assess(report_text, **kwargs) + + +def assess_mutation_capability_proof(report_text, **kwargs): + """#405: exact per-mutation capability proof in reviewer final reports.""" + from reviewer_mutation_capability_proof import ( + assess_mutation_capability_proof as _assess, + ) + + return _assess(report_text, **kwargs) diff --git a/review_workflow_boundary.py b/review_workflow_boundary.py new file mode 100644 index 0000000..f96aaab --- /dev/null +++ b/review_workflow_boundary.py @@ -0,0 +1,259 @@ +"""Reviewer session boundary tracking for workflow-load gate (#403). + +Pre-review commands executed before ``gitea_load_review_workflow`` must be +classified. Boundary violations block downstream reviewer mutations even when +workflow hash proof is present. +""" + +from __future__ import annotations + +import os +import re +from typing import Any + +CLASSIFICATION_READ_ONLY_INVENTORY = "read_only_inventory" +CLASSIFICATION_DIAGNOSTIC = "diagnostic" +CLASSIFICATION_BOUNDARY_VIOLATION = "boundary_violation" +CLASSIFICATION_UNCLASSIFIED = "unclassified" + +ALLOWED_CLASSIFICATIONS = frozenset({ + CLASSIFICATION_READ_ONLY_INVENTORY, + CLASSIFICATION_DIAGNOSTIC, + CLASSIFICATION_BOUNDARY_VIOLATION, + CLASSIFICATION_UNCLASSIFIED, +}) + +_PRE_REVIEW_COMMANDS: list[dict[str, Any]] = [] + +_READ_ONLY_INVENTORY_PATTERNS = ( + re.compile( + r"\bgitea[_-](?:list|view|whoami|get[-_]|resolve[-_]task|check[-_]pr|route[-_]task)", + re.I, + ), + re.compile(r"\bgit\s+(?:fetch|remote\s+update|branch\s+-a|log|show|rev-parse)\b", re.I), + re.compile(r"\bgit\s+status\b", re.I), + re.compile(r"\bgit\s+worktree\s+list\b", re.I), +) + +_DIAGNOSTIC_PATTERNS = ( + re.compile(r"\bgit\s+diff(?:\s+--stat)?\b", re.I), + re.compile(r"\bwhich\s+pytest\b", re.I), + re.compile(r"\bpytest\s+--version\b", re.I), +) + +_BOUNDARY_VIOLATION_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( + (re.compile(r"\b(?:pytest|python\s+-m\s+pytest|python\s+-m\s+unittest)\b", re.I), + "validation command before workflow load"), + (re.compile(r"\bprofiles\.json\b", re.I), "local profile config inspection"), + (re.compile(r"\bgitea-mcp(?:\.v2-contexts)?\.json\b", re.I), + "local Gitea MCP config inspection"), + (re.compile(r"\b\.env(?:\.|$|\b)", re.I), "credential file inspection"), + (re.compile(r"\bkeychain\b", re.I), "credential store inspection"), + (re.compile(r"\bpkill\b", re.I), "MCP repair activity"), + (re.compile(r"\b(?:edit|write|modify).{0,40}\bmcp\b", re.I), + "MCP config exploration"), + (re.compile(r"\bgit\s+(?:add|commit|reset|clean|checkout|merge|rebase|push)\b", re.I), + "git mutation before workflow load"), +) + + +def clear_pre_review_commands() -> None: + """Test helper and session reset.""" + global _PRE_REVIEW_COMMANDS + _PRE_REVIEW_COMMANDS = [] + + +def pre_review_commands() -> list[dict[str, Any]]: + """Return a shallow copy of recorded pre-review commands.""" + return [dict(entry) for entry in _PRE_REVIEW_COMMANDS] + + +def _normalize_path(path: str | None) -> str: + return os.path.realpath(os.path.abspath((path or "").strip() or os.getcwd())) + + +def is_main_checkout_path(cwd: str | None, project_root: str | None) -> bool: + """True when *cwd* is the stable control checkout (not under branches/).""" + if not project_root: + return False + root = _normalize_path(project_root) + path = _normalize_path(cwd) + if path != root: + return False + marker = f"{os.sep}branches{os.sep}" + return marker not in path + + +def classify_pre_review_command( + command: str, + *, + cwd: str | None = None, + project_root: str | None = None, +) -> dict[str, Any]: + """Classify a command executed before workflow load.""" + text = (command or "").strip() + path = _normalize_path(cwd) + root = _normalize_path(project_root) if project_root else None + reasons: list[str] = [] + + for pattern, label in _BOUNDARY_VIOLATION_PATTERNS: + if pattern.search(text): + if label.startswith("validation") and root and not is_main_checkout_path(path, root): + continue + if label.startswith("git mutation") and root and not is_main_checkout_path(path, root): + continue + reasons.append(label) + return { + "command": text, + "cwd": path, + "classification": CLASSIFICATION_BOUNDARY_VIOLATION, + "reasons": reasons, + } + + for pattern in _READ_ONLY_INVENTORY_PATTERNS: + if pattern.search(text): + return { + "command": text, + "cwd": path, + "classification": CLASSIFICATION_READ_ONLY_INVENTORY, + "reasons": [], + } + + for pattern in _DIAGNOSTIC_PATTERNS: + if pattern.search(text): + return { + "command": text, + "cwd": path, + "classification": CLASSIFICATION_DIAGNOSTIC, + "reasons": [], + } + + if root and is_main_checkout_path(path, root): + if re.search(r"\b(?:cat|head|less|read)\b", text, re.I): + if re.search(r"workflow|skill|runbook", text, re.I): + return { + "command": text, + "cwd": path, + "classification": CLASSIFICATION_BOUNDARY_VIOLATION, + "reasons": [ + "canonical workflow viewed as local file without " + "gitea_load_review_workflow (narrative load is not proof)" + ], + } + + return { + "command": text, + "cwd": path, + "classification": CLASSIFICATION_UNCLASSIFIED, + "reasons": [ + "pre-review command not classified; record via " + "gitea_record_pre_review_command before workflow load" + ], + } + + +def record_pre_review_command( + command: str, + *, + cwd: str | None = None, + project_root: str | None = None, + classification: str | None = None, +) -> dict[str, Any]: + """Record and classify a pre-review command for the current session.""" + assessed = classify_pre_review_command( + command, cwd=cwd, project_root=project_root) + if classification: + if classification not in ALLOWED_CLASSIFICATIONS: + assessed["classification"] = CLASSIFICATION_UNCLASSIFIED + assessed["reasons"] = [ + f"unknown classification '{classification}'; fail closed" + ] + else: + assessed["classification"] = classification + assessed["reasons"] = [] + entry = { + **assessed, + "session_pid": os.getpid(), + } + _PRE_REVIEW_COMMANDS.append(entry) + return dict(entry) + + +def assess_boundary_status(project_root: str | None = None) -> dict[str, Any]: + """Summarize pre-review boundary state for session proof and reports.""" + violations = [ + entry for entry in _PRE_REVIEW_COMMANDS + if entry.get("classification") == CLASSIFICATION_BOUNDARY_VIOLATION + ] + unclassified = [ + entry for entry in _PRE_REVIEW_COMMANDS + if entry.get("classification") == CLASSIFICATION_UNCLASSIFIED + ] + reasons: list[str] = [] + for entry in violations: + reasons.extend(entry.get("reasons") or [ + f"boundary violation: {entry.get('command', '')[:80]}" + ]) + for entry in unclassified: + reasons.extend(entry.get("reasons") or [ + "unclassified pre-review command blocks reviewer mutations" + ]) + + clean = not reasons + return { + "boundary_status": "clean" if clean else "violation", + "boundary_clean": clean, + "pre_review_command_count": len(_PRE_REVIEW_COMMANDS), + "boundary_violation_count": len(violations), + "unclassified_command_count": len(unclassified), + "violations": [ + { + "command": v.get("command"), + "cwd": v.get("cwd"), + "reasons": list(v.get("reasons") or []), + } + for v in violations + ], + "reasons": reasons, + } + + +def boundary_blockers(project_root: str | None = None) -> list[str]: + """Reasons reviewer mutations must fail closed due to boundary state.""" + status = assess_boundary_status(project_root) + if status.get("boundary_clean"): + return [] + return list(status.get("reasons") or [ + "reviewer session boundary violation before workflow load" + ]) + + +def workflow_load_helper_result( + load: dict | None, + project_root: str | None = None, +) -> dict[str, Any]: + """Structured helper result for final reports (#403).""" + boundary = assess_boundary_status(project_root) + if load is None: + return { + "workflow_load_proof_present": False, + "workflow_source": None, + "workflow_hash": None, + "final_report_schema_hash": None, + "boundary_status": boundary.get("boundary_status"), + "boundary_clean": False, + "reasons": [ + "gitea_load_review_workflow helper result missing from report" + ], + } + return { + "workflow_load_proof_present": True, + "workflow_source": load.get("workflow_source"), + "workflow_hash": load.get("workflow_hash"), + "final_report_schema_path": load.get("final_report_schema_path"), + "final_report_schema_hash": load.get("final_report_schema_hash"), + "boundary_status": load.get("boundary_status", boundary.get("boundary_status")), + "boundary_clean": bool(load.get("boundary_clean", boundary.get("boundary_clean"))), + "pre_review_command_count": boundary.get("pre_review_command_count"), + "reasons": [], + } \ No newline at end of file diff --git a/review_workflow_load.py b/review_workflow_load.py new file mode 100644 index 0000000..8deb622 --- /dev/null +++ b/review_workflow_load.py @@ -0,0 +1,321 @@ +"""Canonical review-merge workflow load proof for reviewer mutations (#389, #403, #559).""" + +from __future__ import annotations + +import hashlib +import os +import re +from pathlib import Path + +import mcp_session_state +import review_workflow_boundary as boundary + +WORKFLOW_REL_PATH = ( + "skills/llm-project-workflow/workflows/review-merge-pr.md" +) +SCHEMA_REL_PATH = ( + "skills/llm-project-workflow/schemas/review-merge-final-report.md" +) +TASK_MODE = "review-merge-pr" +LOAD_TOOL_NAME = "gitea_load_review_workflow" + +_REVIEW_WORKFLOW_LOAD: dict | None = None + + +def compute_content_hash(text: str) -> str: + """Short deterministic hash for workflow/schema version proof.""" + return hashlib.sha256((text or "").encode("utf-8")).hexdigest()[:12] + + +def _read_text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _canonical_paths(project_root: str) -> tuple[Path, Path]: + root = Path(project_root) + workflow = root / WORKFLOW_REL_PATH + schema = root / SCHEMA_REL_PATH + if not workflow.is_file(): + raise FileNotFoundError(f"canonical workflow missing: {workflow}") + if not schema.is_file(): + raise FileNotFoundError(f"final report schema missing: {schema}") + return workflow, schema + + +def build_canonical_workflow_metadata( + project_root: str, + *, + prompt_text: str | None = None, +) -> dict: + """Load workflow + schema from disk and compute proof metadata.""" + workflow_path, schema_path = _canonical_paths(project_root) + workflow_text = _read_text(workflow_path) + schema_text = _read_text(schema_path) + workflow_hash = compute_content_hash(workflow_text) + schema_hash = compute_content_hash(schema_text) + conflict, conflict_reasons = assess_prompt_conflict(prompt_text) + return { + "workflow_source": WORKFLOW_REL_PATH, + "workflow_path": str(workflow_path), + "task_mode": TASK_MODE, + "workflow_hash": workflow_hash, + "workflow_version": workflow_hash, + "final_report_schema_path": SCHEMA_REL_PATH, + "final_report_schema_hash": schema_hash, + "prompt_conflicts_with_workflow": conflict, + "prompt_conflict_reasons": conflict_reasons, + "load_tool": LOAD_TOOL_NAME, + } + + +def assess_prompt_conflict(prompt_text: str | None) -> tuple[bool, list[str]]: + """Detect obvious task-mode conflicts between prompt and review workflow.""" + if not (prompt_text or "").strip(): + return False, [] + text = prompt_text.lower() + reasons: list[str] = [] + conflicting = ( + (r"\bwork[- ]issue\b", "work-issue author mode"), + (r"\bcreate[- ]issue\b", "create-issue mode"), + (r"\bauthor/coder\b", "author/coder mode"), + (r"\breconcile[- ]landed\b", "reconcile-landed mode"), + ) + for pattern, label in conflicting: + if re.search(pattern, text): + reasons.append( + f"active prompt appears to request {label} while loading " + f"{TASK_MODE} workflow" + ) + return bool(reasons), reasons + + +def _session_binding_fields() -> dict: + """Capture profile identity used to share state across daemon processes.""" + env_lock = (os.environ.get(mcp_session_state.SESSION_PROFILE_LOCK_ENV) or "").strip() + profile_name = (os.environ.get("GITEA_MCP_PROFILE") or "").strip() + remote = (os.environ.get("GITEA_MCP_REMOTE") or "").strip() or None + identity = mcp_session_state.current_profile_identity( + profile_name=profile_name, + session_profile_lock=env_lock, + ) + return { + "session_profile": profile_name or identity, + "session_profile_lock": env_lock or identity, + "profile_identity": identity, + "remote": remote, + } + + +def _persist_workflow_load(record: dict | None) -> dict | None: + """Write durable workflow-load proof (or clear it).""" + binding = _session_binding_fields() + if record is None: + mcp_session_state.clear_state( + kind=mcp_session_state.KIND_WORKFLOW_LOAD, + remote=binding.get("remote"), + profile_identity=binding.get("profile_identity"), + ) + return None + payload = dict(record) + payload.update({ + k: v for k, v in binding.items() if v is not None + }) + return mcp_session_state.save_state( + kind=mcp_session_state.KIND_WORKFLOW_LOAD, + payload=payload, + remote=payload.get("remote"), + org=payload.get("org"), + repo=payload.get("repo"), + profile_identity=payload.get("profile_identity"), + ) + + +def _load_durable_workflow_load() -> dict | None: + binding = _session_binding_fields() + return mcp_session_state.load_state( + kind=mcp_session_state.KIND_WORKFLOW_LOAD, + remote=binding.get("remote"), + profile_identity=binding.get("profile_identity"), + ) + + +def _active_workflow_load() -> dict | None: + """Prefer in-process cache; fall back to durable shared state (#559).""" + global _REVIEW_WORKFLOW_LOAD + if _REVIEW_WORKFLOW_LOAD is not None: + return _REVIEW_WORKFLOW_LOAD + durable = _load_durable_workflow_load() + if durable is not None: + _REVIEW_WORKFLOW_LOAD = dict(durable) + return _REVIEW_WORKFLOW_LOAD + + +def record_review_workflow_load( + project_root: str, + *, + prompt_text: str | None = None, +) -> dict: + """Record workflow load proof for the current MCP session (durable + memory).""" + global _REVIEW_WORKFLOW_LOAD + meta = build_canonical_workflow_metadata( + project_root, prompt_text=prompt_text) + boundary_state = boundary.assess_boundary_status(project_root) + binding = _session_binding_fields() + record = { + **meta, + "session_pid": os.getpid(), + "loaded": True, + "boundary_status": boundary_state.get("boundary_status"), + "boundary_clean": boundary_state.get("boundary_clean"), + "pre_review_command_count": boundary_state.get("pre_review_command_count"), + "boundary_violation_count": boundary_state.get("boundary_violation_count"), + "boundary_reasons": list(boundary_state.get("reasons") or []), + **{k: v for k, v in binding.items() if v is not None}, + } + persisted = _persist_workflow_load(record) + _REVIEW_WORKFLOW_LOAD = dict(persisted or record) + return dict(_REVIEW_WORKFLOW_LOAD) + + +def clear_review_workflow_load() -> None: + """Test helper and review_pr session reset.""" + global _REVIEW_WORKFLOW_LOAD + _REVIEW_WORKFLOW_LOAD = None + _persist_workflow_load(None) + boundary.clear_pre_review_commands() + + +def workflow_load_status(project_root: str | None = None) -> dict: + """Non-throwing status for capability/runtime reports.""" + load = _active_workflow_load() + if load is None: + return { + "workflow_load_proof_present": False, + "workflow_load_valid": False, + "workflow_source": None, + "workflow_hash": None, + "final_report_schema_path": SCHEMA_REL_PATH, + "reasons": [ + f"{LOAD_TOOL_NAME} has not been called in this session " + "(fail closed for reviewer mutations)" + ], + } + reasons = _session_validation_reasons(load, project_root) + boundary_reasons = boundary.boundary_blockers(project_root) + if boundary_reasons: + reasons = list(reasons) + boundary_reasons + return { + "workflow_load_proof_present": True, + "workflow_load_valid": not reasons, + "workflow_source": load.get("workflow_source"), + "workflow_hash": load.get("workflow_hash"), + "task_mode": load.get("task_mode"), + "final_report_schema_path": load.get("final_report_schema_path"), + "final_report_schema_hash": load.get("final_report_schema_hash"), + "prompt_conflicts_with_workflow": load.get( + "prompt_conflicts_with_workflow"), + "session_pid": load.get("session_pid"), + "writer_pid": load.get("writer_pid"), + "profile_identity": load.get("profile_identity"), + "boundary_status": load.get("boundary_status"), + "boundary_clean": load.get("boundary_clean"), + "workflow_load_helper_result": boundary.workflow_load_helper_result( + load, project_root), + "reasons": reasons, + } + + +def _session_validation_reasons( + load: dict, + project_root: str | None, +) -> list[str]: + """Validate durable/in-memory load proof for this session identity (#559).""" + reasons: list[str] = [] + # Cross-process daemon pools are allowed when profile identity matches. + # Reject only when the stored profile identity conflicts with this process. + binding = _session_binding_fields() + stored_identity = ( + load.get("session_profile_lock") + or load.get("profile_identity") + or "" + ).strip() + active_identity = (binding.get("profile_identity") or "").strip() + if ( + stored_identity + and active_identity + and stored_identity != active_identity + and active_identity != "unknown-profile" + and stored_identity != "unknown-profile" + ): + reasons.append( + "workflow load proof profile identity mismatch " + f"(stored={stored_identity!r}, active={active_identity!r}; fail closed)" + ) + return reasons + + # Expired durable records are treated as absent. + identity_reasons = mcp_session_state.identity_match_reasons( + load, + remote=binding.get("remote") or load.get("remote"), + org=load.get("org"), + repo=load.get("repo"), + profile_identity=active_identity or stored_identity, + ) + # Filter out remote-mismatch noise when remote was not bound at load time. + for reason in identity_reasons: + if "missing recorded_at" in reason or "expired" in reason or "future" in reason: + reasons.append(reason) + elif "profile identity mismatch" in reason: + reasons.append(reason) + if reasons: + return reasons + + if load.get("prompt_conflicts_with_workflow"): + reasons.extend(load.get("prompt_conflict_reasons") or [ + "active prompt conflicts with loaded review-merge workflow" + ]) + if project_root: + try: + current = build_canonical_workflow_metadata(project_root) + except OSError as exc: + reasons.append(f"cannot re-verify workflow hash: {exc}") + return reasons + if current["workflow_hash"] != load.get("workflow_hash"): + reasons.append( + "stored workflow hash is stale; reload via " + f"{LOAD_TOOL_NAME} (fail closed)" + ) + if current["final_report_schema_hash"] != load.get( + "final_report_schema_hash"): + reasons.append( + "stored final-report schema hash is stale; reload via " + f"{LOAD_TOOL_NAME} (fail closed)" + ) + return reasons + + +def review_workflow_load_blockers( + project_root: str | None = None, +) -> list[str]: + """Reasons reviewer mutations must fail closed.""" + boundary_reasons = boundary.boundary_blockers(project_root) + load = _active_workflow_load() + if boundary_reasons and load is None: + return boundary_reasons + status = workflow_load_status(project_root) + if not status.get("workflow_load_proof_present"): + return list(status.get("reasons") or []) + boundary_reasons + if not status.get("workflow_load_valid"): + return list(status.get("reasons") or []) + return [] + + +def recovery_handoff_without_replay() -> list[str]: + """Safe next-step lines that must not include approve/merge replay.""" + return [ + "Reload the canonical workflow via gitea_load_review_workflow, then " + "rerun the full review-merge workflow from inventory.", + "Do not call gitea_submit_pr_review, gitea_mark_final_review_decision, " + "or gitea_merge_pr until workflow-load proof is present.", + "Do not include approve/merge replay commands in the recovery handoff.", + ] diff --git a/reviewer_mutation_capability_proof.py b/reviewer_mutation_capability_proof.py new file mode 100644 index 0000000..1488738 --- /dev/null +++ b/reviewer_mutation_capability_proof.py @@ -0,0 +1,129 @@ +"""Exact per-mutation capability proof verifier for reviewer reports (#405). + +A reviewer final report may prove ``review_pr`` capability and then also merge +a PR or delete a remote branch. Merge and branch deletion are separate +mutations that require their own exact capability proof — a nearby capability +must never authorize a different operation. This verifier requires a +mutation-capability table pairing every performed mutation with the exact +task/permission resolved *before* that mutation. +""" + +from __future__ import annotations + +import re + +# Mutations this verifier tracks, with the exact capability tokens that +# authorize each. A row for the mutation must cite one of its own tokens; +# tokens from a different mutation (a "nearby capability") never count. +_REVIEW_TOKENS = ("review_pr", "gitea.pr.review", "gitea.pr.approve", + "gitea.pr.request_changes", "request_changes_pr", "approve_pr") +_MERGE_TOKENS = ("merge_pr", "gitea.pr.merge") +_DELETE_TOKENS = ("delete_branch", "gitea.branch.delete") + +# Detect that a mutation was actually performed (not merely mentioned as a +# non-goal or skipped). +_MERGE_PERFORMED = re.compile( + r"(?:gitea_merge_pr\b(?![^\n]*\b(?:not called|skipped|blocked)\b)|" + r"^\s*[-*]?\s*merge result\s*:\s*merged\b|" + r"\bpr merged\b|\bmerge commit\s*(?:sha)?\s*[:=]?\s*[0-9a-f]{7,})", + re.IGNORECASE | re.MULTILINE, +) +_DELETE_PERFORMED = re.compile( + r"(?:gitea_delete_branch\b(?![^\n]*\b(?:not called|skipped|blocked)\b)|" + r"^\s*[-*]?\s*(?:remote )?branch deleted\s*:|" + r"\bdeleted (?:the )?(?:remote )?branch\b|" + r"^\s*[-*]?\s*branch deletion\s*:\s*(?!skipped|none|not)\S)", + re.IGNORECASE | re.MULTILINE, +) +_REVIEW_PERFORMED = re.compile( + r"(?:gitea_submit_pr_review\b|gitea_mark_final_review_decision\b|" + r"^\s*[-*]?\s*review (?:decision|verdict|mutation)\s*:\s*" + r"(?:approved|request[_ ]changes)\b|\breview submitted\b)", + re.IGNORECASE | re.MULTILINE, +) + +# Post-hoc proof: capability resolved *after* the mutation is never valid. +_POST_HOC = re.compile( + r"capabilit(?:y|ies)\s+(?:resolved|proven|checked)\s+(?:after|post[- ])\s*" + r"(?:the\s+)?(?:merge|deletion|delete|mutation|review)", + re.IGNORECASE, +) + +# The report must carry an explicit mutation-capability table. +_TABLE_MARKER = re.compile( + r"mutation[- ]capability(?:\s+table)?|capability[- ]per[- ]mutation", + re.IGNORECASE, +) + + +def _tokens_present(text: str, tokens: tuple[str, ...]) -> bool: + low = text.lower() + return any(tok.lower() in low for tok in tokens) + + +def assess_mutation_capability_proof(report_text: str) -> dict: + """Validate exact per-mutation capability proof in a reviewer report. + + Returns ``{proven, block, reasons, safe_next_action}``. A report that + performs no mutation beyond an ordinary review passes only when its + review capability is cited; merge/delete each demand their own exact + capability row. Fail closed on nearby-capability substitution, a + missing table, missing rows, or post-hoc proof. + """ + text = report_text or "" + reasons: list[str] = [] + + merged = bool(_MERGE_PERFORMED.search(text)) + deleted = bool(_DELETE_PERFORMED.search(text)) + reviewed = bool(_REVIEW_PERFORMED.search(text)) + + extra_mutation = merged or deleted + + if _POST_HOC.search(text): + reasons.append( + "capability proof recorded after the mutation; exact capability " + "must be resolved before each mutation" + ) + + # A review-only report needs its review capability cited; no table required. + if reviewed and not _tokens_present(text, _REVIEW_TOKENS): + reasons.append( + "review mutation performed without exact review capability proof " + "(review_pr / gitea.pr.review)" + ) + + if extra_mutation and not _TABLE_MARKER.search(text): + reasons.append( + "mutation beyond review performed without a mutation-capability " + "table (mutation, exact task/capability, result, order-before)" + ) + + if merged: + if not _tokens_present(text, _MERGE_TOKENS): + reasons.append( + "merge performed without exact merge capability proof " + "(merge_pr / gitea.pr.merge); nearby review_pr does not " + "authorize merge" + ) + + if deleted: + if not _tokens_present(text, _DELETE_TOKENS): + reasons.append( + "branch deletion performed without exact delete capability " + "proof (delete_branch / gitea.branch.delete); nearby " + "merge_pr does not authorize branch deletion" + ) + + proven = not reasons + return { + "proven": proven, + "block": not proven, + "reasons": reasons, + "safe_next_action": ( + "proceed" + if proven + else "add a mutation-capability table with the exact resolved " + "task/permission and pre-mutation order for every mutation; " + "skip any mutation whose exact capability is unproven" + ), + } diff --git a/reviewer_pr_lease.py b/reviewer_pr_lease.py new file mode 100644 index 0000000..f55390b --- /dev/null +++ b/reviewer_pr_lease.py @@ -0,0 +1,515 @@ +"""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 = "" + +_FIELD_RE = re.compile( + r"^\s*([a-z_]+)\s*:\s*(.+?)\s*$", + re.IGNORECASE | re.MULTILINE, +) +_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE) + +_TERMINAL_PHASES = frozenset({"done", "released", "blocked"}) +_ACTIVE_PHASES = frozenset({ + "claimed", + "validating", + "approved", + "request-changes", + "merging", + "adopted", +}) + +DEFAULT_LEASE_TTL_MINUTES = 120 +STALE_WARNING_MINUTES = 30 +RECLAIMABLE_MINUTES = 60 + +_SESSION_LEASE: dict[str, Any] | None = None + + +def _parse_timestamp(value: str | None) -> datetime | None: + if not value: + return None + text = value.strip() + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + +def _normalize_sha(value: str | None) -> str | None: + text = (value or "").strip().lower() + return text if text and _FULL_SHA.match(text) else None + + +def _parse_pr_ref(value: str | None) -> int | None: + digits = re.sub(r"[^\d]", "", value or "") + return int(digits) if digits.isdigit() else None + + +def new_session_id() -> str: + return f"{os.getpid()}-{uuid.uuid4().hex[:12]}" + + +def format_lease_body( + *, + repo: str, + pr_number: int, + issue_number: int | None, + reviewer_identity: str, + profile: str, + session_id: str, + worktree: str, + phase: str, + candidate_head: str | None, + target_branch: str, + target_branch_sha: str | None, + last_activity: datetime | None = None, + expires_at: datetime | None = None, + blocker: str = "none", +) -> str: + now = last_activity or datetime.now(timezone.utc) + expires = expires_at or (now + timedelta(minutes=DEFAULT_LEASE_TTL_MINUTES)) + last_text = now.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace( + "+00:00", "Z" + ) + expires_text = expires.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace( + "+00:00", "Z" + ) + issue_text = f"#{issue_number}" if issue_number else "none" + lines = [ + MARKER, + f"repo: {repo}", + f"pr: #{pr_number}", + f"issue: {issue_text}", + f"reviewer_identity: {reviewer_identity}", + f"profile: {profile}", + f"session_id: {session_id}", + f"worktree: {worktree}", + f"phase: {phase}", + f"candidate_head: {candidate_head or 'none'}", + f"target_branch: {target_branch}", + f"target_branch_sha: {target_branch_sha or 'none'}", + f"last_activity: {last_text}", + f"expires_at: {expires_text}", + f"blocker: {blocker}", + ] + return "\n".join(lines) + + +def parse_lease_comment(body: str) -> dict[str, Any] | None: + text = body or "" + if MARKER not in text: + return None + fields: dict[str, str] = {} + for match in _FIELD_RE.finditer(text): + fields[match.group(1).strip().lower()] = match.group(2).strip() + if not fields: + return None + return { + "repo": fields.get("repo"), + "pr_number": _parse_pr_ref(fields.get("pr")), + "issue_number": _parse_pr_ref(fields.get("issue")), + "reviewer_identity": fields.get("reviewer_identity"), + "profile": fields.get("profile"), + "session_id": fields.get("session_id"), + "worktree": fields.get("worktree"), + "phase": (fields.get("phase") or "").strip().lower() or None, + "candidate_head": _normalize_sha(fields.get("candidate_head")), + "target_branch": fields.get("target_branch"), + "target_branch_sha": _normalize_sha(fields.get("target_branch_sha")), + "last_activity": fields.get("last_activity"), + "expires_at": fields.get("expires_at"), + "blocker": fields.get("blocker"), + "raw_fields": fields, + } + + +def _lease_entries(comments: list[dict], *, pr_number: int) -> list[dict]: + entries: list[dict] = [] + for comment in comments or []: + parsed = parse_lease_comment(comment.get("body") or "") + if not parsed: + continue + if parsed.get("pr_number") not in (None, pr_number): + continue + entries.append({ + **parsed, + "comment_id": comment.get("id"), + "author": (comment.get("user") or {}).get("login") or comment.get("author"), + "created_at": comment.get("created_at"), + "updated_at": comment.get("updated_at"), + }) + return entries + + +def _lease_expired(lease: dict, *, now: datetime) -> bool: + expires_at = _parse_timestamp(lease.get("expires_at")) + return bool(expires_at and expires_at <= now) + + +def _minutes_since_activity(lease: dict, *, now: datetime) -> float | None: + last = _parse_timestamp(lease.get("last_activity")) + if not last: + return None + return (now - last).total_seconds() / 60.0 + + +def classify_lease_freshness(lease: dict, *, now: datetime | None = None) -> str: + """Return active, stale_warning, reclaimable, expired, or terminal.""" + now = now or datetime.now(timezone.utc) + phase = (lease.get("phase") or "").strip().lower() + if phase in _TERMINAL_PHASES: + return "terminal" + if _lease_expired(lease, now=now): + return "expired" + minutes = _minutes_since_activity(lease, now=now) + if minutes is None: + return "active" + if minutes >= RECLAIMABLE_MINUTES: + return "reclaimable" + if minutes >= STALE_WARNING_MINUTES: + return "stale_warning" + return "active" + + +def find_active_reviewer_lease( + comments: list[dict], + *, + pr_number: int, + now: datetime | None = None, +) -> dict[str, Any] | None: + """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, + pr_merged_or_closed: bool = False, + now: datetime | None = None, +) -> dict[str, Any]: + """Fail closed when another session holds an active lease. + + When *pr_merged_or_closed* is true the PR has already merged/closed, so any + reviewer-lease acquisition or adoption for merge work is moot: fail closed + with a ``post_merge_moot`` reason and never mint a lease body (#515). + """ + now = now or datetime.now(timezone.utc) + reasons: list[str] = [] + existing = find_active_reviewer_lease(comments, pr_number=pr_number, now=now) + post_merge_moot = bool(pr_merged_or_closed) + if post_merge_moot: + reasons.append( + f"post_merge_moot: PR #{pr_number} is already merged/closed; reviewer " + "lease adoption for merge is moot (fail closed)" + ) + if existing: + owner_session = (existing.get("session_id") or "").strip() + freshness = existing.get("freshness") or classify_lease_freshness(existing, now=now) + if owner_session and owner_session != session_id and freshness in { + "active", "stale_warning" + }: + reasons.append( + f"PR #{pr_number} already has active reviewer lease " + f"(session_id={owner_session}, phase={existing.get('phase')})" + ) + elif owner_session and owner_session != session_id and freshness == "reclaimable": + reasons.append( + f"PR #{pr_number} lease is reclaimable but still held by " + f"session_id={owner_session}; explicit reclaim not implemented " + "(fail closed)" + ) + + if not (reviewer_identity or "").strip(): + reasons.append("reviewer identity required for lease acquisition") + if not (session_id or "").strip(): + reasons.append("session_id required for lease acquisition") + if not (worktree or "").strip(): + reasons.append("worktree path required for lease acquisition") + + allowed = not reasons + body = None + if allowed: + body = format_lease_body( + repo=repo, + pr_number=pr_number, + issue_number=issue_number, + reviewer_identity=reviewer_identity, + profile=profile, + session_id=session_id, + worktree=worktree, + phase="claimed", + candidate_head=candidate_head, + target_branch=target_branch, + target_branch_sha=target_branch_sha, + last_activity=now, + ) + return { + "acquire_allowed": allowed, + "reasons": reasons, + "existing_lease": existing, + "lease_body": body, + "session_id": session_id, + "post_merge_moot": post_merge_moot, + } + + +def assess_post_merge_moot_lease( + comments: list[dict], + *, + pr_number: int, + pr_merged: bool = False, + pr_state: str | None = None, + merge_commit_sha: str | None = None, + now: datetime | None = None, +) -> dict[str, Any]: + """Assess a reviewer lease left lingering on an already-merged/closed PR (#515). + + Read-first and fail-safe: + + - Only treats a lease as moot when the live PR state is merged/closed. + - Never proposes touching an *active* lease while the PR is still open + (``cleanup_allowed`` stays false and a refusal reason is returned). + - When the PR is merged/closed and a lease is still active, ``cleanup_allowed`` + is true and a terminal ``phase: released`` lease body (``blocker: + post-merge-moot``) is provided so the moot lease can be neutralised by an + append-only comment — never by deleting a foreign session's comment, and + never by adopting or merging. + + Posting the released body makes that lease terminal, so a subsequent call + finds no active lease and reports nothing left to clean (idempotent). + """ + now = now or datetime.now(timezone.utc) + merged_or_closed = bool(pr_merged) or ( + str(pr_state or "").strip().lower() == "closed" + ) + active = find_active_reviewer_lease(comments, pr_number=pr_number, now=now) + # The newest lease comment is authoritative: once a terminal marker + # (released/done/blocked) is the latest entry, the lease is resolved even if + # an earlier non-terminal comment from the same session still lingers. This + # keeps post-merge cleanup idempotent. + entries = _lease_entries(comments, pr_number=pr_number) + newest = entries[-1] if entries else None + newest_terminal = bool(newest) and ( + (newest.get("phase") or "").strip().lower() in _TERMINAL_PHASES + ) + reasons: list[str] = [] + cleanup_allowed = False + release_body: str | None = None + is_moot = bool(active) and merged_or_closed and not newest_terminal + + if not merged_or_closed: + if active: + reasons.append( + f"PR #{pr_number} is still open; refusing to touch active reviewer " + "lease (fail closed)" + ) + else: + reasons.append( + f"PR #{pr_number} is still open; no post-merge lease cleanup applicable" + ) + elif newest_terminal: + reasons.append( + f"PR #{pr_number} reviewer lease already released/terminal; nothing to clean" + ) + elif active: + cleanup_allowed = True + release_body = format_lease_body( + repo=active.get("repo") or "", + pr_number=pr_number, + issue_number=active.get("issue_number"), + reviewer_identity=active.get("reviewer_identity") or "", + profile=active.get("profile") or "unknown", + session_id=active.get("session_id") or "", + worktree=active.get("worktree") or "", + phase="released", + candidate_head=active.get("candidate_head"), + target_branch=active.get("target_branch") or "master", + target_branch_sha=active.get("target_branch_sha"), + last_activity=now, + blocker="post-merge-moot", + ) + else: + reasons.append( + f"PR #{pr_number} is merged/closed but no active reviewer lease remains; " + "nothing to clean" + ) + + return { + "pr_number": pr_number, + "pr_state": pr_state, + "pr_merged_or_closed": merged_or_closed, + "merge_commit_sha": merge_commit_sha, + "active_lease": active, + "is_moot": is_moot, + "cleanup_allowed": cleanup_allowed, + "release_body": release_body, + "reasons": reasons, + } + + +def record_session_lease( + lease: dict[str, Any], + *, + lease_provenance: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Record the in-session lease mirror for mutation gates. + + *lease_provenance* must be supplied by sanctioned MCP tools (#536). Bare + manual seeding without provenance cannot satisfy merger/reviewer mutation + gates. + """ + global _SESSION_LEASE + stored = dict(lease) + if lease_provenance: + stored["lease_provenance"] = dict(lease_provenance) + _SESSION_LEASE = stored + return dict(_SESSION_LEASE) + + +def clear_session_lease() -> None: + global _SESSION_LEASE + _SESSION_LEASE = None + + +def get_session_lease() -> dict[str, Any] | None: + return dict(_SESSION_LEASE) if _SESSION_LEASE else None + + +def assess_mutation_lease_gate( + *, + pr_number: int, + comments: list[dict], + reviewer_identity: str, + session_id: str | None, + mutation: str, + live_head_sha: str | None, + pinned_head_sha: str | None, + now: datetime | None = None, +) -> dict[str, Any]: + """Reviewer mutations require an owned, current PR lease.""" + now = now or datetime.now(timezone.utc) + reasons: list[str] = [] + session = get_session_lease() + active = find_active_reviewer_lease(comments, pr_number=pr_number, now=now) + + if not session: + reasons.append( + f"no in-session reviewer lease recorded; acquire via " + f"gitea_acquire_reviewer_pr_lease or adopt via " + f"gitea_adopt_merger_pr_lease before {mutation}" + ) + else: + import merger_lease_adoption as mla + + if not mla.is_sanctioned_session_lease(session): + reasons.append( + "in-session lease lacks sanctioned provenance; manual " + "_SESSION_LEASE seeding is not canonical proof — use " + "gitea_acquire_reviewer_pr_lease or gitea_adopt_merger_pr_lease" + ) + if session and session.get("pr_number") != pr_number: + reasons.append( + f"session lease is for PR #{session.get('pr_number')}, not #{pr_number}" + ) + elif session and (session.get("session_id") or "") != ( + session_id or session.get("session_id") + ): + reasons.append("session lease session_id mismatch (fail closed)") + + if active: + owner = (active.get("session_id") or "").strip() + if owner and session_id and owner != session_id: + reasons.append( + f"active PR lease owned by session_id={owner}; current session " + f"cannot {mutation}" + ) + pinned = _normalize_sha(pinned_head_sha) + live = _normalize_sha(live_head_sha) + lease_head = active.get("candidate_head") + if pinned and live and pinned != live: + reasons.append( + "PR head changed during lease; stop and re-validate before " + f"reviewer {mutation}" + ) + if lease_head and live and lease_head != live: + reasons.append( + "live PR head differs from lease candidate_head; refresh lease " + f"before {mutation}" + ) + freshness = active.get("freshness") or classify_lease_freshness(active, now=now) + if freshness in {"expired", "reclaimable"}: + reasons.append(f"reviewer lease freshness is '{freshness}' (fail closed)") + else: + reasons.append(f"no active reviewer lease found on PR #{pr_number}") + + allowed = not reasons + return { + "mutation_allowed": allowed, + "block": not allowed, + "reasons": reasons, + "active_lease": active, + "session_lease": session, + } + + +def assess_lease_inventory( + comments_by_pr: dict[int, list[dict]], + *, + now: datetime | None = None, +) -> dict[str, Any]: + """Summarize lease states across PR comment threads.""" + now = now or datetime.now(timezone.utc) + active: list[dict] = [] + stale: list[dict] = [] + reclaimable: list[dict] = [] + for pr_number, comments in (comments_by_pr or {}).items(): + lease = find_active_reviewer_lease(comments, pr_number=pr_number, now=now) + if not lease: + continue + freshness = lease.get("freshness") or classify_lease_freshness(lease, now=now) + entry = {"pr_number": pr_number, "session_id": lease.get("session_id"), "freshness": freshness} + if freshness == "stale_warning": + stale.append(entry) + elif freshness == "reclaimable": + reclaimable.append(entry) + else: + active.append(entry) + return { + "active_review_leases": active, + "stale_review_leases": stale, + "reclaimable_review_leases": reclaimable, + } \ No newline at end of file diff --git a/reviewer_validation_cwd_proof.py b/reviewer_validation_cwd_proof.py new file mode 100644 index 0000000..15cfb02 --- /dev/null +++ b/reviewer_validation_cwd_proof.py @@ -0,0 +1,233 @@ +"""Explicit worktree and cwd proof for PR review validation (#398).""" + +from __future__ import annotations + +import re +from typing import Any + +_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE) + +_PWD_RE = re.compile( + r"(?:^|\n)\s*(?:pwd|working\s+directory|cwd)\s*:\s*(\S+)", + re.IGNORECASE, +) +_HEAD_RE = re.compile( + r"(?:git\s+rev-parse\s+head|observed\s+head\s+sha)\s*:\s*([0-9a-f]{7,40})", + re.IGNORECASE | re.MULTILINE, +) +_EXPECTED_HEAD_RE = re.compile( + r"(?:expected\s+(?:pr\s+)?head\s+sha|candidate\s+head\s+sha|pinned\s+head)\s*:\s*([0-9a-f]{7,40})", + re.IGNORECASE, +) +_STATUS_RE = re.compile( + r"git\s+status\s+(?:--short\s+--branch|--short|-sb)\s*:\s*(.+)", + re.IGNORECASE, +) +_VALIDATION_CMD_RE = re.compile( + r"validation\s+command\s*:\s*(.+)", + re.IGNORECASE, +) +_GIT_C_CMD_RE = re.compile(r"git\s+-C\s+\S+", re.IGNORECASE) +_CD_CMD_RE = re.compile(r"(?:^|&&\s*)cd\s+\S+", re.IGNORECASE) +_BASELINE_CWD_RE = re.compile( + r"baseline\s+(?:worktree|working\s+directory|cwd)\s*:\s*(\S+)", + re.IGNORECASE, +) +_BASELINE_SHA_RE = re.compile( + r"baseline\s+(?:target\s+)?sha\s*:\s*([0-9a-f]{7,40})", + re.IGNORECASE, +) +_BASELINE_CMD_RE = re.compile( + r"baseline\s+validation\s+command\s*:\s*(.+)", + re.IGNORECASE, +) + + +def _normalize_path(path: str) -> str: + return (path or "").replace("\\", "/").rstrip("/") + + +def _path_under_branches(path: str, project_root: str | None = None) -> bool: + normalized = _normalize_path(path) + if not normalized: + return False + if "/branches/" in f"{normalized}/": + return True + if normalized.endswith("/branches"): + return True + if project_root: + root = _normalize_path(project_root) + if normalized.startswith(f"{root}/"): + rel = normalized[len(root) + 1 :] + return rel == "branches" or rel.startswith("branches/") + return False + + +def _expand_sha(sha: str) -> str: + return (sha or "").strip().lower() + + +def _sha_matches(expected: str, observed: str) -> bool: + exp = _expand_sha(expected) + obs = _expand_sha(observed) + if not exp or not obs: + return False + if len(exp) == 40 and len(obs) == 40: + return exp == obs + return obs.startswith(exp) or exp.startswith(obs) + + +def _command_has_explicit_cwd(command: str, cwd: str) -> bool: + text = (command or "").strip() + if not text: + return False + if _GIT_C_CMD_RE.search(text): + return True + if _CD_CMD_RE.search(text): + return True + if cwd and cwd in text: + return True + return False + + +def assess_validation_cwd_proof_report( + report_text: str, + *, + validation_session: dict | None = None, + project_root: str | None = None, +) -> dict[str, Any]: + """Require cwd/HEAD proof before reviewer validation claims (#398).""" + text = report_text or "" + session = dict(validation_session or {}) + reasons: list[str] = [] + violations: list[str] = [] + + claims_validation = bool( + session.get("validation_ran") + or _VALIDATION_CMD_RE.search(text) + or session.get("command") + ) + if not claims_validation: + return { + "proven": True, + "block": False, + "claims_validation": False, + "reasons": [], + "violations": [], + "safe_next_action": "proceed", + } + + expected_head = ( + session.get("expected_head_sha") + or session.get("candidate_head_sha") + or "" + ).strip() + if not expected_head: + match = _EXPECTED_HEAD_RE.search(text) + expected_head = (match.group(1) if match else "").strip() + + observed_head = (session.get("observed_head_sha") or "").strip() + if not observed_head: + match = _HEAD_RE.search(text) + observed_head = (match.group(1) if match else "").strip() + + cwd = ( + session.get("working_directory") + or session.get("cwd") + or session.get("pwd") + or "" + ).strip() + if not cwd: + match = _PWD_RE.search(text) + cwd = (match.group(1) if match else "").strip().rstrip(",.;") + + command = (session.get("command") or "").strip() + if not command: + match = _VALIDATION_CMD_RE.search(text) + command = (match.group(1) if match else "").strip().rstrip(".;") + + if not cwd: + reasons.append( + "validation claimed without pwd/working-directory proof (#398)" + ) + elif not _path_under_branches(cwd, project_root): + violations.append( + f"validation cwd {cwd!r} is not under branches/ (#398)" + ) + reasons.append( + "reviewer validation must run from a branches/ worktree, " + "not the main checkout (#398)" + ) + + if not observed_head: + reasons.append( + "validation claimed without git rev-parse HEAD / observed HEAD SHA " + "proof (#398)" + ) + elif expected_head and not _sha_matches(expected_head, observed_head): + violations.append( + f"observed HEAD {observed_head} does not match expected " + f"PR head {expected_head} (#398)" + ) + reasons.append("validation HEAD SHA must match pinned PR head (#398)") + + if not _STATUS_RE.search(text) and session.get("git_status") is None: + reasons.append( + "validation claimed without git status --short --branch proof (#398)" + ) + + if command and cwd and not _command_has_explicit_cwd(command, cwd): + if session.get("tool_working_directory") is not True: + reasons.append( + "validation command must use git -C , " + "cd && ..., 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" + ), + } \ No newline at end of file diff --git a/root_checkout_guard.py b/root_checkout_guard.py new file mode 100644 index 0000000..03af9e7 --- /dev/null +++ b/root_checkout_guard.py @@ -0,0 +1,148 @@ +"""Root checkout guard (#475). + +The project root checkout is the stable control checkout on master/prgs/master. +Author/reviewer/merge flows must fail closed when the control checkout is +contaminated (wrong branch, detached HEAD, dirty, or HEAD behind/ahead of +prgs/master). Isolated ``branches/...`` worktrees remain allowed. +""" + +from __future__ import annotations + +import os +import subprocess + +from author_mutation_worktree import is_path_under_branches +from reviewer_worktree import parse_dirty_tracked_files + +REMEDIATION = ( + "Root checkout is not on master. Preserve state, switch root back to master, " + "and use scripts/worktree-review or the sanctioned issue worktree flow." +) + +BASE_BRANCHES = frozenset({"master", "main", "dev"}) +REMOTE_MASTER_REFS = ("prgs/master", "refs/remotes/prgs/master") + + +def resolve_remote_master_sha( + canonical_repo_root: str, + *, + remote_refs: tuple[str, ...] | None = None, +) -> str | None: + """Return the commit SHA for the tracking master ref when available.""" + root = (canonical_repo_root or "").strip() + if not root: + return None + for ref in remote_refs or REMOTE_MASTER_REFS: + res = subprocess.run( + ["git", "-C", root, "rev-parse", "--verify", ref], + capture_output=True, + text=True, + check=False, + ) + if res.returncode == 0: + sha = (res.stdout or "").strip() + if sha: + return sha + return None + + +resolve_tracking_master_sha = resolve_remote_master_sha + + +def assess_root_checkout_guard( + *, + workspace_path: str, + canonical_repo_root: str, + current_branch: str | None, + head_sha: str | None, + porcelain_status: str, + remote_master_sha: str | None, + resolved_role: str | None = None, + actual_role: str | None = None, +) -> dict: + """Fail closed when the control checkout is not clean master/prgs/master. + + ``resolved_role`` is the preflight-resolved *task* role and ``actual_role`` + is the *active profile* role (#540). The reconciler exemption honours either + signal so a ``comment_issue`` preflight (which stamps the task role as + ``author``) cannot strip a genuine reconciler of its exemption. An actual + author profile classifies as ``author`` in both signals, so author blocking + on a contaminated control checkout is preserved. + + Merger *strictness* (a merger must not be auto-exempted by working from a + ``branches/`` worktree) stays keyed on the resolved task role: merge + operations resolve their own task role, and widening the merger test with + ``actual_role`` would wrongly subject a merger operating from its clean + workspace under a non-merge task to full control-checkout checks. + """ + reasons: list[str] = [] + root = os.path.realpath(canonical_repo_root) + workspace = os.path.realpath(workspace_path) + branch = (current_branch or "").strip() + dirty_files = parse_dirty_tracked_files(porcelain_status) + + if resolved_role == "reconciler" or actual_role == "reconciler": + return _assessment(True, [], root, workspace, branch, head_sha, dirty_files) + + if resolved_role != "merger" and is_path_under_branches(workspace, root): + return _assessment(True, [], root, workspace, branch, head_sha, dirty_files) + + if dirty_files: + reasons.append( + "control checkout has tracked local edits before role work " + f"(dirty files: {', '.join(dirty_files)})" + ) + + if not branch: + reasons.append("control checkout is detached HEAD; expected branch 'master'") + elif branch not in BASE_BRANCHES: + reasons.append( + f"control checkout branch '{branch}' is not a stable base branch " + f"({'/'.join(sorted(BASE_BRANCHES))})" + ) + + if remote_master_sha and head_sha and head_sha != remote_master_sha: + reasons.append( + "control checkout HEAD does not match prgs/master " + f"(HEAD {head_sha[:12]}, prgs/master {remote_master_sha[:12]})" + ) + + proven = not reasons + return _assessment(proven, reasons, root, workspace, branch or None, head_sha, dirty_files) + + +def format_root_checkout_guard_error(assessment: dict) -> str: + """Single RuntimeError message for MCP preflight gates.""" + root = assessment.get("canonical_repo_root") or "(unknown)" + workspace = assessment.get("workspace_path") or "(unknown)" + reasons = "; ".join(assessment.get("reasons") or ["unknown root checkout violation"]) + return ( + f"Root checkout guard (#475): {reasons}. " + f"canonical repository root: {root}; workspace: {workspace}. " + f"{REMEDIATION}" + ) + + +def _assessment( + proven: bool, + reasons: list[str], + canonical_repo_root: str, + workspace_path: str, + current_branch: str | None, + head_sha: str | None, + dirty_files: list[str], +) -> dict: + return { + "proven": proven, + "block": not proven, + "reasons": reasons, + "canonical_repo_root": canonical_repo_root, + "workspace_path": workspace_path, + "current_branch": current_branch, + "head_sha": head_sha, + "dirty_files": dirty_files, + "remediation": REMEDIATION, + } + + +assess_root_checkout = assess_root_checkout_guard \ No newline at end of file diff --git a/run-tests.sh b/run-tests.sh new file mode 100755 index 0000000..6930108 --- /dev/null +++ b/run-tests.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PYTHON="$ROOT_DIR/venv/bin/python" + +if [[ ! -x "$PYTHON" ]]; then + echo "ERROR: expected virtualenv Python at $PYTHON" >&2 + echo "Create the venv first, then run: venv/bin/python -m pytest" >&2 + exit 1 +fi + +exec "$PYTHON" -m pytest "$@" diff --git a/scripts/worktree-start b/scripts/worktree-start index 09c3bee..a189164 100755 --- a/scripts/worktree-start +++ b/scripts/worktree-start @@ -38,13 +38,21 @@ fi branch="$1" start_ref="${2:-prgs/master}" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd "$script_dir/.." && pwd)" + # Enforce issue-linked, traceable branch names (issue → branch → worktree → PR). if [[ "$allow_unlinked" -eq 0 ]]; then - if [[ ! -f "/tmp/gitea_issue_lock.json" ]]; then - echo "Error: Issue lock file '/tmp/gitea_issue_lock.json' is missing. You must lock exactly one issue before branch creation (fail closed)." >&2 + locked_branch=$(python3 -c " +import sys +sys.path.insert(0, '$repo_root') +import issue_lock_store +print(issue_lock_store.resolve_locked_branch_for_session('$branch')) +") + if [[ -z "$locked_branch" ]]; then + echo "Error: No session issue lock is bound. Call gitea_lock_issue before branch creation (fail closed)." >&2 exit 2 fi - locked_branch=$(python3 -c "import json; print(json.load(open('/tmp/gitea_issue_lock.json')).get('branch_name', ''))") if [[ "$branch" != "$locked_branch" ]]; then echo "Error: Requested branch '$branch' does not match locked branch '$locked_branch' (fail closed)." >&2 exit 2 @@ -68,8 +76,6 @@ EOF fi fi -script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -repo_root="$(cd "$script_dir/.." && pwd)" worktree_name="${branch//\//-}" worktree_path="$repo_root/branches/$worktree_name" diff --git a/skills/llm-project-workflow/schemas/review-merge-final-report.md b/skills/llm-project-workflow/schemas/review-merge-final-report.md index 9eb3592..6ef00bc 100644 --- a/skills/llm-project-workflow/schemas/review-merge-final-report.md +++ b/skills/llm-project-workflow/schemas/review-merge-final-report.md @@ -63,8 +63,14 @@ Do not use legacy fields: `Pinned reviewed head`, `Scratch worktree used`, - Current status: - Safe next action: - Safety statement: +- Workflow-load helper result: ``` +The **Workflow-load helper result** field must carry structured output from +`gitea_load_review_workflow` (workflow_hash, final_report_schema_hash, +boundary_status). Narrative claims that workflow files were viewed locally are +not sufficient (#403). + ### Already-landed handoff overrides When eligibility class is `ALREADY_LANDED_RECONCILE_REQUIRED`: diff --git a/skills/llm-project-workflow/templates/controller-issue-acceptance.md b/skills/llm-project-workflow/templates/controller-issue-acceptance.md new file mode 100644 index 0000000..148ef30 --- /dev/null +++ b/skills/llm-project-workflow/templates/controller-issue-acceptance.md @@ -0,0 +1,68 @@ +# Controller issue-acceptance prompt + +Use after a PR merges when auditing whether the linked issue is truly complete. + +```text +Audit issue # against its acceptance criteria after merged PR #. +Post a Controller Issue Acceptance comment with checked criteria, validation +reviewed, controller decision, next actor, and paste-ready next prompt. +Do not mark the issue accepted unless every required criterion is satisfied. +``` + +## Comment template + +```text +## Controller Issue Acceptance + +STATE: + + +WHO_IS_NEXT: + + +NEXT_ACTION: + + +NEXT_PROMPT: + + +ISSUE: +#... + +MERGED_PR: +#... + +MERGE_COMMIT: +<40-character SHA> + +ACCEPTANCE_CRITERIA_CHECKED: +- [x] ... +- [ ] ... + +VALIDATION_REVIEWED: + + +CONTROLLER_DECISION: + + +WHY: + + +MISSING_WORK: + + +FOLLOW_UP_ISSUES: + + +BLOCKERS: + + +LAST_UPDATED_BY: + +``` + +## Rejection paths + +When rejecting completion, `STATE` must name the gap (`needs-tests`, +`needs-docs`, `more-work-required`, etc.), `MISSING_WORK` must be explicit, +and `NEXT_PROMPT` must be ready for the next author session. \ No newline at end of file diff --git a/skills/llm-project-workflow/templates/merge-pr.md b/skills/llm-project-workflow/templates/merge-pr.md index ae99544..515ae10 100644 --- a/skills/llm-project-workflow/templates/merge-pr.md +++ b/skills/llm-project-workflow/templates/merge-pr.md @@ -10,6 +10,10 @@ Load the canonical workflow first: Final report schema: `schemas/review-merge-final-report.md`. Rules (llm-project-workflow): +- Repository targeting (#530): pass explicit `remote=`, `org=`, and `repo=` on + every gitea-tools call (e.g. `remote=prgs org=Scaled-Tech-Consulting + repo=Gitea-Tools`). A bare `remote=prgs` can resolve to the wrong default repo + and is blocked when it disagrees with the local git remote URL. - Only an eligible, NON-author reviewer merges. If authenticated user == PR author → STOP. - Do not merge unless the PR is open, mergeable, and its checks/review pass. @@ -41,7 +45,15 @@ Steps: 8. Confirm remote master now contains the merge commit (or the expected changes if squash merged). *Note: Gitea PR "closed" state is NOT equivalent to "merged". Do not assume a closed PR succeeded without verifying the actual landed changes.* -Then run the cleanup template (worktree-cleanup.md): +Post-merge cleanup (#517): merger sessions must NOT perform ad hoc cleanup. +- Record merge mutations separately from cleanup mutations in the controller handoff. +- Hand cleanup to a `prgs-reconciler` session — never raw `git branch -d`, + `git push --delete`, curl/API comment deletion, or local scripts. +- Reconciler cleanup must cite authorized MCP tools + (`gitea_reconcile_merged_cleanups`, `gitea_cleanup_post_merge_moot_lease`, + `gitea_delete_branch`, etc.) plus `gitea.branch.delete` capability proof. + +Then run the cleanup template (worktree-cleanup.md) in a reconciler session: - Verify expected file/commit presence on master (post-merge file-presence verification): - Run: git fetch --prune; git checkout master; git pull master --ff-only - Verify that the expected files added/modified in the PR are present on master (or absent if deleted). diff --git a/skills/llm-project-workflow/templates/post-merge-cleanup-handoff.md b/skills/llm-project-workflow/templates/post-merge-cleanup-handoff.md new file mode 100644 index 0000000..37a0ba6 --- /dev/null +++ b/skills/llm-project-workflow/templates/post-merge-cleanup-handoff.md @@ -0,0 +1,27 @@ +# Template: post-merge cleanup (reconciler only) + +Copy, fill the `<...>` fields, and paste as the task prompt. Run only after merge +is confirmed on remote master. Merger sessions must hand off here — never perform +this cleanup inline (#517). + +```text +Task: MCP-native post-merge cleanup for PR # / issue #. + +Rules: +- Active profile must be reconciler (`prgs-reconciler`) with `gitea.branch.delete`. +- Use MCP tools only — no raw git branch delete, no API comment deletion scripts. +- Record cleanup mutations separately from merge mutations in the handoff. + +Steps: +1. `gitea_resolve_task_capability(task="reconcile_merged_cleanups", remote=prgs)` +2. Confirm PR # merged on /master. +3. `gitea_cleanup_post_merge_moot_lease` if a reviewer lease remains (append-only). +4. `gitea_reconcile_merged_cleanups` for branch/worktree cleanup with dry-run first. +5. Report authorized cleanup tools used and reconciler capability proof. + +Handoff ledger (required fields): +- Merge mutations: (none — merger already recorded gitea_merge_pr) +- Cleanup mutations: list exact MCP tools invoked +- Reconciler capability: profile + gitea.branch.delete proof +- Next actor: controller acceptance or none +``` \ No newline at end of file diff --git a/skills/llm-project-workflow/templates/review-pr.md b/skills/llm-project-workflow/templates/review-pr.md index 391f962..a959634 100644 --- a/skills/llm-project-workflow/templates/review-pr.md +++ b/skills/llm-project-workflow/templates/review-pr.md @@ -35,6 +35,10 @@ Load the canonical workflow first: Final report schema: `schemas/review-merge-final-report.md`. Rules (llm-project-workflow): +- Repository targeting (#530): pass explicit `remote=`, `org=`, and `repo=` on + every gitea-tools call (e.g. `remote=prgs org=Scaled-Tech-Consulting + repo=Gitea-Tools`). A bare `remote=prgs` can resolve to the wrong default repo + and is blocked when it disagrees with the local git remote URL. - Review in a SEPARATE detached review worktree, never the author's folder. - Worktree safety (#233): before checkout, diff, validation, review, or merge, report the starting worktree path and whether it was dirty. If unrelated diff --git a/skills/llm-project-workflow/templates/start-issue.md b/skills/llm-project-workflow/templates/start-issue.md index 4b8c159..be09053 100644 --- a/skills/llm-project-workflow/templates/start-issue.md +++ b/skills/llm-project-workflow/templates/start-issue.md @@ -15,6 +15,10 @@ Rules (llm-project-workflow): - Work only in an isolated branch worktree under branches/. The main checkout is orchestration/status only. - Do not self-review or self-merge. +- Repository targeting (#530): pass explicit `remote=`, `org=`, and `repo=` on + every gitea-tools call (e.g. `remote=prgs org=Scaled-Tech-Consulting + repo=Gitea-Tools`). A bare `remote=prgs` can resolve to the wrong default repo + and is blocked when it disagrees with the local git remote URL. Steps: 0. Work Selection Rule — before any claim, branch, or file edits, acquire or diff --git a/skills/llm-project-workflow/templates/worktree-cleanup.md b/skills/llm-project-workflow/templates/worktree-cleanup.md index a9ffa95..4884525 100644 --- a/skills/llm-project-workflow/templates/worktree-cleanup.md +++ b/skills/llm-project-workflow/templates/worktree-cleanup.md @@ -26,3 +26,43 @@ Steps: Handoff: merge confirmed, issue closed, branch+worktree removed, checkout clean. ``` + +## Branches cleanup audit integrity (#404) + +Any bulk or multi-path cleanup under `branches/` must capture auditable before/after +identity for every initial directory and registered worktree. Use +`worktree_cleanup_audit.capture_cleanup_snapshot` before and after cleanup, record +every intentional removal in a removal log (path, method, order, timestamp, +pre-removal proof), then run `reconcile_cleanup_audit` and +`assess_cleanup_audit_integrity`. + +The cleanup report must include a reconciliation table: + +* initial count +* removed count +* preserved count +* missing-unexplained count +* final count + +Fail closed when: + +* a preserved (active PR, dirty, claim/lease, or unsafe) worktree disappears without + a removal log entry or explicit explanation +* the removal log omits a removed clean-stale path +* final counts do not reconcile with initial minus removed + +If another session removes or mutates a worktree during cleanup, record the path +under explained missing entries — never treat silent disappearance as success. + +## Bulk `branches/` cleanup audit (#404) + +Before removing multiple session-owned worktrees: + +1. Call `gitea_capture_branches_worktree_snapshot` and record the before snapshot. +2. Remove only paths classified as `clean_stale_removable` with explicit per-path proof. +3. Log every removal with path, method, and timestamp/order. +4. Capture an after snapshot with the same tool. +5. Call `gitea_assess_worktree_cleanup_integrity` with before, after, and the removal log. +6. Fail closed when any protected path (active PR, dirty, claim/lease) disappears + without an explained state transition. +7. Final report must include the reconciliation table and `git worktree list` proof. diff --git a/skills/llm-project-workflow/workflows/reconcile-landed-pr.md b/skills/llm-project-workflow/workflows/reconcile-landed-pr.md index 10ba60d..c58f3fe 100644 --- a/skills/llm-project-workflow/workflows/reconcile-landed-pr.md +++ b/skills/llm-project-workflow/workflows/reconcile-landed-pr.md @@ -304,6 +304,40 @@ If any required mutation capability is missing: * include safe next action (profile switch, human close, or dedicated reconciler profile) +## 15A. Audit vs cleanup phase (#419) + +Reconciliation audits are **read-only** unless a separate cleanup phase is +explicitly authorized. + +**Audit phase forbids** (``audit_reconciliation_mode.check_audit_mutation_allowed`` +fails closed): + +* ``gitea_delete_branch`` +* ``git branch -D`` +* ``git worktree remove`` +* pushes +* issue/PR mutations +* file edits + +Dry-run merged-cleanup reconciliation (``gitea_reconcile_merged_cleanups`` with +``dry_run=True``) stays in audit phase. Execution requires: + +1. Operator approval or workflow authorization +2. Exact ``delete_branch`` capability proof (``gitea.branch.delete``) +3. Proof branch/worktree is safe to remove +4. Before/after state snapshot + +Call ``gitea_authorize_reconciliation_cleanup_phase`` before any cleanup +mutation. Final reports must not claim ``no mutations`` if cleanup occurred. +Classify cleanup mutations as: + +* remote branch deletion → **External-state mutations** +* local branch deletion → **Git ref mutations** +* worktree removal → **Cleanup mutations** + +``audit_reconciliation_mode.assess_audit_reconciliation_report`` validates +these boundaries in final reports. + ## 16. Mutation classification Use precise mutation categories in the final report: @@ -341,6 +375,24 @@ Include: * confirmation that no normal review, approval, request-changes, or merge was performed +## 18A. Reconciler close proof is enforced (#306) + +When a reconciler run closes a PR, the final-report validator +(`final_report_validator` rule `reconcile.close_proof_fields`) **blocks** the +handoff unless it carries all four close proofs. The prompt is guidance; the +MCP validator is the authority. + +A close is detected from `PRs closed: #` (or a session close lock). Once a +close is reported, the handoff must include: + +* `Capabilities proven:` naming `gitea.pr.close` — the exact close capability +* `Ancestor proof:` — the landed/ancestor proof for the closed PR +* `PRs closed:` — the PR close result (the closed PR number) +* `Linked issue live status:` (or `Issues closed:`) — the linked-issue result + +Comment-only and blocked reconciliations (no PR close) are unaffected: the rule +returns no finding when nothing was closed. + ## 19. Local artifact and report consistency rule Do not create local walkthrough, notes, markdown, JSON, or report artifacts diff --git a/skills/llm-project-workflow/workflows/review-merge-pr.md b/skills/llm-project-workflow/workflows/review-merge-pr.md index 5b0e10a..5a90ea5 100644 --- a/skills/llm-project-workflow/workflows/review-merge-pr.md +++ b/skills/llm-project-workflow/workflows/review-merge-pr.md @@ -36,6 +36,44 @@ If available, load it first and report: If the canonical workflow cannot be loaded and the project requires it, stop and produce a recovery handoff only. +## 0A. Workflow-load and session boundary anchor (#403) + +The MCP gate is the authority — not local file viewing. + +Before any reviewer mutation: + +1. Record pre-review commands with `gitea_record_pre_review_command` when they + are not automatically classified (inventory/diagnostic commands may be + recorded explicitly for proof). +2. Call `gitea_load_review_workflow` to establish workflow hash proof **and** + session boundary state in the same in-process session proof. +3. Do not claim the workflow was loaded by reading + `skills/llm-project-workflow/workflows/review-merge-pr.md` as a local file; + that narrative does not satisfy the validator. + +Allowed before workflow load (classify as `read_only_inventory` or +`diagnostic`): + +* `gitea_whoami`, `gitea_resolve_task_capability`, `gitea_list_prs`, + `gitea_view_pr`, `gitea_get_runtime_context` +* `git fetch` / `git remote update` for inventory +* `git status`, `git worktree list` (read-only) + +Boundary violations (block downstream reviewer mutations even after load): + +* validation commands (`pytest`, `python -m unittest`) in the main checkout +* local profile/credential/config inspection (`profiles.json`, `gitea-mcp.json`, + `.env`, keychain dumps) +* MCP repair (`pkill`, MCP config edits) +* git mutations before workflow load + +Final reports must include a structured **Workflow-load helper result** block +copied from `gitea_load_review_workflow`, including at minimum: + +* `workflow_hash` +* `final_report_schema_hash` +* `boundary_status` (`clean` or `violation`) + ## 1. Start with live identity, profile, runtime, and capability checks Prove: @@ -568,6 +606,28 @@ If the cause is unknown, do not erase the earlier failure with plain `gitea_validate_review_final_report` rejects reports that omit known earlier validation failures when `validation_session.observed_failures` is supplied. +## 21B. Validation status taxonomy (#406) + +When the final report summarizes how validation concluded, use one of these +**validation status** labels (distinct from per-command pass/fail entries): + +* `passed` — raw PR-head validation passed on the unmodified head. +* `failed` — raw PR-head validation failed and no allowed resolution path + was proven. +* `baseline-equivalent failure accepted` — only when a clean baseline + worktree under `branches/` proves matching failure signatures on the target + branch (baseline path, target SHA, exact commands, failure lists, and + `failure signatures match: true`). +* `raw-head failure resolved by merge simulation` — raw PR-head validation + failed, but merge simulation into the current target passed cleanly; report + merge simulation under `Worktree/index mutations` with full #317 proof. +* `passed after transient failure investigation` — a later run passed after an + earlier failure in the same session; document the failure history (#396). + +Do not use `baseline-equivalent failure accepted` when only merge simulation +resolved the failure. Do not use bare `passed` when raw PR-head validation +failed unless one of the resolution statuses above applies. + ## 22. Baseline validation rule Do not run tests in the main checkout. @@ -604,6 +664,28 @@ Do not claim “full-suite failures are pre-existing” unless baseline proof is ## 23. Validation command proof rule +Before any diff, test, or compile validation, record in the same command +transcript or final report: + +* `pwd` or explicit working directory +* `git rev-parse HEAD` +* `git status --short --branch` +* expected PR head SHA (candidate head SHA) + +Validation commands must use one of: + +* `git -C ...` +* `cd && ...` in the same command +* tool-provided explicit working-directory metadata + +Do not rely on inferred shell cwd from a prior command in a different block. + +`gitea_validate_review_final_report` rejects validation claims without +cwd/HEAD proof when `validation_session` is supplied. + +Baseline validation must document baseline worktree path, baseline target SHA, +cwd proof, exact baseline command, and baseline result using the same rules. + Report the exact validation command as executed. Report the working directory where validation ran. @@ -732,6 +814,59 @@ 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. + +## 26B-1. Merger lease adoption (#536) + +When review and merge are **different sessions**, the merger must adopt the +reviewer's lease — never manually seed `reviewer_pr_lease._SESSION_LEASE`. + +Before merge in a merger-only session: + +1. Confirm `approval_at_current_head` via `gitea_get_pr_review_feedback`. +2. Call `gitea_adopt_merger_pr_lease` from a clean merger worktree under + `branches/`, passing `expected_head_sha` pinned to the approved head. +3. Quote the adoption comment id and `adopted_from_session_id` in the handoff. +4. Proceed to `gitea_merge_pr` only after adoption succeeds. + +Manual in-process lease seeding is rejected by mutation gates (fail closed). + +## 26C. Conflict-fix lease and stale-head protection (#399) + +Before validating, approving, or merging a PR: + +1. Check for an active conflict-fix lease on the PR; stop if one is active. +2. Pin `expected_head_sha` before validation and pass it to + `gitea_mark_final_review_decision`, `gitea_submit_pr_review`, and + `gitea_merge_pr`. +3. Re-fetch live PR head immediately before approval and merge; refuse when + live head differs from the reviewed SHA. + +Final reports must state: + +* reviewed head SHA +* final live head SHA before approval +* final live head SHA before merge +* whether any push occurred during validation + ## 27. Merge rules Before merge, rerun fresh live checks: @@ -742,6 +877,7 @@ Before merge, rerun fresh live checks: * author safety * PR re-fetch * reviewed head SHA unchanged +* visible APPROVED review applies to the **current live PR head SHA** (`approval_at_current_head`); if the head moved after approval, re-review at the new head before merge (#471) * target branch freshly fetched * PR still open * PR still mergeable @@ -758,6 +894,7 @@ Do not merge if: * capability state is stale * worktree is dirty * PR head changed +* approval is stale (approved SHA ≠ current live head SHA) * validation failed * inventory was incomplete * PR is already landed @@ -787,6 +924,16 @@ Confirm: Clean only the session-owned `branches/` review worktree if the project workflow explicitly allows cleanup. +Review, baseline, and merge-simulation worktrees created during this run are +transient and are removed automatically at successful completion once they are +clean, carry no open PR, and hold no active lease (#401). Use +`gitea_audit_worktree_cleanup` (read-only) to classify `branches/` entries; only +`clean_stale_removable` and `detached_review_leftover` may be removed, one-by-one, +after `git worktree list` proof plus per-worktree proof of path, branch/HEAD, +clean/dirty status, no active PR/lease, and the removal result. Dirty, +active-PR, active-issue, and leased worktrees are never deleted automatically; a +failed removal must be reported with the leftover path and reason. + Do not delete or mutate unrelated branches/worktrees. Do not touch the main checkout except to update the stable branch after merge if explicitly allowed by the workflow. @@ -797,6 +944,61 @@ Do not update the main checkout if merge failed, was blocked, or produced reconc If any local artifact is created after final cleanup, run and report a new final status check. +## 28A. Post-merge cleanup proof checklist (#402) + +Successful tool execution is not proof that cleanup was authorized. Before claiming remote branch deletion or local worktree removal, the final report must carry the full safety checklist below. If any gate is missing, report `CLEANUP_SKIPPED` with the exact blocker — never perform cleanup and never claim it was performed. + +### Remote branch deletion checklist + +When `gitea_delete_branch` (or equivalent) deletes the merged PR head branch, report: + +* Delete-branch capability resolved: name the task (`delete_branch` / `cleanup_branch` / `reconcile_merged_cleanups`) and permission (`gitea.branch.delete`) with resolver proof before the delete call +* Merge result: merged +* Merge commit SHA: full 40-character SHA +* Merged PR head branch / deleted branch: exact branch name (must match) +* Branch protection: none / branch is not protected +* Open PR inventory proof: no other open PR references the branch +* Active heartbeat/claim/lease: none + +### Local worktree removal checklist + +When removing session-owned review/simulation worktrees under `branches/`, report: + +* Session-owned worktree path: exact path under `branches/` +* Pre-removal tracked state: clean +* Pre-removal untracked state: clean +* Git worktree list after removal: command output or equivalent proof + +### Skipped cleanup + +If any gate fails, report: + +* Cleanup outcome: `CLEANUP_SKIPPED` +* Cleanup blocker: exact missing gate (for example `gitea.branch.delete capability not resolved`) + +Skipped cleanup with an exact blocker passes validation. Performed-cleanup claims without the checklist fail validation. + +## 28B. MCP-native cleanup only (#517) + +Post-merge cleanup of leases, comments, branches, and worktrees must go through +explicit MCP tools — never raw git, curl/API scripts, or ad hoc helper scripts. + +Merger and reviewer sessions must **not** perform cleanup inline. Record: + +* **Merge mutations** — only `gitea_merge_pr` (or review mutations for review-only runs) +* **Cleanup mutations** — only authorized reconciler MCP tools, cited by exact tool name + +Authorized cleanup tools include `gitea_reconcile_merged_cleanups`, +`gitea_cleanup_post_merge_moot_lease`, `gitea_delete_branch`, and +`gitea_cleanup_merged_pr_branch` (when available). Lease cleanup uses +append-only release comments — never delete another session's lease comment. + +Hand cleanup to a `prgs-reconciler` session with `gitea.branch.delete` capability +proof. Raw `git branch -d`, `git push --delete`, and comment-deletion API calls +are blocked in final-report validation. + +Template: `templates/post-merge-cleanup-handoff.md`. + ## 29. Recovery handoff rules If blocked, produce a recovery handoff with: @@ -895,6 +1097,26 @@ Use precise wording: Do not collapse review, merge, cleanup, or external-state mutations into vague wording. +## 31B. Mutation-capability table (#405) + +Every performed mutation requires exact capability proof resolved **before** that +mutation executes. Nearby capabilities never authorize a different operation — +`review_pr` does not authorize `merge_pr`, and `merge_pr` does not authorize +`delete_branch` / `gitea.branch.delete`. + +When any mutation beyond a bare review occurs (merge, branch delete, issue +close/comment, etc.), the final report must include a **mutation-capability table** +with one row per performed mutation: + +* mutation (tool/action name) +* exact task/capability resolved (for example `merge_pr` / `gitea.pr.merge`) +* result +* order/timestamp proof that capability was resolved before the mutation + +If exact capability proof is missing, skip the mutation or stop the workflow — +never claim a performed mutation without its row. Post-hoc capability proof after +the mutation fails validation. + ## 31A. Local artifact and report consistency rule Do not create local walkthrough, notes, markdown, JSON, or report artifacts during reviewer runs unless the canonical workflow or operator explicitly requires it. @@ -1127,6 +1349,7 @@ Controller Handoff: * Files reviewed: * Validation: * Validation failure history: +* Validation cwd/HEAD proof: * Official validation integrity status: * Terminal review mutation: * Review decision: diff --git a/skills/llm-project-workflow/workflows/work-issue.md b/skills/llm-project-workflow/workflows/work-issue.md index f450cd7..c7f5747 100644 --- a/skills/llm-project-workflow/workflows/work-issue.md +++ b/skills/llm-project-workflow/workflows/work-issue.md @@ -144,6 +144,14 @@ If the main checkout is dirty before selection, stop and produce a recovery hand If the main checkout becomes dirty during the run, stop and produce a recovery handoff unless the change is explicitly allowed by the canonical workflow. +### Stacked PRs (explicit exception, #484) + +Normal author work stays base-equivalent to `master`/`main`/`dev`. A **stacked PR** — deliberately based on another unmerged PR's branch — is the only sanctioned non-master base, and only when the operator/controller explicitly chooses it: + +- Branch the `branches/` worktree from the dependency's branch, then lock with `gitea_lock_issue(..., stacked_base_branch=, stacked_base_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=)`. The body must state: `Stacked on PR # / issue #`, `Base branch: `, `Head branch: `, `Do not merge before PR #`, 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. @@ -297,6 +305,36 @@ Report: Do not create another branch/PR for the same issue unless the project explicitly allows taking over or updating existing work and exact capability is proven. +### 10A. Duplicate-work gate phases (#400) + +Before any file edits, prove duplicate-work clearance with +`gitea_assess_work_issue_duplicate` or `gitea_lock_issue` (which runs the same +gate). The gate checks live: + +* open PRs linked to the issue (head branch or Closes/Fixes reference), +* remote branches matching `issue-`, +* 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. @@ -577,6 +615,34 @@ 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. @@ -638,6 +704,39 @@ Do not update the main checkout unless the canonical workflow explicitly allows Any cleanup is a mutation and must be reported. +### 22A. Session-owned worktree cleanup and TTL (#401) + +Every session-owned worktree created under `branches/` has ownership metadata: +path, workflow type, issue number, PR number, branch/head SHA, creator +identity/profile, created timestamp, last-used timestamp, and cleanup +eligibility. + +Cleanup is classification-driven. `gitea_audit_worktree_cleanup` (read-only) +classifies every `branches/` entry as exactly one of: + +* `active_open_pr` — branch has an open PR; never auto-removed. +* `active_issue_work` — active claim/lease or fresh issue worktree; never + auto-removed. +* `dirty_local_worktree` — uncommitted changes; never auto-removed. +* `clean_stale_removable` — clean, no PR, no lease; removable. +* `detached_review_leftover` — clean detached review/baseline/merge-simulation + worktree; removable. +* `unsafe_unknown` — protected base checkout or unknown workflow type; never + auto-removed. + +Only `clean_stale_removable` and `detached_review_leftover` may be removed, and +only one-by-one after `git worktree list` proof plus per-worktree proof of: +worktree path, branch/HEAD, clean/dirty status, no active PR/lease, and the +removal result. Review, baseline, and merge-simulation worktrees are removed +automatically at successful workflow completion; issue/conflict-fix worktrees +are removed only after their TTL (`GITEA_WORKTREE_TTL_HOURS`, default 24h) +expires and no lock/lease is held. + +Dirty, active-PR, active-issue, and leased worktrees are never deleted +automatically. If a removal fails, the final report must list the leftover +worktree path and the reason. Include the `git worktree list` output as final +cleanup verification. + ## 23. Recovery handoff rules If blocked, produce a recovery handoff with: @@ -717,6 +816,12 @@ Use only precise categories: * External-state mutations: * Read-only diagnostics: +Issue-lock file (`/tmp/gitea_issue_lock.json`) read/write/delete is always an +external-state mutation. Never claim `External-state mutations: none` after +seeding, restoring, or removing that file. Manual lock seeding is not a normal +recovery path (#447); use `gitea_lock_issue` or the #442 adoption recovery path +instead. Link broader redesign: #438. + `git fetch`, `git remote update`, and any command that updates refs must be listed under `Git ref mutations`, not read-only diagnostics. If `git reset --hard`, checkout, clean, worktree add/remove, merge simulation, merge abort, or similar commands occurred, report them under `Worktree/index mutations`. diff --git a/stacked_pr_support.py b/stacked_pr_support.py new file mode 100644 index 0000000..e3cab47 --- /dev/null +++ b/stacked_pr_support.py @@ -0,0 +1,211 @@ +"""Stacked-PR support for author issue locks and PR creation (#484). + +Normal author work locks a worktree that is base-equivalent to ``master``/ +``main``/``dev`` and opens a PR against one of those base branches. A *stacked* +PR is deliberately based on another unmerged PR's branch, so its worktree is not +master-equivalent and its PR base is not a normal base branch. + +This module holds the pure decision logic that lets: + +* ``gitea_lock_issue`` approve a non-master base **only** when it is explicitly + declared and proven to correspond to an open pull request, and +* ``gitea_create_pr`` accept that approved base while still rejecting arbitrary, + mismatched, or stale (merged/closed) branches. + +The normal master-based path is unchanged: when no stacked base is declared, and +when the PR base is a normal base branch, these helpers are inert. Nothing here +bypasses the issue lock — a stacked base is recorded *on* the lock and re-checked +at PR time. +""" + +from __future__ import annotations + +BASE_BRANCHES = frozenset({"master", "main", "dev"}) + +# Phrases that satisfy the required merge-ordering statement in a stacked PR body. +MERGE_ORDER_PHRASES = ("do not merge before", "do not merge until") + + +def is_base_branch(base: str | None, base_branches: frozenset[str] | None = None) -> bool: + """True when ``base`` is a normal base branch (master/main/dev).""" + bases = base_branches or BASE_BRANCHES + return (base or "").strip() in bases + + +def _pr_head_ref(pr: dict) -> str: + head = pr.get("head") or {} + if isinstance(head, dict): + return (head.get("ref") or "").strip() + return (str(head) if head else "").strip() + + +def find_open_pr_for_branch(open_prs: list[dict] | None, branch: str | None) -> dict | None: + """Return the first OPEN PR whose head ref equals ``branch`` (else ``None``).""" + branch = (branch or "").strip() + if not branch: + return None + for pr in open_prs or []: + if (pr.get("state") or "").strip().lower() != "open": + continue + if _pr_head_ref(pr) == branch: + return pr + return None + + +def assess_stacked_base_declaration( + *, + stacked_base_branch: str | None, + stacked_base_pr: int | None, + open_prs: list[dict] | None, +) -> dict: + """Validate an explicit stacked-base declaration at lock time. + + Returns a dict with ``block`` (fail closed), ``reasons``, ``declared`` + (whether a stacked base was requested), and ``approved`` (the metadata to + persist on the lock when valid, else ``None``). + """ + branch = (stacked_base_branch or "").strip() + if not branch: + # No stacked base requested — normal master-based lock path. + return {"block": False, "reasons": [], "approved": None, "declared": False} + + if branch in BASE_BRANCHES: + return { + "block": True, + "declared": True, + "approved": None, + "reasons": [ + f"stacked base '{branch}' is already a normal base branch; do not " + "declare a base branch as a stacked base" + ], + } + + if stacked_base_pr is None: + return { + "block": True, + "declared": True, + "approved": None, + "reasons": [ + "stacked base branch declared without stacked_base_pr; a stacked PR " + "must cite the open PR that owns the base branch" + ], + } + + pr = find_open_pr_for_branch(open_prs, branch) + if pr is None: + return { + "block": True, + "declared": True, + "approved": None, + "reasons": [ + f"stacked base branch '{branch}' does not correspond to any OPEN pull " + "request; arbitrary or stale branches are not allowed as stacked bases" + ], + } + + if int(pr.get("number")) != int(stacked_base_pr): + return { + "block": True, + "declared": True, + "approved": None, + "reasons": [ + f"declared stacked_base_pr #{stacked_base_pr} does not match the open " + f"PR #{pr.get('number')} that owns base branch '{branch}'" + ], + } + + return { + "block": False, + "declared": True, + "reasons": [], + "approved": { + "branch": branch, + "pr_number": int(pr.get("number")), + "verified_open": True, + }, + } + + +def assess_stacked_pr_body( + body: str | None, *, base_branch: str | None, pr_number: int | None +) -> list[str]: + """Return the list of missing stacked-PR documentation fields (empty = ok).""" + text = body or "" + low = text.lower() + missing: list[str] = [] + if base_branch and base_branch not in text: + missing.append(f"base branch '{base_branch}'") + if pr_number is not None and f"#{pr_number}" not in text: + missing.append(f"stacked-on PR reference '#{pr_number}'") + if not any(phrase in low for phrase in MERGE_ORDER_PHRASES): + missing.append("merge-ordering statement (e.g. 'Do not merge before PR #')") + 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} diff --git a/task_capability_map.py b/task_capability_map.py index 1d40a78..c86ce2a 100644 --- a/task_capability_map.py +++ b/task_capability_map.py @@ -68,6 +68,10 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = { "permission": "gitea.pr.merge", "role": "reviewer", }, + "adopt_merger_pr_lease": { + "permission": "gitea.pr.comment", + "role": "reviewer", + }, "blind_pr_queue_review": { "permission": "gitea.pr.review", "role": "reviewer", @@ -104,6 +108,10 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = { "permission": "gitea.read", "role": "author", }, + "reconciliation_cleanup": { + "permission": "gitea.branch.delete", + "role": "author", + }, "work_issue": { "permission": "gitea.pr.create", "role": "author", diff --git a/test_mcp_conn.py b/test_mcp_conn.py new file mode 100644 index 0000000..7481ce9 --- /dev/null +++ b/test_mcp_conn.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Live health-check script to verify Gitea MCP namespace connections. + +Spawns the MCP server processes as defined in the IDE's global config, +performs the JSON-RPC handshake, and queries the tools list to verify +that the connection is fully operational and doesn't return EOF. +""" +import json +import os +import subprocess +import sys + +def test_connection(name, config): + print(f"Testing MCP connection for '{name}'...") + command = config.get("command") + args = config.get("args", []) + env = config.get("env", {}) + + # Merge current environment + run_env = os.environ.copy() + run_env.update(env) + + # Spawn subprocess + try: + proc = subprocess.Popen( + [command] + args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + env=run_env + ) + except Exception as e: + print(f" [FAIL] Failed to spawn process: {e}") + return False + + # Send initialize request + init_req = { + "jsonrpc": "2.0", + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "healthcheck", "version": "1.0"} + }, + "id": 1 + } + + try: + proc.stdin.write(json.dumps(init_req) + "\n") + proc.stdin.flush() + + # Read response + line = proc.stdout.readline() + if not line: + stderr_content = proc.stderr.read() + print(f" [FAIL] Received EOF from process. Stderr:\n{stderr_content}") + proc.terminate() + return False + + print(f" [OK] Received initialize response: {line.strip()[:150]}...") + + # Send initialized notification + init_notif = { + "jsonrpc": "2.0", + "method": "notifications/initialized" + } + proc.stdin.write(json.dumps(init_notif) + "\n") + proc.stdin.flush() + + # Send tools/list request + list_req = { + "jsonrpc": "2.0", + "method": "tools/list", + "params": {}, + "id": 2 + } + proc.stdin.write(json.dumps(list_req) + "\n") + proc.stdin.flush() + + line = proc.stdout.readline() + if not line: + print(" [FAIL] Received EOF on tools/list request.") + proc.terminate() + return False + + res = json.loads(line) + if "error" in res: + print(f" [FAIL] Server returned error: {res['error']}") + proc.terminate() + return False + + tools = res.get("result", {}).get("tools", []) + tool_names = [t.get("name") for t in tools] + print(f" [OK] Successfully retrieved {len(tool_names)} tools: {tool_names[:5]}...") + + proc.terminate() + return True + except Exception as e: + print(f" [FAIL] Error during handshake: {e}") + proc.terminate() + return False + +def main(): + config_path = "/Users/jasonwalker/.gemini/config/mcp_config.json" + try: + with open(config_path) as f: + mcp_config = json.load(f) + except Exception as e: + print(f"Failed to load mcp_config.json: {e}") + sys.exit(1) + + servers = mcp_config.get("mcpServers", {}) + failed = False + for name in ["gitea-author", "gitea-reviewer"]: + if name in servers: + if not test_connection(name, servers[name]): + failed = True + else: + print(f"Server '{name}' not found in mcp_config.json") + + if failed: + sys.exit(1) + else: + print("All Gitea MCP connection tests passed!") + +if __name__ == "__main__": + main() diff --git a/tests/conftest.py b/tests/conftest.py index 7bbf78c..1c34986 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,14 +18,25 @@ def _reset_mutation_authority(monkeypatch): explicitly. """ monkeypatch.delenv("GITEA_SESSION_PROFILE_LOCK", raising=False) + # Isolate durable session-state files so tests never share host cache (#559). + import tempfile + + _state_tmp = tempfile.TemporaryDirectory(prefix="gitea-session-state-") + monkeypatch.setenv("GITEA_MCP_SESSION_STATE_DIR", _state_tmp.name) try: import mcp_server except Exception: + _state_tmp.cleanup() yield return monkeypatch.setattr(mcp_server, "_MUTATION_AUTHORITY", None) monkeypatch.setattr(mcp_server, "_IDENTITY_CACHE", {}) monkeypatch.setattr(mcp_server, "_REVIEW_DECISION_LOCK", None) + try: + import review_workflow_load + review_workflow_load._REVIEW_WORKFLOW_LOAD = None + except Exception: + pass try: import capability_stop_terminal capability_stop_terminal.clear() @@ -37,3 +48,13 @@ def _reset_mutation_authority(monkeypatch): capability_stop_terminal.clear() except Exception: pass + try: + import review_workflow_load + review_workflow_load._REVIEW_WORKFLOW_LOAD = None + except Exception: + pass + try: + mcp_server._REVIEW_DECISION_LOCK = None + except Exception: + pass + _state_tmp.cleanup() diff --git a/tests/test_agent_temp_artifacts.py b/tests/test_agent_temp_artifacts.py index 903eab8..7684d98 100644 --- a/tests/test_agent_temp_artifacts.py +++ b/tests/test_agent_temp_artifacts.py @@ -74,18 +74,24 @@ ISSUE_WRITE_ENV = { class TestIssueLockArtifactWarning(unittest.TestCase): def setUp(self): - self._env_patcher = patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True) + self._lock_dir = tempfile.TemporaryDirectory() + env = {**ISSUE_WRITE_ENV, "GITEA_ISSUE_LOCK_DIR": self._lock_dir.name} + self._env_patcher = patch.dict(os.environ, env, clear=True) self._env_patcher.start() def tearDown(self): self._env_patcher.stop() + self._lock_dir.cleanup() + @patch( + "mcp_server.issue_duplicate_context_fetcher", + return_value=([], [], {"status": "not_claimed"}), + ) @patch("mcp_server.api_get_all", return_value=[]) @patch("mcp_server._auth", return_value="token x") @patch("mcp_server._resolve", return_value=("h", "o", "r")) - @patch("mcp_server.ISSUE_LOCK_FILE", new_callable=lambda: tempfile.mktemp()) @patch("issue_lock_worktree.read_worktree_git_state") - def test_lock_success_includes_artifact_warning(self, mock_state, _lock_file, *_mocks): + def test_lock_success_includes_artifact_warning(self, mock_state, *_mocks): mock_state.return_value = { "current_branch": "master", "porcelain_status": "?? _emit_payload.py\n", diff --git a/tests/test_assess_conflict_fix_push_tool.py b/tests/test_assess_conflict_fix_push_tool.py new file mode 100644 index 0000000..36166cd --- /dev/null +++ b/tests/test_assess_conflict_fix_push_tool.py @@ -0,0 +1,139 @@ +"""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": ""} + + 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() \ No newline at end of file diff --git a/tests/test_audit.py b/tests/test_audit.py index adb9242..a44885a 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -157,6 +157,7 @@ class _AuditWiringBase(unittest.TestCase): def tearDown(self): mcp_server._IDENTITY_CACHE.clear() + mcp_server.review_workflow_load.clear_review_workflow_load() self._dir.cleanup() def _env(self, **extra): @@ -284,8 +285,43 @@ 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 tests.test_mcp_server import _init_reviewer_session, _install_owned_reviewer_lease + import reviewer_pr_lease + import review_workflow_boundary + import review_workflow_load + + # Session init clears any prior session lease (#407) and loads workflow (#389). + _init_reviewer_session("prgs") + self.addCleanup(review_workflow_load.clear_review_workflow_load) + self.addCleanup(review_workflow_boundary.clear_pre_review_commands) + self._lease_patch = _install_owned_reviewer_lease(8) + self._lease_patch.start() + self._auth_identity_patch = patch( + "mcp_server._authenticated_username", return_value="reviewer-bot" + ) + self._auth_identity_patch.start() + self._pr_lease_comments_patch = patch( + "mcp_server._list_pr_lease_comments", return_value=[] + ) + self._pr_lease_comments_patch.start() + self._pr_work_lease_patch = patch( + "mcp_server._pr_work_lease_reviewer_block", + return_value=dict(_NO_PR_WORK_LEASE_BLOCK), + ) + self._pr_work_lease_patch.start() + self.addCleanup(self._auth_identity_patch.stop) + self.addCleanup(self._lease_patch.stop) + self.addCleanup(self._pr_lease_comments_patch.stop) + self.addCleanup(self._pr_work_lease_patch.stop) + self.addCleanup(reviewer_pr_lease.clear_session_lease) + def _pr(self, author, state="open", sha="abc123", mergeable=True): return {"user": {"login": author}, "state": state, "head": {"sha": sha}, "mergeable": mergeable} @@ -298,12 +334,14 @@ class TestGatedToolAudit(_AuditWiringBase): {"login": "merger-bot"}, self._pr("author-bot"), self._pr("author-bot"), [{"id": 1, "user": {"login": "reviewer-bot"}, "state": "APPROVED", - "submitted_at": "2026-07-06T10:00:00Z", "dismissed": False}], + "commit_id": "abc123", "submitted_at": "2026-07-06T10:00:00Z", + "dismissed": False}], {}, {"merged_commit_sha": "c1"}, ] env = self._env(GITEA_PROFILE_NAME="gitea-merger", GITEA_ALLOWED_OPERATIONS="read,merge") with patch.dict(os.environ, env, clear=True): + mcp_server.gitea_load_review_workflow() r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8", expected_head_sha="abc123", remote="prgs") self.assertTrue(r["performed"]) @@ -323,6 +361,7 @@ class TestGatedToolAudit(_AuditWiringBase): env = self._env(GITEA_PROFILE_NAME="gitea-merger", GITEA_ALLOWED_OPERATIONS="read,merge") with patch.dict(os.environ, env, clear=True): + mcp_server.gitea_load_review_workflow() r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8", remote="prgs") self.assertFalse(r["performed"]) recs = self._records() @@ -334,7 +373,9 @@ class TestGatedToolAudit(_AuditWiringBase): @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) def test_submit_review_success_audited(self, _auth, mock_api): + # mark_final_review_decision and submit each run eligibility (user + PR). mock_api.side_effect = [ + {"login": "reviewer-bot"}, self._pr("author-bot"), {"login": "reviewer-bot"}, self._pr("author-bot"), {"id": 7, "state": "APPROVED"}, [{"id": 7, "user": {"login": "reviewer-bot"}, "state": "APPROVED", @@ -343,12 +384,16 @@ class TestGatedToolAudit(_AuditWiringBase): env = self._env(GITEA_PROFILE_NAME="gitea-reviewer", GITEA_ALLOWED_OPERATIONS="read,review,approve") with patch.dict(os.environ, env, clear=True): - from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision - init_review_decision_lock("prgs", "review_pr") - gitea_mark_final_review_decision(8, "approve", remote="prgs") - r = gitea_submit_pr_review(pr_number=8, action="approve", - body="LGTM", remote="prgs", - final_review_decision_ready=True) + from mcp_server import gitea_mark_final_review_decision + + gitea_mark_final_review_decision( + 8, "approve", expected_head_sha="abc123", remote="prgs", + ) + r = gitea_submit_pr_review( + pr_number=8, action="approve", + body="LGTM", remote="prgs", + final_review_decision_ready=True, + ) self.assertTrue(r["performed"]) recs = self._records() self.assertEqual(len(recs), 1) diff --git a/tests/test_audit_reconciliation_mode.py b/tests/test_audit_reconciliation_mode.py new file mode 100644 index 0000000..a117fab --- /dev/null +++ b/tests/test_audit_reconciliation_mode.py @@ -0,0 +1,291 @@ +"""Tests for audit vs cleanup reconciliation mode (#419).""" +import os +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) + +import audit_reconciliation_mode as arm +import mcp_server +from audit_reconciliation_mode import ( + AUDIT_FORBIDDEN_TASKS, + PHASE_AUDIT, + PHASE_CLEANUP, + assess_audit_command_allowed, + assess_audit_reconciliation_report, + authorize_cleanup_phase, + check_audit_mutation_allowed, + check_cleanup_execution_allowed, + classify_cleanup_mutation, + clear_phase, + enter_audit_phase, +) +from final_report_validator import assess_final_report_validator +from review_proofs import assess_audit_reconciliation_report as proofs_assess +from task_capability_map import required_permission, required_role + +DELETE_PROFILE = { + "profile_name": "prgs-author-delete", + "allowed_operations": ["gitea.read", "gitea.branch.delete"], + "forbidden_operations": [], + "audit_label": "prgs-author-delete", +} + +READ_PROFILE = { + "profile_name": "prgs-author", + "allowed_operations": ["gitea.read", "gitea.issue.comment"], + "forbidden_operations": ["gitea.branch.delete"], + "audit_label": "prgs-author", +} + +READ_ENV = { + "GITEA_MCP_CONFIG": os.path.join( + os.path.dirname(__file__), "..", "profiles.json" + ), + "GITEA_MCP_PROFILE": "prgs-author", +} + + +def _authorize_cleanup(**kwargs): + defaults = { + "operator_approved": True, + "delete_capability_proven": True, + "safety_proof": {"safe_to_delete_remote": True}, + "before_after_snapshot": { + "before": "remote branch exists", + "after": "remote branch absent", + }, + } + defaults.update(kwargs) + return authorize_cleanup_phase(**defaults) + + +def _cleanup_report(**overrides): + base = ( + "Task mode: reconcile-landed-pr\n" + "Workflow source: workflows/reconcile-landed-pr.md\n" + "Audit phase: read-only assessment\n" + "Cleanup phase authorized: true\n" + "Delete-branch capability proven: true\n" + "Branch safe to remove: true\n" + "Before/after state snapshot: remote branch feat/x present → absent\n" + "External-state mutations: deleted remote branch feat/x\n" + "Git ref mutations: none\n" + "Cleanup mutations: removed worktree branches/feat-x\n" + ) + for key, value in overrides.items(): + base = base.replace(key, value) + return base + + +class TestAuditPhaseGates(unittest.TestCase): + def setUp(self): + clear_phase() + enter_audit_phase("reconcile-landed-pr") + + def tearDown(self): + clear_phase() + + def test_audit_blocks_delete_branch_task(self): + allowed, reasons = check_audit_mutation_allowed("delete_branch") + self.assertFalse(allowed) + self.assertTrue(reasons) + + def test_audit_blocks_worktree_shell_commands(self): + allowed, reasons = assess_audit_command_allowed( + "git worktree remove branches/feat-x" + ) + self.assertFalse(allowed) + self.assertTrue(reasons) + + def test_audit_blocks_local_branch_delete_command(self): + allowed, reasons = assess_audit_command_allowed("git branch -D feat/x") + self.assertFalse(allowed) + + def test_audit_allows_read_tasks(self): + allowed, _ = check_audit_mutation_allowed("reconcile_landed_pr") + self.assertTrue(allowed) + + def test_forbidden_set_covers_issue_and_pr_mutations(self): + for task in ("close_pr", "comment_issue", "commit_files", "push_branch"): + self.assertIn(task, AUDIT_FORBIDDEN_TASKS) + + +class TestCleanupAuthorization(unittest.TestCase): + def setUp(self): + clear_phase() + enter_audit_phase("reconcile_merged_cleanups") + + def tearDown(self): + clear_phase() + + def test_cleanup_without_approval_blocked(self): + result = authorize_cleanup_phase( + delete_capability_proven=True, + safety_proof={"safe_to_delete_remote": True}, + before_after_snapshot={"before": "a", "after": "b"}, + ) + self.assertFalse(result["authorized"]) + + def test_cleanup_without_capability_proof_blocked(self): + result = _authorize_cleanup(delete_capability_proven=False) + self.assertFalse(result["authorized"]) + + def test_cleanup_without_snapshot_blocked(self): + result = _authorize_cleanup(before_after_snapshot={"before": "", "after": ""}) + self.assertFalse(result["authorized"]) + + def test_authorized_cleanup_switches_phase(self): + result = _authorize_cleanup() + self.assertTrue(result["authorized"]) + self.assertEqual(result["phase"], PHASE_CLEANUP) + + def test_cleanup_execution_allowed_only_after_authorization(self): + self.assertFalse(check_cleanup_execution_allowed()[0]) + _authorize_cleanup() + self.assertTrue(check_cleanup_execution_allowed()[0]) + + +class TestReportVerifier(unittest.TestCase): + def test_false_no_mutations_after_cleanup_blocked(self): + report = ( + "Task mode: reconcile-landed-pr\n" + "No mutations performed.\n" + "delete_remote_branch feat/dup\n" + ) + result = assess_audit_reconciliation_report(report) + self.assertFalse(result["proven"]) + self.assertIn("no mutations", result["reasons"][0].lower()) + + def test_cleanup_without_authorization_fields_blocked(self): + report = ( + "Task mode: reconcile-landed-pr\n" + "remove_local_worktree branches/feat-x\n" + ) + result = assess_audit_reconciliation_report(report) + self.assertFalse(result["proven"]) + + def test_authorized_cleanup_report_passes(self): + result = assess_audit_reconciliation_report(_cleanup_report()) + self.assertTrue(result["proven"]) + + def test_mutation_classification_enforced(self): + report = ( + "Task mode: reconcile-landed-pr\n" + "Cleanup phase authorized: true\n" + "Delete-branch capability proven: true\n" + "Branch safe to remove: true\n" + "Before/after state snapshot: present\n" + "remove_local_worktree branches/feat-x\n" + ) + result = assess_audit_reconciliation_report(report) + self.assertFalse(result["proven"]) + + def test_proofs_export_matches_module(self): + report = "No mutations performed.\ndelete_remote_branch feat/dup" + self.assertEqual( + proofs_assess(report)["proven"], + assess_audit_reconciliation_report(report)["proven"], + ) + + def test_final_report_validator_includes_boundary_rule(self): + report = "No mutations performed.\ndelete_remote_branch feat/dup" + result = assess_final_report_validator( + report_text=report, + task_kind="reconcile_already_landed", + ) + self.assertTrue(result["blocked"]) + rule_ids = [f["rule_id"] for f in result["findings"]] + self.assertIn("reconcile.audit_cleanup_boundary", rule_ids) + + +class TestMutationClassification(unittest.TestCase): + def test_remote_delete_is_external_state(self): + self.assertEqual( + classify_cleanup_mutation("delete_remote_branch"), + "external-state", + ) + + def test_worktree_remove_is_cleanup(self): + self.assertEqual( + classify_cleanup_mutation("remove_local_worktree"), + "cleanup", + ) + + +class TestMcpGates(unittest.TestCase): + def setUp(self): + clear_phase() + enter_audit_phase("reconcile_merged_cleanups") + self.mock_api = patch("mcp_server.api_request").start() + self.mock_auth = patch( + "mcp_server.get_auth_header", return_value="token test" + ).start() + + def tearDown(self): + patch.stopall() + clear_phase() + mcp_server._IDENTITY_CACHE.clear() + + @patch.dict(os.environ, READ_ENV, clear=True) + @patch("mcp_server.get_profile", return_value=DELETE_PROFILE) + def test_delete_branch_blocked_in_audit_phase(self, _profile): + mcp_server.record_preflight_check("whoami") + mcp_server.record_preflight_check("capability", resolved_role="author") + result = mcp_server.gitea_delete_branch(branch="feat/dup", remote="prgs") + self.assertFalse(result["success"]) + self.assertEqual(result["audit_phase"], PHASE_AUDIT) + self.mock_api.assert_not_called() + + @patch.dict(os.environ, READ_ENV, clear=True) + @patch("mcp_server.get_profile", return_value=DELETE_PROFILE) + def test_reconcile_execute_blocked_without_cleanup_auth(self, _profile): + with self.assertRaises(ValueError): + mcp_server.gitea_reconcile_merged_cleanups( + dry_run=False, + execute_confirmed=False, + remote="prgs", + ) + + @patch.dict(os.environ, READ_ENV, clear=True) + @patch("mcp_server.get_profile", return_value=READ_PROFILE) + def test_cleanup_auth_fails_without_delete_capability(self, _profile): + result = mcp_server.gitea_authorize_reconciliation_cleanup_phase( + operator_approved=True, + delete_capability_proven=True, + safe_to_delete_remote=True, + before_state="exists", + after_state="gone", + ) + self.assertFalse(result["authorized"]) + self.assertFalse(result["delete_capability_verified"]) + + @patch.dict(os.environ, READ_ENV, clear=True) + @patch("mcp_server.get_profile", return_value=DELETE_PROFILE) + def test_delete_branch_allowed_after_cleanup_authorization(self, _profile): + mcp_server.gitea_authorize_reconciliation_cleanup_phase( + operator_approved=True, + delete_capability_proven=True, + safe_to_delete_remote=True, + before_state="exists", + after_state="gone", + ) + mcp_server.record_preflight_check("whoami") + mcp_server.record_preflight_check("capability", resolved_role="author") + self.mock_api.return_value = {} + result = mcp_server.gitea_delete_branch(branch="feat/dup", remote="prgs") + self.assertTrue(result["success"]) + + +class TestTaskCapabilityMap(unittest.TestCase): + def test_reconciliation_cleanup_maps_delete_permission(self): + self.assertEqual( + required_permission("reconciliation_cleanup"), + "gitea.branch.delete", + ) + self.assertEqual(required_role("reconciliation_cleanup"), "author") + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_commit_files_capability.py b/tests/test_commit_files_capability.py index d1203bd..c1474ab 100644 --- a/tests/test_commit_files_capability.py +++ b/tests/test_commit_files_capability.py @@ -76,7 +76,14 @@ class CommitFilesCapabilityBase(unittest.TestCase): with open(self.config_path, "w", encoding="utf-8") as fh: fh.write(json.dumps(CONFIG)) + self._dup_fetcher_patcher = patch( + "mcp_server.issue_duplicate_context_fetcher", + return_value=([], [], {"status": "not_claimed"}), + ) + self._dup_fetcher_patcher.start() + def tearDown(self): + self._dup_fetcher_patcher.stop() self._remotes.stop() mcp_server._IDENTITY_CACHE.clear() mcp_server._preflight_whoami_called, mcp_server._preflight_capability_called = ( diff --git a/tests/test_commit_payloads.py b/tests/test_commit_payloads.py index e8e4123..d3b3e52 100644 --- a/tests/test_commit_payloads.py +++ b/tests/test_commit_payloads.py @@ -66,7 +66,19 @@ class TestCommitPayloads(unittest.TestCase): ) self.locked_worktree_path = os.path.realpath(self.locked_worktree_dir.name) - self.lock_file_path = "/tmp/gitea_issue_lock.json" + import issue_lock_store + import issue_lock_provenance + + self._lock_dir = tempfile.TemporaryDirectory() + os.environ["GITEA_ISSUE_LOCK_DIR"] = self._lock_dir.name + + work_lease = { + "operation_type": "author_issue_work", + "issue_number": 263, + "branch": "feat/issue-263-native-commit-payloads", + "claimant": {"username": "test-user", "profile": "test-author"}, + "expires_at": "2999-01-01T00:00:00Z", + } self.lock_data = { "issue_number": 263, "branch_name": "feat/issue-263-native-commit-payloads", @@ -74,9 +86,13 @@ class TestCommitPayloads(unittest.TestCase): "org": "Example-Org", "repo": "Example-Repo", "worktree_path": self.locked_worktree_path, + "work_lease": work_lease, + "lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance( + tool="gitea_lock_issue", + claimant=work_lease.get("claimant"), + ), } - with open(self.lock_file_path, "w", encoding="utf-8") as fh: - fh.write(json.dumps(self.lock_data)) + self.lock_file_path = issue_lock_store.bind_session_lock(self.lock_data) # Reset preflight status to bypass/pass verification in tests self.orig_whoami_called = mcp_server._preflight_whoami_called @@ -84,7 +100,14 @@ class TestCommitPayloads(unittest.TestCase): mcp_server._preflight_whoami_called = True mcp_server._preflight_capability_called = True + self._dup_fetcher_patcher = patch( + "mcp_server.issue_duplicate_context_fetcher", + return_value=([], [], {"status": "not_claimed"}), + ) + self._dup_fetcher_patcher.start() + def tearDown(self): + self._dup_fetcher_patcher.stop() self._remotes.stop() mcp_server._IDENTITY_CACHE.clear() @@ -93,8 +116,7 @@ class TestCommitPayloads(unittest.TestCase): self._dir.cleanup() self.locked_worktree_dir.cleanup() - if os.path.exists(self.lock_file_path): - os.remove(self.lock_file_path) + self._lock_dir.cleanup() def _env(self, profile: str) -> dict: return { @@ -103,6 +125,7 @@ class TestCommitPayloads(unittest.TestCase): "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") diff --git a/tests/test_create_issue_workspace_guard.py b/tests/test_create_issue_workspace_guard.py index 2cd9f41..7c8a2e8 100644 --- a/tests/test_create_issue_workspace_guard.py +++ b/tests/test_create_issue_workspace_guard.py @@ -38,8 +38,18 @@ class TestCreateIssueWorkspaceGuard(unittest.TestCase): @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): + @patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40) + @patch( + "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", + return_value={ + "current_branch": "master", + "head_sha": "a" * 40, + "porcelain_status": "", + }, + ) + def test_create_issue_stable_checkout_rejected( + self, _git, _remote_sha, _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): @@ -106,20 +116,32 @@ class TestCreateIssueWorkspaceGuard(unittest.TestCase): @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_isdir, mock_exists, _git, _get_all, mock_api, _role, _ns, _prof, _auth): - # Mock subprocess.run for git --git-common-dir to return a different path - mock_res = MagicMock() - mock_res.stdout = "/Users/jasonwalker/Development/some-other-repo/.git\n" - mock_run.return_value = mock_res - + 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 self.assertRaises(RuntimeError) as ctx: - srv.gitea_create_issue( - title="Test issue", body="body", worktree_path=wrong_repo_path - ) + 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) diff --git a/tests/test_final_report_validator.py b/tests/test_final_report_validator.py index 672d82e..70121f4 100644 --- a/tests/test_final_report_validator.py +++ b/tests/test_final_report_validator.py @@ -41,6 +41,10 @@ def _review_handoff(**overrides): "- Selected PR: #203", "- Reviewer eligibility: eligible", "- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9", + "- Reviewed head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9", + "- Final live head SHA before approval: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9", + "- Final live head SHA before merge: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9", + "- Push occurred during validation: no", "- Worktree path: branches/review-203", "- Worktree dirty: clean", "- Scratch worktree used: yes (branches/review-203)", @@ -494,6 +498,81 @@ class TestCanonicalReconcileSchema(unittest.TestCase): ) +class TestReconcilerCloseProof(unittest.TestCase): + """Issue #306: a reconciler PR close must carry its proof fields.""" + + def _closed_report(self, **kwargs): + # A report that claims PR #99 was closed via the reconciler path. + report = ( + _reconcile_handoff(**kwargs) + .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" + ) + ) + return report + + def test_full_proof_close_passes(self): + result = assess_final_report_validator( + self._closed_report(), "reconcile_already_landed" + ) + self.assertEqual(result["grade"], "A") + self.assertFalse( + any(f["rule_id"] == "reconcile.close_proof_fields" for f in result["findings"]) + ) + + def test_close_without_capability_proof_blocks(self): + # Claims PRs closed: #99 but never proves gitea.pr.close capability. + report = _reconcile_handoff().replace("- PRs closed: none", "- PRs closed: #99") + result = assess_final_report_validator(report, "reconcile_already_landed") + self.assertTrue(result["blocked"]) + self.assertTrue( + any(f["rule_id"] == "reconcile.close_proof_fields" for f in result["findings"]) + ) + + def test_close_without_ancestor_proof_blocks(self): + report = self._closed_report(drop=("Ancestor proof",)) + result = assess_final_report_validator(report, "reconcile_already_landed") + self.assertTrue(result["blocked"]) + self.assertTrue( + any(f["rule_id"] == "reconcile.close_proof_fields" for f in result["findings"]) + ) + + def test_close_without_linked_issue_result_blocks(self): + report = self._closed_report(drop=("Linked issue live status", "Issues closed")) + result = assess_final_report_validator(report, "reconcile_already_landed") + self.assertTrue(result["blocked"]) + self.assertTrue( + any(f["rule_id"] == "reconcile.close_proof_fields" for f in result["findings"]) + ) + + def test_close_lock_requires_proof_even_if_text_says_none(self): + # Session lock proves a PR was closed; the report omits close proof. + result = assess_final_report_validator( + _reconcile_handoff(), + "reconcile_already_landed", + reconciler_close_lock={"pr_closed": True}, + ) + self.assertTrue(result["blocked"]) + self.assertTrue( + any(f["rule_id"] == "reconcile.close_proof_fields" for f in result["findings"]) + ) + + def test_comment_only_reconcile_needs_no_close_proof(self): + result = assess_final_report_validator( + _reconcile_handoff(), "reconcile_already_landed" + ) + self.assertEqual(result["grade"], "A") + self.assertFalse( + any(f["rule_id"] == "reconcile.close_proof_fields" for f in result["findings"]) + ) + + class TestEntryPoint(unittest.TestCase): def test_unknown_task_kind_blocks(self): result = assess_final_report_validator("report", "unknown_mode") diff --git a/tests/test_issue_540_comment_role_poison.py b/tests/test_issue_540_comment_role_poison.py new file mode 100644 index 0000000..6cc6122 --- /dev/null +++ b/tests/test_issue_540_comment_role_poison.py @@ -0,0 +1,271 @@ +"""Regression tests for #540: comment_issue preflight must not poison the +actual reconciler role for #274 branch-only / #475 root-checkout exemptions. + +`resolve_task_capability("comment_issue")` stamps +``_preflight_resolved_role = "author"`` because ``comment_issue`` has +``required_role_kind = author``. Before the fix, ``_effective_workspace_role`` +preferred that stamp and a genuine ``prgs-reconciler`` session was treated as an +author inside the #274 branch-only mutation guard and the #475 root checkout +guard, blocking ``gitea_create_issue_comment`` from the control checkout. + +The fix keys the role exemptions off the *actual profile role* as well, so the +exemption survives the poisoned task role while author profiles stay blocked. +""" +from __future__ import annotations + +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 # noqa: E402 +import root_checkout_guard as rcg # noqa: E402 + +FAKE_AUTH = "token test" +CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3]) +MASTER_SHA = "a" * 40 +OTHER_SHA = "b" * 40 + +RECONCILER_PROFILE = { + "profile_name": "prgs-reconciler", + "allowed_operations": [ + "gitea.read", + "gitea.pr.close", + "gitea.pr.comment", + "gitea.issue.comment", + ], + "forbidden_operations": [ + "gitea.pr.approve", + "gitea.pr.merge", + "gitea.pr.create", + "gitea.branch.push", + "gitea.repo.commit", + ], + "audit_label": "prgs-reconciler", +} + +AUTHOR_PROFILE = { + "profile_name": "prgs-author", + "allowed_operations": [ + "gitea.read", + "gitea.issue.comment", + "gitea.issue.create", + "gitea.pr.create", + "gitea.branch.push", + "gitea.repo.commit", + ], + "forbidden_operations": [ + "gitea.pr.approve", + "gitea.pr.merge", + ], + "audit_label": "prgs-author", +} + +REVIEWER_PROFILE = { + "profile_name": "prgs-reviewer", + "allowed_operations": ["gitea.read", "gitea.pr.approve", "gitea.pr.merge"], + "forbidden_operations": ["gitea.pr.create", "gitea.branch.push"], + "audit_label": "prgs-reviewer", +} + + +class TestActualProfileRole(unittest.TestCase): + """`_actual_profile_role` ignores the poisoned preflight task role (#540).""" + + def tearDown(self): + srv._preflight_resolved_role = None + + @patch("gitea_mcp_server.get_profile", return_value=RECONCILER_PROFILE) + def test_reconciler_profile_role_survives_author_task_stamp(self, _profile): + srv._preflight_resolved_role = "author" # comment_issue poison + self.assertEqual(srv._actual_profile_role(), "reconciler") + # _effective_workspace_role is still poisoned to author (unchanged #510)... + self.assertEqual(srv._effective_workspace_role(), "author") + + @patch("gitea_mcp_server.get_profile", return_value=AUTHOR_PROFILE) + def test_author_profile_role_is_author(self, _profile): + srv._preflight_resolved_role = "author" + self.assertEqual(srv._actual_profile_role(), "author") + + @patch("gitea_mcp_server.get_profile", return_value=REVIEWER_PROFILE) + def test_reviewer_profile_role_is_reviewer(self, _profile): + srv._preflight_resolved_role = "author" + self.assertEqual(srv._actual_profile_role(), "reviewer") + + +class TestBranchesOnlyExemptionRealRole(unittest.TestCase): + """#274 branches-only exemption keys off the actual profile role (#540).""" + + def tearDown(self): + srv._preflight_resolved_role = None + + @patch("gitea_mcp_server.get_profile", return_value=RECONCILER_PROFILE) + def test_reconciler_exempt_despite_author_task_stamp(self, _profile): + srv._preflight_resolved_role = "author" # poison + # Must return without raising and without resolving an author worktree. + with patch("gitea_mcp_server._resolve_namespace_mutation_context") as ctx: + srv._enforce_branches_only_author_mutation() + ctx.assert_not_called() + + @patch("gitea_mcp_server.get_profile", return_value=AUTHOR_PROFILE) + def test_author_not_exempt(self, _profile): + srv._preflight_resolved_role = "author" + sentinel = RuntimeError("author-mutation-guard-reached") + + def _blow_up(*_a, **_k): + raise sentinel + + # Author is not exempt: the guard proceeds to resolve/assess the + # workspace (proven by reaching the patched context resolver). + with patch( + "gitea_mcp_server._resolve_namespace_mutation_context", + side_effect=_blow_up, + ): + with self.assertRaises(RuntimeError) as raised: + srv._enforce_branches_only_author_mutation() + self.assertIs(raised.exception, sentinel) + + +class TestRootCheckoutGuardRealRole(unittest.TestCase): + """#475 root guard honours the actual profile role too (#540).""" + + def _assess(self, **kwargs): + defaults = { + "workspace_path": CONTROL_CHECKOUT_ROOT, + "canonical_repo_root": CONTROL_CHECKOUT_ROOT, + "current_branch": "feat/some-branch", + "head_sha": OTHER_SHA, + "porcelain_status": " M gitea_mcp_server.py\n", + "remote_master_sha": MASTER_SHA, + "resolved_role": "author", # poisoned task role + } + defaults.update(kwargs) + return rcg.assess_root_checkout_guard(**defaults) + + def test_actual_reconciler_exempt_despite_poisoned_resolved_author(self): + result = self._assess(actual_role="reconciler") + self.assertTrue(result["proven"]) + self.assertFalse(result["block"]) + + def test_actual_author_still_blocked_on_contaminated_root(self): + result = self._assess(actual_role="author") + self.assertTrue(result["block"]) + + def test_merger_strictness_stays_keyed_on_resolved_task_role(self): + # When the merge task resolves the merger role, the branches/ auto + # exemption is denied and a clean control checkout is required. + blocked = self._assess( + workspace_path=f"{CONTROL_CHECKOUT_ROOT}/branches/review-pr-1", + current_branch="review-pr-1", + porcelain_status="", + head_sha=OTHER_SHA, + resolved_role="merger", + ) + self.assertTrue(blocked["block"]) + + def test_actual_merger_does_not_over_tighten_non_merge_task(self): + # A merger profile whose current task did NOT resolve to merger keeps + # the branches/ workspace exemption (regression guard for #540): the + # actual_role signal must not force merger strictness here. + result = self._assess( + workspace_path=f"{CONTROL_CHECKOUT_ROOT}/branches/review-pr-1", + current_branch="review-pr-1", + porcelain_status="", + head_sha=OTHER_SHA, + resolved_role="reviewer", + actual_role="merger", + ) + self.assertTrue(result["proven"]) + self.assertFalse(result["block"]) + + def test_backward_compatible_without_actual_role(self): + # No actual_role supplied: behaviour falls back to resolved_role. + result = self._assess(resolved_role="reconciler") + self.assertTrue(result["proven"]) + + +class TestReconcilerCommentThroughCanonicalPath(unittest.TestCase): + """Integration: reconciler comment survives the poisoned author task role.""" + + def setUp(self): + srv._preflight_whoami_called = True + srv._preflight_capability_called = True + srv._preflight_whoami_violation = False + srv._preflight_capability_violation = False + srv._preflight_capability_baseline_porcelain = "" + 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 + srv._preflight_resolved_role = None + + @patch("gitea_mcp_server._get_workspace_porcelain", return_value="") + @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) + @patch("gitea_mcp_server.api_request") + @patch( + "gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", + return_value=MASTER_SHA, + ) + @patch( + "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", + return_value={ + "current_branch": "master", + "head_sha": MASTER_SHA, + "porcelain_status": "", + }, + ) + @patch("gitea_mcp_server.get_profile", return_value=RECONCILER_PROFILE) + def test_reconciler_comment_from_control_checkout_succeeds( + self, _profile, _git, _remote_sha, mock_api, _auth, _porcelain + ): + srv._preflight_resolved_role = "author" # comment_issue poison + mock_api.return_value = {"id": 9001, "html_url": "https://x/y"} + 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) + os.environ.pop("GITEA_RECONCILER_WORKTREE", None) + result = srv.gitea_create_issue_comment( + 515, "canonical reconciler audit", remote="prgs" + ) + self.assertTrue(result["success"]) + self.assertEqual(result["comment_id"], 9001) + mock_api.assert_called_once() + + @patch("gitea_mcp_server._get_workspace_porcelain", return_value="") + @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) + @patch("gitea_mcp_server.api_request") + @patch( + "gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", + return_value=MASTER_SHA, + ) + @patch( + "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", + return_value={ + "current_branch": "master", + "head_sha": MASTER_SHA, + "porcelain_status": "", + }, + ) + @patch("gitea_mcp_server.get_profile", return_value=AUTHOR_PROFILE) + def test_author_comment_from_control_checkout_blocked( + self, _profile, _git, _remote_sha, mock_api, _auth, _porcelain + ): + srv._preflight_resolved_role = "author" + 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) + with self.assertRaises(RuntimeError): + srv.gitea_create_issue_comment( + 515, "author note", remote="prgs" + ) + mock_api.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_issue_acceptance_gate.py b/tests/test_issue_acceptance_gate.py new file mode 100644 index 0000000..ead154d --- /dev/null +++ b/tests/test_issue_acceptance_gate.py @@ -0,0 +1,183 @@ +"""Tests for controller issue-acceptance gate (#500).""" +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import issue_acceptance_gate # noqa: E402 +from final_report_validator import assess_final_report_validator # noqa: E402 + + +def _accepted_comment(**overrides): + lines = [ + "## Controller Issue Acceptance", + "", + "STATE:", + "accepted", + "", + "WHO_IS_NEXT:", + "controller", + "", + "NEXT_ACTION:", + "Close the tracker follow-up after verification.", + "", + "NEXT_PROMPT:", + "Verify deployment checklist for issue #500 and post acceptance.", + "", + "ISSUE:", + "#500", + "", + "MERGED_PR:", + "#503", + "", + "MERGE_COMMIT:", + "0fdc8f582026b72a229d59a172c0a63ac4aaeaf9", + "", + "ACCEPTANCE_CRITERIA_CHECKED:", + "- [x] documentation added", + "- [x] validator tests added", + "", + "VALIDATION_REVIEWED:", + "pytest tests/test_issue_acceptance_gate.py -q", + "", + "CONTROLLER_DECISION:", + "accepted", + "", + "WHY:", + "All acceptance criteria satisfied with proof.", + "", + "MISSING_WORK:", + "none", + "", + "FOLLOW_UP_ISSUES:", + "none", + "", + "BLOCKERS:", + "none", + "", + "LAST_UPDATED_BY:", + "jcwalker3 / prgs-controller / 2026-07-08", + ] + text = "\n".join(lines) + for key, value in overrides.items(): + text = text.replace(f"{key}:\n", f"{key}:\n{value}\n", 1) + return text + + +def _rejection_comment(state="needs-tests"): + text = _accepted_comment() + text = text.replace("STATE:\naccepted", f"STATE:\n{state}") + text = text.replace( + "CONTROLLER_DECISION:\naccepted", + "CONTROLLER_DECISION:\nrejected", + ) + text = text.replace( + "ACCEPTANCE_CRITERIA_CHECKED:\n- [x] documentation added\n- [x] validator tests added", + "ACCEPTANCE_CRITERIA_CHECKED:\n- [ ] regression tests for rejection paths", + ) + text = text.replace( + "MISSING_WORK:\nnone", + "MISSING_WORK:\nAdd regression tests for controller rejection paths.", + ) + text = text.replace( + "NEXT_PROMPT:\nVerify deployment checklist for issue #500 and post acceptance.", + "NEXT_PROMPT:\nImplement the missing tests for issue #500 and reopen the PR.", + ) + return text + + +class TestControllerAcceptanceComment(unittest.TestCase): + def test_accepted_comment_valid(self): + result = issue_acceptance_gate.validate_controller_acceptance_comment( + _accepted_comment() + ) + self.assertTrue(result["valid"], result["reasons"]) + + def test_missing_who_is_next_rejected(self): + text = _accepted_comment().replace("WHO_IS_NEXT:\ncontroller\n", "WHO_IS_NEXT:\n\n") + result = issue_acceptance_gate.validate_controller_acceptance_comment(text) + self.assertFalse(result["valid"]) + self.assertTrue(any("WHO_IS_NEXT" in r for r in result["reasons"])) + + def test_missing_next_prompt_on_rejection(self): + text = _rejection_comment() + text = text.replace( + "NEXT_PROMPT:\nImplement the missing tests for issue #500 and reopen the PR.\n", + "NEXT_PROMPT:\n\n", + ) + result = issue_acceptance_gate.validate_controller_acceptance_comment(text) + self.assertFalse(result["valid"]) + self.assertTrue(any("NEXT_PROMPT" in r for r in result["reasons"])) + + def test_vague_merge_only_completion_detected(self): + text = "PR merged. Issue complete." + self.assertTrue(issue_acceptance_gate.claims_merge_only_issue_complete(text)) + + def test_rejection_paths_require_missing_work(self): + for state in ( + "more-work-required", + "needs-tests", + "needs-docs", + "needs-feature-enhancement", + "needs-follow-up-issue", + ): + text = _rejection_comment(state=state).replace( + "MISSING_WORK:\nAdd regression tests for controller rejection paths.\n", + "MISSING_WORK:\n\n", + ) + result = issue_acceptance_gate.validate_controller_acceptance_comment(text) + self.assertFalse(result["valid"], state) + self.assertTrue(any("MISSING_WORK" in r for r in result["reasons"])) + + def test_accepted_requires_checked_criteria(self): + text = _accepted_comment().replace( + "ACCEPTANCE_CRITERIA_CHECKED:\n- [x] documentation added\n- [x] validator tests added\n", + "ACCEPTANCE_CRITERIA_CHECKED:\n- [ ] documentation added\n", + ) + result = issue_acceptance_gate.validate_controller_acceptance_comment(text) + self.assertFalse(result["valid"]) + self.assertTrue(any("checked acceptance criterion" in r for r in result["reasons"])) + + +class TestFinalReportIntegration(unittest.TestCase): + def test_merge_only_complete_blocks_work_issue_report(self): + report = ( + "## Controller Handoff\n\n" + "- Task: work issue #500\n" + "- Merge result: merged\n" + "- Current status: PR merged; issue complete\n" + ) + result = assess_final_report_validator(report, "work_issue") + self.assertTrue( + any( + f["rule_id"] == "shared.issue_acceptance_gate" + for f in result["findings"] + ), + result["findings"], + ) + + def test_pending_acceptance_allowed(self): + report = ( + "## Controller Handoff\n\n" + "- Task: work issue #500\n" + "- Merge result: merged\n" + "- Current status: controller acceptance pending\n" + ) + result = assess_final_report_validator(report, "work_issue") + self.assertFalse( + any( + f["rule_id"] == "shared.issue_acceptance_gate" + for f in result["findings"] + ), + result["findings"], + ) + + def test_accepted_block_allows_complete_claim(self): + report = _accepted_comment() + "\n\nIssue is complete." + gate = issue_acceptance_gate.validate_final_report_issue_acceptance(report) + self.assertTrue(gate["valid"], gate["reasons"]) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_issue_comment_workspace_guard.py b/tests/test_issue_comment_workspace_guard.py new file mode 100644 index 0000000..5359786 --- /dev/null +++ b/tests/test_issue_comment_workspace_guard.py @@ -0,0 +1,257 @@ +import os +import sys +import unittest +from pathlib import Path +from unittest.mock import MagicMock, patch + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import gitea_mcp_server as srv + + +FAKE_AUTH = {"Authorization": "token test-token"} +CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3]) + + +class TestIssueCommentWorkspaceGuard(unittest.TestCase): + AUTHOR_ENV = { + "GITEA_PROFILE_NAME": "gitea-author", + "GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment", + } + + def setUp(self): + srv._preflight_whoami_called = True + srv._preflight_capability_called = True + srv._preflight_resolved_role = "author" + srv._preflight_resolved_task = "comment_issue" + srv._preflight_whoami_violation = False + srv._preflight_capability_violation = False + srv._preflight_reviewer_violation_files = [] + + self._orig_in_test = srv._preflight_in_test_mode + srv._preflight_in_test_mode = lambda: False + self.addCleanup(self._restore_preflight_mode) + + def _restore_preflight_mode(self): + srv._preflight_in_test_mode = self._orig_in_test + + def _git_state(self, valid_worktree: str): + def side_effect(path): + real = os.path.realpath(path) + if real == os.path.realpath(CONTROL_CHECKOUT_ROOT): + return { + "current_branch": "master", + "head_sha": "a" * 40, + "porcelain_status": "", + } + if real == os.path.realpath(valid_worktree): + return { + "current_branch": "fix/issue-560-issue-comment-worktree-path", + "head_sha": "b" * 40, + "porcelain_status": "", + } + return { + "current_branch": "other", + "head_sha": "c" * 40, + "porcelain_status": "", + } + + return side_effect + + def _subprocess(self, valid_worktree: str, outside_worktree: str | None = None): + def side_effect(cmd, *args, **kwargs): + result = MagicMock(returncode=0, stdout="") + cwd = "" + if isinstance(cmd, list) and "-C" in cmd: + cwd = os.path.realpath(cmd[cmd.index("-C") + 1]) + + if isinstance(cmd, list) and "--show-toplevel" in cmd: + if cwd == os.path.realpath(valid_worktree): + result.stdout = f"{valid_worktree}\n" + elif outside_worktree and cwd == os.path.realpath(outside_worktree): + result.stdout = f"{outside_worktree}\n" + else: + result.stdout = f"{CONTROL_CHECKOUT_ROOT}\n" + return result + + if isinstance(cmd, list) and "--git-common-dir" in cmd: + if cwd == os.path.realpath(valid_worktree): + result.stdout = f"{CONTROL_CHECKOUT_ROOT}/.git\n" + elif outside_worktree and cwd == os.path.realpath(outside_worktree): + result.stdout = "/tmp/other-repo/.git\n" + else: + result.stdout = f"{CONTROL_CHECKOUT_ROOT}/.git\n" + return result + + return result + + return side_effect + + @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) + @patch("gitea_mcp_server.api_request") + @patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40) + def test_comment_rejects_control_checkout_without_worktree_path( + self, _remote_sha, mock_api, _auth + ): + valid_worktree = os.path.join( + CONTROL_CHECKOUT_ROOT, + "branches", + "issue-560-issue-comment-worktree-path", + ) + mock_api.return_value = {"id": 42} + with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT): + with patch( + "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", + side_effect=self._git_state(valid_worktree), + ): + with self.assertRaises(RuntimeError) as ctx: + srv.gitea_create_issue_comment( + issue_number=557, + body="canonical evidence", + remote="prgs", + ) + self.assertIn("stable control checkout", str(ctx.exception)) + mock_api.assert_not_called() + + @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) + @patch("gitea_mcp_server.api_request") + @patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40) + @patch("os.path.isdir", return_value=True) + @patch("os.path.exists", return_value=True) + def test_comment_accepts_valid_branches_worktree_path_with_explicit_repo( + self, _exists, _isdir, _remote_sha, mock_api, _auth + ): + valid_worktree = os.path.join( + CONTROL_CHECKOUT_ROOT, + "branches", + "issue-560-issue-comment-worktree-path", + ) + mock_api.return_value = {"id": 43} + with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT): + with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""): + with patch( + "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", + side_effect=self._git_state(valid_worktree), + ): + with patch( + "gitea_mcp_server.subprocess.run", + side_effect=self._subprocess(valid_worktree), + ): + with patch.dict(os.environ, self.AUTHOR_ENV, clear=True): + result = srv.gitea_create_issue_comment( + issue_number=557, + body="canonical evidence", + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + worktree_path=valid_worktree, + ) + + self.assertTrue(result["success"]) + self.assertTrue(result["performed"]) + self.assertEqual(result["comment_id"], 43) + method, url, _auth_arg, payload = mock_api.call_args[0] + self.assertEqual(method, "POST") + self.assertIn("/repos/Scaled-Tech-Consulting/Gitea-Tools/issues/557/comments", url) + self.assertEqual(payload, {"body": "canonical evidence"}) + + @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) + @patch("gitea_mcp_server.api_request") + @patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40) + @patch("os.path.isdir", return_value=True) + @patch("os.path.exists", return_value=True) + def test_comment_rejects_outside_repo_worktree_path( + self, _exists, _isdir, _remote_sha, mock_api, _auth + ): + valid_worktree = os.path.join( + CONTROL_CHECKOUT_ROOT, + "branches", + "issue-560-issue-comment-worktree-path", + ) + outside_worktree = "/tmp/not-gitea-tools-worktree" + with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT): + with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""): + with patch( + "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", + side_effect=self._git_state(valid_worktree), + ): + with patch( + "gitea_mcp_server.subprocess.run", + side_effect=self._subprocess(valid_worktree, outside_worktree), + ): + with patch.dict(os.environ, self.AUTHOR_ENV, clear=True): + with self.assertRaises(RuntimeError) as ctx: + srv.gitea_create_issue_comment( + issue_number=557, + body="canonical evidence", + remote="prgs", + worktree_path=outside_worktree, + ) + self.assertIn("does not belong to the target repository", str(ctx.exception)) + mock_api.assert_not_called() + + @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) + @patch("gitea_mcp_server.api_request") + def test_comment_still_requires_capability_preflight(self, mock_api, _auth): + srv._preflight_capability_called = False + with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT): + with self.assertRaises(RuntimeError) as ctx: + srv.gitea_create_issue_comment( + issue_number=557, + body="canonical evidence", + remote="prgs", + worktree_path=os.path.join( + CONTROL_CHECKOUT_ROOT, + "branches", + "issue-560-issue-comment-worktree-path", + ), + ) + self.assertIn("Task capability", str(ctx.exception)) + mock_api.assert_not_called() + + @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) + @patch("gitea_mcp_server.api_request") + @patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40) + @patch("os.path.isdir", return_value=True) + @patch("os.path.exists", return_value=True) + def test_comment_still_requires_issue_comment_permission( + self, _exists, _isdir, _remote_sha, mock_api, _auth + ): + valid_worktree = os.path.join( + CONTROL_CHECKOUT_ROOT, + "branches", + "issue-560-issue-comment-worktree-path", + ) + denied_env = { + "GITEA_PROFILE_NAME": "gitea-author", + "GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.comment", + } + with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT): + with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""): + with patch( + "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", + side_effect=self._git_state(valid_worktree), + ): + with patch( + "gitea_mcp_server.subprocess.run", + side_effect=self._subprocess(valid_worktree), + ): + with patch.dict(os.environ, denied_env, clear=True): + result = srv.gitea_create_issue_comment( + issue_number=557, + body="canonical evidence", + remote="prgs", + worktree_path=valid_worktree, + ) + self.assertFalse(result["success"]) + self.assertFalse(result["performed"]) + self.assertIn("permission_report", result) + self.assertEqual( + result["permission_report"]["missing_permission"], + "gitea.issue.comment", + ) + mock_api.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_issue_lock_adoption.py b/tests/test_issue_lock_adoption.py new file mode 100644 index 0000000..270248e --- /dev/null +++ b/tests/test_issue_lock_adoption.py @@ -0,0 +1,227 @@ +"""Unit tests for own-branch lock adoption decision (#442 / #443).""" +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from issue_lock_adoption import ( # noqa: E402 + ADOPT, + BLOCK_COMPETING, + NO_MATCH, + assess_own_branch_adoption, + build_adoption_proof, + build_non_adoption_lock_proof, +) + +REQ = "feat/issue-420-server-code-parity" + + +class TestAssessOwnBranchAdoption(unittest.TestCase): + def test_exact_own_branch_is_adopted(self): + result = assess_own_branch_adoption( + issue_number=420, + requested_branch=REQ, + existing_branches=[{"name": REQ, "commit_sha": "934688a"}], + ) + self.assertEqual(result["outcome"], ADOPT) + self.assertTrue(result["adopt"]) + self.assertFalse(result["block"]) + self.assertEqual(result["matched_branch"], REQ) + self.assertEqual(result["matched_head_sha"], "934688a") + + def test_exact_own_branch_adopted_when_sha_missing(self): + result = assess_own_branch_adoption( + issue_number=420, requested_branch=REQ, existing_branches=[REQ] + ) + self.assertEqual(result["outcome"], ADOPT) + self.assertIsNone(result["matched_head_sha"]) + + def test_different_branch_same_issue_blocks(self): + result = assess_own_branch_adoption( + issue_number=420, + requested_branch=REQ, + existing_branches=[{"name": "feat/issue-420-other-work"}], + ) + self.assertEqual(result["outcome"], BLOCK_COMPETING) + self.assertTrue(result["block"]) + self.assertFalse(result["adopt"]) + self.assertIn("feat/issue-420-other-work", result["competing_branches"]) + self.assertIn("fail closed", result["reason"]) + + def test_own_branch_plus_competing_branch_blocks(self): + # Ambiguous ownership: fail closed even though the exact branch exists. + result = assess_own_branch_adoption( + issue_number=420, + requested_branch=REQ, + existing_branches=[{"name": REQ}, {"name": "feat/issue-420-rogue"}], + ) + self.assertEqual(result["outcome"], BLOCK_COMPETING) + self.assertTrue(result["block"]) + self.assertEqual(result["competing_branches"], ["feat/issue-420-rogue"]) + + def test_no_matching_branch_is_normal_path(self): + result = assess_own_branch_adoption( + issue_number=420, + requested_branch=REQ, + existing_branches=[{"name": "feat/issue-999-unrelated"}], + ) + self.assertEqual(result["outcome"], NO_MATCH) + self.assertFalse(result["block"]) + self.assertFalse(result["adopt"]) + + def test_empty_branch_list_is_normal_path(self): + result = assess_own_branch_adoption( + issue_number=420, requested_branch=REQ, existing_branches=[] + ) + self.assertEqual(result["outcome"], NO_MATCH) + + def test_higher_issue_number_branch_does_not_block_lower_issue_adoption(self): + # issue-420 must not be treated as competing work for issue #42. + own_branch = "feat/issue-42-widget" + result = assess_own_branch_adoption( + issue_number=42, + requested_branch=own_branch, + existing_branches=[ + {"name": own_branch, "commit_sha": "abc1234"}, + {"name": "feat/issue-420-server-code-parity"}, + ], + ) + self.assertEqual(result["outcome"], ADOPT) + self.assertTrue(result["adopt"]) + self.assertFalse(result["block"]) + self.assertEqual(result["matched_branch"], own_branch) + + def test_unrelated_higher_number_branch_is_ignored_without_own_branch(self): + result = assess_own_branch_adoption( + issue_number=42, + requested_branch="feat/issue-42-thing", + existing_branches=[{"name": "feat/issue-420-server-code-parity"}], + ) + self.assertEqual(result["outcome"], NO_MATCH) + self.assertFalse(result["block"]) + self.assertFalse(result["adopt"]) + + +class TestBuildAdoptionProof(unittest.TestCase): + def test_proof_has_all_required_fields(self): + assessment = assess_own_branch_adoption( + issue_number=420, + requested_branch=REQ, + existing_branches=[{"name": REQ, "commit_sha": "934688a"}], + ) + proof = build_adoption_proof( + issue_number=420, + branch_name=REQ, + assessment=assessment, + open_pr_checked=True, + competing_lock_checked=True, + lock_file_path="/tmp/example-lock.json", + lock_file_status="written", + ) + for key in ( + "issue_number", + "branch_name", + "branch_head_commit", + "adoption_reason", + "no_existing_pr_proof", + "no_competing_live_lock_proof", + "lock_file_path", + "lock_file_status", + ): + self.assertIn(key, proof) + self.assertEqual(proof["branch_head_commit"], "934688a") + self.assertTrue(proof["no_existing_pr_proof"]) + self.assertTrue(proof["no_competing_live_lock_proof"]) + + +class TestExplicitAdoptionProofFields(unittest.TestCase): + """#477: explicit, citable adoption-proof fields for all outcomes.""" + + def _proof(self, assessment, branch): + return build_adoption_proof( + issue_number=420, + branch_name=branch, + assessment=assessment, + open_pr_checked=True, + competing_lock_checked=True, + lock_file_path="/tmp/example-lock.json", + lock_file_status="written", + ) + + def test_adopt_proof_exposes_explicit_fields(self): + assessment = assess_own_branch_adoption( + issue_number=420, + requested_branch=REQ, + existing_branches=[{"name": REQ, "commit_sha": "934688a"}], + ) + proof = self._proof(assessment, REQ) + self.assertEqual(proof["adoption_decision"], "ADOPT") + self.assertTrue(proof["adopted"]) + self.assertEqual(proof["adopted_branch"], REQ) + self.assertEqual(proof["adopted_branch_head"], "934688a") + self.assertEqual(proof["competing_branch_check"]["result"], "clear") + self.assertEqual(proof["competing_branch_check"]["competing_branches"], []) + self.assertIn("gitea_create_pr", proof["safe_next_action"]) + self.assertIn("exactly matches", proof["matcher_summary"]) + + def test_block_proof_reports_competing_and_does_not_claim_adoption(self): + assessment = assess_own_branch_adoption( + issue_number=420, + requested_branch=REQ, + existing_branches=[{"name": "feat/issue-420-rogue"}], + ) + proof = self._proof(assessment, REQ) + self.assertEqual(proof["adoption_decision"], "BLOCK_COMPETING") + self.assertFalse(proof["adopted"]) + self.assertIsNone(proof["adopted_branch"]) + self.assertIsNone(proof["adopted_branch_head"]) + self.assertEqual(proof["competing_branch_check"]["result"], "blocked") + self.assertIn( + "feat/issue-420-rogue", + proof["competing_branch_check"]["competing_branches"], + ) + self.assertIn("fail closed", proof["safe_next_action"]) + + def test_no_match_proof_does_not_claim_adoption(self): + assessment = assess_own_branch_adoption( + issue_number=420, + requested_branch=REQ, + existing_branches=[{"name": "feat/issue-999-unrelated"}], + ) + proof = self._proof(assessment, REQ) + self.assertEqual(proof["adoption_decision"], "NO_MATCH") + self.assertFalse(proof["adopted"]) + self.assertIsNone(proof["adopted_branch"]) + self.assertEqual(proof["competing_branch_check"]["result"], "clear") + + def test_substring_collision_stays_boundary_safe(self): + # issue-42 must not adopt/claim against an issue-420 branch (#440/#477). + own = "feat/issue-42-widget" + assessment = assess_own_branch_adoption( + issue_number=42, + requested_branch=own, + existing_branches=[ + {"name": own, "commit_sha": "abc1234"}, + {"name": "feat/issue-420-server-code-parity"}, + ], + ) + proof = self._proof(assessment, own) + self.assertEqual(proof["adoption_decision"], "ADOPT") + self.assertEqual(proof["adopted_branch"], own) + self.assertEqual(proof["competing_branch_check"]["competing_branches"], []) + + def test_non_adoption_lock_proof_is_adoption_free(self): + proof = build_non_adoption_lock_proof( + issue_number=196, branch_name="feat/issue-196-mutations" + ) + self.assertEqual(proof["adoption_decision"], "NO_MATCH") + self.assertFalse(proof["adopted"]) + self.assertIsNone(proof["adopted_branch"]) + self.assertIsNone(proof["adopted_branch_head"]) + self.assertEqual(proof["competing_branch_check"]["result"], "clear") + self.assertIn("no adoption", proof["safe_next_action"].lower()) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_issue_lock_provenance.py b/tests/test_issue_lock_provenance.py new file mode 100644 index 0000000..3e64090 --- /dev/null +++ b/tests/test_issue_lock_provenance.py @@ -0,0 +1,130 @@ +"""Tests for issue-lock provenance and external-state disclosure (#447).""" + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import issue_lock_provenance as ilp # noqa: E402 +from final_report_validator import assess_final_report_validator # noqa: E402 + + +def _sanctioned_lock(**overrides): + work_lease = { + "operation_type": "author_issue_work", + "issue_number": 447, + "branch": "feat/issue-447-lock-provenance", + "claimant": {"username": "jcwalker3", "profile": "prgs-author"}, + "expires_at": "2999-01-01T00:00:00Z", + } + data = { + "issue_number": 447, + "branch_name": "feat/issue-447-lock-provenance", + "work_lease": work_lease, + "lock_provenance": ilp.build_sanctioned_lock_provenance( + tool="gitea_lock_issue", + claimant=work_lease["claimant"], + ), + } + data.update(overrides) + return data + + +class TestLockProvenanceForCreatePr(unittest.TestCase): + def test_sanctioned_lock_passes(self): + result = ilp.assess_lock_file_for_create_pr(_sanctioned_lock()) + self.assertTrue(result["proven"]) + self.assertFalse(result["block"]) + + def test_manual_seed_without_provenance_blocked(self): + result = ilp.assess_lock_file_for_create_pr( + {"issue_number": 420, "branch_name": "feat/x", "work_lease": {}} + ) + self.assertTrue(result["block"]) + self.assertIn("lock_provenance", result["reasons"][0]) + + def test_operator_override_requires_reason(self): + result = ilp.assess_lock_file_for_create_pr( + _sanctioned_lock( + lock_provenance=ilp.build_sanctioned_lock_provenance( + tool="operator_override", + source=ilp.SOURCE_OPERATOR_OVERRIDE, + ) + ) + ) + self.assertTrue(result["block"]) + + +class TestExternalStateReportRules(unittest.TestCase): + def test_seed_with_external_none_blocked(self): + report = ( + "Restored /tmp/gitea_issue_lock.json to unblock PR creation.\n" + "- External-state mutations: none\n" + ) + result = ilp.assess_issue_lock_external_state_report(report) + self.assertTrue(result["block"]) + + def test_seed_with_disclosure_passes(self): + report = ( + "Restored /tmp/gitea_issue_lock.json after MCP restart.\n" + "- External-state mutations: wrote /tmp/gitea_issue_lock.json\n" + ) + result = ilp.assess_issue_lock_external_state_report(report) + self.assertTrue(result["proven"]) + + def test_remove_claimed_as_cleanup_only_blocked(self): + report = ( + "rm /tmp/gitea_issue_lock.json after PR creation.\n" + "- Cleanup mutations: lock removed\n" + "- External-state mutations: none\n" + ) + result = ilp.assess_issue_lock_external_state_report(report) + self.assertTrue(result["block"]) + + def test_manual_lock_pr_without_override_blocked(self): + report = ( + "Programmatically seeded gitea_issue_lock.json then gitea_create_pr.\n" + "PR #444 created.\n" + ) + result = ilp.assess_manual_lock_pr_without_override(report) + self.assertTrue(result["block"]) + + def test_author_reviewer_same_run_blocked(self): + report = ( + "gitea_create_pr opened PR #444.\n" + "Submitted approve review on PR #444.\n" + ) + result = ilp.assess_author_reviewer_same_run_report(report) + self.assertTrue(result["block"]) + + +class TestFinalReportValidatorIntegration(unittest.TestCase): + def test_work_issue_blocks_hidden_lock_mutation(self): + report = ( + "## Controller Handoff\n" + "- Task: work issue #420\n" + "- External-state mutations: none\n" + "Restored /tmp/gitea_issue_lock.json before PR creation.\n" + ) + result = assess_final_report_validator(report, "work_issue") + rule_ids = {f["rule_id"] for f in result["findings"]} + self.assertIn("shared.issue_lock_external_state", rule_ids) + self.assertTrue(result["blocked"]) + + def test_review_pr_blocks_create_and_approve(self): + report = ( + "## Controller Handoff\n" + "- Task: review PR #444\n" + "- Review decision: approve\n" + "Created PR #444 via gitea_create_pr earlier in this run.\n" + ) + result = assess_final_report_validator(report, "review_pr") + rule_ids = {f["rule_id"] for f in result["findings"]} + self.assertIn("shared.author_reviewer_same_run", rule_ids) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_issue_lock_store.py b/tests/test_issue_lock_store.py new file mode 100644 index 0000000..3928b19 --- /dev/null +++ b/tests/test_issue_lock_store.py @@ -0,0 +1,280 @@ +"""Unit tests for keyed issue-lock storage (#443) and flock hardening (#438).""" +import json +import os +import sys +import tempfile +import threading +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import issue_lock_store as ils # noqa: E402 + + +def _lease(expires_at: str) -> dict: + return { + "operation_type": ils.AUTHOR_ISSUE_WORK_LEASE, + "expires_at": expires_at, + "created_at": "2026-01-01T00:00:00Z", + "last_heartbeat_at": "2026-01-01T00:00:00Z", + } + + +def _lock_record(**overrides) -> dict: + record = { + "issue_number": 420, + "branch_name": "feat/issue-420-server-code-parity", + "remote": "prgs", + "org": "Scaled-Tech-Consulting", + "repo": "Gitea-Tools", + "worktree_path": "/tmp/wt-420", + "work_lease": _lease("2999-01-01T00:00:00Z"), + } + record.update(overrides) + return record + + +class TestIssueLockStore(unittest.TestCase): + def setUp(self): + self._dir = tempfile.TemporaryDirectory() + self.lock_dir = self._dir.name + self._env = mock.patch.dict(os.environ, {"GITEA_ISSUE_LOCK_DIR": self.lock_dir}) + self._env.start() + + def tearDown(self): + self._env.stop() + self._dir.cleanup() + + def test_concurrent_repo_locks_do_not_overwrite(self): + lock_a = _lock_record( + issue_number=108, + branch_name="feat/issue-108-root-menu", + repo="mcp-control-plane", + worktree_path="/tmp/wt-108", + ) + lock_b = _lock_record( + issue_number=420, + branch_name="feat/issue-420-server-code-parity", + repo="Gitea-Tools", + worktree_path="/tmp/wt-420", + ) + path_a = ils.bind_session_lock(lock_a) + with mock.patch("os.getpid", return_value=9999): + path_b = ils.bind_session_lock(lock_b) + + self.assertNotEqual(path_a, path_b) + self.assertTrue(os.path.exists(path_a)) + self.assertTrue(os.path.exists(path_b)) + stored_a = ils.read_lock_file(path_a) + stored_b = ils.read_lock_file(path_b) + self.assertEqual(stored_a["issue_number"], 108) + self.assertEqual(stored_b["issue_number"], 420) + + def test_concurrent_issue_locks_same_repo_do_not_overwrite(self): + lock_a = _lock_record(issue_number=427, branch_name="feat/issue-427-a") + lock_b = _lock_record(issue_number=428, branch_name="feat/issue-428-b") + path_a = ils.bind_session_lock(lock_a) + with mock.patch("os.getpid", return_value=4242): + path_b = ils.bind_session_lock(lock_b) + + self.assertNotEqual(path_a, path_b) + self.assertEqual(ils.read_lock_file(path_a)["issue_number"], 427) + self.assertEqual(ils.read_lock_file(path_b)["issue_number"], 428) + + def test_foreign_live_lease_blocks_overwrite(self): + existing = _lock_record( + branch_name="feat/issue-420-other", + worktree_path="/tmp/other", + work_lease=_lease("2999-01-01T00:00:00Z"), + ) + path = ils.lock_file_path( + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + issue_number=420, + ) + ils.save_lock_file(path, existing) + + incoming = _lock_record(worktree_path="/tmp/mine") + block = ils.assess_foreign_lock_overwrite(existing, incoming) + self.assertIn("live foreign issue lock", block or "") + + def test_expired_lease_allows_takeover_with_conflict_check(self): + existing = _lock_record( + branch_name="feat/issue-420-other", + worktree_path="/tmp/other", + work_lease=_lease("2000-01-01T00:00:00Z"), + ) + incoming = _lock_record(worktree_path="/tmp/mine") + self.assertIsNone(ils.assess_foreign_lock_overwrite(existing, incoming)) + block = ils.assess_same_issue_lease_conflict( + existing, + issue_number=420, + branch_name="feat/issue-420-server-code-parity", + worktree_path="/tmp/mine", + ) + self.assertIn("Recovery review is required", block or "") + + def test_same_owner_lease_conflict_allows_refresh(self): + worktree = "/tmp/wt-420" + existing = _lock_record(worktree_path=worktree) + block = ils.assess_same_issue_lease_conflict( + existing, + issue_number=420, + branch_name="feat/issue-420-server-code-parity", + worktree_path=worktree, + ) + self.assertIsNone(block) + + def test_find_lock_for_branch_after_restart(self): + record = _lock_record() + path = ils.lock_file_path( + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + issue_number=420, + ) + ils.save_lock_file(path, record) + + with mock.patch("os.getpid", return_value=5555): + self.assertIsNone(ils.read_session_issue_lock()) + + found = ils.find_lock_for_branch( + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + branch_name="feat/issue-420-server-code-parity", + ) + self.assertEqual(found["issue_number"], 420) + + def test_has_active_issue_lock_scans_keyed_store(self): + ils.bind_session_lock(_lock_record()) + self.assertTrue( + ils.has_active_issue_lock("feat/issue-420-server-code-parity") + ) + self.assertFalse(ils.has_active_issue_lock("feat/issue-999-other")) + + def test_approved_stacked_base_survives_round_trip(self): + # #484: the approved stacked base recorded on the lock must persist so + # gitea_create_pr can validate the non-master base at PR time. + record = _lock_record( + issue_number=482, + branch_name="feat/issue-482-skip-stale-request-changes-pr", + approved_stacked_base={ + "branch": "feat/issue-478-mcp-menu-shell", + "pr_number": 479, + "verified_open": True, + }, + ) + path = ils.lock_file_path( + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + issue_number=482, + ) + ils.save_lock_file(path, record) + stored = ils.read_lock_file(path) + self.assertEqual(stored["approved_stacked_base"]["branch"], "feat/issue-478-mcp-menu-shell") + self.assertEqual(stored["approved_stacked_base"]["pr_number"], 479) + self.assertTrue(stored["approved_stacked_base"]["verified_open"]) + + def test_atomic_write_preserves_unrelated_lock(self): + path_a = ils.lock_file_path( + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + issue_number=108, + ) + ils.save_lock_file(path_a, _lock_record(issue_number=108, repo="mcp-control-plane")) + path_b = ils.lock_file_path( + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + issue_number=420, + ) + ils.save_lock_file(path_b, _lock_record()) + + self.assertTrue(os.path.exists(path_a)) + self.assertTrue(os.path.exists(path_b)) + self.assertEqual(ils.read_lock_file(path_a)["issue_number"], 108) + + def test_concurrent_bind_same_issue_only_one_wins(self): + barrier = threading.Barrier(2) + results: list[str | Exception] = [] + + def worker(): + barrier.wait() + try: + ils.bind_session_lock( + _lock_record(worktree_path=f"/tmp/wt-{threading.get_ident()}") + ) + results.append("ok") + except Exception as exc: # noqa: BLE001 + results.append(exc) + + threads = [threading.Thread(target=worker) for _ in range(2)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + + successes = [item for item in results if item == "ok"] + failures = [item for item in results if isinstance(item, Exception)] + self.assertEqual(len(successes), 1) + self.assertEqual(len(failures), 1) + failure_text = str(failures[0]).lower() + self.assertTrue( + "active" in failure_text or "lock contention" in failure_text, + failures[0], + ) + + def test_verify_lock_for_mutation_blocks_stale_lock(self): + record = _lock_record( + work_lease=_lease("2000-01-01T00:00:00Z"), + ) + record["pid"] = 999999 + record["session_pid"] = 999999 + result = ils.verify_lock_for_mutation( + record, + issue_number=420, + branch_name="feat/issue-420-server-code-parity", + worktree_path="/tmp/wt-420", + ) + self.assertTrue(result["block"]) + self.assertIn("not live", result["reasons"][0]) + + def test_list_live_locks_excludes_stale_records(self): + live_path = ils.lock_file_path( + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + issue_number=420, + ) + ils.save_lock_file( + live_path, + _lock_record(worktree_path="/tmp/wt-420"), + ) + stale_path = ils.lock_file_path( + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + issue_number=440, + ) + ils.save_lock_file( + stale_path, + _lock_record( + issue_number=440, + branch_name="feat/issue-440-recovery", + work_lease=_lease("2000-01-01T00:00:00Z"), + worktree_path="/tmp/wt-440", + ), + ) + live = ils.list_live_locks(lock_dir=self.lock_dir) + self.assertEqual([entry["issue_number"] for entry in live], [420]) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_issue_lock_worktree.py b/tests/test_issue_lock_worktree.py index 02347f4..67baa78 100644 --- a/tests/test_issue_lock_worktree.py +++ b/tests/test_issue_lock_worktree.py @@ -72,6 +72,59 @@ class TestIssueLockWorktreeAssessment(unittest.TestCase): self.assertTrue(result["proven"]) +class TestStackedBaseEquivalence(unittest.TestCase): + """extra_bases (an approved stacked base) can anchor base-equivalence (#484).""" + + def _git(self, *args): + import subprocess + + subprocess.run( + ["git", "-C", self.repo, *args], + check=True, + capture_output=True, + text=True, + ) + + def setUp(self): + import subprocess + import tempfile + + self.tmp = tempfile.TemporaryDirectory() + self.repo = self.tmp.name + subprocess.run(["git", "init", "-q", self.repo], check=True, capture_output=True) + self._git("config", "user.email", "t@t") + self._git("config", "user.name", "t") + self._git("commit", "--allow-empty", "-q", "-m", "base") + # Rename the default branch away from master/main/dev so no *base* branch + # exists at HEAD — otherwise HEAD would be base-equivalent for free. + self._git("branch", "-m", "trunk") + # Create a non-master "dependency" branch at the same commit, then a + # feature branch off it — mirrors a stacked worktree. + self._git("branch", "feat/issue-100-dep") + self._git("checkout", "-q", "-b", "feat/issue-101-stacked") + + def tearDown(self): + self.tmp.cleanup() + + def test_stacked_base_not_equivalent_without_extra_bases(self): + state = issue_lock_worktree.read_worktree_git_state(self.repo) + # HEAD does not match master/main/dev, so base-equivalence is False. + self.assertFalse(state["base_equivalent"]) + + def test_stacked_base_equivalent_with_extra_bases(self): + state = issue_lock_worktree.read_worktree_git_state( + self.repo, extra_bases=("feat/issue-100-dep",) + ) + self.assertTrue(state["base_equivalent"]) + self.assertEqual(state["base_branch"], "feat/issue-100-dep") + + def test_unrelated_extra_base_does_not_anchor(self): + state = issue_lock_worktree.read_worktree_git_state( + self.repo, extra_bases=("feat/does-not-exist",) + ) + self.assertFalse(state["base_equivalent"]) + + class TestIssueLockWorktreeResolution(unittest.TestCase): def test_explicit_path_wins(self): resolved = issue_lock_worktree.resolve_author_worktree_path( diff --git a/tests/test_issue_work_duplicate_gate.py b/tests/test_issue_work_duplicate_gate.py new file mode 100644 index 0000000..1ae3024 --- /dev/null +++ b/tests/test_issue_work_duplicate_gate.py @@ -0,0 +1,272 @@ +"""Tests for early duplicate-work detection (#400).""" +import os +import sys +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import issue_lock_provenance +import issue_lock_store +import issue_work_duplicate_gate as dup_gate +import mcp_server +from issue_work_duplicate_gate import ( + OUTCOME_DUPLICATE_BRANCH_PREVENTED, + OUTCOME_DUPLICATE_COMMIT_PREVENTED, + OUTCOME_DUPLICATE_PR_PREVENTED, + OUTCOME_DUPLICATE_WORK_NOT_PREVENTED, + PHASE_COMMIT, + PHASE_CREATE_PR, + PHASE_LOCK, + assess_work_issue_duplicate_gate, + assess_work_issue_duplicate_report, +) + + +class TestDuplicateGateAssessment(unittest.TestCase): + def test_clear_issue_passes(self): + result = assess_work_issue_duplicate_gate( + 400, + open_prs=[], + branch_names=["feat/other-issue-99"], + claim_entry={"status": "not_claimed"}, + locked_branch="feat/issue-400-duplicate-work-preflight", + phase=PHASE_LOCK, + ) + self.assertFalse(result["block"]) + self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_WORK_NOT_PREVENTED) + + def test_open_pr_blocks(self): + prs = [{ + "number": 397, + "title": "feat: handoff", + "body": "Closes #395", + "head": {"ref": "feat/issue-395-proof-backed-review-handoff"}, + }] + result = assess_work_issue_duplicate_gate( + 395, + open_prs=prs, + branch_names=[], + phase=PHASE_LOCK, + ) + self.assertTrue(result["block"]) + self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_PR_PREVENTED) + + def test_conflicting_remote_branch_blocks(self): + result = assess_work_issue_duplicate_gate( + 395, + open_prs=[], + branch_names=["feat/issue-395-proof-backed-handoff-claims"], + locked_branch="feat/issue-395-new-attempt", + phase=PHASE_LOCK, + ) + self.assertTrue(result["block"]) + self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_BRANCH_PREVENTED) + + def test_active_claim_on_other_branch_blocks(self): + result = assess_work_issue_duplicate_gate( + 398, + open_prs=[], + branch_names=[], + claim_entry={ + "status": "active", + "latest_heartbeat": { + "branch": "feat/issue-398-validation-cwd-proof", + }, + }, + locked_branch="feat/issue-398-other-branch", + phase=PHASE_LOCK, + ) + self.assertTrue(result["block"]) + + def test_commit_phase_maps_to_commit_outcome(self): + prs = [{ + "number": 411, + "title": "x", + "body": "Closes #398", + "head": {"ref": "feat/issue-398-validation-cwd-proof"}, + }] + result = assess_work_issue_duplicate_gate( + 398, + open_prs=prs, + branch_names=[], + locked_branch="feat/issue-398-alt", + phase=PHASE_COMMIT, + ) + self.assertTrue(result["block"]) + self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_COMMIT_PREVENTED) + + def test_stale_claim_does_not_block_by_status_alone(self): + result = assess_work_issue_duplicate_gate( + 400, + open_prs=[], + branch_names=[], + claim_entry={"status": "reclaimable", "reasons": ["stale"]}, + locked_branch="feat/issue-400-duplicate-work-preflight", + phase=PHASE_LOCK, + ) + self.assertFalse(result["block"]) + + +class TestDuplicateReportOutcome(unittest.TestCase): + def test_requires_exactly_one_outcome(self): + bad = assess_work_issue_duplicate_report("work finished") + self.assertFalse(bad["complete"]) + + good = assess_work_issue_duplicate_report( + "Duplicate work not prevented for issue #400." + ) + self.assertTrue(good["complete"]) + self.assertEqual(good["outcome"], OUTCOME_DUPLICATE_WORK_NOT_PREVENTED) + + +class TestInjectableDuplicateFetcher(unittest.TestCase): + @patch("mcp_server.api_get_all", return_value=[]) + @patch("mcp_server.get_auth_header", return_value="token x") + def test_lock_issue_uses_injected_fetcher(self, _auth, _api): + seen = {} + + def fetcher(h, o, r, auth, issue_number): + seen["issue_number"] = issue_number + return [], [], {"status": "not_claimed"} + + with patch( + "mcp_server.issue_duplicate_context_fetcher", + side_effect=fetcher, + ), patch( + "mcp_server.issue_lock_worktree.read_worktree_git_state", + return_value={ + "current_branch": "master", + "porcelain_status": "", + "base_equivalent": True, + }, + ): + with tempfile.TemporaryDirectory() as lock_dir: + with patch.dict(os.environ, { + "GITEA_ALLOWED_OPERATIONS": "gitea.issue.comment", + "GITEA_ISSUE_LOCK_DIR": lock_dir, + }, clear=True): + mcp_server.gitea_lock_issue( + issue_number=400, + branch_name="feat/issue-400-duplicate-work-preflight", + remote="prgs", + ) + self.assertEqual(seen["issue_number"], 400) + + +class TestMcpDuplicateRecheck(unittest.TestCase): + def setUp(self): + self._dir = tempfile.TemporaryDirectory() + self._env_patch = patch.dict( + os.environ, + {"GITEA_ISSUE_LOCK_DIR": self._dir.name}, + clear=False, + ) + self._env_patch.start() + self._remotes = patch.dict(mcp_server.REMOTES, { + "prgs": {"host": "gitea.example.com", "org": "Example-Org", + "repo": "Example-Repo"}, + }) + self._remotes.start() + mcp_server._IDENTITY_CACHE.clear() + + def tearDown(self): + patch.stopall() + self._dir.cleanup() + + def _write_lock(self, issue_number=400, branch="feat/issue-400-x"): + worktree_path = os.path.realpath(os.getcwd()) + work_lease = { + "operation_type": "author_issue_work", + "issue_number": issue_number, + "branch": branch, + "claimant": {"username": "test-user", "profile": "test-author"}, + "expires_at": "2999-01-01T00:00:00Z", + } + issue_lock_store.bind_session_lock({ + "issue_number": issue_number, + "branch_name": branch, + "remote": "prgs", + "org": "Example-Org", + "repo": "Example-Repo", + "worktree_path": worktree_path, + "work_lease": work_lease, + "lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance( + tool="gitea_lock_issue", + claimant=work_lease.get("claimant"), + ), + }) + + @patch("mcp_server._assess_issue_duplicate_gate") + @patch("mcp_server.get_profile", return_value={ + "profile_name": "test-author", + "allowed_operations": ["gitea.read", "gitea.repo.commit"], + "forbidden_operations": [], + "audit_label": "test-author", + }) + @patch("mcp_server.get_auth_header", return_value="token x") + def test_commit_files_blocked_on_recheck(self, _auth, _profile, mock_gate): + self._write_lock() + mock_gate.return_value = { + "block": True, + "reasons": ["open PR #412 already covers issue #400"], + "outcome": OUTCOME_DUPLICATE_COMMIT_PREVENTED, + "safe_next_action": "stop", + } + mcp_server.record_preflight_check("whoami") + mcp_server.record_preflight_check("capability", resolved_role="author") + with patch( + "mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", + return_value=(True, []), + ): + result = mcp_server.gitea_commit_files( + files=[{ + "operation": "create", + "path": "a.txt", + "content_plain": "hi", + }], + message="test", + remote="prgs", + ) + self.assertFalse(result["success"]) + self.assertIn("duplicate_gate", result) + + @patch("mcp_server._assess_issue_duplicate_gate") + @patch("mcp_server.get_profile", return_value={ + "profile_name": "test-author", + "allowed_operations": ["gitea.read", "gitea.pr.create"], + "forbidden_operations": [], + "audit_label": "test-author", + }) + @patch("mcp_server.get_auth_header", return_value="token x") + def test_create_pr_returns_handoff_on_duplicate(self, _auth, _profile, mock_gate): + self._write_lock() + mock_gate.return_value = { + "block": True, + "reasons": ["open PR #412 already covers issue #400"], + "outcome": OUTCOME_DUPLICATE_PR_PREVENTED, + "safe_next_action": "reconciliation handoff", + } + mcp_server.record_preflight_check("whoami") + mcp_server.record_preflight_check("capability", resolved_role="author") + with patch( + "mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", + return_value=(True, []), + ): + result = mcp_server.gitea_create_pr( + title="feat: x (Closes #400)", + head="feat/issue-400-x", + base="master", + body="Closes #400", + remote="prgs", + worktree_path=os.path.realpath(os.getcwd()), + ) + self.assertFalse(result["success"]) + self.assertIsNone(result.get("number")) + self.assertIn("duplicate_gate", result) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_llm_agent_sha.py b/tests/test_llm_agent_sha.py index 1a6ecff..2751d9d 100644 --- a/tests/test_llm_agent_sha.py +++ b/tests/test_llm_agent_sha.py @@ -22,6 +22,7 @@ from unittest.mock import patch sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) +import mcp_server # noqa: E402 from mcp_server import ( # noqa: E402 gitea_check_pr_eligibility, gitea_merge_pr, @@ -122,15 +123,19 @@ class TestShaCannotBypassSelfReview(unittest.TestCase): @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) def test_review_tool_refuses_self_approval_despite_sha(self, _auth, mock_api, mock_get_all): - mock_get_all.return_value = [{"number": 9, "title": "PR 9", "state": "open", "head": {"ref": "branch9", "sha": "abc1234"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "jcwalker3"}}] + from mcp_server import init_review_decision_lock + from tests.test_mcp_server import FULL_HEAD_SHA, _seed_ready_review_decision + + head_sha = FULL_HEAD_SHA + mock_get_all.return_value = [{"number": 9, "title": "PR 9", "state": "open", "head": {"ref": "branch9", "sha": head_sha}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "jcwalker3"}}] mock_api.side_effect = [ {"login": "jcwalker3"}, # /user (inventory) {"login": "jcwalker3"}, # /user (submit eligibility) - {"user": {"login": "jcwalker3"}, "state": "open", "head": {"sha": "abc1234"}, "mergeable": True}, # /pulls/9 + {"user": {"login": "jcwalker3"}, "state": "open", "head": {"sha": head_sha}, "mergeable": True}, # /pulls/9 ] - from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision init_review_decision_lock("prgs", "review_pr") - gitea_mark_final_review_decision(9, "approve", remote="prgs") + with patch("mcp_server._list_pr_lease_comments", return_value=[]): + _seed_ready_review_decision(9, "approve", sha=head_sha, remote="prgs") env = self._env(SHA_WOULD_BE_REVIEWER, "reviewer") with patch.dict(os.environ, env, clear=True): r = gitea_review_pr( diff --git a/tests/test_llm_workflow_split.py b/tests/test_llm_workflow_split.py index 44d80ce..5e997aa 100644 --- a/tests/test_llm_workflow_split.py +++ b/tests/test_llm_workflow_split.py @@ -87,6 +87,14 @@ def test_reconcile_landed_workflow_contract(): assert "PARTIAL_RECONCILE_COMMENT_THEN_STOP" in text assert "RECOVERY_HANDOFF_ONLY" in text assert "resolve_partial_reconciliation_plan" in text + assert "check_audit_mutation_allowed" in text + assert "gitea_authorize_reconciliation_cleanup_phase" in text + + +def test_audit_reconciliation_verifier_exported(): + from review_proofs import assess_audit_reconciliation_report + + assert callable(assess_audit_reconciliation_report) def test_create_issue_workflow_contract(): @@ -213,6 +221,12 @@ def test_validation_failure_history_verifier_exported(): 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 diff --git a/tests/test_lock_issue_mcp_registration.py b/tests/test_lock_issue_mcp_registration.py new file mode 100644 index 0000000..4b20763 --- /dev/null +++ b/tests/test_lock_issue_mcp_registration.py @@ -0,0 +1,127 @@ +"""Regression tests for gitea_lock_issue MCP tool registration (#521).""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from unittest.mock import patch + +import issue_lock_store +import mcp_server +from mcp_server import gitea_create_pr, gitea_lock_issue + +FAKE_AUTH = "Basic dGVzdDp0ZXN0" + +ISSUE_WRITE_ENV = { + "GITEA_ALLOWED_OPERATIONS": ( + "gitea.issue.create,gitea.issue.close,gitea.issue.comment" + ), +} + +CREATE_PR_ENV = { + "GITEA_PROFILE_NAME": "author-test", + "GITEA_ALLOWED_OPERATIONS": ( + "gitea.read,gitea.pr.create,gitea.branch.push," + "gitea.issue.create,gitea.issue.close,gitea.issue.comment" + ), + "GITEA_FORBIDDEN_OPERATIONS": ( + "gitea.pr.approve,gitea.pr.merge,gitea.pr.review" + ), +} + + +def _clean_master_git_state_for_lock(): + return { + "current_branch": "master", + "porcelain_status": "", + "base_equivalent": True, + "inspected_git_root": "/scratch/wt", + "base_branch": "origin/master", + } + + +def _registered_tool_names() -> set[str]: + manager = mcp_server.mcp._tool_manager + tools = getattr(manager, "_tools", None) or {} + return set(tools.keys()) + + +class TestLockIssueMcpRegistration(unittest.TestCase): + def test_gitea_lock_issue_registered_as_public_mcp_tool(self): + names = _registered_tool_names() + self.assertIn("gitea_lock_issue", names) + + def test_internal_list_open_pulls_not_exposed_as_mcp_tool(self): + names = _registered_tool_names() + self.assertNotIn("_list_open_pulls", names) + + +class TestCreatePrLockRegistrationFlow(unittest.TestCase): + def setUp(self): + self._lock_dir = tempfile.TemporaryDirectory() + self._env_patcher = patch.dict( + os.environ, + { + **CREATE_PR_ENV, + "GITEA_ISSUE_LOCK_DIR": self._lock_dir.name, + }, + clear=True, + ) + self._env_patcher.start() + 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._env_patcher.stop() + self._lock_dir.cleanup() + + @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", + return_value=(True, [])) + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_create_pr_still_fails_closed_without_issue_lock(self, _auth, _role): + with self.assertRaises(RuntimeError) as ctx: + gitea_create_pr( + title="feat: X Closes #521", + head="feat/issue-521-lock-issue-registration", + remote="prgs", + ) + self.assertIn("Issue lock is missing", str(ctx.exception)) + + @patch("mcp_server.api_request") + @patch( + "mcp_server.issue_lock_worktree.read_worktree_git_state", + return_value=_clean_master_git_state_for_lock(), + ) + @patch("mcp_server.api_get_all", return_value=[]) + @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", + return_value=(True, [])) + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_create_pr_proceeds_after_valid_issue_lock( + self, _auth, _role, _api, _git_state, mock_api_request + ): + worktree = os.path.realpath(os.getcwd()) + mock_api_request.return_value = {"number": 521, "html_url": "https://example/pr/521"} + lock_res = gitea_lock_issue( + issue_number=521, + branch_name="feat/issue-521-lock-issue-registration", + remote="prgs", + worktree_path=worktree, + ) + self.assertTrue(lock_res["success"]) + lock_path = lock_res["lock_file_path"] + self.assertTrue(os.path.exists(lock_path)) + lock = issue_lock_store.read_lock_file(lock_path) + self.assertEqual(lock["issue_number"], 521) + + res = gitea_create_pr( + title="fix: restore lock tool registration Closes #521", + head="feat/issue-521-lock-issue-registration", + remote="prgs", + worktree_path=worktree, + ) + self.assertEqual(res["number"], 521) \ No newline at end of file diff --git a/tests/test_mcp_menu_script.py b/tests/test_mcp_menu_script.py new file mode 100644 index 0000000..a447648 --- /dev/null +++ b/tests/test_mcp_menu_script.py @@ -0,0 +1,124 @@ +"""Hermetic tests for repository-root MCP operator menu (#478).""" +import os +import stat +import unittest +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCRIPT = REPO_ROOT / "mcp-menu.sh" +DOCS = REPO_ROOT / "docs" / "mcp-menu.md" + +REQUIRED_MENU_LABELS = ( + "Project status / root checkout health", + "Author workflow prompts", + "Reviewer workflow prompts", + "Merger workflow prompts", + "Reconciler workflow prompts", + "Onboarding new project to this MCP workflow", + "Proxmox deployment menu placeholder", + "Create Proxmox LXC placeholder", + "Run tests", + "Exit", +) + +DANGEROUS_PATTERNS = ( + "git push --force", + "git push -f", + "--force-with-lease", + "delete-branch", + "gitea_delete_branch", + "issue-locks", + "issue_lock_store", + "curl ", + "wget ", +) + + +class TestMcpMenuScript(unittest.TestCase): + def setUp(self): + self.assertTrue(SCRIPT.is_file(), "mcp-menu.sh must exist at repo root") + self.content = SCRIPT.read_text(encoding="utf-8") + + def test_script_exists_at_repo_root(self): + self.assertEqual(SCRIPT.name, "mcp-menu.sh") + self.assertEqual(SCRIPT.parent, REPO_ROOT) + + def test_executable_bit_is_set(self): + mode = SCRIPT.stat().st_mode + self.assertTrue(mode & stat.S_IXUSR, "mcp-menu.sh must be executable by owner") + + def test_shebang(self): + first_line = self.content.splitlines()[0] + self.assertEqual(first_line, "#!/usr/bin/env bash") + + def test_uses_set_euo_pipefail(self): + self.assertIn("set -euo pipefail", self.content) + + def test_no_dangerous_commands(self): + lowered = self.content.lower() + for pattern in DANGEROUS_PATTERNS: + with self.subTest(pattern=pattern): + self.assertNotIn(pattern.lower(), lowered) + + def test_no_branch_deletion_verbs(self): + for token in ("git branch -D", "git branch -d", "push :refs"): + with self.subTest(token=token): + self.assertNotIn(token, self.content) + + def test_contains_required_menu_labels(self): + for label in REQUIRED_MENU_LABELS: + with self.subTest(label=label): + self.assertIn(label, self.content) + + def test_run_tests_prefers_run_tests_sh(self): + self.assertIn('run-tests.sh', self.content) + run_tests_idx = self.content.index("run_tests()") + run_tests_body = self.content[run_tests_idx : run_tests_idx + 800] + pytest_idx = run_tests_body.find("pytest") + run_tests_sh_idx = run_tests_body.find("run-tests.sh") + self.assertGreater(pytest_idx, 0) + self.assertGreater(run_tests_sh_idx, 0) + self.assertLess(run_tests_sh_idx, pytest_idx) + + def test_run_tests_fail_closed_without_runner(self): + self.assertIn("fail closed", self.content.lower()) + self.assertIn("exit 1", self.content) + + def test_proxmox_entries_are_placeholders(self): + self.assertIn("TODO / issue-backed", self.content) + self.assertIn("NOT implemented", self.content) + + def test_root_health_shows_required_fields(self): + health_fn = self._extract_function("show_root_checkout_health") + for snippet in ( + "status --short --branch", + "rev-parse HEAD", + "prgs/master", + "WARNING: root checkout", + ): + with self.subTest(snippet=snippet): + self.assertIn(snippet, health_fn) + + def test_docs_mention_mcp_menu_sh(self): + self.assertTrue(DOCS.is_file(), "docs/mcp-menu.md must exist") + docs_text = DOCS.read_text(encoding="utf-8") + self.assertIn("./mcp-menu.sh", docs_text) + self.assertIn("placeholder", docs_text.lower()) + + def _extract_function(self, name: str) -> str: + marker = f"{name}() {{" + start = self.content.index(marker) + depth = 0 + for idx in range(start, len(self.content)): + char = self.content[idx] + if char == "{": + depth += 1 + elif char == "}": + depth -= 1 + if depth == 0: + return self.content[start : idx + 1] + self.fail(f"Could not parse function {name}") + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_mcp_native_cleanup_proof.py b/tests/test_mcp_native_cleanup_proof.py new file mode 100644 index 0000000..d965b0d --- /dev/null +++ b/tests/test_mcp_native_cleanup_proof.py @@ -0,0 +1,251 @@ +"""Tests for MCP-native post-merge cleanup proof (#517).""" +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 mcp_native_cleanup_proof import ( # noqa: E402 + assess_authorized_reconciler_cleanup_path, + assess_mcp_native_cleanup_proof, + assess_merger_cleanup_handoff_guidance, + assess_raw_branch_delete_report, + assess_raw_comment_delete_report, +) + + +def _authorized_cleanup_report(**overrides): + fields = { + "Merge mutations": "gitea_merge_pr on PR #100", + "Cleanup mutations": ( + "gitea_reconcile_merged_cleanups deleted remote branch; " + "gitea_cleanup_post_merge_moot_lease released lease" + ), + "Reconciler capability": "prgs-reconciler / gitea.branch.delete resolved", + "Next actor": "reconciler session complete", + } + fields.update(overrides) + lines = ["## Controller Handoff", ""] + lines.extend(f"- {key}: {value}" for key, value in fields.items()) + return "\n".join(lines) + + +class TestRawBranchDeleteBlocked(unittest.TestCase): + def test_git_branch_d_blocked(self): + report = "Cleanup mutations: git branch -d feat/issue-1-x" + result = assess_raw_branch_delete_report(report) + self.assertTrue(result["block"]) + self.assertIn("git branch -d", result["commands"][0]) + + def test_git_push_delete_in_ledger_blocked(self): + report = "- Git ref mutations: git push origin --delete feat/x" + result = assess_raw_branch_delete_report(report) + self.assertTrue(result["block"]) + + def test_narrative_git_push_delete_not_blocked(self): + report = "\n".join([ + "## Validation", + "Workflow §28B forbids raw git push --delete for cleanup.", + "- Cleanup mutations: none", + "- Git ref mutations: none", + "- Worktree mutations: none", + ]) + result = assess_raw_branch_delete_report(report) + self.assertFalse(result["block"]) + + def test_authorized_mcp_path_passes(self): + report = _authorized_cleanup_report() + result = assess_raw_branch_delete_report(report) + self.assertFalse(result["block"]) + + +class TestRawCommentDeleteBlocked(unittest.TestCase): + def test_curl_delete_comment_in_ledger_blocked(self): + report = ( + "- Cleanup mutations: curl -X DELETE https://gitea.example/api/v1/repos/o/r/" + "issues/9/comments/42" + ) + result = assess_raw_comment_delete_report(report) + self.assertTrue(result["block"]) + + def test_narrative_comment_delete_not_blocked(self): + report = "\n".join([ + "Files reviewed: post_merge_cleanup_proof.py (raw comment delete patterns)", + "Validation note: never use delete_issue_comment for lease cleanup.", + "- Cleanup mutations: none", + "- Git ref mutations: none", + "- Worktree mutations: none", + ]) + result = assess_raw_comment_delete_report(report) + self.assertFalse(result["block"]) + + def test_delete_issue_comment_helper_in_ledger_blocked(self): + report = "- Cleanup mutations: delete_issue_comment purged lease comments" + result = assess_raw_comment_delete_report(report) + self.assertTrue(result["block"]) + + def test_moot_lease_append_only_passes(self): + report = _authorized_cleanup_report() + result = assess_raw_comment_delete_report(report) + self.assertFalse(result["block"]) + + +class TestAuthorizedReconcilerPath(unittest.TestCase): + def test_cleanup_without_mcp_tool_blocked(self): + report = "\n".join([ + "## Controller Handoff", + "- Cleanup mutations: removed branch manually after merge", + "- Reconciler capability: prgs-reconciler", + ]) + result = assess_authorized_reconciler_cleanup_path(report) + self.assertTrue(result["block"]) + self.assertTrue(any("authorized MCP" in r for r in result["reasons"])) + + def test_authorized_cleanup_succeeds(self): + result = assess_authorized_reconciler_cleanup_path(_authorized_cleanup_report()) + self.assertFalse(result["block"]) + + def test_git_ref_cleanup_without_mcp_tool_blocked(self): + report = "\n".join([ + "## Controller Handoff", + "- Cleanup mutations: none", + "- Git ref mutations: git branch -D feat/issue-517-test", + "- Reconciler capability: prgs-reconciler", + ]) + result = assess_authorized_reconciler_cleanup_path(report) + self.assertTrue(result["block"]) + self.assertTrue( + any("Git ref mutations cleanup must name" in r for r in result["reasons"]) + ) + + def test_worktree_cleanup_without_mcp_tool_blocked(self): + report = "\n".join([ + "## Controller Handoff", + "- Cleanup mutations: none", + "- Worktree mutations: git worktree remove branches/review-pr542", + "- Reconciler capability: prgs-reconciler", + ]) + result = assess_authorized_reconciler_cleanup_path(report) + self.assertTrue(result["block"]) + self.assertTrue( + any("Worktree mutations cleanup must name" in r for r in result["reasons"]) + ) + + def test_git_fetch_in_git_ref_mutations_not_cleanup_claim(self): + report = "\n".join([ + "## Controller Handoff", + "- Cleanup mutations: none", + "- Git ref mutations: git fetch prgs master", + ]) + result = assess_authorized_reconciler_cleanup_path(report) + self.assertFalse(result["block"]) + + +class TestMergerHandoffGuidance(unittest.TestCase): + def test_merger_cleanup_without_handoff_blocked(self): + report = "Merger deleted remote branch and removed worktree after merge" + result = assess_merger_cleanup_handoff_guidance(report) + self.assertTrue(result["block"]) + + def test_merger_cleanup_with_reconciler_handoff_passes(self): + report = ( + "Merger deleted remote branch; next actor: reconciler for worktree audit" + ) + result = assess_merger_cleanup_handoff_guidance(report) + self.assertFalse(result["block"]) + + +class TestCompositeVerifier(unittest.TestCase): + def test_full_authorized_report_passes(self): + result = assess_mcp_native_cleanup_proof(_authorized_cleanup_report()) + self.assertFalse(result["block"], result["reasons"]) + + def test_raw_bypasses_fail_composite(self): + report = "\n".join([ + "## Controller Handoff", + "- Merge mutations: gitea_merge_pr", + "- Cleanup mutations: git branch -D feat/x; curl -X DELETE .../comments/1", + ]) + result = assess_mcp_native_cleanup_proof(report) + self.assertTrue(result["block"]) + self.assertGreaterEqual(len(result["reasons"]), 2) + + def test_narrative_cleanup_mentions_pass_when_ledgers_none(self): + report = "\n".join([ + "## Review summary", + "Validated workflow §28B MCP-native cleanup guidance.", + "Files reviewed describe raw git branch -d and delete_issue_comment blocks.", + "", + "## Controller Handoff", + "- Cleanup mutations: none", + "- Git ref mutations: none", + "- Worktree mutations: none", + ]) + result = assess_mcp_native_cleanup_proof(report) + self.assertFalse(result["block"], result["reasons"]) + + def test_git_ref_cleanup_bypass_blocked_in_composite(self): + report = "\n".join([ + "## Controller Handoff", + "- Cleanup mutations: none", + "- Git ref mutations: git branch -D feat/issue-517-test", + "- Reconciler capability: prgs-reconciler", + ]) + result = assess_mcp_native_cleanup_proof(report) + self.assertTrue(result["block"]) + self.assertTrue( + any("Git ref mutations cleanup must name" in r for r in result["reasons"]) + ) + + +class TestFinalReportValidatorIntegration(unittest.TestCase): + def test_validator_blocks_raw_branch_delete_in_review_report(self): + report = "\n".join([ + "## Controller Handoff", + "- Cleanup mutations: git branch -d feat/issue-517-test", + ]) + result = assess_final_report_validator(report, "review_pr") + rules = {f["rule_id"] for f in result["findings"]} + self.assertIn("shared.mcp_native_cleanup_proof", rules) + + def test_validator_passes_authorized_cleanup_report(self): + result = assess_final_report_validator(_authorized_cleanup_report(), "review_pr") + blocked = [f for f in result["findings"] if f.get("severity") == "block"] + mcp_blocks = [ + f for f in blocked if f.get("rule_id") == "shared.mcp_native_cleanup_proof" + ] + self.assertEqual(mcp_blocks, []) + + def test_validator_passes_narrative_cleanup_mentions_with_none_ledgers(self): + report = "\n".join([ + "## Review summary", + "Workflow §28B documents raw git push --delete and delete_issue_comment blocks.", + "", + "## Controller Handoff", + "- Cleanup mutations: none", + "- Git ref mutations: none", + "- Worktree mutations: none", + ]) + result = assess_final_report_validator(report, "review_pr") + mcp_blocks = [ + f for f in result["findings"] + if f.get("rule_id") == "shared.mcp_native_cleanup_proof" + and f.get("severity") == "block" + ] + self.assertEqual(mcp_blocks, []) + + def test_validator_blocks_git_ref_cleanup_bypass(self): + report = "\n".join([ + "## Controller Handoff", + "- Cleanup mutations: none", + "- Git ref mutations: git branch -D feat/issue-517-test", + "- Reconciler capability: prgs-reconciler", + ]) + result = assess_final_report_validator(report, "reconcile_already_landed") + rules = {f["rule_id"] for f in result["findings"] if f.get("severity") == "block"} + self.assertIn("shared.mcp_native_cleanup_proof", rules) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index 4002b5e..503e879 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -6,6 +6,7 @@ the MCP protocol) with mocked API responses. import json import os import sys +import tempfile import unittest from unittest.mock import patch, MagicMock @@ -45,8 +46,10 @@ from gitea_auth import get_profile # noqa: E402 import gitea_config # noqa: E402 import mcp_server +import issue_lock_store FAKE_AUTH = "Basic dGVzdDp0ZXN0" +FULL_HEAD_SHA = "a" * 40 _NO_BLOCKER_FEEDBACK = { "success": True, @@ -55,11 +58,18 @@ _NO_BLOCKER_FEEDBACK = { } +def _init_reviewer_session(remote="prgs"): + """Seed review decision lock and required workflow-load proof (#389).""" + init_review_decision_lock(remote, "review_pr") + mcp_server.gitea_load_review_workflow() + + def _mark_request_changes_ready(pr_number=8, **kwargs): """Mark a request_changes decision ready with the #332 duplicate- suppression feedback fetch stubbed to 'no existing blocker'.""" with patch("mcp_server.gitea_get_pr_review_feedback", return_value=dict(_NO_BLOCKER_FEEDBACK)): + kwargs.setdefault("expected_head_sha", "abc123") return gitea_mark_final_review_decision( pr_number, "request_changes", **kwargs) @@ -79,6 +89,110 @@ def _visible_approval_reviews(reviewer="reviewer-bot", sha="abc123"): return [_formal_review(reviewer, "APPROVED", sha=sha)] +_DEFAULT_LEASE_SESSION = "mcp-test-reviewer-lease" +_NO_PR_WORK_LEASE_BLOCK = {"block": False, "reasons": [], "mutation_allowed": True} + + +def _reviewer_lease_comment( + pr_number, + *, + session_id=_DEFAULT_LEASE_SESSION, + head_sha="abc123", + reviewer="reviewer-bot", +): + from datetime import datetime, timezone + + import reviewer_pr_lease + + body = reviewer_pr_lease.format_lease_body( + repo="Scaled-Tech-Consulting/Gitea-Tools", + pr_number=pr_number, + issue_number=407, + reviewer_identity=reviewer, + profile="gitea-reviewer", + session_id=session_id, + worktree="branches/review-test", + phase="claimed", + candidate_head=head_sha, + target_branch="master", + target_branch_sha="b" * 40, + last_activity=datetime.now(timezone.utc), + ) + return {"id": 9001, "body": body, "user": {"login": reviewer}} + + +def _install_owned_reviewer_lease( + pr_number, + *, + session_id=_DEFAULT_LEASE_SESSION, + head_sha="abc123", +): + import merger_lease_adoption as mla + import reviewer_pr_lease + + reviewer_pr_lease.clear_session_lease() + reviewer_pr_lease.record_session_lease({ + "pr_number": pr_number, + "session_id": session_id, + "candidate_head": head_sha, + "target_branch": "master", + "comment_id": 9001, + }, lease_provenance=mla.build_lease_provenance( + source=mla.SOURCE_ACQUIRE, + comment_id=9001, + )) + return patch( + "mcp_server._fetch_pr_comments", + return_value=[ + _reviewer_lease_comment( + pr_number, + session_id=session_id, + head_sha=head_sha, + ) + ], + ) + + +def _seed_ready_review_decision( + pr_number, + action, + *, + sha=FULL_HEAD_SHA, + remote="prgs", + org=None, + repo=None, +): + """Mark the review-decision lock ready without mark_final API calls (#399).""" + import mcp_server as _m + + resolved_org, resolved_repo = org, repo + if remote in _m.REMOTES: + _, resolved_org, resolved_repo = _m._resolve(remote, None, org, repo) + profile_name = (_m.get_profile().get("profile_name") or "").strip() + session_lock = ( + (os.environ.get(_m.SESSION_PROFILE_LOCK_ENV) or "").strip() + or profile_name + ) + _m._save_review_decision_lock({ + "task": "review_pr", + "remote": remote, + "session_pid": os.getpid(), + "session_profile": profile_name, + "session_profile_lock": session_lock, + "final_review_decision_ready": True, + "ready_pr_number": pr_number, + "ready_action": action, + "ready_expected_head_sha": sha, + "ready_remote": remote, + "ready_org": resolved_org, + "ready_repo": resolved_repo, + "live_mutations": [], + "correction_authorized": False, + "correction_reason": None, + }) + _m.gitea_load_review_workflow() + + # Issue-write tools are profile-gated (#69). ISSUE_WRITE_ENV = { "GITEA_ALLOWED_OPERATIONS": ( @@ -101,17 +215,48 @@ ISSUE_LOCK_FILE = "/tmp/gitea_issue_lock.json" def _sample_issue_lock(issue_number=123, branch_name="feat/x", **overrides): + import issue_lock_provenance + + work_lease = { + "operation_type": "author_issue_work", + "issue_number": issue_number, + "branch": branch_name, + "claimant": {"username": "test-user", "profile": "test-author"}, + "expires_at": "2999-01-01T00:00:00Z", + } record = { "issue_number": issue_number, "branch_name": branch_name, "remote": "dadeschools", "org": "Scaled-Tech-Consulting", "repo": "Gitea-Tools", + "worktree_path": "/tmp/test-worktree", + "work_lease": work_lease, + "lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance( + tool="gitea_lock_issue", + claimant=work_lease.get("claimant"), + ), } record.update(overrides) return record +def _clear_duplicate_context_fetcher(*_args, **_kwargs): + """Default injectable duplicate-work context for lock/create_pr tests.""" + return [], [], {"status": "not_claimed"} + + +def _bind_test_lock(**overrides) -> str: + remote = overrides.get("remote", "dadeschools") + record = _sample_issue_lock(**overrides) + if remote in mcp_server.REMOTES: + profile = mcp_server.REMOTES[remote] + record.setdefault("org", profile["org"]) + record.setdefault("repo", profile["repo"]) + record["remote"] = remote + return issue_lock_store.bind_session_lock(record) + + # --------------------------------------------------------------------------- # Create Issue # --------------------------------------------------------------------------- @@ -166,55 +311,78 @@ class TestCreateIssue(unittest.TestCase): # --------------------------------------------------------------------------- class TestCreatePR(unittest.TestCase): + @patch( + "mcp_server.issue_duplicate_context_fetcher", + return_value=([], [], {"status": "not_claimed"}), + ) @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, [])) @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) - @patch("os.path.exists", return_value=True) - @patch("builtins.open") - def test_creates_pr(self, mock_open, mock_exists, _auth, mock_api, _role): - lock_json = json.dumps(_sample_issue_lock(issue_number=123, branch_name="feat/x")) - mock_open.return_value.__enter__.return_value.read.return_value = lock_json + def test_creates_pr(self, _auth, mock_api, _role, _dup_fetcher): + worktree = os.path.realpath(os.getcwd()) mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"} - with patch.dict(os.environ, CREATE_PR_ENV, clear=True): - result = gitea_create_pr(title="feat: X Closes #123", head="feat/x", base="main") + with tempfile.TemporaryDirectory() as lock_dir: + env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir} + with patch.dict(os.environ, env, clear=True): + _bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree) + result = gitea_create_pr( + title="feat: X Closes #123", + head="feat/x", + base="main", + worktree_path=worktree, + ) self.assertEqual(result["number"], 3) self.assertNotIn("url", result) - mock_exists.assert_called_with(ISSUE_LOCK_FILE) - mock_open.assert_called_with(ISSUE_LOCK_FILE, "r", encoding="utf-8") payload = mock_api.call_args[0][3] self.assertEqual(payload["head"], "feat/x") self.assertEqual(payload["base"], "main") self.assertIn("Closes #123", payload["title"]) + @patch( + "mcp_server.issue_duplicate_context_fetcher", + return_value=([], [], {"status": "not_claimed"}), + ) @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, [])) @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) - @patch("os.path.exists", return_value=True) - @patch("builtins.open") - def test_create_pr_reveal_opt_in_includes_url(self, mock_open, mock_exists, _auth, mock_api, _role): - lock_json = json.dumps(_sample_issue_lock(issue_number=123, branch_name="feat/x")) - mock_open.return_value.__enter__.return_value.read.return_value = lock_json + def test_create_pr_reveal_opt_in_includes_url(self, _auth, mock_api, _role, _dup_fetcher): + worktree = os.path.realpath(os.getcwd()) mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"} - env = {**CREATE_PR_ENV, "GITEA_MCP_REVEAL_ENDPOINTS": "1"} - with patch.dict(os.environ, env, clear=True): - result = gitea_create_pr(title="feat: X Closes #123", head="feat/x", base="main") + with tempfile.TemporaryDirectory() as lock_dir: + env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir, "GITEA_MCP_REVEAL_ENDPOINTS": "1"} + with patch.dict(os.environ, env, clear=True): + _bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree) + result = gitea_create_pr( + title="feat: X Closes #123", + head="feat/x", + base="main", + worktree_path=worktree, + ) self.assertIn("pulls/3", result["url"]) @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, [])) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) - @patch("os.path.exists", return_value=True) - @patch("builtins.open") - def test_create_pr_locked_issue_mismatch_fails(self, mock_open, mock_exists, _auth, _role): - lock_json = json.dumps(_sample_issue_lock(issue_number=123, branch_name="feat/x")) - mock_open.return_value.__enter__.return_value.read.return_value = lock_json - with patch.dict(os.environ, CREATE_PR_ENV, clear=True): - with self.assertRaises(ValueError) as ctx: - gitea_create_pr(title="feat: X Closes #999", head="feat/x", base="main") + def test_create_pr_locked_issue_mismatch_fails(self, _auth, _role): + worktree = os.path.realpath(os.getcwd()) + with tempfile.TemporaryDirectory() as lock_dir: + env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir} + with patch.dict(os.environ, env, clear=True): + _bind_test_lock( + issue_number=123, + branch_name="feat/x", + worktree_path=worktree, + ) + with self.assertRaises(ValueError) as ctx: + gitea_create_pr( + title="feat: X Closes #999", + head="feat/x", + base="main", + worktree_path=worktree, + ) self.assertIn("Closes #123", str(ctx.exception)) - mock_open.assert_called_with(ISSUE_LOCK_FILE, "r", encoding="utf-8") # --------------------------------------------------------------------------- @@ -512,6 +680,31 @@ class TestViewPR(unittest.TestCase): class TestMergePR(unittest.TestCase): """Gated merge workflow (#16). gitea_merge_pr is the only merge path.""" + def setUp(self): + import reviewer_pr_lease + + mcp_server.gitea_load_review_workflow() + 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.addCleanup(self._auth_identity_patch.stop) + self.addCleanup(self._lease_patch.stop) + self.addCleanup(reviewer_pr_lease.clear_session_lease) + self._pr_lease_comments_patch = patch( + "mcp_server._list_pr_lease_comments", return_value=[] + ) + self._pr_lease_comments_patch.start() + self.addCleanup(self._pr_lease_comments_patch.stop) + 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._pr_work_lease_patch.stop) + def _pr(self, author, state="open", sha="abc123", mergeable=True): return { "user": {"login": author}, @@ -584,7 +777,8 @@ class TestMergePR(unittest.TestCase): with patch.dict(os.environ, env, clear=True): r = gitea_merge_pr( pr_number=8, confirmation=self._confirm(8), - expected_changed_files=["b.py", "a.py"], remote="prgs") + expected_changed_files=["b.py", "a.py"], + expected_head_sha="abc123", remote="prgs") self.assertTrue(r["performed"]) # -- read-back / cleanup surfacing (#98) ----------------------------------- @@ -602,8 +796,10 @@ class TestMergePR(unittest.TestCase): env = {"GITEA_PROFILE_NAME": "gitea-merger", "GITEA_ALLOWED_OPERATIONS": "read,merge"} with patch.dict(os.environ, env, clear=True): - r = gitea_merge_pr(pr_number=8, confirmation=self._confirm(8), - remote="prgs") + r = gitea_merge_pr( + pr_number=8, confirmation=self._confirm(8), + expected_head_sha="abc123", remote="prgs", + ) # The merge itself is still reported performed/successful. self.assertTrue(r["performed"]) self.assertEqual(r["merge_result"], "PR #8 merged via 'merge'.") @@ -631,8 +827,10 @@ class TestMergePR(unittest.TestCase): env = {"GITEA_PROFILE_NAME": "gitea-merger", "GITEA_ALLOWED_OPERATIONS": "read,merge"} with patch.dict(os.environ, env, clear=True): - r = gitea_merge_pr(pr_number=8, confirmation=self._confirm(8), - remote="prgs") + r = gitea_merge_pr( + pr_number=8, confirmation=self._confirm(8), + expected_head_sha="abc123", remote="prgs", + ) self.assertTrue(r["performed"]) self.assertEqual(r["merge_commit"], "c9") self.assertTrue(r["cleanup_status"].startswith("skipped (cleanup error:")) @@ -646,7 +844,7 @@ class TestMergePR(unittest.TestCase): env = {"GITEA_PROFILE_NAME": "gitea-merger", "GITEA_ALLOWED_OPERATIONS": "read,merge"} with patch.dict(os.environ, env, clear=True): - r = gitea_merge_pr(pr_number=8, confirmation="", remote="prgs") + r = gitea_merge_pr(pr_number=8, confirmation="", expected_head_sha="abc123", remote="prgs") self.assertFalse(r["performed"]) self.assertTrue(any("explicit confirmation required" in x for x in r["reasons"])) mock_api.assert_not_called() @@ -657,7 +855,7 @@ class TestMergePR(unittest.TestCase): env = {"GITEA_PROFILE_NAME": "gitea-merger", "GITEA_ALLOWED_OPERATIONS": "read,merge"} with patch.dict(os.environ, env, clear=True): - r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 9", remote="prgs") + r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 9", expected_head_sha="abc123", remote="prgs") self.assertFalse(r["performed"]) mock_api.assert_not_called() @@ -772,9 +970,11 @@ class TestMergePR(unittest.TestCase): pr_number=8, confirmation=self._confirm(8), expected_head_sha="deadbeef", remote="prgs") self.assertFalse(r["performed"]) - self.assertIn( - "expected head SHA does not match current PR head (fail closed)", - r["reasons"]) + self.assertTrue(any( + "expected head SHA does not match current PR head (fail closed)" in reason + or "PR head changed during lease" in reason + for reason in r["reasons"] + )) self._assert_no_merge_call(mock_api) @patch("mcp_server.api_request") @@ -789,7 +989,8 @@ class TestMergePR(unittest.TestCase): with patch.dict(os.environ, env, clear=True): r = gitea_merge_pr( pr_number=8, confirmation=self._confirm(8), - expected_changed_files=["a.py", "b.py"], remote="prgs") + expected_changed_files=["a.py", "b.py"], + expected_head_sha="abc123", remote="prgs") self.assertFalse(r["performed"]) self.assertIn( "PR changed files do not match expected_changed_files (fail closed)", @@ -802,7 +1003,7 @@ class TestMergePR(unittest.TestCase): def test_invalid_merge_method_rejected(self, mock_api): with patch.dict(os.environ, {}, clear=True): r = gitea_merge_pr( - pr_number=8, confirmation="MERGE PR 8", do="octopus", remote="prgs") + pr_number=8, confirmation="MERGE PR 8", do="octopus", expected_head_sha="abc123", remote="prgs") self.assertFalse(r["performed"]) self.assertTrue(any("unknown merge method" in x for x in r["reasons"])) mock_api.assert_not_called() @@ -820,7 +1021,9 @@ class TestMergePR(unittest.TestCase): "GITEA_TOKEN": "super-secret-token"} with patch.dict(os.environ, env, clear=True): r = gitea_merge_pr( - pr_number=8, confirmation=self._confirm(8), remote="prgs") + pr_number=8, confirmation=self._confirm(8), + expected_head_sha="abc123", remote="prgs", + ) blob = repr(r).lower() for secret in ("super-secret-token", "authorization", "basic ", FAKE_AUTH.lower()): self.assertNotIn(secret, blob) @@ -837,12 +1040,38 @@ class TestMergePR(unittest.TestCase): "GITEA_ALLOWED_OPERATIONS": "read,merge"} with patch.dict(os.environ, env, clear=True): r = gitea_merge_pr( - pr_number=8, confirmation=self._confirm(8), remote="prgs") + pr_number=8, confirmation=self._confirm(8), + expected_head_sha="abc123", remote="prgs", + ) self.assertFalse(r["performed"]) blob = repr(r) self.assertIn("[REDACTED]", blob) self.assertNotIn("abc-secret-xyz", blob) + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_merge_blocked_on_stale_approval_head(self, _auth, mock_api): + old_sha = "8b61c4b41f1b49b271ed3b99657431cf06eeda3e" + new_sha = "3e4b721d60e97147ba0704773cf57cd0d42cbe31" + mock_api.side_effect = [ + {"login": "merger-bot"}, self._pr("author-bot", sha=new_sha), + self._pr("author-bot", sha=new_sha), + [_formal_review("reviewer-bot", "APPROVED", sha=old_sha)], + ] + env = {"GITEA_PROFILE_NAME": "gitea-merger", + "GITEA_ALLOWED_OPERATIONS": "read,merge"} + with patch.dict(os.environ, env, clear=True): + r = gitea_merge_pr( + pr_number=8, confirmation=self._confirm(8), remote="prgs", + expected_head_sha=new_sha) + self.assertFalse(r["performed"]) + self.assertTrue(r.get("approval_visible")) + self.assertFalse(r.get("approval_at_current_head")) + self.assertTrue(any("stale approval" in x for x in r["reasons"])) + self.assertTrue(any(old_sha in x for x in r["reasons"])) + self.assertTrue(any(new_sha in x for x in r["reasons"])) + self._assert_no_merge_call(mock_api) + @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) def test_merge_blocked_without_visible_approval(self, _auth, mock_api): @@ -855,7 +1084,9 @@ class TestMergePR(unittest.TestCase): "GITEA_ALLOWED_OPERATIONS": "read,merge"} with patch.dict(os.environ, env, clear=True): r = gitea_merge_pr( - pr_number=8, confirmation=self._confirm(8), remote="prgs") + pr_number=8, confirmation=self._confirm(8), + expected_head_sha="abc123", remote="prgs", + ) self.assertFalse(r["performed"]) self.assertFalse(r.get("approval_visible")) self.assertTrue(any("no visible APPROVED review" in x for x in r["reasons"])) @@ -876,7 +1107,9 @@ class TestMergePR(unittest.TestCase): "GITEA_ALLOWED_OPERATIONS": "read,merge"} with patch.dict(os.environ, env, clear=True): r = gitea_merge_pr( - pr_number=8, confirmation=self._confirm(8), remote="prgs") + pr_number=8, confirmation=self._confirm(8), + expected_head_sha="abc123", remote="prgs", + ) self.assertFalse(r["performed"]) self.assertTrue(r.get("has_blocking_change_requests")) self.assertTrue(any("REQUEST_CHANGES" in x for x in r["reasons"])) @@ -977,16 +1210,18 @@ class TestReviewPR(unittest.TestCase): "forbidden_operations": [], "base_url": None, } - mock_get_all.return_value = [{"number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1", "sha": "abc1234"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "jcwalker3"}}] + head_sha = FULL_HEAD_SHA + mock_get_all.return_value = [{"number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1", "sha": head_sha}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "jcwalker3"}}] # mock_api responses: 1) /user (inventory), 2) /user (eligibility), 3) /pulls/1 (eligibility) mock_api.side_effect = [ {"login": "jcwalker3"}, # /api/v1/user (inventory) {"login": "jcwalker3"}, # /api/v1/user (submit eligibility) - {"user": {"login": "jcwalker3"}, "state": "open", "head": {"sha": "abc1234"}, "mergeable": True}, # /pulls/1 + {"user": {"login": "jcwalker3"}, "state": "open", "head": {"sha": head_sha}, "mergeable": True}, # /pulls/1 ] from mcp_server import init_review_decision_lock init_review_decision_lock("prgs", "review_pr") - gitea_mark_final_review_decision(1, "approve", remote="prgs") + with patch("mcp_server._list_pr_lease_comments", return_value=[]): + _seed_ready_review_decision(1, "approve", sha=head_sha, remote="prgs") result = gitea_review_pr( pr_number=1, event="APPROVE", @@ -1642,7 +1877,7 @@ class TestReviewDecisionValidationGate(unittest.TestCase): """Block incidental live review mutations during validation.""" PR = 203 - SHA = "abc123" + SHA = FULL_HEAD_SHA def _pr(self, author, sha=SHA): return { @@ -1653,7 +1888,32 @@ class TestReviewDecisionValidationGate(unittest.TestCase): } def setUp(self): - init_review_decision_lock("prgs", "review_pr") + import reviewer_pr_lease + + _init_reviewer_session("prgs") + self._lease_patch = _install_owned_reviewer_lease( + self.PR, head_sha=self.SHA, + ) + self._lease_patch.start() + self._auth_identity_patch = patch( + "mcp_server._authenticated_username", return_value="reviewer-bot" + ) + self._auth_identity_patch.start() + self.addCleanup(self._auth_identity_patch.stop) + self.addCleanup(self._lease_patch.stop) + self.addCleanup(reviewer_pr_lease.clear_session_lease) + 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._pr_lease_comments_patch.stop) + self.addCleanup(self._pr_work_lease_patch.stop) + def _env(self): return patch.dict(os.environ, { @@ -1748,8 +2008,32 @@ class TestSubmitPrReview(unittest.TestCase): """Gated review-mutation tool (#15).""" def setUp(self): - init_review_decision_lock("prgs", "review_pr") - gitea_mark_final_review_decision(8, "approve", remote="prgs") + import reviewer_pr_lease + + _init_reviewer_session("prgs") + 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() + gitea_mark_final_review_decision( + 8, "approve", remote="prgs", expected_head_sha="abc123", + ) + 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 { @@ -1909,10 +2193,11 @@ class TestSubmitPrReview(unittest.TestCase): @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) def test_comment_succeeds_when_review_eligible(self, _auth, mock_api): - gitea_mark_final_review_decision(8, "comment", remote="prgs") mock_api.side_effect = [ + {"login": "reviewer-bot"}, self._pr("author-bot"), {"login": "reviewer-bot"}, self._pr("author-bot"), {"id": 3}, ] + gitea_mark_final_review_decision(8, "comment", remote="prgs", expected_head_sha="abc123") env = {"GITEA_PROFILE_NAME": "gitea-reviewer", "GITEA_ALLOWED_OPERATIONS": "read,review"} with patch.dict(os.environ, env, clear=True): @@ -1928,12 +2213,15 @@ class TestSubmitPrReview(unittest.TestCase): with patch("mcp_server.get_auth_header", return_value=FAKE_AUTH), \ patch("mcp_server.api_request") as mock_api: mock_api.side_effect = [ + {"login": "jcwalker3"}, self._pr("jcwalker3"), {"login": "jcwalker3"}, self._pr("jcwalker3"), {"id": 4}, ] env = {"GITEA_PROFILE_NAME": "gitea-reviewer", "GITEA_ALLOWED_OPERATIONS": "read,review"} with patch.dict(os.environ, env, clear=True): - gitea_mark_final_review_decision(8, "comment", remote="prgs") + gitea_mark_final_review_decision( + 8, "comment", remote="prgs", expected_head_sha="abc123", + ) r = gitea_submit_pr_review( pr_number=8, action="comment", body="note", remote="prgs", final_review_decision_ready=True, @@ -1976,20 +2264,22 @@ class TestSubmitPrReview(unittest.TestCase): with patch("mcp_server.get_auth_header", return_value=FAKE_AUTH), \ patch("mcp_server.api_request") as mock_api: mock_api.side_effect = [ - {"login": "reviewer-bot"}, self._pr("author-bot", sha="abc123"), + {"login": "reviewer-bot"}, self._pr("author-bot", sha="deadbeef"), ] env = {"GITEA_PROFILE_NAME": "gitea-reviewer", "GITEA_ALLOWED_OPERATIONS": "read,review,approve"} with patch.dict(os.environ, env, clear=True): r = gitea_submit_pr_review( pr_number=8, action="approve", - expected_head_sha="deadbeef", remote="prgs", + expected_head_sha="abc123", remote="prgs", final_review_decision_ready=True, ) self.assertFalse(r["performed"]) - self.assertIn( - "expected head SHA does not match current PR head (fail closed)", - r["reasons"]) + self.assertTrue(any( + "expected head SHA does not match current PR head (fail closed)" in reason + or "PR head changed during lease" in reason + for reason in r["reasons"] + )) self._assert_no_mutation(mock_api) def test_head_sha_match_allows(self): @@ -2032,7 +2322,7 @@ class TestSubmitPrReview(unittest.TestCase): "GITEA_ALLOWED_OPERATIONS": "read,review,approve", "GITEA_TOKEN": "super-secret-token"} with patch.dict(os.environ, env, clear=True): - gitea_mark_final_review_decision(5, "approve", remote="prgs") + gitea_mark_final_review_decision(5, "approve", remote="prgs", expected_head_sha="abc123") r = gitea_submit_pr_review( pr_number=5, action="approve", remote="prgs", final_review_decision_ready=True, @@ -2052,9 +2342,8 @@ class TestSubmitPrReview(unittest.TestCase): env = {"GITEA_PROFILE_NAME": "gitea-reviewer", "GITEA_ALLOWED_OPERATIONS": "read,review,approve"} with patch.dict(os.environ, env, clear=True): - gitea_mark_final_review_decision(5, "approve", remote="prgs") r = gitea_submit_pr_review( - pr_number=5, action="approve", remote="prgs", + pr_number=8, action="approve", remote="prgs", final_review_decision_ready=True, ) self.assertFalse(r["performed"]) @@ -2141,8 +2430,8 @@ class TestSubmitPrReview(unittest.TestCase): os.remove(spoof_path) def test_mark_final_decision_rejects_remote_mismatch(self): - init_review_decision_lock("prgs", "review_pr") - r = gitea_mark_final_review_decision(8, "approve", remote="dadeschools") + _init_reviewer_session("prgs") + r = gitea_mark_final_review_decision(8, "approve", remote="dadeschools", expected_head_sha="abc123") self.assertFalse(r["marked_ready"]) self.assertTrue(any("does not match locked remote" in x for x in r["reasons"])) @@ -2154,28 +2443,30 @@ class TestSubmitPrReview(unittest.TestCase): {"id": 42, "state": "APPROVED"}, [_formal_review("reviewer-bot", "APPROVED", review_id=42)], {"login": "reviewer-bot"}, self._pr("author-bot"), + {"login": "reviewer-bot"}, self._pr("author-bot"), {"id": 43, "state": "REQUEST_CHANGES"}, [_formal_review("reviewer-bot", "REQUEST_CHANGES", review_id=43)], ] env = {"GITEA_PROFILE_NAME": "gitea-reviewer", "GITEA_ALLOWED_OPERATIONS": "read,review,approve,request_changes"} - gitea_mark_final_review_decision(8, "approve", remote="prgs") - with patch.dict(os.environ, env, clear=True): - first = gitea_submit_pr_review( - pr_number=8, action="approve", remote="prgs", - final_review_decision_ready=True, - ) - auth = gitea_authorize_review_correction( - prior_review_id=42, - prior_review_state="approve", - reason="operator approved correcting mistaken approve", - operator_authorized=True, - ) - _mark_request_changes_ready(remote="prgs") - second = gitea_submit_pr_review( - pr_number=8, action="request_changes", remote="prgs", - final_review_decision_ready=True, - ) + with patch("mcp_server.gitea_get_pr_review_feedback", + return_value=dict(_NO_BLOCKER_FEEDBACK)): + with patch.dict(os.environ, env, clear=True): + first = gitea_submit_pr_review( + pr_number=8, action="approve", remote="prgs", + final_review_decision_ready=True, + ) + auth = gitea_authorize_review_correction( + prior_review_id=42, + prior_review_state="approve", + reason="operator approved correcting mistaken approve", + operator_authorized=True, + ) + _mark_request_changes_ready(remote="prgs") + second = gitea_submit_pr_review( + pr_number=8, action="request_changes", remote="prgs", + final_review_decision_ready=True, + ) self.assertTrue(first["performed"]) self.assertTrue(auth["authorized"]) self.assertTrue(second["performed"]) @@ -2187,7 +2478,7 @@ class TestSubmitPrReview(unittest.TestCase): "GITEA_ALLOWED_OPERATIONS": "read,review,approve"} with patch.dict(os.environ, env, clear=True): # Mark decision for specific remote, org, repo - gitea_mark_final_review_decision(8, "approve", remote="prgs", org="MyOrg", repo="MyRepo") + gitea_mark_final_review_decision(8, "approve", remote="prgs", expected_head_sha="abc123", org="MyOrg", repo="MyRepo") # Mismatched remote r = gitea_submit_pr_review( @@ -2223,12 +2514,18 @@ if __name__ == "__main__": class TestTrackerHygieneCleanup(unittest.TestCase): def setUp(self): + mcp_server.gitea_load_review_workflow() self.mock_api = patch("mcp_server.api_request").start() self.mock_auth = patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start() patch("gitea_audit.audit_enabled", return_value=True).start() self.mock_audit = patch("gitea_audit.write_event").start() # gitea.pr.close: closing a PR via gitea_edit_pr is capability-gated (#216). patch("mcp_server.get_profile", return_value={"profile_name": "test", "allowed_operations": ["read", "merge", "edit", "close", "gitea.pr.close", "gitea.issue.close"], "audit_label": "test", "forbidden_operations": []}).start() + patch("mcp_server._list_pr_lease_comments", return_value=[]).start() + patch( + "mcp_server._pr_work_lease_reviewer_block", + return_value=dict(_NO_PR_WORK_LEASE_BLOCK), + ).start() def tearDown(self): patch.stopall() @@ -2273,16 +2570,24 @@ class TestTrackerHygieneCleanup(unittest.TestCase): self.assertEqual(res["cleanup_status"].get(1), "not present") def test_merge_pr_with_closes_removes_label(self): + import reviewer_pr_lease + + head_sha = FULL_HEAD_SHA + lease_patch = _install_owned_reviewer_lease(1, head_sha=head_sha) + lease_patch.start() + self.addCleanup(lease_patch.stop) + self.addCleanup(reviewer_pr_lease.clear_session_lease) + def api_side_effect(method, url, auth, payload=None): if method == "GET" and "/user" in url: return {"login": "merger"} if method == "GET" and url.endswith("/reviews"): - return [_formal_review("reviewer", "APPROVED", sha="sha123")] + return [_formal_review("reviewer", "APPROVED", sha=head_sha)] if method == "GET" and "pulls/1" in url and "/files" not in url: return { "user": {"login": "author"}, "state": "open", - "head": {"sha": "sha123", "ref": "feat/my-branch"}, + "head": {"sha": head_sha, "ref": "feat/my-branch"}, "base": {"ref": "main"}, "mergeable": True, "merged_commit_sha": "merge123", @@ -2302,21 +2607,32 @@ class TestTrackerHygieneCleanup(unittest.TestCase): return {} self.mock_api.side_effect = api_side_effect - res = gitea_merge_pr(pr_number=1, confirmation="MERGE PR 1", do="merge") + res = gitea_merge_pr( + pr_number=1, confirmation="MERGE PR 1", do="merge", + expected_head_sha=head_sha, + ) self.assertTrue(res["performed"]) self.assertEqual(res["cleanup_status"].get(123), "released") def test_merge_pr_with_branch_name_removes_label(self): + import reviewer_pr_lease + + head_sha = FULL_HEAD_SHA + lease_patch = _install_owned_reviewer_lease(1, head_sha=head_sha) + lease_patch.start() + self.addCleanup(lease_patch.stop) + self.addCleanup(reviewer_pr_lease.clear_session_lease) + def api_side_effect(method, url, auth, payload=None): if method == "GET" and "/user" in url: return {"login": "merger"} if method == "GET" and url.endswith("/reviews"): - return [_formal_review("reviewer", "APPROVED", sha="sha123")] + return [_formal_review("reviewer", "APPROVED", sha=head_sha)] if method == "GET" and "pulls/1" in url and "/files" not in url: return { "user": {"login": "author"}, "state": "open", - "head": {"sha": "sha123", "ref": "fix/issue-123-slug"}, + "head": {"sha": head_sha, "ref": "fix/issue-123-slug"}, "base": {"ref": "main"}, "mergeable": True, "merged_commit_sha": "merge123", @@ -2336,7 +2652,10 @@ class TestTrackerHygieneCleanup(unittest.TestCase): return {} self.mock_api.side_effect = api_side_effect - res = gitea_merge_pr(pr_number=1, confirmation="MERGE PR 1", do="merge") + res = gitea_merge_pr( + pr_number=1, confirmation="MERGE PR 1", do="merge", + expected_head_sha=head_sha, + ) self.assertTrue(res["performed"]) self.assertEqual(res["cleanup_status"].get(123), "released") @@ -2988,10 +3307,12 @@ class TestVerifyMutationAuthority(unittest.TestCase): # profile; the active profile resolves as reviewer — side-channel # override rejected even with a matching in-process authority. self._authority() - with patch.dict(os.environ, - {"GITEA_SESSION_PROFILE_LOCK": "prgs-author"}): - with self.assertRaises(RuntimeError) as ctx: - mcp_server.verify_mutation_authority("prgs") + with patch("mcp_server.gitea_config.is_runtime_switching_enabled", + return_value=False): + with patch.dict(os.environ, + {"GITEA_SESSION_PROFILE_LOCK": "prgs-author"}): + with self.assertRaises(RuntimeError) as ctx: + mcp_server.verify_mutation_authority("prgs") self.assertIn("side-channel override rejected", str(ctx.exception)) def test_foreign_pid_authority_is_not_trusted(self): @@ -3042,22 +3363,37 @@ class TestIssueLocking(unittest.TestCase): """Test issue locking and PR gating constraints.""" def setUp(self): - self._env_patcher = patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True) + 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() + self._dup_fetcher_patcher = patch( + "mcp_server.issue_duplicate_context_fetcher", + return_value=([], [], {"status": "not_claimed"}), + ) + self.mock_dup_fetcher = self._dup_fetcher_patcher.start() def tearDown(self): + self._dup_fetcher_patcher.stop() self._env_patcher.stop() - if os.path.exists(ISSUE_LOCK_FILE): - os.remove(ISSUE_LOCK_FILE) + self._lock_dir.cleanup() + + def _create_pr_env(self) -> dict: + return { + **CREATE_PR_ENV, + "GITEA_ISSUE_LOCK_DIR": self._lock_dir.name, + } @patch( "mcp_server.issue_lock_worktree.read_worktree_git_state", return_value=_clean_master_git_state_for_lock(), ) - @patch("mcp_server.api_get_all") + @patch("mcp_server.api_get_all", return_value=[]) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) - def test_lock_issue_success(self, _auth, mock_api, _git_state): - mock_api.return_value = [] # no open PRs + def test_lock_issue_success(self, _auth, _api, _git_state): res = gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs") self.assertTrue(res["success"]) self.assertEqual(res["work_lease"]["operation_type"], "author_issue_work") @@ -3067,9 +3403,8 @@ class TestIssueLocking(unittest.TestCase): self.assertIn("expires_at", res["work_lease"]) self.assertIn("last_heartbeat_at", res["work_lease"]) self.assertEqual(res["work_lease"]["claimant"]["profile"], "gitea-default") - self.assertTrue(os.path.exists(ISSUE_LOCK_FILE)) - with open(ISSUE_LOCK_FILE, encoding="utf-8") as f: - lock = json.load(f) + self.assertIn("lock_file_path", res) + lock = issue_lock_store.read_lock_file(res["lock_file_path"]) self.assertIn("worktree_path", lock) self.assertIn("work_lease", lock) @@ -3082,35 +3417,48 @@ class TestIssueLocking(unittest.TestCase): "mcp_server.issue_lock_worktree.read_worktree_git_state", return_value=_clean_master_git_state_for_lock(), ) - @patch("mcp_server.api_get_all") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) - def test_lock_issue_reused_by_open_pr_branch(self, _auth, mock_api, _git_state): - mock_api.return_value = [{ + def test_lock_issue_reused_by_open_pr_branch(self, _auth, _git_state): + self.mock_dup_fetcher.return_value = ([{ "number": 200, "head": {"ref": "feat/issue-196-boundary"}, "title": "Some PR", - "body": "No closes ref" - }] + "body": "No closes ref", + }], [], {"status": "not_claimed"}) with self.assertRaises(ValueError) as ctx: gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs") - self.assertIn("already tied to an open PR", str(ctx.exception)) + self.assertIn("open PR #200 already covers issue", str(ctx.exception)) @patch( "mcp_server.issue_lock_worktree.read_worktree_git_state", return_value=_clean_master_git_state_for_lock(), ) - @patch("mcp_server.api_get_all") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) - def test_lock_issue_reused_by_open_pr_closes_ref(self, _auth, mock_api, _git_state): - mock_api.return_value = [{ + def test_lock_issue_reused_by_open_pr_closes_ref(self, _auth, _git_state): + self.mock_dup_fetcher.return_value = ([{ "number": 200, "head": {"ref": "feat/other-branch"}, "title": "Some PR", - "body": "fixes #196" - }] + "body": "fixes #196", + }], [], {"status": "not_claimed"}) with self.assertRaises(ValueError) as ctx: gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs") - self.assertIn("already tied to an open PR", str(ctx.exception)) + self.assertIn("open PR #200 already covers issue", str(ctx.exception)) + + @patch( + "mcp_server.issue_lock_worktree.read_worktree_git_state", + return_value=_clean_master_git_state_for_lock(), + ) + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_lock_issue_reused_by_remote_branch(self, _auth, _git_state): + self.mock_dup_fetcher.return_value = ( + [], + ["feat/issue-196-existing-work"], + {"status": "not_claimed"}, + ) + with self.assertRaises(ValueError) as ctx: + gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs") + self.assertIn("remote branch(es) already match issue pattern", str(ctx.exception)) @patch( "mcp_server.issue_lock_worktree.read_worktree_git_state", @@ -3118,41 +3466,114 @@ class TestIssueLocking(unittest.TestCase): ) @patch("mcp_server.api_get_all") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) - def test_lock_issue_reused_by_remote_branch(self, _auth, mock_api, _git_state): - mock_api.side_effect = [ - [], - [{"name": "feat/issue-196-existing-work"}], - ] - with self.assertRaises(ValueError) as ctx: - gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs") - self.assertIn("already has matching branch", str(ctx.exception)) + def test_lock_issue_adopts_exact_own_branch(self, _auth, mock_api, _git_state): + branch = "feat/issue-196-mutations" + self.mock_dup_fetcher.return_value = ([], [branch], {"status": "not_claimed"}) + mock_api.return_value = [{"name": branch, "commit": {"id": "abc123"}}] + res = gitea_lock_issue(issue_number=196, branch_name=branch, remote="prgs") + self.assertTrue(res["success"]) + self.assertIn("adoption", res) + self.assertEqual(res["adoption"]["branch_head_commit"], "abc123") - def test_lock_issue_blocks_active_same_operation_lease(self): - with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f: - json.dump({ + @patch( + "mcp_server.issue_lock_worktree.read_worktree_git_state", + return_value=_clean_master_git_state_for_lock(), + ) + @patch("mcp_server.api_get_all") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_lock_issue_adoption_response_has_explicit_proof(self, _auth, mock_api, _git_state): + # #477 AC1: the live lock response must carry citable adoption proof. + branch = "feat/issue-196-mutations" + self.mock_dup_fetcher.return_value = ([], [branch], {"status": "not_claimed"}) + mock_api.return_value = [{"name": branch, "commit": {"id": "abc123"}}] + res = gitea_lock_issue(issue_number=196, branch_name=branch, remote="prgs") + proof = res["adoption"] + self.assertEqual(proof["adoption_decision"], "ADOPT") + self.assertTrue(proof["adopted"]) + self.assertEqual(proof["adopted_branch"], branch) + self.assertEqual(proof["adopted_branch_head"], "abc123") + self.assertEqual(proof["competing_branch_check"]["result"], "clear") + self.assertIn("gitea_create_pr", proof["safe_next_action"]) + self.assertIn("196", proof["matcher_summary"]) + + @patch( + "mcp_server.issue_lock_worktree.read_worktree_git_state", + return_value=_clean_master_git_state_for_lock(), + ) + @patch("mcp_server.api_get_all", return_value=[]) + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_lock_issue_no_match_response_does_not_claim_adoption(self, _auth, _api, _git_state): + # #477 AC2/AC3: a normal (NO_MATCH) lock must carry adoption-free proof + # and must NOT expose an ``adoption`` block. + res = gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs") + self.assertTrue(res["success"]) + self.assertNotIn("adoption", res) + check = res["adoption_check"] + self.assertEqual(check["adoption_decision"], "NO_MATCH") + self.assertFalse(check["adopted"]) + self.assertIsNone(check["adopted_branch"]) + self.assertEqual(check["competing_branch_check"]["result"], "clear") + + @patch( + "mcp_server.issue_lock_worktree.read_worktree_git_state", + return_value=_clean_master_git_state_for_lock(), + ) + @patch("mcp_server.api_get_all", return_value=[]) + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_lock_issue_blocks_active_same_operation_lease(self, _auth, _api, _git_state): + prgs_repo = mcp_server.REMOTES["prgs"]["repo"] + issue_lock_store.save_lock_file( + issue_lock_store.lock_file_path( + remote="prgs", + org="Scaled-Tech-Consulting", + repo=prgs_repo, + issue_number=196, + ), + { "issue_number": 196, "branch_name": "feat/issue-196-other-work", + "remote": "prgs", + "org": "Scaled-Tech-Consulting", + "repo": prgs_repo, "worktree_path": "/tmp/other-worktree", "work_lease": { "operation_type": "author_issue_work", "expires_at": "2999-01-01T00:00:00Z", }, - }, f) + }, + ) with self.assertRaises(RuntimeError) as ctx: gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs") self.assertIn("already has an active author_issue_work lease", str(ctx.exception)) - def test_lock_issue_blocks_expired_same_operation_lease_for_recovery(self): - with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f: - json.dump({ + @patch( + "mcp_server.issue_lock_worktree.read_worktree_git_state", + return_value=_clean_master_git_state_for_lock(), + ) + @patch("mcp_server.api_get_all", return_value=[]) + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_lock_issue_blocks_expired_same_operation_lease_for_recovery(self, _auth, _api, _git_state): + prgs_repo = mcp_server.REMOTES["prgs"]["repo"] + issue_lock_store.save_lock_file( + issue_lock_store.lock_file_path( + remote="prgs", + org="Scaled-Tech-Consulting", + repo=prgs_repo, + issue_number=196, + ), + { "issue_number": 196, "branch_name": "feat/issue-196-other-work", + "remote": "prgs", + "org": "Scaled-Tech-Consulting", + "repo": prgs_repo, "worktree_path": "/tmp/other-worktree", "work_lease": { "operation_type": "author_issue_work", "expires_at": "2000-01-01T00:00:00Z", }, - }, f) + }, + ) with self.assertRaises(RuntimeError) as ctx: gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs") self.assertIn("Recovery review is required before takeover", str(ctx.exception)) @@ -3219,9 +3640,7 @@ class TestIssueLocking(unittest.TestCase): return_value=(True, [])) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) def test_create_pr_missing_lock_fails(self, _auth, _role): - if os.path.exists(ISSUE_LOCK_FILE): - os.remove(ISSUE_LOCK_FILE) - with patch.dict(os.environ, CREATE_PR_ENV, clear=True): + with patch.dict(os.environ, self._create_pr_env(), clear=True): with self.assertRaises(RuntimeError) as ctx: gitea_create_pr(title="feat: X Closes #196", head="feat/issue-196-mutations", remote="prgs") self.assertIn("Issue lock is missing", str(ctx.exception)) @@ -3230,37 +3649,64 @@ class TestIssueLocking(unittest.TestCase): return_value=(True, [])) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) def test_create_pr_branch_mismatch_fails(self, _auth, _role): - with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f: - json.dump(_sample_issue_lock( - issue_number=196, branch_name="feat/issue-196-mutations"), f) - with patch.dict(os.environ, CREATE_PR_ENV, clear=True): + worktree = os.path.realpath(os.getcwd()) + _bind_test_lock( + issue_number=196, + branch_name="feat/issue-196-mutations", + remote="prgs", + worktree_path=worktree, + ) + with patch.dict(os.environ, self._create_pr_env(), clear=True): with self.assertRaises(ValueError) as ctx: - gitea_create_pr(title="feat: X Closes #196", head="feat/issue-196-different", remote="prgs") + gitea_create_pr( + title="feat: X Closes #196", + head="feat/issue-196-different", + remote="prgs", + worktree_path=worktree, + ) self.assertIn("does not match locked branch", str(ctx.exception)) @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, [])) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) def test_create_pr_forbidden_terms_fails(self, _auth, _role): - with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f: - json.dump(_sample_issue_lock( - issue_number=196, branch_name="feat/issue-196-mutations"), f) - with patch.dict(os.environ, CREATE_PR_ENV, clear=True): + worktree = os.path.realpath(os.getcwd()) + _bind_test_lock( + issue_number=196, + branch_name="feat/issue-196-mutations", + remote="prgs", + worktree_path=worktree, + ) + with patch.dict(os.environ, self._create_pr_env(), clear=True): for term in ("equivalent to #196", "related to #196", "same as #196"): with self.assertRaises(ValueError) as ctx: - gitea_create_pr(title=f"feat: X {term}", head="feat/issue-196-mutations", remote="prgs") + gitea_create_pr( + title=f"feat: X {term}", + head="feat/issue-196-mutations", + remote="prgs", + worktree_path=worktree, + ) self.assertIn("contains forbidden term", str(ctx.exception)) @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, [])) @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) def test_create_pr_missing_closes_ref_fails(self, _auth, _role): - with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f: - json.dump(_sample_issue_lock( - issue_number=196, branch_name="feat/issue-196-mutations"), f) - with patch.dict(os.environ, CREATE_PR_ENV, clear=True): + worktree = os.path.realpath(os.getcwd()) + _bind_test_lock( + issue_number=196, + branch_name="feat/issue-196-mutations", + remote="prgs", + worktree_path=worktree, + ) + with patch.dict(os.environ, self._create_pr_env(), clear=True): with self.assertRaises(ValueError) as ctx: - gitea_create_pr(title="feat: X refs #196", head="feat/issue-196-mutations", remote="prgs") + gitea_create_pr( + title="feat: X refs #196", + head="feat/issue-196-mutations", + remote="prgs", + worktree_path=worktree, + ) self.assertIn("must contain 'Closes #196' or 'Fixes #196' exactly", str(ctx.exception)) @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", @@ -3268,13 +3714,13 @@ class TestIssueLocking(unittest.TestCase): @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) def test_create_pr_worktree_mismatch_fails(self, _auth, _role): scratch = os.path.realpath("/tmp/gitea-tools-author-scratch/issue-249-pr") - with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f: - json.dump(_sample_issue_lock( - issue_number=249, - branch_name="feat/issue-249-issue-lock-scratch-worktree", - worktree_path=scratch, - ), f) - with patch.dict(os.environ, CREATE_PR_ENV, clear=True): + _bind_test_lock( + issue_number=249, + branch_name="feat/issue-249-issue-lock-scratch-worktree", + worktree_path=scratch, + remote="prgs", + ) + with patch.dict(os.environ, self._create_pr_env(), clear=True): with self.assertRaises(ValueError) as ctx: gitea_create_pr( title="feat: lock scratch worktree Closes #249", @@ -3284,6 +3730,41 @@ class TestIssueLocking(unittest.TestCase): ) self.assertIn("does not match locked worktree", str(ctx.exception)) + @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", + return_value=(True, [])) + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_create_pr_manual_lock_seed_blocked(self, _auth, _role): + worktree = os.path.realpath(os.getcwd()) + with tempfile.TemporaryDirectory() as lock_dir: + env = {**self._create_pr_env(), "GITEA_ISSUE_LOCK_DIR": lock_dir} + with patch.dict(os.environ, env, clear=True): + issue_lock_store.save_lock_file( + issue_lock_store.lock_file_path( + remote="prgs", + org="Scaled-Tech-Consulting", + repo=mcp_server.REMOTES["prgs"]["repo"], + issue_number=447, + lock_dir=lock_dir, + ), + _sample_issue_lock( + issue_number=447, + branch_name="feat/issue-447-lock-provenance", + remote="prgs", + org="Scaled-Tech-Consulting", + repo=mcp_server.REMOTES["prgs"]["repo"], + worktree_path=worktree, + lock_provenance=None, + ), + ) + with self.assertRaises(RuntimeError) as ctx: + gitea_create_pr( + title="feat: lock provenance Closes #447", + head="feat/issue-447-lock-provenance", + remote="prgs", + worktree_path=worktree, + ) + self.assertIn("lock provenance", str(ctx.exception).lower()) + @patch("mcp_server.api_request") @patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, [])) @@ -3291,13 +3772,13 @@ class TestIssueLocking(unittest.TestCase): def test_create_pr_honors_scratch_worktree_lock(self, _auth, _role, mock_api): scratch = os.path.realpath("/tmp/gitea-tools-author-scratch/issue-249-e2e") mock_api.return_value = {"number": 250, "html_url": "https://example/pr/250"} - with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f: - json.dump(_sample_issue_lock( - issue_number=249, - branch_name="feat/issue-249-issue-lock-scratch-worktree", - worktree_path=scratch, - ), f) - with patch.dict(os.environ, CREATE_PR_ENV, clear=True): + _bind_test_lock( + issue_number=249, + branch_name="feat/issue-249-issue-lock-scratch-worktree", + worktree_path=scratch, + remote="prgs", + ) + with patch.dict(os.environ, self._create_pr_env(), clear=True): res = gitea_create_pr( title="feat: issue-lock scratch worktree Closes #249", head="feat/issue-249-issue-lock-scratch-worktree", @@ -3413,7 +3894,7 @@ class TestPreflightVerification(unittest.TestCase): os.environ["GITEA_TEST_PORCELAIN"] = " M reviewer_edit.py\n" with self.assertRaises(RuntimeError) as ctx: mcp_server.verify_preflight_purity() - self.assertIn("Reviewer profile is forbidden from modifying tracked workspace files", str(ctx.exception)) + self.assertIn("forbidden from modifying tracked workspace files", str(ctx.exception)) self.assertIn("reviewer_edit.py", str(ctx.exception)) # Foreign pre-existing dirty state does not block when unchanged. @@ -3479,7 +3960,172 @@ class TestPreflightVerification(unittest.TestCase): with self.assertRaises(RuntimeError) as ctx: mcp_server.verify_preflight_purity(worktree_path=worktree) msg = str(ctx.exception) - self.assertIn("active task workspace root", msg) - self.assertIn("inspected git root", msg) - self.assertIn("dirty files: task_file.py", msg) - self.assertIn("dirty scope:", msg) + self.assertIn("resolved workspace", msg) + self.assertIn(worktree, msg) + self.assertIn("worktree_path argument", msg) + self.assertIn("task_file.py", msg) + self.assertIn("author namespace", msg) + + +class TestIssue546Deadlock(unittest.TestCase): + def setUp(self): + import reviewer_pr_lease + import review_workflow_load + import mcp_server + reviewer_pr_lease.clear_session_lease() + review_workflow_load.clear_review_workflow_load() + mcp_server._preflight_capability_called = False + mcp_server._preflight_resolved_role = None + mcp_server._REVIEW_DECISION_LOCK = None + + self.auth_user_patch = patch("mcp_server._authenticated_username", return_value="reviewer-bot") + self.auth_user_patch.start() + self.addCleanup(self.auth_user_patch.stop) + + self.verify_purity_patch = patch("mcp_server.verify_preflight_purity", return_value=None) + self.verify_purity_patch.start() + self.addCleanup(self.verify_purity_patch.stop) + + self.verify_workspace_patch = patch("mcp_server._verify_role_mutation_workspace", return_value="/workspace") + self.verify_workspace_patch.start() + self.addCleanup(self.verify_workspace_patch.stop) + + self.get_profile_patch = patch("mcp_server.get_profile", return_value={ + "profile_name": "prgs-reviewer", + "allowed_operations": ["gitea.read", "gitea.pr.comment", "gitea.pr.review", "gitea.pr.approve"], + "forbidden_operations": [], + }) + self.get_profile_patch.start() + self.addCleanup(self.get_profile_patch.stop) + + 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.pr_work_lease_patch.stop) + + self.auth_header_patch = patch("mcp_server.get_auth_header", return_value="token test") + self.auth_header_patch.start() + self.addCleanup(self.auth_header_patch.stop) + + def test_workflow_no_deadlock(self): + import mcp_server + import reviewer_pr_lease + + # 1. Resolve capability + res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs") + self.assertTrue(res["allowed_in_current_session"]) + + # 2. Load review workflow + res = mcp_server.gitea_load_review_workflow() + self.assertTrue(res["success"]) + + # Mock API requests for lease comments and PR retrieval + comments = [] + def mock_api_side(method, url, auth, data=None): + if "/api/v1/user" in url: + return {"login": "reviewer-bot"} + if "/pulls/" in url: + if "/reviews" in url: + if method == "POST": + return {"id": 2001, "state": "APPROVED"} + return [{"id": 2001, "user": {"login": "reviewer-bot"}, "state": "APPROVED", "commit_id": "abc123"}] + return {"user": {"login": "author-user"}, "state": "open", "head": {"sha": "abc123"}, "mergeable": True} + if "/comments" in url: + if method == "POST": + comments.append({"id": 1001, "body": data["body"], "user": {"login": "reviewer-bot"}}) + return {"id": 1001} + return comments + return {} + + with patch("mcp_server.api_request", side_effect=mock_api_side): + # 3. Acquire reviewer lease + res = mcp_server.gitea_acquire_reviewer_pr_lease( + pr_number=550, + worktree="/workspace", + candidate_head="abc123", + remote="prgs", + ) + self.assertTrue(res["success"]) + self.assertEqual(res["comment_id"], 1001) + + # verify lease is recorded in session + session_lease = reviewer_pr_lease.get_session_lease() + self.assertIsNotNone(session_lease) + self.assertEqual(session_lease["pr_number"], 550) + + # 4. Resolve capability again (simulating subsequent tool call preflight check/token refresh) + # This should NOT clear the session lease or decision lock! + mcp_server._preflight_capability_called = False # simulate clearance from prior mutation + res = mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs") + self.assertTrue(res["allowed_in_current_session"]) + self.assertIsNotNone(reviewer_pr_lease.get_session_lease()) # Preserved! + + # 5. Mark final review decision APPROVED + res = mcp_server.gitea_mark_final_review_decision( + pr_number=550, + action="approve", + expected_head_sha="abc123", + remote="prgs", + ) + self.assertTrue(res["marked_ready"]) + + # 6. Submit review (should succeed without deadlock) + res = mcp_server.gitea_submit_pr_review( + pr_number=550, + action="approve", + body="APPROVED", + expected_head_sha="abc123", + final_review_decision_ready=True, + remote="prgs", + worktree_path="/workspace", + ) + self.assertTrue(res["performed"]) + + def test_release_reviewer_pr_lease(self): + import mcp_server + import reviewer_pr_lease + + # Resolve capability and load workflow + mcp_server.gitea_resolve_task_capability(task="review_pr", remote="prgs") + mcp_server.gitea_load_review_workflow() + + comments = [_reviewer_lease_comment(550, session_id=_DEFAULT_LEASE_SESSION, head_sha="abc123")] + posted_comments = [] + + def mock_api_side(method, url, auth, data=None): + if "/api/v1/user" in url: + return {"login": "reviewer-bot"} + if "/pulls/" in url: + return {"user": {"login": "author-user"}, "state": "open", "head": {"sha": "abc123"}, "mergeable": True} + if "/comments" in url: + if method == "POST": + new_c = {"id": 1002, "body": data["body"], "user": {"login": "reviewer-bot"}} + comments.append(new_c) + posted_comments.append(new_c) + return {"id": 1002} + return comments + return {} + + # Set session lease in memory + import merger_lease_adoption as mla + reviewer_pr_lease.record_session_lease({ + "pr_number": 550, + "session_id": _DEFAULT_LEASE_SESSION, + "candidate_head": "abc123", + "target_branch": "master", + "comment_id": 9001, + }, lease_provenance=mla.build_lease_provenance(source=mla.SOURCE_ACQUIRE, comment_id=9001)) + + with patch("mcp_server.api_request", side_effect=mock_api_side): + # Release lease + res = mcp_server.gitea_release_reviewer_pr_lease(pr_number=550, remote="prgs") + self.assertTrue(res["success"]) + self.assertTrue(res["released"]) + self.assertEqual(res["comment_id"], 1002) + + # verify lease is cleared in session + self.assertIsNone(reviewer_pr_lease.get_session_lease()) + # verify comment phase is released + self.assertIn("phase: released", posted_comments[0]["body"]) diff --git a/tests/test_mcp_session_state.py b/tests/test_mcp_session_state.py new file mode 100644 index 0000000..ebfc0ce --- /dev/null +++ b/tests/test_mcp_session_state.py @@ -0,0 +1,207 @@ +"""Tests for durable MCP session state shared across daemon processes (#559).""" + +from __future__ import annotations + +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +sys_path_root = str(Path(__file__).resolve().parent.parent) +import sys + +if sys_path_root not in sys.path: + sys.path.insert(0, sys_path_root) + +import mcp_session_state +import review_workflow_load +import mcp_server + + +class TestMcpSessionStateStore(unittest.TestCase): + def setUp(self): + self._tmpdir = tempfile.TemporaryDirectory() + self.state_dir = self._tmpdir.name + self._env = patch.dict( + os.environ, + { + mcp_session_state.STATE_DIR_ENV: self.state_dir, + mcp_session_state.SESSION_PROFILE_LOCK_ENV: "prgs-reviewer", + "GITEA_MCP_PROFILE": "prgs-reviewer", + }, + clear=False, + ) + self._env.start() + + def tearDown(self): + self._env.stop() + self._tmpdir.cleanup() + + def test_round_trip_same_identity(self): + saved = mcp_session_state.save_state( + kind=mcp_session_state.KIND_WORKFLOW_LOAD, + payload={"loaded": True, "workflow_hash": "abc123def456"}, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + ) + self.assertIsNotNone(saved) + self.assertEqual(saved["workflow_hash"], "abc123def456") + self.assertIn("recorded_at", saved) + + loaded = mcp_session_state.load_state( + kind=mcp_session_state.KIND_WORKFLOW_LOAD, + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + ) + self.assertIsNotNone(loaded) + self.assertEqual(loaded["workflow_hash"], "abc123def456") + self.assertEqual(loaded["profile_identity"], "prgs-reviewer") + + def test_profile_mismatch_returns_none(self): + mcp_session_state.save_state( + kind=mcp_session_state.KIND_DECISION_LOCK, + payload={"final_review_decision_ready": True, "remote": "prgs"}, + remote="prgs", + ) + with patch.dict( + os.environ, + {mcp_session_state.SESSION_PROFILE_LOCK_ENV: "prgs-author"}, + clear=False, + ): + loaded = mcp_session_state.load_state( + kind=mcp_session_state.KIND_DECISION_LOCK, + remote="prgs", + ) + self.assertIsNone(loaded) + + def test_clear_removes_file(self): + mcp_session_state.save_state( + kind=mcp_session_state.KIND_WORKFLOW_LOAD, + payload={"loaded": True}, + remote="prgs", + ) + path = mcp_session_state.state_file_path( + kind=mcp_session_state.KIND_WORKFLOW_LOAD, + remote="prgs", + profile_identity="prgs-reviewer", + state_dir=self.state_dir, + ) + self.assertTrue(os.path.exists(path)) + mcp_session_state.clear_state( + kind=mcp_session_state.KIND_WORKFLOW_LOAD, + remote="prgs", + profile_identity="prgs-reviewer", + ) + self.assertFalse(os.path.exists(path)) + + def test_files_are_private_mode(self): + mcp_session_state.save_state( + kind=mcp_session_state.KIND_WORKFLOW_LOAD, + payload={"loaded": True}, + remote="prgs", + ) + path = mcp_session_state.state_file_path( + kind=mcp_session_state.KIND_WORKFLOW_LOAD, + remote="prgs", + profile_identity="prgs-reviewer", + state_dir=self.state_dir, + ) + mode = os.stat(path).st_mode & 0o777 + self.assertEqual(mode, 0o600) + + +class TestWorkflowLoadCrossProcess(unittest.TestCase): + def setUp(self): + self._tmpdir = tempfile.TemporaryDirectory() + self._env = patch.dict( + os.environ, + { + mcp_session_state.STATE_DIR_ENV: self._tmpdir.name, + mcp_session_state.SESSION_PROFILE_LOCK_ENV: "prgs-reviewer", + "GITEA_MCP_PROFILE": "prgs-reviewer", + "GITEA_MCP_REMOTE": "prgs", + }, + clear=False, + ) + self._env.start() + review_workflow_load.clear_review_workflow_load() + + def tearDown(self): + review_workflow_load.clear_review_workflow_load() + self._env.stop() + self._tmpdir.cleanup() + + def test_different_pid_same_profile_accepted(self): + root = str(Path(__file__).resolve().parent.parent) + recorded = review_workflow_load.record_review_workflow_load(root) + self.assertTrue(recorded["loaded"]) + + # Simulate another daemon process: clear memory, keep durable file, + # and change apparent PID identity only (profile remains the same). + review_workflow_load._REVIEW_WORKFLOW_LOAD = None + status = review_workflow_load.workflow_load_status(root) + self.assertTrue(status["workflow_load_proof_present"]) + self.assertTrue( + status["workflow_load_valid"], + msg=status.get("reasons"), + ) + + def test_profile_mismatch_blocks(self): + root = str(Path(__file__).resolve().parent.parent) + review_workflow_load.record_review_workflow_load(root) + review_workflow_load._REVIEW_WORKFLOW_LOAD = None + with patch.dict( + os.environ, + {mcp_session_state.SESSION_PROFILE_LOCK_ENV: "prgs-author"}, + clear=False, + ): + # Durable load uses active identity; mismatched profile key misses. + status = review_workflow_load.workflow_load_status(root) + self.assertFalse(status["workflow_load_proof_present"]) + + +class TestDecisionLockCrossProcess(unittest.TestCase): + def setUp(self): + self._tmpdir = tempfile.TemporaryDirectory() + self._env = patch.dict( + os.environ, + { + mcp_session_state.STATE_DIR_ENV: self._tmpdir.name, + mcp_session_state.SESSION_PROFILE_LOCK_ENV: "prgs-reviewer", + }, + clear=False, + ) + self._env.start() + mcp_server._save_review_decision_lock(None) + review_workflow_load.clear_review_workflow_load() + + def tearDown(self): + mcp_server._save_review_decision_lock(None) + review_workflow_load.clear_review_workflow_load() + self._env.stop() + self._tmpdir.cleanup() + + def test_decision_lock_survives_memory_clear(self): + with patch.object(mcp_server, "get_profile", return_value={ + "profile_name": "prgs-reviewer", + }): + mcp_server.init_review_decision_lock("prgs", "review_pr", force=True) + lock = mcp_server._load_review_decision_lock() + self.assertIsNotNone(lock) + self.assertEqual(lock["remote"], "prgs") + self.assertFalse(lock["final_review_decision_ready"]) + + # New process: memory empty, durable state remains. + mcp_server._REVIEW_DECISION_LOCK = None + restored = mcp_server._load_review_decision_lock() + self.assertIsNotNone(restored) + self.assertEqual(restored["remote"], "prgs") + reasons = mcp_server._review_decision_session_reasons(restored) + self.assertEqual(reasons, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mcp_stale_runtime.py b/tests/test_mcp_stale_runtime.py new file mode 100644 index 0000000..19310c2 --- /dev/null +++ b/tests/test_mcp_stale_runtime.py @@ -0,0 +1,71 @@ +import os +import unittest +from unittest.mock import patch, MagicMock +from datetime import datetime + +import gitea_mcp_server + +class TestMcpStaleRuntime(unittest.TestCase): + @patch("subprocess.run") + @patch("os.path.getmtime") + @patch("os.path.exists") + @patch("os.getpid") + @patch.dict("os.environ", {"GITEA_FORCE_MCP_RUNTIME_CHECK": "1"}) + def test_stale_and_missing_runtimes(self, mock_getpid, mock_exists, mock_getmtime, mock_run): + # Setup mocks + mock_getpid.return_value = 12345 + mock_exists.return_value = True + + # Code modification time: Jul 8 2026, 14:00:00 + code_time = datetime(2026, 7, 8, 14, 0, 0) + mock_getmtime.return_value = code_time.timestamp() + + # Mock ps -ax output + # PID 12345 is self (started at 13:00:00 - stale) + # PID 54321 is prgs-author (started at 15:00:00 - fresh) + # prgs-reviewer is missing + ps_output = ( + " PID LSTART COMMAND\n" + "12345 Wed Jul 8 13:00:00 2026 /path/to/python mcp_server.py\n" + "54321 Wed Jul 8 15:00:00 2026 /path/to/python mcp_server.py\n" + ) + + mock_run_ps = MagicMock() + mock_run_ps.stdout = ps_output + + # Mock env output for ps eww + mock_run_env12345 = MagicMock() + mock_run_env12345.stdout = "GITEA_MCP_PROFILE=prgs-reconciler" + + mock_run_env54321 = MagicMock() + mock_run_env54321.stdout = "GITEA_MCP_PROFILE=prgs-author" + + def side_effect(args, **kwargs): + if args[0] == "ps" and "eww" in args: + pid = args[2] + if pid == "12345": + return mock_run_env12345 + elif pid == "54321": + return mock_run_env54321 + elif args[0] == "ps": + return mock_run_ps + raise ValueError(f"Unexpected subprocess run args: {args}") + + mock_run.side_effect = side_effect + + # Test 1: required reviewer role matching prgs-reviewer (which is missing) + reasons = gitea_mcp_server._check_mcp_runtimes_diagnostics("review_pr", ["prgs-reviewer"]) + + self.assertTrue(any("stale-runtime: The active Gitea MCP server process is stale" in r for r in reasons)) + self.assertTrue(any("stale-runtime: None of the matching profiles for task" in r for r in reasons)) + + # Test 2: required author role matching prgs-author (which is fresh) + reasons_author = gitea_mcp_server._check_mcp_runtimes_diagnostics("create_issue", ["prgs-author"]) + + # Still contains self stale error + self.assertTrue(any("stale-runtime: The active Gitea MCP server process is stale" in r for r in reasons_author)) + # But does NOT contain missing author profile error + self.assertFalse(any("None of the matching profiles for task" in r for r in reasons_author)) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_merge_approval_gate.py b/tests/test_merge_approval_gate.py new file mode 100644 index 0000000..94f11ac --- /dev/null +++ b/tests/test_merge_approval_gate.py @@ -0,0 +1,59 @@ +"""Hermetic tests for merge approval head pinning (#471).""" +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from merge_approval_gate import assess_merge_approval_head # noqa: E402 + +HEAD_OLD = "8b61c4b41f1b49b271ed3b99657431cf06eeda3e" +HEAD_NEW = "3e4b721d60e97147ba0704773cf57cd0d42cbe31" + + +class TestMergeApprovalGate(unittest.TestCase): + def test_fresh_approval_at_current_head(self): + result = assess_merge_approval_head( + current_head_sha=HEAD_NEW, + latest_by_reviewer={ + "reviewer1": { + "verdict": "APPROVED", + "dismissed": False, + "reviewed_head_sha": HEAD_NEW, + "submitted_at": "2026-07-06T12:00:00Z", + } + }, + ) + self.assertTrue(result["approval_at_current_head"]) + self.assertIsNone(result["stale_approval_block_reason"]) + + def test_stale_approval_after_rebase(self): + result = assess_merge_approval_head( + current_head_sha=HEAD_NEW, + latest_by_reviewer={ + "reviewer1": { + "verdict": "APPROVED", + "dismissed": False, + "reviewed_head_sha": HEAD_OLD, + "submitted_at": "2026-07-06T10:00:00Z", + } + }, + ) + self.assertFalse(result["approval_at_current_head"]) + self.assertEqual(result["latest_approved_head_sha"], HEAD_OLD) + self.assertIn("stale approval", result["stale_approval_block_reason"]) + self.assertIn(HEAD_OLD, result["stale_approval_block_reason"]) + self.assertIn(HEAD_NEW, result["stale_approval_block_reason"]) + self.assertIn("re-review PR at current head", result["stale_approval_block_reason"]) + + def test_no_approval_entries(self): + result = assess_merge_approval_head( + current_head_sha=HEAD_NEW, + latest_by_reviewer={}, + ) + self.assertFalse(result["approval_at_current_head"]) + self.assertIsNone(result["latest_approved_head_sha"]) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_merger_lease_adoption.py b/tests/test_merger_lease_adoption.py new file mode 100644 index 0000000..8cd681d --- /dev/null +++ b/tests/test_merger_lease_adoption.py @@ -0,0 +1,431 @@ +"""Merger lease adoption / recovery (#536).""" + +import os +import sys +import unittest +from datetime import datetime, timezone +from unittest.mock import patch + +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) + +import merger_lease_adoption as mla +import reviewer_pr_lease as leases + + +def _lease_comment( + pr_number: int, + session_id: str, + *, + phase: str = "claimed", + profile: str = "prgs-reviewer", + identity: str = "sysadmin", + candidate_head: str = "a" * 40, + comment_id: int = 100, +) -> dict: + body = leases.format_lease_body( + repo="Scaled-Tech-Consulting/Gitea-Tools", + pr_number=pr_number, + issue_number=536, + reviewer_identity=identity, + profile=profile, + session_id=session_id, + worktree="branches/review-pr536", + phase=phase, + candidate_head=candidate_head, + target_branch="master", + target_branch_sha="b" * 40, + last_activity=datetime.now(timezone.utc), + ) + return {"id": comment_id, "body": body, "user": {"login": identity}} + + +HEAD = "f" * 40 + + +class TestSanctionedProvenance(unittest.TestCase): + def setUp(self): + leases.clear_session_lease() + + def test_manual_session_seed_rejected_by_mutation_gate(self): + comments = [_lease_comment(536, "reviewer-session", candidate_head=HEAD)] + leases.record_session_lease({ + "pr_number": 536, + "session_id": "merger-session", + "candidate_head": HEAD, + "comment_id": 999, + }) + result = leases.assess_mutation_lease_gate( + pr_number=536, + comments=comments, + reviewer_identity="sysadmin", + session_id="merger-session", + mutation="merge", + live_head_sha=HEAD, + pinned_head_sha=HEAD, + ) + self.assertTrue(result["block"]) + self.assertTrue( + any("sanctioned provenance" in r for r in result["reasons"]) + ) + + def test_sanctioned_acquire_provenance_allows_mutation(self): + comments = [_lease_comment(536, "my-session", candidate_head=HEAD)] + leases.record_session_lease( + { + "pr_number": 536, + "session_id": "my-session", + "candidate_head": HEAD, + "comment_id": 101, + }, + lease_provenance=mla.build_lease_provenance( + source=mla.SOURCE_ACQUIRE, + comment_id=101, + ), + ) + result = leases.assess_mutation_lease_gate( + pr_number=536, + comments=comments, + reviewer_identity="sysadmin", + session_id="my-session", + mutation="merge", + live_head_sha=HEAD, + pinned_head_sha=HEAD, + ) + self.assertFalse(result["block"]) + + +class TestAdoptAssessment(unittest.TestCase): + def test_adopt_allowed_for_merger_with_reviewer_lease_and_approval(self): + comments = [_lease_comment(536, "reviewer-sess", candidate_head=HEAD)] + result = mla.assess_adopt_merger_lease( + comments, + pr_number=536, + adopter_identity="sysadmin", + adopter_profile="prgs-merger", + adopter_session_id="merger-sess", + repo="Scaled-Tech-Consulting/Gitea-Tools", + issue_number=536, + worktree="branches/merge-pr536", + expected_head_sha=HEAD, + live_head_sha=HEAD, + approval_at_head=True, + pr_open=True, + ) + self.assertTrue(result["adopt_allowed"]) + self.assertIn(mla.ADOPTION_MARKER, result["adoption_body"]) + self.assertIn("adopted_from_session_id: reviewer-sess", result["adoption_body"]) + + def test_adopt_blocked_without_approval(self): + comments = [_lease_comment(536, "reviewer-sess", candidate_head=HEAD)] + result = mla.assess_adopt_merger_lease( + comments, + pr_number=536, + adopter_identity="sysadmin", + adopter_profile="prgs-merger", + adopter_session_id="merger-sess", + repo="Scaled-Tech-Consulting/Gitea-Tools", + issue_number=536, + worktree="branches/merge-pr536", + expected_head_sha=HEAD, + live_head_sha=HEAD, + approval_at_head=False, + pr_open=True, + ) + self.assertFalse(result["adopt_allowed"]) + self.assertTrue(any("APPROVED" in r for r in result["reasons"])) + + def test_adopt_blocked_for_non_merger_profile(self): + comments = [_lease_comment(536, "reviewer-sess", candidate_head=HEAD)] + result = mla.assess_adopt_merger_lease( + comments, + pr_number=536, + adopter_identity="sysadmin", + adopter_profile="prgs-reviewer", + adopter_session_id="merger-sess", + repo="Scaled-Tech-Consulting/Gitea-Tools", + issue_number=536, + worktree="branches/review-pr536", + expected_head_sha=HEAD, + live_head_sha=HEAD, + approval_at_head=True, + pr_open=True, + ) + self.assertFalse(result["adopt_allowed"]) + self.assertTrue(any("merger profile" in r for r in result["reasons"])) + + def test_adopt_blocked_when_pr_not_open(self): + comments = [_lease_comment(536, "reviewer-sess", candidate_head=HEAD)] + result = mla.assess_adopt_merger_lease( + comments, + pr_number=536, + adopter_identity="sysadmin", + adopter_profile="prgs-merger", + adopter_session_id="merger-sess", + repo="Scaled-Tech-Consulting/Gitea-Tools", + issue_number=536, + worktree="branches/merge-pr536", + expected_head_sha=HEAD, + live_head_sha=HEAD, + approval_at_head=True, + pr_open=False, + ) + self.assertFalse(result["adopt_allowed"]) + + +class TestMergerHandoffMutationGate(unittest.TestCase): + def setUp(self): + leases.clear_session_lease() + + def test_cross_session_merge_blocked_without_adoption_comment(self): + reviewer_comment = _lease_comment(536, "reviewer-sess", candidate_head=HEAD) + provenance = mla.build_lease_provenance( + source=mla.SOURCE_ADOPT, + comment_id=7001, + adopted_from_session_id="reviewer-sess", + ) + leases.record_session_lease( + { + "pr_number": 536, + "session_id": "merger-sess", + "candidate_head": HEAD, + "comment_id": 7001, + "phase": "adopted", + }, + lease_provenance=provenance, + ) + result = leases.assess_mutation_lease_gate( + pr_number=536, + comments=[reviewer_comment], + reviewer_identity="sysadmin", + session_id="merger-sess", + mutation="merge", + live_head_sha=HEAD, + pinned_head_sha=HEAD, + ) + self.assertTrue(result["block"]) + self.assertTrue(any("owned by session_id=reviewer-sess" in r for r in result["reasons"])) + + def test_cross_session_merge_allowed_after_adoption_comment(self): + reviewer_comment = _lease_comment(536, "reviewer-sess", candidate_head=HEAD) + adoption_body = mla.format_adoption_body( + repo="Scaled-Tech-Consulting/Gitea-Tools", + pr_number=536, + issue_number=536, + adopter_identity="sysadmin", + adopter_profile="prgs-merger", + adopter_session_id="merger-sess", + worktree="branches/merge-pr536", + candidate_head=HEAD, + target_branch="master", + target_branch_sha="b" * 40, + adopted_from_session_id="reviewer-sess", + adopted_from_profile="prgs-reviewer", + adopted_from_reviewer_identity="sysadmin", + adopted_from_comment_id=100, + ) + adoption_comment = { + "id": 7001, + "body": adoption_body, + "user": {"login": "sysadmin"}, + } + provenance = mla.build_lease_provenance( + source=mla.SOURCE_ADOPT, + comment_id=7001, + adopted_from_session_id="reviewer-sess", + ) + leases.record_session_lease( + { + "pr_number": 536, + "session_id": "merger-sess", + "candidate_head": HEAD, + "comment_id": 7001, + "phase": "adopted", + }, + lease_provenance=provenance, + ) + result = leases.assess_mutation_lease_gate( + pr_number=536, + comments=[reviewer_comment, adoption_comment], + reviewer_identity="sysadmin", + session_id="merger-sess", + mutation="merge", + live_head_sha=HEAD, + pinned_head_sha=HEAD, + ) + self.assertFalse(result["block"]) + + +class TestAdoptTool(unittest.TestCase): + def setUp(self): + leases.clear_session_lease() + # Patch only the role workspace verifier (which internally does the single + # verify_preflight_purity); the direct duplicate call was removed to prevent + # capability state clearing. See fix for #548. + patch("mcp_server._verify_role_mutation_workspace", return_value=None).start() + patch("gitea_audit.audit_enabled", return_value=False).start() + mcp_server = __import__("mcp_server") + mcp_server._IDENTITY_CACHE.clear() + mcp_server.init_review_decision_lock("prgs", "adopt_merger_pr_lease") + mcp_server.record_preflight_check("whoami") + mcp_server.record_preflight_check( + "capability", "reviewer", resolved_task="adopt_merger_pr_lease" + ) + + def tearDown(self): + patch.stopall() + leases.clear_session_lease() + + @patch("mcp_server._authenticated_username", return_value="sysadmin") + @patch("mcp_server.get_auth_header", return_value="Basic dGVzdDp0ZXN0") + @patch("mcp_server.get_profile") + @patch("mcp_server.api_request") + def test_adopt_tool_posts_proof_and_records_sanctioned_session( + self, mock_api, mock_profile, _mock_auth, _mock_user + ): + mock_profile.return_value = { + "profile_name": "prgs-merger", + "allowed_operations": [ + "gitea.read", + "gitea.pr.comment", + "gitea.pr.merge", + ], + "forbidden_operations": [], + } + reviewer_comment = _lease_comment(536, "reviewer-sess", candidate_head=HEAD) + posted_bodies: list[str] = [] + + def _side(method, url, auth=None, payload=None, *a, **k): + m = (method or "").upper() + if m == "POST": + if payload and payload.get("body"): + posted_bodies.append(payload["body"]) + return {"id": 7001} + if "/pulls/" in url and "/files" not in url: + return { + "state": "open", + "number": 536, + "head": {"sha": HEAD}, + "merged": False, + } + if "/reviews" in url: + return [] + if "/comments" in url: + return [reviewer_comment] + return {} + + mock_api.side_effect = _side + from mcp_server import gitea_adopt_merger_pr_lease + + with patch.object( + __import__("mcp_server"), + "gitea_get_pr_review_feedback", + return_value={ + "approval_at_current_head": True, + "latest_approved_head_sha": HEAD, + }, + ): + with patch.dict( + os.environ, + { + "GITEA_PROFILE_NAME": "prgs-merger", + "GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.comment,gitea.pr.merge", + }, + clear=True, + ): + result = gitea_adopt_merger_pr_lease( + pr_number=536, + worktree="branches/merge-pr536", + expected_head_sha=HEAD, + issue_number=536, + remote="prgs", + ) + self.assertTrue(result["success"]) + self.assertTrue(result["adopted"]) + self.assertEqual(result["adopted_from_session_id"], "reviewer-sess") + self.assertEqual(result["adoption_comment_id"], 7001) + self.assertEqual(len(posted_bodies), 1) + self.assertIn(mla.ADOPTION_MARKER, posted_bodies[0]) + + session = leases.get_session_lease() + self.assertTrue(mla.is_sanctioned_session_lease(session)) + + adoption_comment = { + "id": 7001, + "body": posted_bodies[0], + "user": {"login": "sysadmin"}, + } + gate = leases.assess_mutation_lease_gate( + pr_number=536, + comments=[reviewer_comment, adoption_comment], + reviewer_identity="sysadmin", + session_id=result["session_id"], + mutation="merge", + live_head_sha=HEAD, + pinned_head_sha=HEAD, + ) + self.assertFalse(gate["block"]) + + def test_adopt_preserves_preflight_capability_state_no_duplicate_clear(self): + """Prove fix for #548: adopt calls _verify (single purity) and does not + trigger duplicate verify that would clear capability state, avoiding + need for temp local patches or raw fallbacks. + """ + from unittest.mock import patch, MagicMock + import mcp_server as mcp_server_mod + + # fresh preflight state (as controller/reconciler would have from resolve) + mcp_server_mod.record_preflight_check("whoami") + mcp_server_mod.record_preflight_check( + "capability", "merger", resolved_task="adopt_merger_pr_lease" + ) + initial_cap_called = mcp_server_mod._preflight_capability_called + + verify_patch = patch("mcp_server.verify_preflight_purity", wraps=mcp_server_mod.verify_preflight_purity) + def _role_side(*a, **k): + # simulate the real _verify which calls verify once with path + mcp_server_mod.verify_preflight_purity(k.get("remote") or a[0] if a else None, worktree_path=k.get("worktree") or "branches/work", task=k.get("task")) + return "branches/work" + role_patch = patch("mcp_server._verify_role_mutation_workspace", side_effect=_role_side) + with verify_patch as vmock, role_patch, \ + patch("mcp_server.get_auth_header", return_value="token test"), \ + patch("mcp_server._authenticated_username", return_value="sysadmin"): + # minimal mocks to let adopt reach end without full api + with patch("mcp_server.get_profile") as gp: + gp.return_value = { + "profile_name": "prgs-merger", + "allowed_operations": ["gitea.read", "gitea.pr.comment", "gitea.pr.merge"], + "forbidden_operations": [], + } + with patch("mcp_server.api_request") as api: + def _side(url, *a, **k): + if "/pulls/" in str(url) and "/comments" not in str(url): + return {"state": "open", "head": {"sha": "deadbeef"*5}, "merged": False, "number": 999} + if "/comments" in str(url): + return [] # list of comments for fetch + return {"id": 42} + api.side_effect = _side + with patch("mcp_server.gitea_get_pr_review_feedback", return_value={"approval_at_current_head": True}): + with patch("merger_lease_adoption.assess_adopt_merger_lease", return_value={ + "adopt_allowed": True, + "active_lease": {"session_id": "rev", "profile": "prgs-reviewer", "reviewer_identity": "sysadmin", "issue_number": 548, "target_branch": "master"}, + "adoption_body": " adopted", + }): + res = mcp_server_mod.gitea_adopt_merger_pr_lease( + pr_number=999, + worktree="branches/merge-test", + expected_head_sha="deadbeef"*5, + issue_number=548, + remote="prgs", + ) + self.assertTrue(res.get("success")) + # verify_preflight_purity should have been invoked exactly once (via the _verify_role path) + # not twice (old dup would have cleared state mid-way, requiring temp patches) + self.assertEqual(vmock.call_count, 1) + # capability state management: the single verify consumes (clears) as designed; + # prior state was valid, no unexpected clear between "checks" or hidden reset + # (the removed dup line was the source of the #548 clearing bug) + self.assertTrue(initial_cap_called) + # final may be set by other records in flow, but key is single invocation and success without bypasses + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_namespace_workspace_binding.py b/tests/test_namespace_workspace_binding.py new file mode 100644 index 0000000..b9c270c --- /dev/null +++ b/tests/test_namespace_workspace_binding.py @@ -0,0 +1,240 @@ +"""Tests for namespace-scoped MCP workspace binding (#510).""" + +from __future__ import annotations + +import os +import sys +import unittest +from pathlib import Path +from unittest import mock +from unittest.mock import MagicMock + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import gitea_mcp_server as srv # noqa: E402 +import namespace_workspace_binding as nwb # noqa: E402 + +REPO_ROOT = Path(__file__).resolve().parent.parent +if REPO_ROOT.parent.name == "branches": + CONTROL_ROOT = str(REPO_ROOT.parent.parent) +else: + CONTROL_ROOT = str(REPO_ROOT) + +AUTHOR_DIRTY = f"{CONTROL_ROOT}/branches/mcp-author-worktree" +MERGER_CLEAN = f"{CONTROL_ROOT}/branches/merge-pr487-submit" +REVIEWER_CLEAN = f"{CONTROL_ROOT}/branches/review-pr487-submit" +RECONCILER_CLEAN = f"{CONTROL_ROOT}/branches/reconcile-pr487" +MCP_PROCESS_ROOT = CONTROL_ROOT + + +class TestNamespaceWorkspaceModule(unittest.TestCase): + def test_author_env_ignored_for_merger_namespace(self): + workspace, source = nwb.resolve_namespace_workspace( + role_kind="merger", + worktree_path=None, + process_project_root=MCP_PROCESS_ROOT, + env={ + nwb.AUTHOR_WORKTREE_ENV: AUTHOR_DIRTY, + nwb.MERGER_WORKTREE_ENV: MERGER_CLEAN, + }, + profile_name="gitea-merger", + ) + self.assertEqual(workspace, os.path.realpath(MERGER_CLEAN)) + self.assertEqual(source, f"{nwb.MERGER_WORKTREE_ENV} environment variable") + + def test_author_env_ignored_for_reviewer_namespace(self): + workspace, source = nwb.resolve_namespace_workspace( + role_kind="reviewer", + worktree_path=None, + process_project_root=MCP_PROCESS_ROOT, + env={ + nwb.AUTHOR_WORKTREE_ENV: AUTHOR_DIRTY, + nwb.REVIEWER_WORKTREE_ENV: REVIEWER_CLEAN, + }, + ) + self.assertEqual(workspace, os.path.realpath(REVIEWER_CLEAN)) + self.assertEqual(source, f"{nwb.REVIEWER_WORKTREE_ENV} environment variable") + + def test_author_env_ignored_for_reconciler_namespace(self): + workspace, source = nwb.resolve_namespace_workspace( + role_kind="reconciler", + worktree_path=None, + process_project_root=MCP_PROCESS_ROOT, + env={ + nwb.AUTHOR_WORKTREE_ENV: AUTHOR_DIRTY, + nwb.RECONCILER_WORKTREE_ENV: RECONCILER_CLEAN, + }, + ) + self.assertEqual(workspace, os.path.realpath(RECONCILER_CLEAN)) + self.assertEqual(source, f"{nwb.RECONCILER_WORKTREE_ENV} environment variable") + + def test_merger_profile_maps_to_merger_namespace(self): + role = nwb.normalize_role_kind("reviewer", profile_name="gitea-merger") + self.assertEqual(role, "merger") + + def test_error_message_includes_path_and_binding_source(self): + msg = nwb.format_namespace_workspace_binding_error( + role_kind="merger", + workspace_path=AUTHOR_DIRTY, + binding_source=f"{nwb.AUTHOR_WORKTREE_ENV} environment variable", + dirty_files=["gitea_mcp_server.py"], + ignored_bindings=[f"{nwb.AUTHOR_WORKTREE_ENV}={AUTHOR_DIRTY} (ignored for merger namespace)"], + ) + self.assertIn(AUTHOR_DIRTY, msg) + self.assertIn("via", msg.lower()) + self.assertIn(nwb.AUTHOR_WORKTREE_ENV, msg) + self.assertIn("Do not clean or reset foreign role worktrees", msg) + + +class TestNamespaceWorkspaceIntegration(unittest.TestCase): + def setUp(self): + self._saved = { + "whoami_called": srv._preflight_whoami_called, + "capability_called": srv._preflight_capability_called, + "resolved_role": srv._preflight_resolved_role, + "whoami_violation": srv._preflight_whoami_violation, + "capability_violation": srv._preflight_capability_violation, + "in_test": srv._preflight_in_test_mode, + } + srv._preflight_whoami_called = True + srv._preflight_capability_called = True + srv._preflight_whoami_violation = False + srv._preflight_capability_violation = False + srv._preflight_in_test_mode = lambda: False + self._env_patch = mock.patch.dict(os.environ, {"GITEA_TEST_PORCELAIN": ""}, clear=False) + self._env_patch.start() + + def tearDown(self): + srv._preflight_whoami_called = self._saved["whoami_called"] + srv._preflight_capability_called = self._saved["capability_called"] + srv._preflight_resolved_role = self._saved["resolved_role"] + srv._preflight_whoami_violation = self._saved["whoami_violation"] + srv._preflight_capability_violation = self._saved["capability_violation"] + srv._preflight_in_test_mode = self._saved["in_test"] + self._env_patch.stop() + for key in ( + nwb.AUTHOR_WORKTREE_ENV, + nwb.MERGER_WORKTREE_ENV, + nwb.REVIEWER_WORKTREE_ENV, + nwb.RECONCILER_WORKTREE_ENV, + nwb.ACTIVE_WORKTREE_ENV, + ): + os.environ.pop(key, None) + + def _merger_profile(self): + return { + "profile_name": "gitea-merger", + "allowed_operations": ["gitea.pr.merge", "gitea.read"], + "forbidden_operations": ["gitea.pr.create", "gitea.branch.push"], + } + + def _reviewer_profile(self): + return { + "profile_name": "prgs-reviewer", + "allowed_operations": ["gitea.pr.approve", "gitea.pr.review", "gitea.read"], + "forbidden_operations": ["gitea.pr.create", "gitea.branch.push"], + } + + def _reconciler_profile(self): + return { + "profile_name": "prgs-reconciler", + "allowed_operations": ["gitea.pr.close", "gitea.read"], + "forbidden_operations": [ + "gitea.pr.approve", + "gitea.pr.merge", + "gitea.pr.create", + ], + } + + def _author_profile(self): + return { + "profile_name": "prgs-author", + "allowed_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.read"], + "forbidden_operations": ["gitea.pr.merge", "gitea.pr.approve"], + } + + @mock.patch("subprocess.run") + @mock.patch("os.path.isdir", return_value=True) + @mock.patch("os.path.exists", return_value=True) + def test_dirty_author_worktree_does_not_block_merger_with_clean_workspace( + self, _exists, _isdir, mock_run + ): + mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n") + os.environ[nwb.AUTHOR_WORKTREE_ENV] = AUTHOR_DIRTY + os.environ[nwb.MERGER_WORKTREE_ENV] = MERGER_CLEAN + srv._preflight_resolved_role = "reviewer" + with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT): + with mock.patch("gitea_mcp_server.get_profile", return_value=self._merger_profile()): + srv.verify_preflight_purity("prgs", worktree_path=MERGER_CLEAN) + + @mock.patch("subprocess.run") + @mock.patch("os.path.isdir", return_value=True) + @mock.patch("os.path.exists", return_value=True) + def test_dirty_author_worktree_does_not_block_reviewer_with_clean_workspace( + self, _exists, _isdir, mock_run + ): + mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n") + os.environ[nwb.AUTHOR_WORKTREE_ENV] = AUTHOR_DIRTY + os.environ[nwb.REVIEWER_WORKTREE_ENV] = REVIEWER_CLEAN + srv._preflight_resolved_role = "reviewer" + with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT): + with mock.patch("gitea_mcp_server.get_profile", return_value=self._reviewer_profile()): + srv.verify_preflight_purity("prgs", worktree_path=REVIEWER_CLEAN) + + @mock.patch("subprocess.run") + @mock.patch("os.path.isdir", return_value=True) + @mock.patch("os.path.exists", return_value=True) + def test_dirty_author_worktree_does_not_block_reconciler_with_clean_workspace( + self, _exists, _isdir, mock_run + ): + mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n") + os.environ[nwb.AUTHOR_WORKTREE_ENV] = AUTHOR_DIRTY + os.environ[nwb.RECONCILER_WORKTREE_ENV] = RECONCILER_CLEAN + srv._preflight_resolved_role = "reconciler" + with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT): + with mock.patch("gitea_mcp_server.get_profile", return_value=self._reconciler_profile()): + srv.verify_preflight_purity("prgs", worktree_path=RECONCILER_CLEAN) + + @mock.patch("subprocess.run") + @mock.patch("os.path.isdir", return_value=True) + @mock.patch("os.path.exists", return_value=True) + def test_dirty_active_task_workspace_still_blocks_mutations( + self, _exists, _isdir, mock_run + ): + mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n") + os.environ[nwb.MERGER_WORKTREE_ENV] = MERGER_CLEAN + srv._preflight_resolved_role = "reviewer" + dirty = " M namespace_workspace_binding.py\n" + with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT): + with mock.patch("gitea_mcp_server.get_profile", return_value=self._merger_profile()): + with mock.patch("gitea_mcp_server._get_workspace_porcelain", return_value=dirty): + with self.assertRaises(RuntimeError) as ctx: + srv.verify_preflight_purity("prgs", worktree_path=MERGER_CLEAN) + self.assertIn(MERGER_CLEAN, str(ctx.exception)) + self.assertIn("binding", str(ctx.exception).lower()) + + def test_root_workspace_mutation_still_blocked_for_author(self): + srv._preflight_resolved_role = "author" + with mock.patch.object(srv, "PROJECT_ROOT", CONTROL_ROOT): + with mock.patch("gitea_mcp_server.get_profile", return_value=self._author_profile()): + with mock.patch( + "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", + return_value={"current_branch": "master"}, + ): + with self.assertRaises(RuntimeError) as ctx: + srv.verify_preflight_purity("prgs") + self.assertIn("stable control checkout", str(ctx.exception)) + + @mock.patch("subprocess.run") + @mock.patch("os.path.isdir", return_value=True) + @mock.patch("os.path.exists", return_value=True) + def test_pr487_style_merge_binds_clean_merger_workspace( + self, _exists, _isdir, mock_run + ): + mock_run.return_value = MagicMock(returncode=0, stdout=f"{CONTROL_ROOT}/.git\n") + os.environ[nwb.AUTHOR_WORKTREE_ENV] = AUTHOR_DIRTY + srv._preflight_resolved_role = "reviewer" + with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT): + with mock.patch("gitea_mcp_server.get_profile", return_value=self._merger_profile()): + resolved = srv._verify_role_mutation_workspace("prgs") + self.assertEqual(resolved, os.path.realpath(MCP_PROCESS_ROOT)) \ No newline at end of file diff --git a/tests/test_permission_reports.py b/tests/test_permission_reports.py index 5483ee1..b06e416 100644 --- a/tests/test_permission_reports.py +++ b/tests/test_permission_reports.py @@ -95,10 +95,15 @@ class PermissionReportBase(unittest.TestCase): self._dir = tempfile.TemporaryDirectory() self.config_path = os.path.join(self._dir.name, "profiles.json") self._write_config(CONFIG) + import review_workflow_load + review_workflow_load.record_review_workflow_load(mcp_server.PROJECT_ROOT) def tearDown(self): + import review_workflow_load + review_workflow_load.clear_review_workflow_load() self._remotes.stop() mcp_server._IDENTITY_CACHE.clear() + mcp_server.review_workflow_load.clear_review_workflow_load() gitea_config._active_profile_override = None self._dir.cleanup() @@ -230,7 +235,9 @@ class TestEligibilityDenialReport(PermissionReportBase): return PR_PAYLOAD mock_api.side_effect = fake_api mcp_server.init_review_decision_lock("prgs", "review_pr") - mcp_server.gitea_mark_final_review_decision(42, "approve", remote="prgs") + from tests.test_mcp_server import _seed_ready_review_decision + + _seed_ready_review_decision(42, "approve", remote="prgs") with patch.dict(os.environ, self._env("author-profile")): res = mcp_server.gitea_submit_pr_review( pr_number=42, action="approve", body="lgtm", remote="prgs", @@ -248,6 +255,8 @@ class TestEligibilityDenialReport(PermissionReportBase): return {"login": "author-user"} return PR_PAYLOAD mock_api.side_effect = fake_api + mcp_server.init_review_decision_lock("prgs", "review_pr") + mcp_server.gitea_load_review_workflow() with patch.dict(os.environ, self._env("author-profile")): res = mcp_server.gitea_merge_pr( pr_number=42, confirmation="MERGE PR 42", @@ -272,7 +281,9 @@ class TestReviewCommentPathUsesCanonicalOp(PermissionReportBase): return PR_PAYLOAD mock_api.side_effect = fake_api mcp_server.init_review_decision_lock("prgs", "review_pr") - mcp_server.gitea_mark_final_review_decision(42, "comment", remote="prgs") + from tests.test_mcp_server import _seed_ready_review_decision + + _seed_ready_review_decision(42, "comment", remote="prgs") with patch.dict(os.environ, self._env("author-profile")): res = mcp_server.gitea_submit_pr_review( pr_number=42, action="comment", body="finding", remote="prgs", diff --git a/tests/test_post_merge_cleanup_proof.py b/tests/test_post_merge_cleanup_proof.py new file mode 100644 index 0000000..10e5bf6 --- /dev/null +++ b/tests/test_post_merge_cleanup_proof.py @@ -0,0 +1,169 @@ +"""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() \ No newline at end of file diff --git a/tests/test_post_merge_moot_lease.py b/tests/test_post_merge_moot_lease.py new file mode 100644 index 0000000..a71cb72 --- /dev/null +++ b/tests/test_post_merge_moot_lease.py @@ -0,0 +1,257 @@ +"""Post-merge moot reviewer-lease handling (#515). + +Covers: + a. Reviewer-lease acquisition/adoption is refused on an already-merged/closed + PR (fail closed, no mutation). + b. The post-merge moot cleanup path is safe and idempotent. + c. An active foreign lease on an *open* PR is never force-cleaned. +""" + +import os +import sys +import unittest +from datetime import datetime, timezone +from unittest.mock import patch + +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) + +import reviewer_pr_lease as leases # noqa: E402 +from mcp_server import ( # noqa: E402 + gitea_acquire_reviewer_pr_lease, + gitea_cleanup_post_merge_moot_lease, +) + +FAKE_AUTH = "Basic dGVzdDp0ZXN0" +MERGER_ENV = { + "GITEA_PROFILE_NAME": "prgs-merger", + "GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.pr.comment", +} +PR = 487 +ISSUE = 485 +SESSION = "97274-676d20a825c4" + + +def _lease_comment(pr_number=PR, session_id=SESSION, *, phase="claimed", + candidate_head="a" * 40): + body = leases.format_lease_body( + repo="Scaled-Tech-Consulting/Gitea-Tools", + pr_number=pr_number, + issue_number=ISSUE, + reviewer_identity="sysadmin", + profile="prgs-reviewer", + session_id=session_id, + worktree="branches/review-pr487", + phase=phase, + candidate_head=candidate_head, + target_branch="master", + target_branch_sha="b" * 40, + last_activity=datetime.now(timezone.utc), + ) + return {"id": 6603, "body": body, "user": {"login": "sysadmin"}} + + +# --------------------------------------------------------------------------- # +# Pure logic +# --------------------------------------------------------------------------- # +class TestAcquireRefusedOnMergedPR(unittest.TestCase): + def test_acquire_refused_when_pr_merged_or_closed(self): + result = leases.assess_acquire_lease( + [_lease_comment()], + pr_number=PR, + reviewer_identity="sysadmin", + profile="prgs-merger", + session_id="new-session", + repo="Scaled-Tech-Consulting/Gitea-Tools", + issue_number=ISSUE, + worktree="branches/merge-pr487", + candidate_head="a" * 40, + target_branch="master", + target_branch_sha="b" * 40, + pr_merged_or_closed=True, + ) + self.assertFalse(result["acquire_allowed"]) + self.assertTrue(result["post_merge_moot"]) + self.assertIsNone(result["lease_body"]) + self.assertTrue(any("post_merge_moot" in r for r in result["reasons"])) + + def test_acquire_still_allowed_on_open_pr_without_flag(self): + result = leases.assess_acquire_lease( + [], + pr_number=PR, + reviewer_identity="sysadmin", + profile="prgs-reviewer", + session_id="s1", + repo="Scaled-Tech-Consulting/Gitea-Tools", + issue_number=ISSUE, + worktree="branches/review-pr487", + candidate_head="a" * 40, + target_branch="master", + target_branch_sha="b" * 40, + ) + self.assertTrue(result["acquire_allowed"]) + self.assertFalse(result["post_merge_moot"]) + + +class TestPostMergeMootAssessment(unittest.TestCase): + def test_merged_pr_with_active_lease_is_moot_and_cleanable(self): + a = leases.assess_post_merge_moot_lease( + [_lease_comment()], + pr_number=PR, + pr_merged=True, + pr_state="closed", + merge_commit_sha="c" * 40, + ) + self.assertTrue(a["pr_merged_or_closed"]) + self.assertTrue(a["is_moot"]) + self.assertTrue(a["cleanup_allowed"]) + self.assertIsNotNone(a["release_body"]) + self.assertIn("phase: released", a["release_body"]) + self.assertIn("blocker: post-merge-moot", a["release_body"]) + + def test_open_pr_active_lease_never_cleaned(self): + a = leases.assess_post_merge_moot_lease( + [_lease_comment()], + pr_number=PR, + pr_merged=False, + pr_state="open", + ) + self.assertFalse(a["pr_merged_or_closed"]) + self.assertFalse(a["is_moot"]) + self.assertFalse(a["cleanup_allowed"]) + self.assertIsNone(a["release_body"]) + self.assertTrue(any("still open" in r for r in a["reasons"])) + + def test_merged_pr_without_lease_nothing_to_clean(self): + a = leases.assess_post_merge_moot_lease( + [], pr_number=PR, pr_merged=True, pr_state="closed") + self.assertTrue(a["pr_merged_or_closed"]) + self.assertFalse(a["is_moot"]) + self.assertFalse(a["cleanup_allowed"]) + self.assertTrue(any("nothing to clean" in r for r in a["reasons"])) + + def test_cleanup_is_idempotent(self): + """After the released marker is posted, a re-assess finds nothing to clean.""" + first = leases.assess_post_merge_moot_lease( + [_lease_comment()], pr_number=PR, pr_merged=True, pr_state="closed") + self.assertTrue(first["cleanup_allowed"]) + released_comment = { + "id": 7000, "body": first["release_body"], "user": {"login": "sysadmin"}} + second = leases.assess_post_merge_moot_lease( + [_lease_comment(), released_comment], + pr_number=PR, pr_merged=True, pr_state="closed") + self.assertFalse(second["is_moot"]) + self.assertFalse(second["cleanup_allowed"]) + + +# --------------------------------------------------------------------------- # +# Server tools +# --------------------------------------------------------------------------- # +def _api_side_effect(*, pr_state, pr_merged, comments, posted_id=9999): + """Build an api_request side effect keyed on method + url.""" + calls = {"post": []} + + def _side(method, url, auth=None, payload=None, *a, **k): + m = (method or "").upper() + if m == "POST": + calls["post"].append({"url": url, "payload": payload}) + return {"id": posted_id} + if "/comments" in url: + return list(comments) + if "/pulls/" in url: + pr = {"state": pr_state, "number": PR, "merge_commit_sha": "c" * 40} + if pr_merged: + pr["merged"] = True + pr["merged_at"] = "2026-07-08T07:46:04Z" + return pr + if "/issues/" in url: + return {"state": "closed" if pr_merged else "open", "number": ISSUE} + return {} + + return _side, calls + + +class TestAcquireToolRefusesMergedPR(unittest.TestCase): + def setUp(self): + leases.clear_session_lease() + + @patch("mcp_server.verify_preflight_purity", return_value=None) + @patch("mcp_server._authenticated_username", return_value="sysadmin") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + @patch("mcp_server.api_request") + def test_acquire_tool_fails_closed_on_merged_pr_without_posting( + self, mock_api, _auth, _user, _purity): + side, calls = _api_side_effect( + pr_state="closed", pr_merged=True, comments=[]) + mock_api.side_effect = side + with patch.dict(os.environ, MERGER_ENV, clear=True): + result = gitea_acquire_reviewer_pr_lease( + pr_number=PR, + worktree="branches/merge-pr487", + candidate_head="a" * 40, + issue_number=ISSUE, + remote="prgs", + ) + self.assertFalse(result["success"]) + self.assertFalse(result["acquired"]) + self.assertTrue(result.get("post_merge_moot")) + self.assertEqual(calls["post"], [], "must not post a lease comment") + + +class TestCleanupTool(unittest.TestCase): + def setUp(self): + leases.clear_session_lease() + + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + @patch("mcp_server.api_request") + def test_read_only_reports_moot_without_mutating(self, mock_api, _auth): + side, calls = _api_side_effect( + pr_state="closed", pr_merged=True, comments=[_lease_comment()]) + mock_api.side_effect = side + with patch.dict(os.environ, MERGER_ENV, clear=True): + result = gitea_cleanup_post_merge_moot_lease( + pr_number=PR, apply=False, remote="prgs") + self.assertTrue(result["success"]) + self.assertTrue(result["pr_merged_or_closed"]) + self.assertTrue(result["lease_moot"]) + self.assertFalse(result["cleanup_performed"]) + self.assertTrue(result["no_merge_or_adoption"]) + self.assertEqual(result["mode"], "read_only") + self.assertEqual(calls["post"], []) + + @patch("mcp_server.verify_preflight_purity", return_value=None) + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + @patch("mcp_server.api_request") + def test_apply_posts_released_marker_on_merged_pr( + self, mock_api, _auth, _purity): + side, calls = _api_side_effect( + pr_state="closed", pr_merged=True, comments=[_lease_comment()]) + mock_api.side_effect = side + with patch.dict(os.environ, MERGER_ENV, clear=True): + result = gitea_cleanup_post_merge_moot_lease( + pr_number=PR, apply=True, remote="prgs") + self.assertTrue(result["success"]) + self.assertTrue(result["cleanup_performed"]) + self.assertEqual(result["released_comment_id"], 9999) + self.assertEqual(len(calls["post"]), 1) + self.assertIn("phase: released", calls["post"][0]["payload"]["body"]) + self.assertIn("post-merge-moot", calls["post"][0]["payload"]["body"]) + + @patch("mcp_server.verify_preflight_purity", return_value=None) + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + @patch("mcp_server.api_request") + def test_apply_refuses_to_clean_open_pr(self, mock_api, _auth, _purity): + side, calls = _api_side_effect( + pr_state="open", pr_merged=False, comments=[_lease_comment()]) + mock_api.side_effect = side + with patch.dict(os.environ, MERGER_ENV, clear=True): + result = gitea_cleanup_post_merge_moot_lease( + pr_number=PR, apply=True, remote="prgs") + self.assertFalse(result["cleanup_performed"]) + self.assertFalse(result["pr_merged_or_closed"]) + self.assertEqual(calls["post"], [], "never force-clean an open PR lease") + self.assertTrue( + any("still open" in r for r in result.get("cleanup_skipped_reason", []))) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pr_lease_comments_non_list_guard.py b/tests/test_pr_lease_comments_non_list_guard.py new file mode 100644 index 0000000..9b21a24 --- /dev/null +++ b/tests/test_pr_lease_comments_non_list_guard.py @@ -0,0 +1,81 @@ +"""Regression tests for non-list API payloads on PR/issue comment listing (#485).""" +import os +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) + +from mcp_server import ( # noqa: E402 + _list_pr_lease_comments, + gitea_list_issue_comments, +) + +FAKE_AUTH = "Basic dGVzdDp0ZXN0" +AUTHOR_ENV = { + "GITEA_PROFILE_NAME": "gitea-author", + "GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment", +} + + +class TestPrLeaseCommentsNonListGuard(unittest.TestCase): + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_list_pr_lease_comments_non_list_payload_returns_empty(self, _auth, mock_api): + mock_api.return_value = {"message": "Unauthorized"} + result = _list_pr_lease_comments( + 12, remote="prgs", host=None, org=None, repo=None) + self.assertEqual(result, []) + # Single comments GET only — no pre-flight PR lookup (#519 isolation). + mock_api.assert_called_once() + _method, url, _auth_arg = mock_api.call_args[0][:3] + self.assertIn("/issues/12/comments", url) + self.assertNotIn("/pulls/", url) + + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_list_pr_lease_comments_none_returns_empty(self, _auth, mock_api): + mock_api.return_value = None + result = _list_pr_lease_comments( + 12, remote="prgs", host=None, org=None, repo=None) + self.assertEqual(result, []) + + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_list_pr_lease_comments_list_payload_unchanged(self, _auth, mock_api): + comment = {"id": 7, "body": ""} + mock_api.return_value = [comment] + result = _list_pr_lease_comments( + 12, remote="prgs", host=None, org=None, repo=None, limit=5) + self.assertEqual(result, [comment]) + + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_list_issue_comments_non_list_payload_returns_empty(self, _auth, mock_api): + mock_api.return_value = {"message": "Unauthorized"} + with patch.dict(os.environ, AUTHOR_ENV, clear=True): + result = gitea_list_issue_comments(issue_number=9, remote="prgs") + self.assertTrue(result["success"]) + self.assertEqual(result["comments"], []) + + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + def test_list_issue_comments_list_payload_unchanged(self, _auth, mock_api): + mock_api.return_value = [ + { + "id": 101, + "user": {"login": "alice"}, + "body": "hello", + "created_at": "2026-07-03T00:00:00Z", + "updated_at": "2026-07-03T01:00:00Z", + } + ] + with patch.dict(os.environ, AUTHOR_ENV, clear=True): + result = gitea_list_issue_comments(issue_number=9, remote="prgs") + self.assertTrue(result["success"]) + self.assertEqual(len(result["comments"]), 1) + self.assertEqual(result["comments"][0]["author"], "alice") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pr_queue_inventory.py b/tests/test_pr_queue_inventory.py index aeb8bc4..ddd9902 100644 --- a/tests/test_pr_queue_inventory.py +++ b/tests/test_pr_queue_inventory.py @@ -11,6 +11,7 @@ from mcp_server import ( gitea_view_pr, gitea_review_pr, gitea_check_pr_eligibility, + gitea_load_review_workflow, ) import gitea_config @@ -128,18 +129,29 @@ class TestPRQueueInventory(unittest.TestCase): "forbidden_operations": [], "base_url": None, } + + from mcp_server import init_review_decision_lock + from tests.test_mcp_server import ( + FULL_HEAD_SHA, + _install_owned_reviewer_lease, + _seed_ready_review_decision, + ) + import reviewer_pr_lease + + head_sha = FULL_HEAD_SHA mock_fetch.return_value = _final_page_fetch([ - {"number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1", "sha": "abc1"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "other_user"}} + {"number": 1, "title": "PR 1", "state": "open", + "head": {"ref": "branch1", "sha": head_sha}, + "base": {"ref": "master"}, "mergeable": True, + "user": {"login": "other_user"}} ]) - # mock_api: inventory whoami, eligibility whoami, eligibility PR, - # POST review (#244: state + visible-verdict GET reviews). mock_api.side_effect = [ {"login": "reviewer1"}, {"login": "reviewer1"}, { "user": {"login": "other_user"}, "state": "open", - "head": {"sha": "abc1"}, + "head": {"sha": head_sha}, "mergeable": True, }, {"id": 100, "state": "APPROVED"}, @@ -148,17 +160,27 @@ class TestPRQueueInventory(unittest.TestCase): "id": 100, "user": {"login": "reviewer1"}, "state": "APPROVED", - "commit_id": "abc1", + "commit_id": head_sha, "submitted_at": "2026-07-06T10:00:00Z", "dismissed": False, } ], ] - from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision - init_review_decision_lock("prgs", "review_pr") - gitea_mark_final_review_decision(1, "approve", remote="prgs") - result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs", final_review_decision_ready=True) + with patch("mcp_server._authenticated_username", return_value="reviewer1"), \ + patch("mcp_server._list_pr_lease_comments", return_value=[]): + init_review_decision_lock("prgs", "review_pr") + _seed_ready_review_decision(1, "approve", sha=head_sha, remote="prgs") + lease_patch = _install_owned_reviewer_lease( + 1, head_sha=head_sha, session_id="inventory-review-lease", + ) + lease_patch.start() + self.addCleanup(lease_patch.stop) + self.addCleanup(reviewer_pr_lease.clear_session_lease) + result = gitea_review_pr( + pr_number=1, event="APPROVE", remote="prgs", + final_review_decision_ready=True, + ) self.assertTrue(result["success"]) self.assertIn("=== PR Queue Inventory ===", result["message"]) self.assertIn("Repository:", result["message"]) diff --git a/tests/test_pr_work_lease.py b/tests/test_pr_work_lease.py new file mode 100644 index 0000000..8dbcb33 --- /dev/null +++ b/tests/test_pr_work_lease.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""Regression tests for conflict-fix and reviewer PR work leases (#399).""" + +from __future__ import annotations + +import os +import sys +import unittest +from datetime import datetime, timedelta, timezone + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from pr_work_lease import ( # noqa: E402 + CONFLICT_FIX_LEASE_MARKER, + REVIEWER_LEASE_MARKER, + assess_conflict_fix_final_report, + assess_conflict_fix_push, + assess_head_sha_equality, + assess_reviewer_mutation_blocked, + assess_reviewer_stale_head_final_report, + format_conflict_fix_lease_body, + parse_conflict_fix_lease_comment, + parse_reviewer_lease_comment, +) + +HEAD_A = "a" * 40 +HEAD_B = "b" * 40 +NOW = datetime(2026, 7, 7, 15, 0, tzinfo=timezone.utc) + + +def _reviewer_lease_body(*, phase: str = "validating", expires_minutes: int = 60) -> str: + expires = (NOW + timedelta(minutes=expires_minutes)).isoformat().replace("+00:00", "Z") + return "\n".join([ + REVIEWER_LEASE_MARKER, + "pr: #376", + "phase: " + phase, + f"candidate_head: {HEAD_A}", + f"expires_at: {expires}", + "profile: prgs-reviewer", + ]) + + +def _conflict_fix_body(*, phase: str = "claimed", worktree: str = "branches/fix-376") -> str: + expires = (NOW + timedelta(minutes=60)).isoformat().replace("+00:00", "Z") + return "\n".join([ + CONFLICT_FIX_LEASE_MARKER, + "pr: #376", + f"phase: {phase}", + f"worktree: {worktree}", + f"head_before: {HEAD_A}", + f"expires_at: {expires}", + "profile: prgs-author", + ]) + + +class TestLeaseParsing(unittest.TestCase): + def test_parse_reviewer_lease(self): + parsed = parse_reviewer_lease_comment(_reviewer_lease_body()) + self.assertEqual(parsed["pr_number"], 376) + self.assertEqual(parsed["phase"], "validating") + self.assertEqual(parsed["candidate_head"], HEAD_A) + + def test_parse_conflict_fix_lease(self): + parsed = parse_conflict_fix_lease_comment(_conflict_fix_body()) + self.assertEqual(parsed["pr_number"], 376) + self.assertEqual(parsed["phase"], "claimed") + + +class TestConflictFixPushGate(unittest.TestCase): + def test_blocks_push_during_active_reviewer_lease(self): + comments = [{"body": _reviewer_lease_body()}] + result = assess_conflict_fix_push( + pr_number=376, + comments=comments, + branch_head_before=HEAD_A, + branch_head_after=HEAD_B, + worktree_path="branches/fix-376", + push_cwd="/proj/branches/fix-376", + is_fast_forward=True, + now=NOW, + ) + self.assertFalse(result["push_allowed"]) + self.assertTrue(any("reviewer lease" in r for r in result["reasons"])) + + def test_rejects_non_fast_forward(self): + result = assess_conflict_fix_push( + pr_number=376, + comments=[], + branch_head_before=HEAD_A, + branch_head_after=HEAD_B, + worktree_path="branches/fix-376", + push_cwd="/proj/branches/fix-376", + is_fast_forward=False, + now=NOW, + ) + self.assertFalse(result["push_allowed"]) + self.assertTrue(any("non-fast-forward" in r for r in result["reasons"])) + + def test_wrong_cwd_push_attempt(self): + result = assess_conflict_fix_push( + pr_number=376, + comments=[], + branch_head_before=HEAD_A, + branch_head_after=HEAD_B, + worktree_path="branches/fix-376", + push_cwd="/proj/master", + is_fast_forward=True, + now=NOW, + ) + self.assertFalse(result["push_allowed"]) + self.assertTrue(any("cwd" in r.lower() for r in result["reasons"])) + + def test_sibling_conflict_fix_collision(self): + comments = [{"body": _conflict_fix_body(phase="pushing", worktree="branches/other")}] + result = assess_conflict_fix_push( + pr_number=376, + comments=comments, + branch_head_before=HEAD_A, + branch_head_after=HEAD_B, + worktree_path="branches/fix-376", + push_cwd="/proj/branches/fix-376", + is_fast_forward=True, + now=NOW, + ) + self.assertFalse(result["push_allowed"]) + self.assertTrue(any("sibling conflict-fix" in r for r in result["reasons"])) + + +class TestReviewerMutationGate(unittest.TestCase): + def test_blocks_review_during_conflict_fix(self): + comments = [{"body": _conflict_fix_body(phase="pushing")}] + result = assess_reviewer_mutation_blocked( + pr_number=376, + comments=comments, + reviewed_head_sha=HEAD_A, + live_head_sha=HEAD_A, + mutation="approve", + now=NOW, + ) + self.assertFalse(result["mutation_allowed"]) + self.assertTrue(any("conflict-fix lease" in r for r in result["reasons"])) + + def test_stale_head_blocks_approval(self): + result = assess_reviewer_mutation_blocked( + pr_number=376, + comments=[], + reviewed_head_sha=HEAD_A, + live_head_sha=HEAD_B, + mutation="merge", + now=NOW, + ) + self.assertFalse(result["mutation_allowed"]) + self.assertTrue(result["head_check"]["head_changed"]) + + def test_head_equality_required_fields(self): + result = assess_head_sha_equality(HEAD_A, HEAD_B) + self.assertFalse(result["proven"]) + self.assertTrue(result["head_changed"]) + + +class TestFinalReportProof(unittest.TestCase): + def test_reviewer_stale_head_report_requires_fields(self): + result = assess_reviewer_stale_head_final_report("no head proof here") + self.assertFalse(result["proven"]) + + def test_reviewer_stale_head_report_passes(self): + report = "\n".join([ + f"Reviewed head SHA: {HEAD_A}", + f"Final live head SHA before approval: {HEAD_A}", + f"Final live head SHA before merge: {HEAD_A}", + "Push occurred during validation: no", + ]) + result = assess_reviewer_stale_head_final_report(report) + self.assertTrue(result["proven"]) + + def test_conflict_fix_report_requires_fields(self): + result = assess_conflict_fix_final_report("incomplete") + self.assertFalse(result["proven"]) + + def test_conflict_fix_report_passes(self): + report = "\n".join([ + f"Branch head before push: {HEAD_A}", + f"Branch head after push: {HEAD_B}", + "Active reviewer lease status: none", + "Whether push was fast-forward: yes", + "Whether any reviewer was active: no", + ]) + result = assess_conflict_fix_final_report(report) + self.assertTrue(result["proven"]) + + +class TestFormatLease(unittest.TestCase): + def test_format_conflict_fix_lease_includes_marker(self): + body = format_conflict_fix_lease_body( + pr_number=376, + branch="feat/x", + worktree="branches/fix-376", + profile="prgs-author", + head_before=HEAD_A, + ) + self.assertIn(CONFLICT_FIX_LEASE_MARKER, body) + parsed = parse_conflict_fix_lease_comment(body) + self.assertEqual(parsed["pr_number"], 376) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_preflight_read_survival.py b/tests/test_preflight_read_survival.py new file mode 100644 index 0000000..6da8af6 --- /dev/null +++ b/tests/test_preflight_read_survival.py @@ -0,0 +1,106 @@ +"""#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() \ No newline at end of file diff --git a/tests/test_reconciler_close_workspace_guard.py b/tests/test_reconciler_close_workspace_guard.py new file mode 100644 index 0000000..54a24e4 --- /dev/null +++ b/tests/test_reconciler_close_workspace_guard.py @@ -0,0 +1,89 @@ +"""Reconciler close_pr must not require author branches/ worktree (#468).""" +import os +import sys +import unittest +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import gitea_mcp_server as srv + +FAKE_AUTH = "token test" +CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3]) + +RECONCILER_PROFILE = { + "profile_name": "prgs-reconciler", + "allowed_operations": ["gitea.read", "gitea.pr.close", "gitea.pr.comment"], + "forbidden_operations": [ + "gitea.pr.approve", + "gitea.pr.merge", + "gitea.pr.review", + "gitea.pr.create", + "gitea.branch.push", + "gitea.repo.commit", + ], + "audit_label": "prgs-reconciler", +} + + +class TestReconcilerCloseWorkspaceGuard(unittest.TestCase): + def setUp(self): + srv._preflight_whoami_called = True + srv._preflight_capability_called = True + srv._preflight_whoami_violation = False + srv._preflight_capability_violation = False + self._orig_in_test = srv._preflight_in_test_mode + srv._preflight_in_test_mode = lambda: False + + def tearDown(self): + srv._preflight_in_test_mode = self._orig_in_test + + @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) + @patch("gitea_mcp_server._namespace_mutation_block", return_value=None) + @patch("gitea_mcp_server.get_profile", return_value=RECONCILER_PROFILE) + @patch("gitea_mcp_server.api_request") + def test_reconciler_close_pr_from_control_checkout_succeeds( + self, mock_api, _profile, _ns, _auth + ): + srv._preflight_resolved_role = "reconciler" + mock_api.return_value = { + "number": 414, + "title": "old", + "body": "", + "state": "closed", + "html_url": "https://gitea.example.com/pulls/414", + } + with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT): + with patch.dict(os.environ, {}, clear=False): + os.environ.pop("GITEA_AUTHOR_WORKTREE", None) + os.environ.pop("GITEA_ACTIVE_WORKTREE", None) + result = srv.gitea_edit_pr(414, state="closed", remote="prgs") + self.assertTrue(result["success"]) + self.assertEqual(result["state"], "closed") + mock_api.assert_called_once() + + @patch("gitea_mcp_server._auth", return_value=FAKE_AUTH) + @patch("gitea_mcp_server._profile_permission_block", return_value=None) + @patch("gitea_mcp_server._namespace_mutation_block", return_value=None) + @patch( + "gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", + return_value=(True, []), + ) + @patch("gitea_mcp_server.api_get_all", return_value=[]) + @patch( + "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", + return_value={"current_branch": "master"}, + ) + def test_author_create_issue_still_blocked_on_control_checkout( + self, _git, _get_all, _role, _ns, _prof, _auth + ): + srv._preflight_resolved_role = "author" + with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT): + with self.assertRaises(RuntimeError) as ctx: + srv.gitea_create_issue(title="Test", body="body") + self.assertIn("stable control checkout", str(ctx.exception)) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_remote_repo_guard.py b/tests/test_remote_repo_guard.py new file mode 100644 index 0000000..9fbfa47 --- /dev/null +++ b/tests/test_remote_repo_guard.py @@ -0,0 +1,178 @@ +"""Regression coverage for the remote/repo mismatch guard (#530). + +Bare ``remote=prgs`` historically resolved to ``Scaled-Tech-Consulting/Timesheet`` +(the hardcoded ``REMOTES`` default) instead of ``Scaled-Tech-Consulting/Gitea-Tools``, +which is what the local git remote actually points at. That silent mismatch caused +false 404s and risked mutating the wrong repository. The guard fails closed when the +MCP-resolved org/repo disagrees with the local git remote URL and the caller did not +pass explicit ``org``/``repo``. +""" + +import os +import unittest +from unittest import mock + +import remote_repo_guard + + +LOCAL_GITEA_TOOLS_URL = "https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git" + + +class TestAssessRemoteRepoMatch(unittest.TestCase): + def test_explicit_org_and_repo_skips_guard(self): + # Caller supplied both org and repo explicitly: never block, even on mismatch. + assessment = remote_repo_guard.assess_remote_repo_match( + remote="prgs", + resolved_org="Scaled-Tech-Consulting", + resolved_repo="Timesheet", + local_remote_url=LOCAL_GITEA_TOOLS_URL, + org_explicit=True, + repo_explicit=True, + ) + self.assertTrue(assessment["proven"]) + self.assertFalse(assessment["block"]) + self.assertEqual(assessment["reasons"], []) + + def test_missing_local_remote_url_skips_guard(self): + # Best-effort lookup: no local remote URL (non-checkout usage) => do not block. + assessment = remote_repo_guard.assess_remote_repo_match( + remote="prgs", + resolved_org="Scaled-Tech-Consulting", + resolved_repo="Timesheet", + local_remote_url=None, + org_explicit=False, + repo_explicit=False, + ) + self.assertTrue(assessment["proven"]) + self.assertFalse(assessment["block"]) + + def test_matching_repo_is_proven(self): + assessment = remote_repo_guard.assess_remote_repo_match( + remote="prgs", + resolved_org="Scaled-Tech-Consulting", + resolved_repo="Gitea-Tools", + local_remote_url=LOCAL_GITEA_TOOLS_URL, + org_explicit=False, + repo_explicit=False, + ) + self.assertTrue(assessment["proven"]) + self.assertFalse(assessment["block"]) + + def test_regression_prgs_default_timesheet_vs_local_gitea_tools(self): + # The exact #530 scenario: default repo Timesheet, local remote Gitea-Tools. + assessment = remote_repo_guard.assess_remote_repo_match( + remote="prgs", + resolved_org="Scaled-Tech-Consulting", + resolved_repo="Timesheet", + local_remote_url=LOCAL_GITEA_TOOLS_URL, + org_explicit=False, + repo_explicit=False, + ) + self.assertFalse(assessment["proven"]) + self.assertTrue(assessment["block"]) + self.assertTrue(assessment["reasons"]) + self.assertEqual(assessment["resolved_repo"], "Timesheet") + self.assertEqual(assessment["resolved_org"], "Scaled-Tech-Consulting") + + def test_only_org_explicit_still_checks_repo(self): + # Repo left as default => still guarded even if org was explicit. + assessment = remote_repo_guard.assess_remote_repo_match( + remote="prgs", + resolved_org="Scaled-Tech-Consulting", + resolved_repo="Timesheet", + local_remote_url=LOCAL_GITEA_TOOLS_URL, + org_explicit=True, + repo_explicit=False, + ) + self.assertTrue(assessment["block"]) + + def test_case_insensitive_match(self): + assessment = remote_repo_guard.assess_remote_repo_match( + remote="prgs", + resolved_org="scaled-tech-consulting", + resolved_repo="gitea-tools", + local_remote_url=LOCAL_GITEA_TOOLS_URL, + org_explicit=False, + repo_explicit=False, + ) + self.assertTrue(assessment["proven"]) + + def test_format_error_mentions_resolved_and_local(self): + assessment = remote_repo_guard.assess_remote_repo_match( + remote="prgs", + resolved_org="Scaled-Tech-Consulting", + resolved_repo="Timesheet", + local_remote_url=LOCAL_GITEA_TOOLS_URL, + org_explicit=False, + repo_explicit=False, + ) + message = remote_repo_guard.format_remote_repo_guard_error(assessment) + self.assertIn("Timesheet", message) + self.assertIn("Gitea-Tools", message) + self.assertIn("org=", message) + self.assertIn("repo=", message) + + +class TestResolveServerWiring(unittest.TestCase): + """The guard is wired into gitea_mcp_server._resolve, so every read/lookup/ + mutation tool that resolves a target fails closed on a repo mismatch. + + Under pytest the guard is bypassed unless GITEA_FORCE_REMOTE_REPO_CHECK is set, + so these tests set the force flag and patch the local remote lookup + REMOTES. + """ + + def setUp(self): + import gitea_mcp_server + + self.server = gitea_mcp_server + + force = mock.patch.dict( + os.environ, {"GITEA_FORCE_REMOTE_REPO_CHECK": "1"}, clear=False + ) + force.start() + self.addCleanup(force.stop) + + url_patch = mock.patch.object( + gitea_mcp_server, + "_local_git_remote_url", + return_value=LOCAL_GITEA_TOOLS_URL, + ) + url_patch.start() + self.addCleanup(url_patch.stop) + + def _set_prgs_default_repo(self, repo): + original = dict(self.server.REMOTES["prgs"]) + self.addCleanup(self.server.REMOTES.__setitem__, "prgs", original) + self.server.REMOTES["prgs"] = {**original, "repo": repo} + + def test_resolve_blocks_on_default_repo_mismatch(self): + self._set_prgs_default_repo("Timesheet") + with self.assertRaises(RuntimeError) as ctx: + self.server._resolve("prgs", None, None, None) + self.assertIn("Timesheet", str(ctx.exception)) + self.assertIn("Gitea-Tools", str(ctx.exception)) + + def test_resolve_allows_explicit_org_and_repo(self): + self._set_prgs_default_repo("Timesheet") + # Explicit org/repo is authoritative even if it does not match local remote. + host, org, repo = self.server._resolve( + "prgs", None, "Scaled-Tech-Consulting", "Timesheet" + ) + self.assertEqual((org, repo), ("Scaled-Tech-Consulting", "Timesheet")) + + def test_resolve_allows_matching_default(self): + self._set_prgs_default_repo("Gitea-Tools") + host, org, repo = self.server._resolve("prgs", None, None, None) + self.assertEqual((org, repo), ("Scaled-Tech-Consulting", "Gitea-Tools")) + + def test_lookup_tool_cannot_silently_query_different_repo(self): + # gitea_view_issue resolves the target via _resolve first, so a bare + # remote=prgs pointing at the wrong default repo fails closed before any + # API call is made. + self._set_prgs_default_repo("Timesheet") + with self.assertRaises(RuntimeError): + self.server.gitea_view_issue(issue_number=1, remote="prgs") + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_review_feedback.py b/tests/test_review_feedback.py index 01fff0c..72cbe61 100644 --- a/tests/test_review_feedback.py +++ b/tests/test_review_feedback.py @@ -115,6 +115,22 @@ class TestPRReviewFeedbackDiscovery(unittest.TestCase): result["latest_review_state_by_reviewer"], {"reviewer1": "APPROVED"}) self.assertFalse(result["has_blocking_change_requests"]) self.assertTrue(result["approval_visible"]) + self.assertTrue(result["approval_at_current_head"]) + + @patch("mcp_server.api_request") + @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) + @patch("mcp_server.get_profile") + def test_stale_approval_not_at_current_head(self, mock_get_profile, _auth, mock_api): + mock_get_profile.return_value = self._profile() + mock_api.side_effect = [ + _pr_details(head_sha="newhead3"), + [_review("reviewer1", "APPROVED", commit_id="oldhead1")], + ] + result = gitea_get_pr_review_feedback(pr_number=5, remote="prgs") + self.assertTrue(result["approval_visible"]) + self.assertFalse(result["approval_at_current_head"]) + self.assertEqual(result["latest_approved_head_sha"], "oldhead1") + self.assertIn("stale approval", result["stale_approval_block_reason"]) @patch("mcp_server.api_request") @patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) diff --git a/tests/test_review_final_report_schema.py b/tests/test_review_final_report_schema.py index fbcb5a2..3b2211e 100644 --- a/tests/test_review_final_report_schema.py +++ b/tests/test_review_final_report_schema.py @@ -19,7 +19,11 @@ def _minimal_review_report(**overrides): "- Issue/PR: #182 / PR #203", "- Branch/SHA: feat/x @ 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9", "- Files changed: review_proofs.py", - "- Validation: pytest tests/test_review_proofs.py -q in branches/review-203", + "- 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", @@ -80,7 +84,7 @@ class TestReviewFinalReportSchema(unittest.TestCase): def test_reviewed_head_without_validation_blocks(self): report = _minimal_review_report().replace( - "- Validation: pytest tests/test_review_proofs.py -q in branches/review-203", + "- Validation: pass: pytest tests/test_review_proofs.py -q in branches/review-203", "- Validation: not run", ) report += "\n- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9" diff --git a/tests/test_review_proofs.py b/tests/test_review_proofs.py index f132fc2..03d3a6a 100644 --- a/tests/test_review_proofs.py +++ b/tests/test_review_proofs.py @@ -2346,6 +2346,7 @@ class TestWorkIssueFinalReport(unittest.TestCase): "- 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): diff --git a/tests/test_review_workflow_boundary.py b/tests/test_review_workflow_boundary.py new file mode 100644 index 0000000..2d1fc1a --- /dev/null +++ b/tests/test_review_workflow_boundary.py @@ -0,0 +1,138 @@ +"""Tests for workflow-load session boundary tracking (#403).""" + +import os +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) + +import final_report_validator +import review_workflow_boundary +import review_workflow_load +import mcp_server + + +class TestPreReviewClassification(unittest.TestCase): + def setUp(self): + review_workflow_boundary.clear_pre_review_commands() + review_workflow_load.clear_review_workflow_load() + + def test_inventory_command_allowed(self): + result = review_workflow_boundary.classify_pre_review_command( + "gitea_list_prs remote=prgs", + cwd="/tmp", + project_root="/repo/Gitea-Tools", + ) + self.assertEqual( + result["classification"], + review_workflow_boundary.CLASSIFICATION_READ_ONLY_INVENTORY, + ) + + def test_main_checkout_pytest_is_boundary_violation(self): + root = "/repo/Gitea-Tools" + result = review_workflow_boundary.classify_pre_review_command( + "python -m pytest tests/", + cwd=root, + project_root=root, + ) + self.assertEqual( + result["classification"], + review_workflow_boundary.CLASSIFICATION_BOUNDARY_VIOLATION, + ) + + def test_profiles_json_inspection_is_boundary_violation(self): + result = review_workflow_boundary.classify_pre_review_command( + "cat profiles.json", + cwd="/repo/Gitea-Tools", + project_root="/repo/Gitea-Tools", + ) + self.assertEqual( + result["classification"], + review_workflow_boundary.CLASSIFICATION_BOUNDARY_VIOLATION, + ) + + +class TestWorkflowLoadBoundaryGate(unittest.TestCase): + def setUp(self): + review_workflow_boundary.clear_pre_review_commands() + review_workflow_load.clear_review_workflow_load() + mcp_server.init_review_decision_lock("prgs", "review_pr") + mcp_server.record_preflight_check("whoami") + mcp_server.record_preflight_check("capability", "reviewer") + + def _root(self) -> str: + return str(__import__("pathlib").Path(__file__).resolve().parent.parent) + + def test_boundary_violation_blocks_mutation_after_load(self): + root = self._root() + review_workflow_boundary.record_pre_review_command( + "python -m pytest tests/", + cwd=root, + project_root=root, + ) + res = mcp_server.gitea_load_review_workflow() + self.assertFalse(res["success"]) + self.assertEqual(res["boundary_status"], "violation") + blocked = mcp_server.gitea_mark_final_review_decision( + 42, "approve", remote="prgs") + self.assertFalse(blocked["marked_ready"]) + joined = " ".join(blocked["reasons"]).lower() + self.assertTrue( + "validation" in joined or "boundary" in joined or "workflow" in joined + ) + + def test_clean_inventory_then_load_passes(self): + root = self._root() + review_workflow_boundary.record_pre_review_command( + "gitea_list_prs remote=prgs", + cwd=root, + project_root=root, + ) + res = mcp_server.gitea_load_review_workflow() + self.assertTrue(res["success"]) + self.assertEqual(res["boundary_status"], "clean") + blockers = review_workflow_load.review_workflow_load_blockers(root) + self.assertEqual(blockers, []) + + def test_file_view_narrative_fails_validator_without_helper(self): + report = ( + "## Controller Handoff\n" + "- Task: review-merge-pr\n" + "- I read the canonical workflow review-merge-pr.md before review.\n" + ) + findings = final_report_validator.assess_final_report_validator( + report, + task_kind="review_pr", + ) + self.assertTrue(any( + f["rule_id"] == "reviewer.workflow_load_boundary" + for f in findings.get("findings") or [] + )) + + def test_helper_result_passes_validator(self): + root = self._root() + review_workflow_load.record_review_workflow_load(root) + helper = review_workflow_boundary.workflow_load_helper_result( + review_workflow_load._REVIEW_WORKFLOW_LOAD, + root, + ) + report = ( + "## Controller Handoff\n" + "- Task: review-merge-pr\n" + f"- Workflow-load helper result: workflow_hash: {helper['workflow_hash']}; " + f"boundary_status: {helper['boundary_status']}\n" + ) + findings = final_report_validator.assess_final_report_validator( + report, + task_kind="review_pr", + ) + boundary_findings = [ + f for f in (findings.get("findings") or []) + if f.get("rule_id") == "reviewer.workflow_load_boundary" + ] + self.assertEqual(boundary_findings, []) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_review_workflow_load.py b/tests/test_review_workflow_load.py new file mode 100644 index 0000000..8d1e42a --- /dev/null +++ b/tests/test_review_workflow_load.py @@ -0,0 +1,179 @@ +"""Tests for canonical review workflow load proof (#389).""" + +import os +import sys +import unittest +from unittest.mock import patch + +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) + +import review_workflow_load +import mcp_server + + +class TestReviewWorkflowLoadModule(unittest.TestCase): + def setUp(self): + review_workflow_load.clear_review_workflow_load() + mcp_server._save_review_decision_lock(None) + + def test_load_records_hash_and_schema(self): + root = str(__import__("pathlib").Path(__file__).resolve().parent.parent) + recorded = review_workflow_load.record_review_workflow_load(root) + self.assertEqual( + recorded["workflow_source"], + review_workflow_load.WORKFLOW_REL_PATH, + ) + self.assertEqual(recorded["task_mode"], "review-merge-pr") + self.assertRegex(recorded["workflow_hash"], r"^[0-9a-f]{12}$") + self.assertEqual( + recorded["final_report_schema_path"], + review_workflow_load.SCHEMA_REL_PATH, + ) + status = review_workflow_load.workflow_load_status(root) + self.assertTrue(status["workflow_load_proof_present"]) + self.assertTrue(status["workflow_load_valid"]) + + def test_profile_identity_mismatch_blocks(self): + """#559: different daemon PIDs are OK; profile identity mismatch is not.""" + import tempfile + from unittest.mock import patch + + import mcp_session_state + + root = str(__import__("pathlib").Path(__file__).resolve().parent.parent) + with tempfile.TemporaryDirectory() as tmp: + with patch.dict( + os.environ, + { + mcp_session_state.STATE_DIR_ENV: tmp, + mcp_session_state.SESSION_PROFILE_LOCK_ENV: "prgs-reviewer", + }, + clear=False, + ): + review_workflow_load.clear_review_workflow_load() + review_workflow_load.record_review_workflow_load(root) + # Corrupt the in-memory profile identity while keeping PID. + review_workflow_load._REVIEW_WORKFLOW_LOAD[ + "session_profile_lock" + ] = "other-profile" + review_workflow_load._REVIEW_WORKFLOW_LOAD[ + "profile_identity" + ] = "other-profile" + blockers = review_workflow_load.review_workflow_load_blockers(root) + self.assertTrue( + any("profile identity mismatch" in b for b in blockers), + msg=blockers, + ) + review_workflow_load.clear_review_workflow_load() + + def test_prompt_conflict_detected(self): + conflict, reasons = review_workflow_load.assess_prompt_conflict( + "Run work-issue author implementation only") + self.assertTrue(conflict) + self.assertTrue(reasons) + + +class TestReviewWorkflowLoadGates(unittest.TestCase): + def setUp(self): + review_workflow_load.clear_review_workflow_load() + mcp_server.init_review_decision_lock("prgs", "review_pr") + mcp_server.record_preflight_check("whoami") + mcp_server.record_preflight_check("capability", "reviewer") + + def _load_workflow(self): + return mcp_server.gitea_load_review_workflow() + + def test_mcp_helper_returns_required_fields(self): + res = self._load_workflow() + self.assertTrue(res["success"]) + self.assertTrue(res["loaded"]) + self.assertIn("workflow_source", res) + self.assertIn("workflow_hash", res) + self.assertIn("final_report_schema_path", res) + self.assertIn("final_report_schema_hash", res) + + def test_mark_final_blocked_without_load(self): + res = mcp_server.gitea_mark_final_review_decision( + 42, "approve", remote="prgs") + self.assertFalse(res["marked_ready"]) + self.assertTrue(any( + "gitea_load_review_workflow" in r for r in res["reasons"])) + self.assertTrue(any( + "approve/merge replay" in r.lower() or "Do not call" in r + for r in res["reasons"])) + + def test_submit_review_blocked_without_load(self): + with patch("mcp_server.gitea_check_pr_eligibility") as elig: + elig.return_value = { + "eligible": True, + "authenticated_user": "rev", + "profile_name": "prgs-reviewer", + "pr_author": "author", + "head_sha": "abc123", + "reasons": [], + } + res = mcp_server.gitea_submit_pr_review( + 42, + "approve", + remote="prgs", + final_review_decision_ready=True, + ) + self.assertFalse(res["performed"]) + self.assertTrue(any( + "gitea_load_review_workflow" in r for r in res["reasons"])) + + def test_merge_blocked_without_load(self): + res = mcp_server.gitea_merge_pr( + 42, + confirmation="MERGE PR 42", + remote="prgs", + ) + self.assertFalse(res["performed"]) + self.assertTrue(any( + "gitea_load_review_workflow" in r for r in res["reasons"])) + + def test_resolve_capability_reports_missing_load(self): + with patch.object(mcp_server, "_ensure_matching_profile"): + with patch.object( + mcp_server.gitea_config, "is_runtime_switching_enabled", + return_value=False): + with patch.object( + mcp_server, "_authenticated_username", + return_value="rev"): + res = mcp_server.gitea_resolve_task_capability( + "review_pr", remote="prgs") + proof = res.get("workflow_load_proof") or {} + self.assertFalse(proof.get("workflow_load_valid")) + self.assertTrue(any( + "gitea_load_review_workflow" in g + for g in res.get("task_role_guidance") or [])) + + def test_init_review_lock_clears_prior_load(self): + self._load_workflow() + mcp_server.init_review_decision_lock("prgs", "review_pr") + blockers = review_workflow_load.review_workflow_load_blockers( + str(__import__("pathlib").Path(__file__).resolve().parent.parent)) + self.assertTrue(blockers) + + def test_dry_run_allowed_without_load(self): + with patch("mcp_server.get_auth_header", return_value="Basic dGVzdA=="), \ + patch("mcp_server._list_pr_lease_comments", return_value=[]), \ + patch("mcp_server.gitea_check_pr_eligibility") as elig: + elig.return_value = { + "eligible": True, + "authenticated_user": "rev", + "profile_name": "prgs-reviewer", + "pr_author": "author", + "head_sha": "abc123", + "reasons": [], + } + res = mcp_server.gitea_dry_run_pr_review( + 42, "approve", remote="prgs") + self.assertNotIn( + "gitea_load_review_workflow", + " ".join(res.get("reasons") or []), + ) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_reviewer_mutation_capability_proof.py b/tests/test_reviewer_mutation_capability_proof.py new file mode 100644 index 0000000..2f91dd0 --- /dev/null +++ b/tests/test_reviewer_mutation_capability_proof.py @@ -0,0 +1,141 @@ +"""Tests for exact per-mutation capability proof in reviewer reports (#405).""" + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from reviewer_mutation_capability_proof import assess_mutation_capability_proof +from review_proofs import assess_mutation_capability_proof as proofs_assess +from final_report_validator import assess_final_report_validator + + +REVIEW_ONLY = """ +Review decision: approved +Mutation capability table: +- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | resolved before review +""" + +MERGE_PROVEN = """ +Review decision: approved +Merge result: merged 0123456789ab +Mutation capability table: +- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review +- gitea_merge_pr | merge_pr (gitea.pr.merge) | merged | resolved before merge +""" + +MERGE_AND_DELETE_PROVEN = """ +Review decision: approved +Merge result: merged 0123456789ab +Remote branch deleted: feat/issue-x +Mutation capability table: +- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review +- gitea_merge_pr | merge_pr (gitea.pr.merge) | merged | before merge +- gitea_delete_branch | delete_branch (gitea.branch.delete) | deleted | before delete +""" + + +class TestModule(unittest.TestCase): + def test_review_only_with_capability_passes(self): + r = assess_mutation_capability_proof(REVIEW_ONLY) + self.assertTrue(r["proven"], r["reasons"]) + + def test_merge_with_exact_capability_passes(self): + r = assess_mutation_capability_proof(MERGE_PROVEN) + self.assertTrue(r["proven"], r["reasons"]) + + def test_merge_and_delete_fully_proven_passes(self): + r = assess_mutation_capability_proof(MERGE_AND_DELETE_PROVEN) + self.assertTrue(r["proven"], r["reasons"]) + + def test_review_pr_does_not_authorize_merge(self): + report = """ +Review decision: approved +Merge result: merged 0123456789ab +Mutation capability table: +- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review +""" + r = assess_mutation_capability_proof(report) + self.assertFalse(r["proven"]) + self.assertTrue(any("merge" in x.lower() for x in r["reasons"]), r["reasons"]) + + def test_merge_pr_does_not_authorize_branch_deletion(self): + report = """ +Review decision: approved +Merge result: merged 0123456789ab +Remote branch deleted: feat/issue-x +Mutation capability table: +- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review +- gitea_merge_pr | merge_pr (gitea.pr.merge) | merged | before merge +""" + r = assess_mutation_capability_proof(report) + self.assertFalse(r["proven"]) + self.assertTrue(any("delet" in x.lower() for x in r["reasons"]), r["reasons"]) + + def test_delete_skipped_when_capability_missing_passes(self): + report = """ +Review decision: approved +Merge result: merged 0123456789ab +Branch deletion: skipped — delete_branch capability not available +Mutation capability table: +- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review +- gitea_merge_pr | merge_pr (gitea.pr.merge) | merged | before merge +""" + r = assess_mutation_capability_proof(report) + self.assertTrue(r["proven"], r["reasons"]) + + def test_missing_table_when_merging_blocks(self): + report = """ +Review decision: approved +Merge result: merged 0123456789ab +merge_pr gitea.pr.merge resolved +""" + r = assess_mutation_capability_proof(report) + self.assertFalse(r["proven"]) + self.assertTrue(any("table" in x.lower() for x in r["reasons"]), r["reasons"]) + + def test_post_hoc_proof_blocks(self): + report = """ +Review decision: approved +Merge result: merged 0123456789ab +Mutation capability table: +- gitea_merge_pr | merge_pr (gitea.pr.merge) | merged | capability resolved after merge +""" + r = assess_mutation_capability_proof(report) + self.assertFalse(r["proven"]) + self.assertTrue(any("after" in x.lower() for x in r["reasons"]), r["reasons"]) + + def test_review_without_capability_blocks(self): + report = "Review decision: approved\nreview submitted\n" + r = assess_mutation_capability_proof(report) + self.assertFalse(r["proven"]) + + def test_no_mutation_no_requirement(self): + r = assess_mutation_capability_proof("Selected PR: #1\nSkipped, no action.") + self.assertTrue(r["proven"], r["reasons"]) + + +class TestWiring(unittest.TestCase): + def test_review_proofs_wrapper_matches_module(self): + self.assertEqual( + proofs_assess(MERGE_PROVEN)["proven"], + assess_mutation_capability_proof(MERGE_PROVEN)["proven"], + ) + + def test_final_report_validator_flags_nearby_capability_merge(self): + report = """ +## Controller Handoff +- Task: review_pr +Review decision: approved +Merge result: merged 0123456789ab +Mutation capability table: +- gitea_submit_pr_review | review_pr (gitea.pr.review) | submitted | before review +""" + result = assess_final_report_validator(report, "review_pr") + rule_ids = [f["rule_id"] for f in result.get("findings", [])] + self.assertIn("reviewer.mutation_capability_proof", rule_ids) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_reviewer_pr_lease.py b/tests/test_reviewer_pr_lease.py new file mode 100644 index 0000000..97e5a2e --- /dev/null +++ b/tests/test_reviewer_pr_lease.py @@ -0,0 +1,202 @@ +"""Tests for per-PR reviewer leases (#407).""" + +import sys +import unittest +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) + +import merger_lease_adoption as mla +import reviewer_pr_lease as leases + + +def _lease_comment( + pr_number: int, + session_id: str, + *, + phase: str = "claimed", + minutes_ago: int = 0, + candidate_head: str = "a" * 40, +) -> dict: + now = datetime.now(timezone.utc) - timedelta(minutes=minutes_ago) + body = leases.format_lease_body( + repo="Scaled-Tech-Consulting/Gitea-Tools", + pr_number=pr_number, + issue_number=295, + reviewer_identity="rev1", + profile="prgs-reviewer", + session_id=session_id, + worktree="branches/review-pr382", + phase=phase, + candidate_head=candidate_head, + target_branch="master", + target_branch_sha="b" * 40, + last_activity=now, + ) + return {"id": 1, "body": body, "user": {"login": "rev1"}} + + +class TestReviewerLeaseAcquire(unittest.TestCase): + def setUp(self): + leases.clear_session_lease() + + def test_two_reviewers_cannot_lease_same_pr(self): + comments = [_lease_comment(382, "session-a")] + result = leases.assess_acquire_lease( + comments, + pr_number=382, + reviewer_identity="rev2", + profile="prgs-reviewer", + session_id="session-b", + repo="Scaled-Tech-Consulting/Gitea-Tools", + issue_number=295, + worktree="branches/review-pr382-b", + candidate_head="c" * 40, + target_branch="master", + target_branch_sha="d" * 40, + ) + self.assertFalse(result["acquire_allowed"]) + self.assertTrue(any("already has active" in r for r in result["reasons"])) + + def test_two_reviewers_can_lease_different_prs(self): + comments = [_lease_comment(382, "session-a")] + result = leases.assess_acquire_lease( + comments, + pr_number=383, + reviewer_identity="rev2", + profile="prgs-reviewer", + session_id="session-b", + repo="Scaled-Tech-Consulting/Gitea-Tools", + issue_number=296, + worktree="branches/review-pr383", + candidate_head="c" * 40, + target_branch="master", + target_branch_sha="d" * 40, + ) + self.assertTrue(result["acquire_allowed"]) + self.assertIsNotNone(result["lease_body"]) + + +class TestReviewerLeaseFreshness(unittest.TestCase): + def test_stale_warning_after_30_minutes(self): + lease = leases.parse_lease_comment( + _lease_comment(382, "session-a", minutes_ago=35)["body"] + ) + self.assertEqual( + leases.classify_lease_freshness(lease), + "stale_warning", + ) + + def test_reclaimable_after_60_minutes(self): + lease = leases.parse_lease_comment( + _lease_comment(382, "session-a", minutes_ago=65)["body"] + ) + self.assertEqual( + leases.classify_lease_freshness(lease), + "reclaimable", + ) + + +class TestReviewerLeaseMutationGate(unittest.TestCase): + def setUp(self): + leases.clear_session_lease() + + def test_reviewer_without_lease_cannot_mutate(self): + head = "f" * 40 + comments = [_lease_comment(382, "other-session", candidate_head=head)] + result = leases.assess_mutation_lease_gate( + pr_number=382, + comments=comments, + reviewer_identity="rev1", + session_id="my-session", + mutation="approve", + live_head_sha=head, + pinned_head_sha=head, + ) + self.assertTrue(result["block"]) + + def test_owned_lease_allows_mutation(self): + head = "f" * 40 + comments = [_lease_comment(382, "my-session", candidate_head=head)] + leases.record_session_lease({ + "pr_number": 382, + "session_id": "my-session", + "candidate_head": head, + "target_branch": "master", + "comment_id": 101, + }, lease_provenance=mla.build_lease_provenance( + source=mla.SOURCE_ACQUIRE, + comment_id=101, + )) + result = leases.assess_mutation_lease_gate( + pr_number=382, + comments=comments, + reviewer_identity="rev1", + session_id="my-session", + mutation="approve", + live_head_sha=head, + pinned_head_sha=head, + ) + self.assertFalse(result["block"]) + + def test_head_change_invalidates_lease(self): + reviewed = "f" * 40 + live = "e" * 40 + comments = [_lease_comment(382, "my-session", candidate_head=reviewed)] + leases.record_session_lease({ + "pr_number": 382, + "session_id": "my-session", + "candidate_head": reviewed, + "comment_id": 102, + }, lease_provenance=mla.build_lease_provenance( + source=mla.SOURCE_ACQUIRE, + comment_id=102, + )) + result = leases.assess_mutation_lease_gate( + pr_number=382, + comments=comments, + reviewer_identity="rev1", + session_id="my-session", + mutation="merge", + live_head_sha=live, + pinned_head_sha=reviewed, + ) + self.assertTrue(result["block"]) + self.assertTrue(any("head" in r.lower() for r in result["reasons"])) + + +class TestReviewerLeaseMcpGate(unittest.TestCase): + def setUp(self): + leases.clear_session_lease() + patch("mcp_server.verify_preflight_purity").start() + patch("gitea_audit.audit_enabled", return_value=False).start() + mcp_server = __import__("mcp_server") + mcp_server._IDENTITY_CACHE.clear() + mcp_server.init_review_decision_lock("prgs", "review_pr") + mcp_server.record_preflight_check("whoami") + mcp_server.record_preflight_check("capability", "reviewer") + + def tearDown(self): + patch.stopall() + leases.clear_session_lease() + + def test_reviewer_pr_lease_gate_helper_blocks_without_session(self): + import mcp_server + head = "a" * 40 + with patch("mcp_server._fetch_pr_comments", return_value=[]): + reasons = mcp_server._reviewer_pr_lease_gate( + pr_number=382, + remote="prgs", + host=None, + org=None, + repo=None, + mutation="approve", + live_head_sha=head, + pinned_head_sha=head, + ) + self.assertTrue(any("lease" in r.lower() for r in reasons)) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_reviewer_validation_cwd_proof.py b/tests/test_reviewer_validation_cwd_proof.py new file mode 100644 index 0000000..59b81fc --- /dev/null +++ b/tests/test_reviewer_validation_cwd_proof.py @@ -0,0 +1,135 @@ +"""Tests for validation cwd/HEAD proof verifier (#398).""" +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 reviewer_validation_cwd_proof import assess_validation_cwd_proof_report # noqa: E402 + +ROOT = "/Users/jasonwalker/Development/Gitea-Tools" +WORKTREE = f"{ROOT}/branches/review-feat-issue-398" +HEAD = "f5953549aad5e822f14f52d3ea3c6d7990109384" + + +def _proof_backed_report() -> str: + return "\n".join([ + f"Candidate head SHA: {HEAD}", + f"pwd: {WORKTREE}", + f"git rev-parse HEAD: {HEAD}", + "git status --short --branch: ## feat/issue-398...prgs/master", + f"Validation command: cd {WORKTREE} && venv/bin/python -m pytest tests/ -q", + "Result: 1497 passed, 6 skipped", + ]) + + +class TestValidationCwdProof(unittest.TestCase): + def test_no_validation_claim_passes(self): + result = assess_validation_cwd_proof_report("Review decision: approve") + self.assertTrue(result["proven"]) + + def test_missing_cwd_proof_fails(self): + result = assess_validation_cwd_proof_report( + f"Validation command: pytest tests/\nCandidate head SHA: {HEAD}", + validation_session={"validation_ran": True, "expected_head_sha": HEAD}, + ) + self.assertFalse(result["proven"]) + self.assertTrue(result["block"]) + + def test_main_checkout_cwd_blocks(self): + result = assess_validation_cwd_proof_report( + "\n".join([ + f"Candidate head SHA: {HEAD}", + f"pwd: {ROOT}", + f"git rev-parse HEAD: {HEAD}", + "git status --short --branch: ## master", + "Validation command: pytest tests/ -q", + ]), + validation_session={"validation_ran": True, "expected_head_sha": HEAD}, + project_root=ROOT, + ) + self.assertFalse(result["proven"]) + self.assertTrue(result["violations"]) + + def test_wrong_head_blocks(self): + wrong = "a" * 40 + result = assess_validation_cwd_proof_report( + "\n".join([ + f"Candidate head SHA: {HEAD}", + f"pwd: {WORKTREE}", + f"git rev-parse HEAD: {wrong}", + "git status --short --branch: clean", + f"Validation command: cd {WORKTREE} && pytest -q", + ]), + validation_session={"validation_ran": True, "expected_head_sha": HEAD}, + project_root=ROOT, + ) + self.assertFalse(result["proven"]) + self.assertTrue(result["violations"]) + + def test_fully_proof_backed_passes(self): + result = assess_validation_cwd_proof_report( + _proof_backed_report(), + validation_session={"validation_ran": True, "expected_head_sha": HEAD}, + project_root=ROOT, + ) + self.assertTrue(result["proven"], result["reasons"]) + + def test_baseline_without_cwd_fails(self): + result = assess_validation_cwd_proof_report( + "\n".join([ + _proof_backed_report(), + "Baseline validation command: pytest tests/ -q", + ]), + validation_session={ + "validation_ran": True, + "expected_head_sha": HEAD, + "baseline_validation_ran": True, + }, + project_root=ROOT, + ) + self.assertFalse(result["proven"]) + self.assertTrue( + any("baseline" in r.lower() for r in result["reasons"]) + ) + + def test_baseline_with_full_proof_passes(self): + result = assess_validation_cwd_proof_report( + "\n".join([ + _proof_backed_report(), + f"Baseline worktree: {ROOT}/branches/baseline-master-pr376", + f"Baseline target SHA: {HEAD}", + f"Baseline validation command: cd {ROOT}/branches/baseline-master-pr376 && pytest -q", + ]), + validation_session={ + "validation_ran": True, + "expected_head_sha": HEAD, + "baseline_validation_ran": True, + }, + project_root=ROOT, + ) + self.assertTrue(result["proven"], result["reasons"]) + + def test_final_report_validator_integration(self): + result = assess_final_report_validator( + "Validation command: pytest tests/ -q", + "review_pr", + validation_session={"validation_ran": True}, + ) + self.assertTrue(result["blocked"] or result["downgraded"]) + self.assertTrue( + any( + f.get("rule_id") == "reviewer.validation_cwd_proof" + for f in result.get("findings") or [] + ) + ) + + def test_exported_from_review_proofs(self): + from review_proofs import assess_validation_cwd_proof_report as exported + + self.assertTrue(callable(exported)) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_root_checkout_guard.py b/tests/test_root_checkout_guard.py new file mode 100644 index 0000000..86dc064 --- /dev/null +++ b/tests/test_root_checkout_guard.py @@ -0,0 +1,161 @@ +"""Tests for root checkout guard (#475).""" + +from __future__ import annotations + +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 # noqa: E402 +import root_checkout_guard as rcg # noqa: E402 + +CONTROL_ROOT = str(Path(__file__).resolve().parents[3]) +BRANCHES_WORKTREE = str(Path(__file__).resolve().parents[1]) +MASTER_SHA = "a" * 40 +OTHER_SHA = "b" * 40 + + +class TestAssessRootCheckoutGuard(unittest.TestCase): + def _assess(self, **kwargs): + defaults = { + "workspace_path": CONTROL_ROOT, + "canonical_repo_root": CONTROL_ROOT, + "current_branch": "master", + "head_sha": MASTER_SHA, + "porcelain_status": "", + "remote_master_sha": MASTER_SHA, + "resolved_role": "author", + } + defaults.update(kwargs) + return rcg.assess_root_checkout_guard(**defaults) + + def test_clean_master_control_checkout_allowed(self): + result = self._assess() + self.assertTrue(result["proven"]) + self.assertFalse(result["block"]) + + def test_branches_worktree_allowed_for_author(self): + result = self._assess( + workspace_path=BRANCHES_WORKTREE, + current_branch="feat/issue-475-root-checkout-guard", + head_sha=OTHER_SHA, + resolved_role="author", + ) + self.assertTrue(result["proven"]) + + def test_branches_worktree_allowed_for_reviewer(self): + result = self._assess( + workspace_path=f"{CONTROL_ROOT}/branches/review-pr-1", + current_branch="review-pr-1", + resolved_role="reviewer", + ) + self.assertTrue(result["proven"]) + + def test_reconciler_always_allowed(self): + result = self._assess( + current_branch="feat/some-branch", + head_sha=OTHER_SHA, + porcelain_status=" M gitea_mcp_server.py\n", + resolved_role="reconciler", + ) + self.assertTrue(result["proven"]) + + def test_feature_branch_on_control_checkout_blocked(self): + result = self._assess( + current_branch="feat/issue-99-example", + head_sha=OTHER_SHA, + ) + self.assertTrue(result["block"]) + self.assertIn("not a stable base branch", result["reasons"][0]) + + def test_detached_head_blocked(self): + result = self._assess(current_branch=None) + self.assertTrue(result["block"]) + self.assertIn("detached HEAD", result["reasons"][0]) + + def test_dirty_control_checkout_blocked(self): + result = self._assess(porcelain_status=" M gitea_mcp_server.py\n") + self.assertTrue(result["block"]) + self.assertIn("tracked local edits", result["reasons"][0]) + + def test_head_behind_prgs_master_blocked(self): + result = self._assess( + head_sha=OTHER_SHA, + remote_master_sha=MASTER_SHA, + ) + self.assertTrue(result["block"]) + self.assertIn("does not match prgs/master", result["reasons"][0]) + + def test_merger_requires_clean_control_checkout(self): + result = self._assess( + workspace_path=BRANCHES_WORKTREE, + current_branch="feat/issue-475-root-checkout-guard", + head_sha=OTHER_SHA, + resolved_role="merger", + ) + self.assertTrue(result["block"]) + + +class TestVerifyPreflightRootGuardIntegration(unittest.TestCase): + def setUp(self): + srv._preflight_whoami_called = True + srv._preflight_capability_called = True + srv._preflight_resolved_role = "reviewer" + 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._get_workspace_porcelain", return_value="") + @patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value=MASTER_SHA) + @patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state") + @patch("gitea_mcp_server._resolve_author_mutation_context") + def test_reviewer_from_contaminated_root_blocked( + self, mock_ctx, mock_git, _remote_sha, _porcelain, + ): + srv._preflight_capability_baseline_porcelain = "" + mock_ctx.return_value = { + "workspace_path": CONTROL_ROOT, + "canonical_repo_root": CONTROL_ROOT, + "process_project_root": CONTROL_ROOT, + } + mock_git.return_value = { + "current_branch": "feat/hijacked-root", + "head_sha": OTHER_SHA, + "porcelain_status": "", + } + with self.assertRaises(RuntimeError) as ctx: + srv.verify_preflight_purity("prgs", worktree_path=CONTROL_ROOT) + self.assertIn("Root checkout guard (#475)", str(ctx.exception)) + self.assertIn(rcg.REMEDIATION, str(ctx.exception)) + + @patch("gitea_mcp_server._get_workspace_porcelain", return_value="") + @patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value=MASTER_SHA) + @patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state") + @patch("gitea_mcp_server._resolve_author_mutation_context") + def test_reviewer_from_branches_worktree_allowed( + self, mock_ctx, mock_git, _remote_sha, _porcelain, + ): + srv._preflight_capability_baseline_porcelain = "" + mock_ctx.return_value = { + "workspace_path": BRANCHES_WORKTREE, + "canonical_repo_root": CONTROL_ROOT, + "process_project_root": BRANCHES_WORKTREE, + } + mock_git.return_value = { + "current_branch": "feat/issue-475-root-checkout-guard", + "head_sha": OTHER_SHA, + "porcelain_status": "", + } + srv.verify_preflight_purity("prgs", worktree_path=BRANCHES_WORKTREE) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_run_tests_script.py b/tests/test_run_tests_script.py new file mode 100644 index 0000000..1ba5512 --- /dev/null +++ b/tests/test_run_tests_script.py @@ -0,0 +1,65 @@ +"""Contract checks for the root-level `run-tests.sh` convenience runner (#473). + +`run-tests.sh` is the canonical full-validation entry point. It must invoke the +project virtualenv interpreter, forward extra args to pytest, fail closed when +the venv Python is missing, and stay repo-local (no network, no lock files). +These checks pin that behavior so it cannot silently regress, and confirm the +developer testing guide names the runner. +""" +import stat +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCRIPT = REPO_ROOT / "run-tests.sh" +GUIDE = REPO_ROOT / "docs" / "developer-testing-guidelines.md" + + +def _script_text() -> str: + return SCRIPT.read_text(encoding="utf-8") + + +def test_run_tests_script_exists(): + assert SCRIPT.is_file(), "run-tests.sh must exist at the repository root" + + +def test_run_tests_script_is_executable(): + mode = SCRIPT.stat().st_mode + assert mode & stat.S_IXUSR, "run-tests.sh must be executable (chmod +x)" + + +def test_run_tests_script_has_strict_bash_flags(): + text = _script_text() + assert text.startswith("#!/usr/bin/env bash"), "must use the bash shebang" + assert "set -euo pipefail" in text, "must set -euo pipefail" + + +def test_run_tests_script_uses_venv_pytest_and_forwards_args(): + text = _script_text() + # Resolves the venv interpreter relative to the script's own directory. + assert "venv/bin/python" in text, "must use the project virtualenv Python" + assert "-m pytest" in text, "must run pytest via the module" + assert '"$@"' in text, "must forward extra CLI args to pytest" + + +def test_run_tests_script_fails_closed_without_venv(): + text = _script_text() + # Missing venv must be an explicit, non-zero-exit error, not a silent + # fallback to the wrong interpreter. + assert "if [[ ! -x" in text, "must guard on an executable venv Python" + assert "exit 1" in text, "must exit non-zero when the venv is missing" + assert "ERROR" in text, "must print a clear error message" + + +def test_run_tests_script_stays_repo_local(): + text = _script_text() + # No network calls, no lock-file writes from the runner itself. + for forbidden in ("curl", "wget", "gitea_issue_lock.json"): + assert forbidden not in text, f"runner must not reference {forbidden!r}" + + +def test_guide_names_canonical_runner(): + text = " ".join(GUIDE.read_text(encoding="utf-8").split()) + assert "./run-tests.sh" in text, "testing guide must name ./run-tests.sh" + assert "./run-tests.sh tests/test_mcp_server.py -q" in text, ( + "testing guide must show the focused-validation example" + ) diff --git a/tests/test_stacked_pr_support.py b/tests/test_stacked_pr_support.py new file mode 100644 index 0000000..e592fc1 --- /dev/null +++ b/tests/test_stacked_pr_support.py @@ -0,0 +1,185 @@ +"""Unit tests for stacked-PR base policy (#484).""" +import unittest + +import stacked_pr_support as sps + + +def _pr(number, branch, state="open"): + return {"number": number, "state": state, "head": {"ref": branch}} + + +OPEN_PRS = [ + _pr(479, "feat/issue-478-mcp-menu-shell"), + _pr(481, "feat/issue-477-lock-adoption-proof"), +] + +# Motivating case (#482 stacked on #479 / #478). +STACKED_BODY = ( + "Closes #482.\n\n" + "Stacked on PR #479 / issue #478.\n" + "Base branch: feat/issue-478-mcp-menu-shell\n" + "Head branch: feat/issue-482-skip-stale-request-changes-pr\n" + "Do not merge before PR #479 lands." +) + + +class TestIsBaseBranch(unittest.TestCase): + def test_master_main_dev_are_base(self): + for b in ("master", "main", "dev"): + self.assertTrue(sps.is_base_branch(b)) + + def test_feature_branch_is_not_base(self): + self.assertFalse(sps.is_base_branch("feat/issue-478-mcp-menu-shell")) + + +class TestStackedBaseDeclaration(unittest.TestCase): + def test_no_declaration_is_normal_path(self): + out = sps.assess_stacked_base_declaration( + stacked_base_branch=None, stacked_base_pr=None, open_prs=OPEN_PRS + ) + self.assertFalse(out["block"]) + self.assertFalse(out["declared"]) + self.assertIsNone(out["approved"]) + + def test_valid_open_pr_base_is_approved(self): + out = sps.assess_stacked_base_declaration( + stacked_base_branch="feat/issue-478-mcp-menu-shell", + stacked_base_pr=479, + open_prs=OPEN_PRS, + ) + self.assertFalse(out["block"]) + self.assertEqual(out["approved"]["branch"], "feat/issue-478-mcp-menu-shell") + self.assertEqual(out["approved"]["pr_number"], 479) + self.assertTrue(out["approved"]["verified_open"]) + + def test_missing_pr_number_blocks(self): + out = sps.assess_stacked_base_declaration( + stacked_base_branch="feat/issue-478-mcp-menu-shell", + stacked_base_pr=None, + open_prs=OPEN_PRS, + ) + self.assertTrue(out["block"]) + self.assertIn("without stacked_base_pr", out["reasons"][0]) + + def test_arbitrary_branch_with_no_open_pr_blocks(self): + out = sps.assess_stacked_base_declaration( + stacked_base_branch="feat/random-unrelated-branch", + stacked_base_pr=999, + open_prs=OPEN_PRS, + ) + self.assertTrue(out["block"]) + self.assertIn("does not correspond to any OPEN pull request", out["reasons"][0]) + + def test_stale_merged_base_blocks(self): + merged = [_pr(479, "feat/issue-478-mcp-menu-shell", state="closed")] + out = sps.assess_stacked_base_declaration( + stacked_base_branch="feat/issue-478-mcp-menu-shell", + stacked_base_pr=479, + open_prs=merged, + ) + self.assertTrue(out["block"]) + self.assertIsNone(out["approved"]) + + def test_pr_number_mismatch_blocks(self): + out = sps.assess_stacked_base_declaration( + stacked_base_branch="feat/issue-478-mcp-menu-shell", + stacked_base_pr=481, # wrong PR for this branch + open_prs=OPEN_PRS, + ) + self.assertTrue(out["block"]) + self.assertIn("does not match the open", out["reasons"][0]) + + def test_declaring_a_base_branch_blocks(self): + out = sps.assess_stacked_base_declaration( + stacked_base_branch="master", stacked_base_pr=1, open_prs=OPEN_PRS + ) + self.assertTrue(out["block"]) + self.assertIn("already a normal base branch", out["reasons"][0]) + + +class TestStackedPrBody(unittest.TestCase): + def test_complete_body_has_no_missing_fields(self): + missing = sps.assess_stacked_pr_body( + STACKED_BODY, base_branch="feat/issue-478-mcp-menu-shell", pr_number=479 + ) + self.assertEqual(missing, []) + + def test_missing_all_fields(self): + missing = sps.assess_stacked_pr_body( + "just some text", base_branch="feat/issue-478-mcp-menu-shell", pr_number=479 + ) + self.assertEqual(len(missing), 3) + + def test_missing_merge_ordering_only(self): + body = "Base branch feat/issue-478-mcp-menu-shell for PR #479" + missing = sps.assess_stacked_pr_body( + body, base_branch="feat/issue-478-mcp-menu-shell", pr_number=479 + ) + self.assertEqual(len(missing), 1) + self.assertIn("merge-ordering", missing[0]) + + +class TestCreatePrBase(unittest.TestCase): + APPROVED = {"branch": "feat/issue-478-mcp-menu-shell", "pr_number": 479, "verified_open": True} + + def test_master_base_passes_without_stacked_metadata(self): + out = sps.assess_create_pr_base( + base="master", approved_stacked_base=None, body="Closes #1", open_prs=[] + ) + self.assertFalse(out["block"]) + self.assertFalse(out["stacked"]) + + def test_non_base_without_approval_blocks(self): + out = sps.assess_create_pr_base( + base="feat/issue-478-mcp-menu-shell", + approved_stacked_base=None, + body=STACKED_BODY, + open_prs=OPEN_PRS, + ) + self.assertTrue(out["block"]) + self.assertIn("no approved stacked base", out["reasons"][0]) + + def test_non_base_mismatched_approval_blocks(self): + out = sps.assess_create_pr_base( + base="feat/some-other-branch", + approved_stacked_base=self.APPROVED, + body=STACKED_BODY, + open_prs=OPEN_PRS, + ) + self.assertTrue(out["block"]) + self.assertIn("does not match the issue lock's approved stacked base", out["reasons"][0]) + + def test_approved_base_with_good_body_passes(self): + out = sps.assess_create_pr_base( + base="feat/issue-478-mcp-menu-shell", + approved_stacked_base=self.APPROVED, + body=STACKED_BODY, + open_prs=OPEN_PRS, + ) + self.assertFalse(out["block"]) + self.assertTrue(out["stacked"]) + self.assertEqual(out["stacked_base_pr"], 479) + + def test_approved_base_now_stale_blocks(self): + out = sps.assess_create_pr_base( + base="feat/issue-478-mcp-menu-shell", + approved_stacked_base=self.APPROVED, + body=STACKED_BODY, + open_prs=[_pr(479, "feat/issue-478-mcp-menu-shell", state="merged")], + ) + self.assertTrue(out["block"]) + self.assertIn("no longer corresponds to an OPEN", out["reasons"][0]) + + def test_approved_base_with_incomplete_body_blocks(self): + out = sps.assess_create_pr_base( + base="feat/issue-478-mcp-menu-shell", + approved_stacked_base=self.APPROVED, + body="Closes #482 only", + open_prs=OPEN_PRS, + ) + self.assertTrue(out["block"]) + self.assertIn("must document the stack", out["reasons"][0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_terminal_review_hard_stop.py b/tests/test_terminal_review_hard_stop.py index bd5109b..07edd98 100644 --- a/tests/test_terminal_review_hard_stop.py +++ b/tests/test_terminal_review_hard_stop.py @@ -12,6 +12,8 @@ from unittest.mock import patch import mcp_server +HEAD_SHA = "a" * 40 + def _lock(mutations=None, correction=False): return { @@ -34,7 +36,11 @@ def _lock(mutations=None, correction=False): def _seed(mutations=None, correction=False): + import review_workflow_load + review_workflow_load.record_review_workflow_load(mcp_server.PROJECT_ROOT) mcp_server._save_review_decision_lock(_lock(mutations, correction)) + mcp_server.gitea_load_review_workflow() + APPROVED_A = {"pr_number": 5, "action": "approve", "review_id": 1, @@ -46,6 +52,7 @@ RC_A = {"pr_number": 5, "action": "request_changes", "review_id": 2, class TestTerminalHardStopReasons(unittest.TestCase): def tearDown(self): mcp_server._save_review_decision_lock(None) + mcp_server.review_workflow_load.clear_review_workflow_load() def test_no_lock_no_reasons(self): mcp_server._save_review_decision_lock(None) @@ -90,7 +97,10 @@ class TestTerminalHardStopReasons(unittest.TestCase): class TestMergeHardStopWiring(unittest.TestCase): def tearDown(self): + import review_workflow_load + review_workflow_load.clear_review_workflow_load() mcp_server._save_review_decision_lock(None) + mcp_server.review_workflow_load.clear_review_workflow_load() def test_merge_blocked_after_request_changes(self): _seed([RC_A]) @@ -111,7 +121,10 @@ class TestMergeHardStopWiring(unittest.TestCase): class TestMarkFinalHardStopWiring(unittest.TestCase): def tearDown(self): + import review_workflow_load + review_workflow_load.clear_review_workflow_load() mcp_server._save_review_decision_lock(None) + mcp_server.review_workflow_load.clear_review_workflow_load() def test_mark_ready_blocked_after_terminal_mutation(self): _seed([RC_A]) @@ -127,20 +140,32 @@ def _feedback(blocking, stale=False, success=True): "success": success, "has_blocking_change_requests": blocking, "review_feedback_stale": stale, - "current_head_sha": "abc123", + "current_head_sha": HEAD_SHA, } +def _mark(action, pr_number=6, **kwargs): + kwargs.setdefault("expected_head_sha", HEAD_SHA) + no_lease_block = {"block": False, "reasons": [], "mutation_allowed": True} + with patch("mcp_server._list_pr_lease_comments", return_value=[]), \ + patch("mcp_server._pr_work_lease_reviewer_block", return_value=no_lease_block): + return mcp_server.gitea_mark_final_review_decision( + pr_number=pr_number, action=action, remote="prgs", **kwargs + ) + + class TestDuplicateRequestChangesSuppression(unittest.TestCase): def tearDown(self): + import review_workflow_load + review_workflow_load.clear_review_workflow_load() mcp_server._save_review_decision_lock(None) + mcp_server.review_workflow_load.clear_review_workflow_load() def test_duplicate_request_changes_blocked_at_same_head(self): _seed() with patch.object(mcp_server, "gitea_get_pr_review_feedback", return_value=_feedback(blocking=True, stale=False)): - result = mcp_server.gitea_mark_final_review_decision( - pr_number=6, action="request_changes", remote="prgs") + result = _mark("request_changes") self.assertFalse(result["marked_ready"]) self.assertTrue( any("duplicate" in r for r in result["reasons"]), @@ -150,16 +175,14 @@ class TestDuplicateRequestChangesSuppression(unittest.TestCase): _seed() with patch.object(mcp_server, "gitea_get_pr_review_feedback", return_value=_feedback(blocking=True, stale=True)): - result = mcp_server.gitea_mark_final_review_decision( - pr_number=6, action="request_changes", remote="prgs") + result = _mark("request_changes") self.assertTrue(result["marked_ready"], result.get("reasons")) def test_request_changes_allowed_when_no_blocker(self): _seed() with patch.object(mcp_server, "gitea_get_pr_review_feedback", return_value=_feedback(blocking=False)): - result = mcp_server.gitea_mark_final_review_decision( - pr_number=6, action="request_changes", remote="prgs") + result = _mark("request_changes") self.assertTrue(result["marked_ready"], result.get("reasons")) def test_request_changes_fails_closed_when_feedback_unavailable(self): @@ -167,8 +190,7 @@ class TestDuplicateRequestChangesSuppression(unittest.TestCase): with patch.object(mcp_server, "gitea_get_pr_review_feedback", return_value=_feedback(blocking=False, success=False)): - result = mcp_server.gitea_mark_final_review_decision( - pr_number=6, action="request_changes", remote="prgs") + result = _mark("request_changes") self.assertFalse(result["marked_ready"]) self.assertTrue( any("could not verify" in r for r in result["reasons"]), @@ -178,8 +200,7 @@ class TestDuplicateRequestChangesSuppression(unittest.TestCase): _seed() with patch.object(mcp_server, "gitea_get_pr_review_feedback", side_effect=AssertionError("must not be called")): - result = mcp_server.gitea_mark_final_review_decision( - pr_number=6, action="approve", remote="prgs") + result = _mark("approve") self.assertTrue(result["marked_ready"], result.get("reasons")) diff --git a/tests/test_validation_status_vocabulary.py b/tests/test_validation_status_vocabulary.py new file mode 100644 index 0000000..f01f3e9 --- /dev/null +++ b/tests/test_validation_status_vocabulary.py @@ -0,0 +1,198 @@ +"""Tests for validation status vocabulary (#406).""" +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 validation_status_vocabulary import ( # noqa: E402 + STATUS_BASELINE_EQUIVALENT, + STATUS_FAILED, + STATUS_MERGE_SIM_RESOLVED, + STATUS_PASSED, + STATUS_TRANSIENT_PASS, + assess_validation_status_vocabulary, +) + + +def _handoff(**extra): + fields = { + "Task": "review PR #386", + "Validation status": STATUS_PASSED, + "Raw PR-head validation result": "passed", + "Merge simulation result": "not run", + "Baseline worktree used": "none", + } + fields.update(extra) + lines = ["## Controller Handoff", ""] + lines.extend(f"- {key}: {value}" for key, value in fields.items()) + return "\n".join(lines) + + +class TestValidationStatusVocabulary(unittest.TestCase): + def test_raw_head_pass_status(self): + report = _handoff() + result = assess_validation_status_vocabulary(report) + self.assertFalse(result["block"]) + self.assertEqual(result["status_claimed"], STATUS_PASSED) + + def test_raw_head_failure_with_baseline_match(self): + report = _handoff( + **{ + "Validation status": STATUS_BASELINE_EQUIVALENT, + "Raw PR-head validation result": "failed", + "Baseline worktree used": "branches/baseline-master-pr386", + "Baseline target SHA": "a" * 40, + "Baseline failures": "test_foo failed", + "PR failures": "test_foo failed", + "Failure signatures match": "true", + } + ) + result = assess_validation_status_vocabulary(report) + self.assertFalse(result["block"]) + self.assertTrue(result["baseline_proof_complete"]) + + def test_baseline_equivalent_without_baseline_proof_blocked(self): + report = _handoff( + **{ + "Validation status": STATUS_BASELINE_EQUIVALENT, + "Raw PR-head validation result": "failed", + } + ) + result = assess_validation_status_vocabulary(report) + self.assertTrue(result["block"]) + self.assertIn("baseline-equivalent", result["reasons"][0]) + + def test_merge_simulation_resolution_passes(self): + report = "\n".join([ + _handoff( + **{ + "Validation status": STATUS_MERGE_SIM_RESOLVED, + "Raw PR-head validation result": "failed", + "Merge simulation result": "passed", + } + ), + "Worktree/index mutations: merge simulation in branches/review-pr386", + "Worktree path: branches/review-pr386", + "Pre-simulation clean status: clean", + "Merge result: clean merge", + "Abort command: git merge --abort", + "Post-abort clean status: clean", + ]) + command_log = [ + {"command": "git merge --no-commit prgs/master"}, + {"command": "git merge --abort"}, + ] + result = assess_validation_status_vocabulary( + report, command_log=command_log + ) + self.assertFalse(result["block"]) + self.assertTrue(result["merge_simulation_passed"]) + + def test_merge_simulation_failure_stays_failed(self): + report = _handoff( + **{ + "Validation status": STATUS_FAILED, + "Raw PR-head validation result": "failed", + "Merge simulation result": "failed", + } + ) + result = assess_validation_status_vocabulary(report) + self.assertFalse(result["block"]) + + def test_failed_status_with_passing_merge_sim_blocked(self): + report = "\n".join([ + _handoff( + **{ + "Validation status": STATUS_FAILED, + "Raw PR-head validation result": "failed", + "Merge simulation result": "passed", + } + ), + "Worktree/index mutations: merge simulation", + "Worktree path: branches/review-pr386", + "Pre-simulation clean status: clean", + "Merge result: clean", + "Abort command: git merge --abort", + "Post-abort clean status: clean", + ]) + result = assess_validation_status_vocabulary( + report, + command_log=[{"command": "git merge --no-commit prgs/master"}], + ) + self.assertTrue(result["block"]) + + def test_transient_failure_then_pass(self): + report = _handoff( + **{ + "Validation status": STATUS_TRANSIENT_PASS, + "Raw PR-head validation result": "passed", + "Transient validation failure history": ( + "first run failed with infra flake; rerun passed" + ), + } + ) + result = assess_validation_status_vocabulary(report) + self.assertFalse(result["block"]) + + def test_transient_pass_without_history_blocked(self): + report = _handoff( + **{ + "Validation status": STATUS_TRANSIENT_PASS, + "Raw PR-head validation result": "passed", + } + ) + result = assess_validation_status_vocabulary(report) + self.assertTrue(result["block"]) + + def test_bare_passed_after_raw_failure_blocked(self): + report = _handoff( + **{ + "Validation status": STATUS_PASSED, + "Raw PR-head validation result": "failed", + } + ) + result = assess_validation_status_vocabulary(report) + self.assertTrue(result["block"]) + + def test_wrong_baseline_label_when_merge_sim_used_blocked(self): + report = "\n".join([ + _handoff( + **{ + "Validation status": STATUS_BASELINE_EQUIVALENT, + "Raw PR-head validation result": "failed", + "Merge simulation result": "passed", + } + ), + "Worktree/index mutations: merge simulation", + "Worktree path: branches/review-pr386", + "Pre-simulation clean status: clean", + "Merge result: clean", + "Abort command: git merge --abort", + "Post-abort clean status: clean", + ]) + result = assess_validation_status_vocabulary( + report, + command_log=[{"command": "git merge --no-commit prgs/master"}], + ) + self.assertTrue(result["block"]) + joined = " ".join(result["reasons"]).lower() + self.assertTrue( + "misleading" in joined or "baseline-equivalent" in joined + ) + + def test_final_report_validator_integration_blocks_misleading_label(self): + report = _handoff( + **{ + "Validation status": STATUS_BASELINE_EQUIVALENT, + "Raw PR-head validation result": "failed", + } + ) + result = assess_final_report_validator(report, "review_pr") + blocked_ids = {f["rule_id"] for f in result["findings"]} + self.assertIn("reviewer.validation_status_vocabulary", blocked_ids) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_webui_lease_visibility.py b/tests/test_webui_lease_visibility.py new file mode 100644 index 0000000..a515311 --- /dev/null +++ b/tests/test_webui_lease_visibility.py @@ -0,0 +1,94 @@ +"""Tests for web UI lease visibility (#433).""" +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from starlette.testclient import TestClient + +from webui.app import create_app +from webui.lease_loader import ( + _duplicate_branch_warnings, + _duplicate_pr_warnings, + load_lease_snapshot, + parse_reviewer_lease_comment, + snapshot_to_dict, +) + + +class TestLeaseLoader(unittest.TestCase): + def test_parse_reviewer_lease_comment(self): + body = ( + "\n" + "pr: #42\n" + "reviewer_identity: sysadmin\n" + "profile: prgs-reviewer\n" + "phase: validating\n" + "expires_at: 2026-07-07T20:00:00Z\n" + ) + parsed = parse_reviewer_lease_comment(body) + self.assertIsNotNone(parsed) + assert parsed is not None + self.assertEqual(parsed["pr_number"], 42) + self.assertEqual(parsed["phase"], "validating") + + def test_duplicate_pr_warnings(self): + raw_prs = [ + {"number": 1, "title": "Closes #99", "body": ""}, + {"number": 2, "title": "fixes #99", "body": ""}, + ] + warnings = _duplicate_pr_warnings(raw_prs) + self.assertEqual(len(warnings), 1) + self.assertEqual(warnings[0].issue_number, 99) + self.assertEqual(warnings[0].pr_numbers, (1, 2)) + + def test_duplicate_branch_warnings(self): + warnings = _duplicate_branch_warnings([ + "feat/issue-12-a", + "feat/issue-12-b", + "master", + ]) + self.assertEqual(len(warnings), 1) + self.assertEqual(warnings[0].issue_number, 12) + + def test_load_snapshot_with_injected_fetch(self): + def fetch_prs(_h, _o, _r, _a): + return ([], None) + + def fetch_issues(_h, _o, _r, _a): + return ([], None) + + snapshot = load_lease_snapshot( + fetch_prs=fetch_prs, + fetch_issues=fetch_issues, + fetch_comments=lambda *_a, **_k: [], + issue_lock_path=str(Path("/nonexistent/lock.json")), + ) + data = snapshot_to_dict(snapshot) + self.assertIn("claim_inventory", data) + self.assertIn("collision_history", data) + + +class TestLeaseRoutes(unittest.TestCase): + def setUp(self): + self.client = TestClient(create_app()) + + def test_leases_page_renders(self): + response = self.client.get("/leases") + self.assertEqual(response.status_code, 200) + self.assertIn("Leases", response.text) + self.assertIn("Collision warnings", response.text) + self.assertNotIn("child issue", response.text.lower()) + + def test_api_leases_json(self): + response = self.client.get("/api/leases") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertIn("claim_inventory", data) + self.assertIn("duplicate_prs", data) + self.assertIn("reviewer_leases", data) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_webui_queue_dashboard.py b/tests/test_webui_queue_dashboard.py index cc9ea97..cbdcc16 100644 --- a/tests/test_webui_queue_dashboard.py +++ b/tests/test_webui_queue_dashboard.py @@ -12,12 +12,14 @@ from starlette.testclient import TestClient from webui.app import create_app from webui.queue_loader import ( PaginationMeta, + QueueSnapshot, _classify_issue, _classify_pr, _extract_linked_issue, load_queue_snapshot, snapshot_to_dict, ) +from webui.queue_views import render_queue_page _RECENT = datetime.now(timezone.utc).isoformat() _STALE = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat() @@ -210,5 +212,77 @@ class TestQueueRoutes(unittest.TestCase): self.assertEqual(len(snapshot.prs), 0) +def _empty_pagination() -> PaginationMeta: + return PaginationMeta( + page=1, + per_page=50, + returned_count=0, + has_more=False, + is_final_page=True, + inventory_complete=True, + pages_fetched=1, + ) + + +def _empty_fetch(*_args, **_kwargs): + return [], _empty_pagination() + + +class TestQueueFailClosedUx(unittest.TestCase): + """Regression tests for #458 fail-closed empty-state copy.""" + + def test_fail_closed_view_suppresses_empty_queue_copy(self): + snapshot = QueueSnapshot( + project_id="gitea-tools", + repo_label="Scaled-Tech-Consulting/Gitea-Tools", + prs=(), + issues=(), + pr_pagination=None, + issue_pagination=None, + fetch_error="Gitea credentials unavailable for gitea.prgs.cc", + ) + html = render_queue_page(snapshot) + self.assertIn("Queue unavailable", html) + self.assertIn("Not loaded", html) + self.assertNotIn("No open items.", html) + self.assertIn("pagination: unavailable", html) + + def test_fail_closed_route_does_not_show_empty_queue_copy(self): + client = TestClient(create_app()) + snapshot = load_queue_snapshot( + fetch_prs=_empty_fetch, + fetch_issues=_empty_fetch, + ) + snapshot = QueueSnapshot( + project_id=snapshot.project_id, + repo_label=snapshot.repo_label, + prs=(), + issues=(), + pr_pagination=None, + issue_pagination=None, + fetch_error="Gitea credentials unavailable for gitea.prgs.cc", + ) + with mock.patch("webui.app.load_queue_snapshot", return_value=snapshot): + response = client.get("/queue") + self.assertEqual(response.status_code, 200) + self.assertIn("Queue unavailable", response.text) + self.assertNotIn("No open items.", response.text) + + def test_successful_empty_inventory_shows_empty_copy(self): + snapshot = load_queue_snapshot( + fetch_prs=_empty_fetch, + fetch_issues=_empty_fetch, + ) + self.assertIsNone(snapshot.fetch_error) + self.assertEqual(len(snapshot.prs), 0) + self.assertEqual(len(snapshot.issues), 0) + self.assertTrue(snapshot.pr_pagination.inventory_complete) + + html = render_queue_page(snapshot) + self.assertNotIn("Queue unavailable", html) + self.assertEqual(html.count("No open items."), 2) + self.assertIn("pagination (complete)", html) + + if __name__ == "__main__": unittest.main() \ No newline at end of file diff --git a/tests/test_webui_skeleton.py b/tests/test_webui_skeleton.py index 2960fc5..c66158c 100644 --- a/tests/test_webui_skeleton.py +++ b/tests/test_webui_skeleton.py @@ -46,10 +46,14 @@ class TestWebuiSkeleton(unittest.TestCase): self.assertEqual(response.status_code, 200) self.assertIn("Gitea-Tools", response.text) + def test_leases_is_implemented(self): + response = self.client.get("/leases") + self.assertEqual(response.status_code, 200) + self.assertIn("Leases", response.text) + self.assertIn("Collision warnings", response.text) + def test_extra_stub_routes(self): - for path in ("/worktrees", "/leases"): - with self.subTest(path=path): - self.assertEqual(self.client.get(path).status_code, 200) + self.assertEqual(self.client.get("/worktrees").status_code, 200) def test_actions_is_implemented(self): response = self.client.get("/actions") @@ -67,12 +71,12 @@ class TestWebuiSkeleton(unittest.TestCase): self.assertIn("Live queue", response.text) def test_nav_links_on_all_pages(self): - for path in ("/", "/queue", "/projects", "/prompts", "/runtime", "/audit", "/actions"): + paths = ('/', '/queue', '/projects', '/prompts', '/runtime', '/audit', '/leases', '/actions') + hrefs = ('/queue', '/projects', '/prompts', '/runtime', '/audit', '/leases', '/actions') + for path in paths: with self.subTest(path=path): text = self.client.get(path).text - for href in ( - "/queue", "/projects", "/prompts", "/runtime", "/audit", "/actions", - ): + for href in hrefs: self.assertIn(f'href="{href}"', text) diff --git a/tests/test_workspace_guard_alignment.py b/tests/test_workspace_guard_alignment.py new file mode 100644 index 0000000..4454351 --- /dev/null +++ b/tests/test_workspace_guard_alignment.py @@ -0,0 +1,173 @@ +"""Tests for runtime_context / mutation-guard workspace alignment (#460).""" + +from __future__ import annotations + +import os +import sys +import unittest +from pathlib import Path +from unittest import mock +from unittest.mock import MagicMock + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +import author_mutation_worktree as amw # noqa: E402 +import gitea_mcp_server as srv # noqa: E402 + +CONTROL_ROOT = str(Path(__file__).resolve().parents[3]) +BRANCHES_WORKTREE = str(Path(__file__).resolve().parents[1]) +MCP_PROCESS_ROOT = BRANCHES_WORKTREE + + +class TestCanonicalRepoRoot(unittest.TestCase): + @mock.patch("subprocess.run") + def test_resolves_main_repo_from_branches_worktree(self, mock_run): + mock_run.return_value = MagicMock( + returncode=0, + stdout=f"{CONTROL_ROOT}/.git\n", + ) + root = amw.resolve_canonical_repo_root(BRANCHES_WORKTREE, MCP_PROCESS_ROOT) + self.assertEqual(root, CONTROL_ROOT) + + def test_falls_back_when_git_unavailable(self): + root = amw.resolve_canonical_repo_root("/missing/path", MCP_PROCESS_ROOT) + self.assertEqual(root, os.path.realpath(MCP_PROCESS_ROOT)) + + +class TestWorkspaceRepoMembership(unittest.TestCase): + @mock.patch("os.path.isdir", return_value=True) + @mock.patch("os.path.exists", return_value=True) + @mock.patch("subprocess.run") + def test_valid_branches_worktree_accepted(self, mock_run, *_exists): + mock_run.return_value = MagicMock( + returncode=0, + stdout=f"{CONTROL_ROOT}/.git\n", + ) + result = amw.assess_workspace_repo_membership( + workspace_path=BRANCHES_WORKTREE, + canonical_repo_root=CONTROL_ROOT, + ) + self.assertTrue(result["proven"]) + self.assertFalse(result["block"]) + + @mock.patch("os.path.isdir", return_value=True) + @mock.patch("os.path.exists", return_value=True) + @mock.patch("subprocess.run") + def test_wrong_repo_rejected(self, mock_run, *_exists): + mock_run.return_value = MagicMock( + returncode=0, + stdout="/other/repo/.git\n", + ) + result = amw.assess_workspace_repo_membership( + workspace_path=BRANCHES_WORKTREE, + canonical_repo_root=CONTROL_ROOT, + ) + self.assertTrue(result["block"]) + self.assertIn("does not belong", result["reasons"][0]) + + @mock.patch("os.path.exists", return_value=False) + def test_missing_worktree_rejected(self, *_exists): + result = amw.assess_workspace_repo_membership( + workspace_path=f"{CONTROL_ROOT}/branches/missing-worktree", + canonical_repo_root=CONTROL_ROOT, + ) + self.assertTrue(result["block"]) + self.assertIn("does not exist", result["reasons"][0]) + + +class TestRuntimeContextGuardAlignment(unittest.TestCase): + def setUp(self): + srv._preflight_whoami_called = True + srv._preflight_capability_called = True + srv._preflight_resolved_role = "author" + self._orig_in_test = srv._preflight_in_test_mode + srv._preflight_in_test_mode = lambda: False + self._env_patch = mock.patch.dict( + os.environ, + {}, + clear=False, + ) + self._env_patch.start() + os.environ.pop("GITEA_ACTIVE_WORKTREE", None) + os.environ.pop("GITEA_AUTHOR_WORKTREE", None) + + def tearDown(self): + srv._preflight_in_test_mode = self._orig_in_test + self._env_patch.stop() + + def test_runtime_context_and_guard_share_resolved_workspace(self): + with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT): + ctx = srv._resolve_author_mutation_context(BRANCHES_WORKTREE) + status = srv.assess_preflight_status(worktree_path=BRANCHES_WORKTREE) + self.assertEqual(ctx["workspace_path"], os.path.realpath(BRANCHES_WORKTREE)) + self.assertEqual(ctx["canonical_repo_root"], CONTROL_ROOT) + self.assertFalse(ctx["roots_aligned"]) + self.assertEqual( + status["preflight_workspace"]["active_task_workspace_root"], + os.path.realpath(BRANCHES_WORKTREE), + ) + self.assertEqual( + status["preflight_workspace"]["canonical_repository_root"], + CONTROL_ROOT, + ) + self.assertIn("workspace_root_mismatch", status["preflight_workspace"]) + + @mock.patch("subprocess.run") + @mock.patch("os.path.isdir", return_value=True) + @mock.patch("os.path.exists", return_value=True) + def test_declared_branches_worktree_passes_when_mcp_root_differs( + self, _exists, _isdir, mock_run + ): + mock_run.return_value = MagicMock( + returncode=0, + stdout=f"{CONTROL_ROOT}/.git\n", + ) + with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT): + with mock.patch.dict("os.environ", {"GITEA_TEST_PORCELAIN": ""}, clear=False): + srv.verify_preflight_purity(worktree_path=BRANCHES_WORKTREE) + + @mock.patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40) + @mock.patch( + "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", + return_value={ + "current_branch": "master", + "head_sha": "a" * 40, + "porcelain_status": "", + }, + ) + def test_stable_checkout_still_rejected(self, _git, _remote_sha): + with mock.patch.object(srv, "PROJECT_ROOT", CONTROL_ROOT): + with mock.patch.dict("os.environ", {"GITEA_TEST_PORCELAIN": ""}, clear=False): + with self.assertRaises(RuntimeError) as ctx: + srv.verify_preflight_purity() + self.assertIn("stable control checkout", str(ctx.exception)) + + @mock.patch("gitea_mcp_server.root_checkout_guard.resolve_remote_master_sha", return_value="a" * 40) + @mock.patch( + "gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", + return_value={ + "current_branch": "master", + "head_sha": "a" * 40, + "porcelain_status": "", + }, + ) + @mock.patch("os.path.isdir", return_value=True) + @mock.patch("os.path.exists", return_value=True) + @mock.patch("subprocess.run") + def test_non_branches_worktree_rejected( + self, mock_run, mock_exists, mock_isdir, _git, _remote_sha, + ): + outside = "/tmp/outside-repo-checkout" + mock_run.return_value = MagicMock( + returncode=0, + stdout=f"{CONTROL_ROOT}/.git\n", + ) + with mock.patch.object(srv, "PROJECT_ROOT", CONTROL_ROOT): + with mock.patch.dict("os.environ", {"GITEA_TEST_PORCELAIN": ""}, clear=False): + with self.assertRaises(RuntimeError) as ctx: + srv.verify_preflight_purity(worktree_path=outside) + self.assertIn("not under", str(ctx.exception)) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/tests/test_worktree_cleanup_audit.py b/tests/test_worktree_cleanup_audit.py new file mode 100644 index 0000000..f55b652 --- /dev/null +++ b/tests/test_worktree_cleanup_audit.py @@ -0,0 +1,532 @@ +"""Tests for session-owned worktree cleanup audit, TTL, and integrity (#401, #404).""" + +import sys +import unittest +from datetime import datetime, timedelta, timezone +from unittest.mock import patch + +sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent)) + +import worktree_cleanup_audit as wca # noqa: E402 + + +NOW = datetime(2026, 7, 7, 12, 0, 0, tzinfo=timezone.utc) + + +def _iso(dt): + return dt.isoformat().replace("+00:00", "Z") + + +class TestWorkflowTypeInference(unittest.TestCase): + def test_review_pr_path(self): + self.assertEqual( + wca.infer_workflow_type("branches/review-pr376", "review-pr376"), + wca.WORKFLOW_REVIEW, + ) + + def test_baseline_path(self): + self.assertEqual( + wca.infer_workflow_type("branches/baseline-master-issue-401"), + wca.WORKFLOW_BASELINE, + ) + + def test_merge_simulation_path(self): + self.assertEqual( + wca.infer_workflow_type("branches/merge-sim-pr380"), + wca.WORKFLOW_MERGE_SIMULATION, + ) + + def test_issue_work_branch(self): + self.assertEqual( + wca.infer_workflow_type( + "branches/issue-401-worktree", "feat/issue-401-worktree" + ), + wca.WORKFLOW_ISSUE_WORK, + ) + + def test_conflict_fix_path(self): + self.assertEqual( + wca.infer_workflow_type("branches/conflict-fix-pr376"), + wca.WORKFLOW_CONFLICT_FIX, + ) + + def test_unknown_path(self): + self.assertEqual( + wca.infer_workflow_type("branches/scratchpad"), wca.WORKFLOW_UNKNOWN + ) + + +class TestMetadata(unittest.TestCase): + def test_metadata_fields_present(self): + meta = wca.build_worktree_metadata( + path="branches/issue-401-worktree", + branch="feat/issue-401-worktree", + head_sha="abc123", + creator="jcwalker3", + profile="prgs-author", + created_at=_iso(NOW), + last_used_at=_iso(NOW), + ) + for field in ( + "path", + "workflow_type", + "issue_number", + "pr_number", + "branch", + "head_sha", + "creator", + "profile", + "created_at", + "last_used_at", + "cleanup_eligibility", + ): + self.assertIn(field, meta) + self.assertEqual(meta["issue_number"], 401) + self.assertEqual(meta["workflow_type"], wca.WORKFLOW_ISSUE_WORK) + + def test_review_metadata_auto_removable_flag(self): + meta = wca.build_worktree_metadata(path="branches/review-pr42") + self.assertTrue(meta["auto_remove_on_success"]) + + +class TestTTL(unittest.TestCase): + def test_expired(self): + old = _iso(NOW - timedelta(hours=48)) + self.assertTrue(wca.is_ttl_expired(last_used_at=old, now=NOW, ttl_hours=24)) + + def test_not_expired(self): + recent = _iso(NOW - timedelta(hours=1)) + self.assertFalse( + wca.is_ttl_expired(last_used_at=recent, now=NOW, ttl_hours=24) + ) + + def test_unknown_timestamp_fails_safe(self): + self.assertFalse(wca.is_ttl_expired(last_used_at=None, now=NOW)) + self.assertFalse( + wca.is_ttl_expired(last_used_at="not-a-date", now=NOW) + ) + + +class TestClassification(unittest.TestCase): + def test_successful_review_cleanup(self): + # Scenario 1: clean review worktree -> removable. + cls = wca.classify_worktree( + workflow_type=wca.WORKFLOW_REVIEW, is_dirty=False + ) + self.assertEqual(cls, wca.CLASS_CLEAN_STALE_REMOVABLE) + self.assertTrue(wca.is_removable(cls)) + + def test_dirty_worktree_preserved(self): + # Scenario 3: dirty worktree is never removable. + cls = wca.classify_worktree( + workflow_type=wca.WORKFLOW_REVIEW, is_dirty=True + ) + self.assertEqual(cls, wca.CLASS_DIRTY_LOCAL) + self.assertFalse(wca.is_removable(cls)) + + def test_active_pr_worktree_preserved(self): + # Scenario 4: open PR wins over an otherwise-removable review worktree. + cls = wca.classify_worktree( + workflow_type=wca.WORKFLOW_REVIEW, + is_dirty=False, + has_open_pr=True, + ) + self.assertEqual(cls, wca.CLASS_ACTIVE_OPEN_PR) + self.assertFalse(wca.is_removable(cls)) + + def test_stale_clean_issue_worktree_removable(self): + # Scenario 5: clean issue worktree, TTL expired, no lock -> removable. + cls = wca.classify_worktree( + workflow_type=wca.WORKFLOW_ISSUE_WORK, + is_dirty=False, + ttl_expired=True, + ) + self.assertEqual(cls, wca.CLASS_CLEAN_STALE_REMOVABLE) + self.assertTrue(wca.is_removable(cls)) + + def test_fresh_clean_issue_worktree_preserved(self): + # Clean issue worktree not yet TTL-expired stays active. + cls = wca.classify_worktree( + workflow_type=wca.WORKFLOW_ISSUE_WORK, + is_dirty=False, + ttl_expired=False, + ) + self.assertEqual(cls, wca.CLASS_ACTIVE_ISSUE_WORK) + self.assertFalse(wca.is_removable(cls)) + + def test_detached_review_worktree_classified(self): + # Scenario 6: detached review worktree -> detached_review_leftover. + cls = wca.classify_worktree( + workflow_type=wca.WORKFLOW_REVIEW, + is_dirty=False, + is_detached=True, + ) + self.assertEqual(cls, wca.CLASS_DETACHED_REVIEW_LEFTOVER) + self.assertTrue(wca.is_removable(cls)) + + def test_ttl_expired_but_dirty_preserved(self): + # Scenario 7: dirty wins over TTL expiry. + cls = wca.classify_worktree( + workflow_type=wca.WORKFLOW_ISSUE_WORK, + is_dirty=True, + ttl_expired=True, + ) + self.assertEqual(cls, wca.CLASS_DIRTY_LOCAL) + self.assertFalse(wca.is_removable(cls)) + + def test_lease_protected_worktree_preserved(self): + # Scenario 8: active lease is never removable. + cls = wca.classify_worktree( + workflow_type=wca.WORKFLOW_REVIEW, + is_dirty=False, + has_active_lease=True, + ) + self.assertEqual(cls, wca.CLASS_ACTIVE_ISSUE_WORK) + self.assertFalse(wca.is_removable(cls)) + + def test_active_issue_lock_preserved(self): + cls = wca.classify_worktree( + workflow_type=wca.WORKFLOW_ISSUE_WORK, + is_dirty=False, + has_active_issue_lock=True, + ttl_expired=True, + ) + self.assertEqual(cls, wca.CLASS_ACTIVE_ISSUE_WORK) + self.assertFalse(wca.is_removable(cls)) + + def test_protected_base_worktree_never_removable(self): + cls = wca.classify_worktree( + workflow_type=wca.WORKFLOW_ISSUE_WORK, + is_dirty=False, + is_protected=True, + ttl_expired=True, + ) + self.assertEqual(cls, wca.CLASS_UNSAFE_UNKNOWN) + self.assertFalse(wca.is_removable(cls)) + + def test_unknown_workflow_type_unsafe(self): + cls = wca.classify_worktree( + workflow_type=wca.WORKFLOW_UNKNOWN, is_dirty=False, ttl_expired=True + ) + self.assertEqual(cls, wca.CLASS_UNSAFE_UNKNOWN) + self.assertFalse(wca.is_removable(cls)) + + +class TestRemovalDecision(unittest.TestCase): + def test_safe_removal_proof(self): + decision = wca.assess_worktree_removal( + path="branches/review-pr42", + branch="review-pr42", + head_sha="abc123", + is_dirty=False, + has_open_pr=False, + has_active_lease=False, + classification=wca.CLASS_CLEAN_STALE_REMOVABLE, + ) + self.assertTrue(decision["safe_to_remove"]) + self.assertEqual(decision["block_reasons"], []) + self.assertTrue(decision["clean"]) + self.assertTrue(decision["no_active_pr"]) + self.assertTrue(decision["no_active_lease"]) + + def test_dirty_blocks_removal(self): + decision = wca.assess_worktree_removal( + path="branches/review-pr42", + branch="review-pr42", + head_sha="abc123", + is_dirty=True, + has_open_pr=False, + has_active_lease=False, + classification=wca.CLASS_DIRTY_LOCAL, + ) + self.assertFalse(decision["safe_to_remove"]) + self.assertIn("worktree has uncommitted changes", decision["block_reasons"]) + + def test_open_pr_and_lease_block_removal(self): + decision = wca.assess_worktree_removal( + path="branches/review-pr42", + branch="review-pr42", + head_sha="abc123", + is_dirty=False, + has_open_pr=True, + has_active_lease=True, + classification=wca.CLASS_ACTIVE_OPEN_PR, + ) + self.assertFalse(decision["safe_to_remove"]) + self.assertIn("worktree branch has an open PR", decision["block_reasons"]) + self.assertIn("worktree has an active lease", decision["block_reasons"]) + + +class TestSuccessCleanupPlan(unittest.TestCase): + def test_review_worktree_removed_at_success(self): + # Scenario 1 end-to-end: clean review worktree removed at success. + meta = wca.build_worktree_metadata(path="branches/review-pr42") + plan = wca.plan_success_cleanup( + metadata=meta, is_dirty=False, has_open_pr=False, has_active_lease=False + ) + self.assertTrue(plan["remove"]) + + def test_failed_review_leaves_worktree_reported(self): + # Scenario 2: dirty review worktree preserved and reported at failure. + meta = wca.build_worktree_metadata(path="branches/review-pr42") + plan = wca.plan_success_cleanup( + metadata=meta, is_dirty=True, has_open_pr=False, has_active_lease=False + ) + self.assertFalse(plan["remove"]) + self.assertIn("uncommitted", plan["reason"]) + report = wca.cleanup_failure_report(meta["path"], plan["reason"]) + self.assertFalse(report["removed"]) + self.assertEqual(report["path"], "branches/review-pr42") + + def test_issue_worktree_preserved_by_policy(self): + meta = wca.build_worktree_metadata( + path="branches/issue-401-worktree", branch="feat/issue-401-worktree" + ) + plan = wca.plan_success_cleanup( + metadata=meta, is_dirty=False, has_open_pr=False, has_active_lease=False + ) + self.assertFalse(plan["remove"]) + self.assertIn("preserved by policy", plan["reason"]) + + +class TestPorcelainParser(unittest.TestCase): + def test_parse_branch_and_detached(self): + text = ( + "worktree /repo\n" + "HEAD 1111111111111111111111111111111111111111\n" + "branch refs/heads/master\n" + "\n" + "worktree /repo/branches/review-pr42\n" + "HEAD 2222222222222222222222222222222222222222\n" + "detached\n" + ) + entries = wca.parse_worktree_porcelain(text) + self.assertEqual(len(entries), 2) + self.assertEqual(entries[0]["branch"], "master") + self.assertFalse(entries[0]["detached"]) + self.assertIsNone(entries[1]["branch"]) + self.assertTrue(entries[1]["detached"]) + + +class TestAuditReportAccuracy(unittest.TestCase): + """Scenario 9: cleanup report accuracy over a mixed branches/ directory.""" + + PORCELAIN = ( + "worktree /repo\n" + "HEAD 1111111111111111111111111111111111111111\n" + "branch refs/heads/master\n" + "\n" + "worktree /repo/branches/review-pr42\n" + "HEAD 2222222222222222222222222222222222222222\n" + "branch refs/heads/review-pr42\n" + "\n" + "worktree /repo/branches/issue-400-open-pr\n" + "HEAD 3333333333333333333333333333333333333333\n" + "branch refs/heads/feat/issue-400-open-pr\n" + "\n" + "worktree /repo/branches/issue-401-dirty\n" + "HEAD 4444444444444444444444444444444444444444\n" + "branch refs/heads/feat/issue-401-dirty\n" + ) + + def _fake_dirty(self, path): + if path.endswith("issue-401-dirty"): + return {"exists": True, "dirty": True, "dirty_files": [" M x.py"]} + return {"exists": True, "dirty": False, "dirty_files": []} + + def test_mixed_directory_classified(self): + with patch.object( + wca, + "list_worktrees", + return_value=wca.parse_worktree_porcelain(self.PORCELAIN), + ), patch.object( + wca, "read_worktree_dirty", side_effect=self._fake_dirty + ), patch.object( + wca, "git_worktree_list", return_value="(mocked)" + ): + report = wca.audit_branches_directory( + "/repo", + open_pr_branches={"feat/issue-400-open-pr"}, + ) + + by_path = {wt["path"]: wt for wt in report["worktrees"]} + # main checkout on master -> protected -> unsafe/unknown, not removable + self.assertEqual( + by_path["/repo"]["classification"], wca.CLASS_UNSAFE_UNKNOWN + ) + # clean review worktree -> removable + self.assertEqual( + by_path["/repo/branches/review-pr42"]["classification"], + wca.CLASS_CLEAN_STALE_REMOVABLE, + ) + # open PR branch -> preserved + self.assertEqual( + by_path["/repo/branches/issue-400-open-pr"]["classification"], + wca.CLASS_ACTIVE_OPEN_PR, + ) + # dirty worktree -> preserved + self.assertEqual( + by_path["/repo/branches/issue-401-dirty"]["classification"], + wca.CLASS_DIRTY_LOCAL, + ) + # exactly one removable candidate (the clean review worktree) + self.assertEqual(report["removable_count"], 1) + self.assertEqual( + report["removable_candidates"][0]["path"], + "/repo/branches/review-pr42", + ) + self.assertEqual(report["total"], 4) + self.assertEqual(report["git_worktree_list"], "(mocked)") + + +def _integrity_entry( + path: str, + *, + classification: str, + registered: bool = True, + preserve: bool | None = None, +) -> dict: + preserve_flag = preserve if preserve is not None else classification in { + "active_open_pr", + "active_issue_work", + "dirty_local_worktree", + "unsafe_unknown", + } + return { + "path": path, + "classification": classification, + "preserve": preserve_flag, + "registered_worktree": registered, + "worktree_state": {"exists": True, "clean": classification == "clean_stale_removable"}, + "worktree_record": {"branch": "feat/x"} if registered else None, + } + + +def _integrity_snapshot(entries: list[dict]) -> dict: + return {"entries": entries} + + +class TestParseWorktreePorcelain(unittest.TestCase): + def test_parses_multiple_worktrees(self): + text = "\n".join([ + "worktree /proj/branches/foo", + "HEAD abcdef0123456789abcdef0123456789abcdef0", + "branch refs/heads/feat/foo", + "", + "worktree /proj", + "HEAD 1111111111111111111111111111111111111111", + "branch refs/heads/master", + ]) + parsed = wca.parse_worktree_list_porcelain(text) + self.assertEqual(len(parsed), 2) + self.assertEqual(parsed[0]["branch"], "feat/foo") + + +class TestClassifyEntry(unittest.TestCase): + def test_active_pr_classification(self): + result = wca.classify_branches_entry( + rel_path="branches/feat-issue-1-x", + worktree_record={"branch": "feat/issue-1-x"}, + worktree_state={"exists": True, "clean": True, "dirty_files": []}, + open_pr_branches={"feat/issue-1-x"}, + ) + self.assertEqual(result, "active_open_pr") + + def test_dirty_classification(self): + result = wca.classify_branches_entry( + rel_path="branches/dirty-one", + worktree_record={"branch": "feat/dirty-one"}, + worktree_state={"exists": True, "dirty_files": ["a.py"]}, + open_pr_branches=set(), + ) + self.assertEqual(result, "dirty_local_worktree") + + +class TestCleanupIntegrity(unittest.TestCase): + def test_preserved_worktree_remains(self): + path = "branches/keep-me" + before = _integrity_snapshot([ + _integrity_entry(path, classification="clean_stale_removable", preserve=False), + ]) + after = _integrity_snapshot([ + _integrity_entry(path, classification="clean_stale_removable", preserve=False), + ]) + result = wca.assess_worktree_cleanup_integrity(before=before, after=after) + self.assertTrue(result["integrity_passed"]) + + def test_intentional_removal_passes(self): + path = "branches/remove-me" + before = _integrity_snapshot([ + _integrity_entry(path, classification="clean_stale_removable", preserve=False), + ]) + after = _integrity_snapshot([]) + result = wca.assess_worktree_cleanup_integrity( + before=before, + after=after, + removals=[{ + "path": path, + "method": "git worktree remove", + "pre_removal_proof": "clean status", + }], + ) + self.assertTrue(result["integrity_passed"]) + + def test_dirty_worktree_disappears_fails(self): + path = "branches/dirty-wt" + before = _integrity_snapshot([_integrity_entry(path, classification="dirty_local_worktree")]) + after = _integrity_snapshot([]) + recon = wca.reconcile_cleanup_audit(before, after) + result = wca.assess_cleanup_audit_integrity(recon) + self.assertFalse(result["proven"]) + + def test_active_pr_worktree_disappears_fails(self): + path = "branches/review-pr99" + before = _integrity_snapshot([_integrity_entry(path, classification="active_open_pr")]) + after = _integrity_snapshot([]) + result = wca.assess_worktree_cleanup_integrity(before=before, after=after) + self.assertFalse(result["integrity_passed"]) + + def test_explained_missing_allowed(self): + path = "branches/review-pr382" + before = _integrity_snapshot([ + _integrity_entry(path, classification="detached_review_leftover", preserve=False), + ]) + after = _integrity_snapshot([]) + result = wca.assess_worktree_cleanup_integrity( + before=before, + after=after, + explained_missing={path: "removed concurrently by sibling session"}, + ) + self.assertTrue(result["integrity_passed"]) + + def test_removal_log_omits_clean_stale_fails(self): + path = "branches/a" + before = _integrity_snapshot([ + _integrity_entry(path, classification="clean_stale_removable", preserve=False), + ]) + after = _integrity_snapshot([]) + recon = wca.reconcile_cleanup_audit(before, after, removal_log=[]) + self.assertFalse(recon["removal_log_complete"]) + + +class TestCleanupReportProof(unittest.TestCase): + def test_complete_report_passes(self): + report = "\n".join([ + "Cleanup audit reconciliation table:", + "Initial count: 10", + "Removed count: 3", + "Preserved count: 7", + "Missing-unexplained count: 0", + "Final count: 7", + "Final verification: git worktree list proof attached", + ]) + result = wca.assess_cleanup_audit_final_report(report) + self.assertTrue(result["proven"]) + + def test_incomplete_report_fails(self): + result = wca.assess_cleanup_audit_final_report("removed some worktrees") + self.assertFalse(result["proven"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_worktrees.py b/tests/test_worktrees.py index 45f1ac5..9ed3285 100644 --- a/tests/test_worktrees.py +++ b/tests/test_worktrees.py @@ -20,33 +20,50 @@ def run(script, *args): branch = arg break - lock_file = Path("/tmp/gitea_issue_lock.json") - created_lock = False + lock_dir_ctx = None + extra_env = os.environ.copy() if script == "worktree-start" and branch: import re - import json + import tempfile + import issue_lock_store + m = re.search(r"issue-(\d+)", branch) if not m: m = re.search(r"pr-(\d+)", branch) issue_num = int(m.group(1)) if m else 999 - lock_file.write_text(json.dumps({ + lock_dir_ctx = tempfile.TemporaryDirectory() + extra_env["GITEA_ISSUE_LOCK_DIR"] = lock_dir_ctx.name + record = { "issue_number": issue_num, "branch_name": branch, "remote": "prgs", "org": "Scaled-Tech-Consulting", - "repo": "Gitea-Tools" - }), encoding="utf-8") - created_lock = True + "repo": "Gitea-Tools", + "worktree_path": "/tmp/test-worktree", + "work_lease": { + "operation_type": "author_issue_work", + "expires_at": "2999-01-01T00:00:00Z", + }, + } + path = issue_lock_store.lock_file_path( + remote="prgs", + org="Scaled-Tech-Consulting", + repo="Gitea-Tools", + issue_number=issue_num, + lock_dir=lock_dir_ctx.name, + ) + issue_lock_store.save_lock_file(path, record) try: proc = subprocess.run( ["bash", str(SCRIPTS / script), *args], capture_output=True, text=True, cwd=str(REPO), + env=extra_env, ) return proc.returncode, proc.stdout, proc.stderr finally: - if created_lock and lock_file.exists(): - lock_file.unlink() + if lock_dir_ctx is not None: + lock_dir_ctx.cleanup() class TestWorktreeStart(unittest.TestCase): diff --git a/validation_status_vocabulary.py b/validation_status_vocabulary.py new file mode 100644 index 0000000..6f4e91a --- /dev/null +++ b/validation_status_vocabulary.py @@ -0,0 +1,205 @@ +"""Precise validation-status vocabulary for reviewer final reports (#406).""" + +from __future__ import annotations + +import re +from typing import Any + +from reviewer_merge_simulation import assess_merge_simulation_report + +STATUS_PASSED = "passed" +STATUS_FAILED = "failed" +STATUS_BASELINE_EQUIVALENT = "baseline-equivalent failure accepted" +STATUS_MERGE_SIM_RESOLVED = "raw-head failure resolved by merge simulation" +STATUS_TRANSIENT_PASS = "passed after transient failure investigation" + +ALLOWED_VALIDATION_STATUSES = frozenset({ + STATUS_PASSED, + STATUS_FAILED, + STATUS_BASELINE_EQUIVALENT, + STATUS_MERGE_SIM_RESOLVED, + STATUS_TRANSIENT_PASS, +}) + +_STATUS_FIELD_RE = re.compile( + r"^\s*[-*]?\s*(?:validation status|pr-head validation status|" + r"official validation status)\s*:\s*(.+?)\s*$", + re.IGNORECASE | re.MULTILINE, +) +_RAW_HEAD_RESULT_RE = re.compile( + r"^\s*[-*]?\s*raw pr-head validation result\s*:\s*(.+?)\s*$", + re.IGNORECASE | re.MULTILINE, +) +_MERGE_SIM_RESULT_RE = re.compile( + r"^\s*[-*]?\s*merge simulation result\s*:\s*(.+?)\s*$", + re.IGNORECASE | re.MULTILINE, +) +_BASELINE_WORKTREE_USED_RE = re.compile( + r"^\s*[-*]?\s*baseline (?:validation )?worktree(?: used)?\s*:\s*(.+?)\s*$", + re.IGNORECASE | re.MULTILINE, +) +_BASELINE_TARGET_SHA_RE = re.compile( + r"^\s*[-*]?\s*baseline target sha\s*:\s*([0-9a-f]{7,40})\s*$", + re.IGNORECASE | re.MULTILINE, +) +_FAILURE_SIGNATURE_RE = re.compile( + r"failure signatures match\s*:\s*(true|yes)", + re.IGNORECASE, +) +_BASELINE_FAILURES_RE = re.compile( + r"baseline failures\s*:", + re.IGNORECASE, +) +_TRANSIENT_HISTORY_RE = re.compile( + r"(?:transient validation failure|earlier validation failure|" + r"prior failure|failure history|failed then passed)", + re.IGNORECASE, +) +_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE) + + +def _first_match(pattern: re.Pattern[str], text: str) -> str: + match = pattern.search(text or "") + return (match.group(1).strip() if match else "") + + +def _normalize_status_label(raw: str) -> str: + text = (raw or "").strip().lower() + for status in ALLOWED_VALIDATION_STATUSES: + if text == status.lower(): + return status + return raw.strip() + + +def _baseline_proof_complete(text: str, baseline_proof: dict | None) -> bool: + proof = baseline_proof or {} + worktree = ( + (proof.get("worktree_path") or "").strip() + or _first_match(_BASELINE_WORKTREE_USED_RE, text) + ).lower() + if not worktree or worktree in {"none", "n/a", "not used", "not applicable"}: + return False + if "branches/" not in worktree and not worktree.startswith("branches/"): + return False + target_sha = (proof.get("baseline_target_sha") or "").strip() + if not target_sha: + target_sha = _first_match(_BASELINE_TARGET_SHA_RE, text) + if not _FULL_SHA.match(target_sha or ""): + return False + if proof.get("failure_signatures_match") is True: + return True + if _FAILURE_SIGNATURE_RE.search(text) and _BASELINE_FAILURES_RE.search(text): + return True + return False + + +def _merge_simulation_passed(text: str, command_log: list | None) -> bool: + merge_result = _first_match(_MERGE_SIM_RESULT_RE, text).lower() + if merge_result in {"passed", "pass", "clean", "succeeded", "success"}: + sim = assess_merge_simulation_report(text, command_log=command_log) + return sim.get("proven") and not sim.get("block") + if "pass" in merge_result and "fail" not in merge_result: + sim = assess_merge_simulation_report(text, command_log=command_log) + return sim.get("proven") and not sim.get("block") + return False + + +def _raw_head_failed(text: str) -> bool: + raw = _first_match(_RAW_HEAD_RESULT_RE, text).lower() + if raw in {"failed", "fail", "failure"}: + return True + if "fail" in raw and "pass" not in raw: + return True + return bool(re.search(r"\bfailed\b.*pr-head validation", text, re.IGNORECASE)) + + +def _raw_head_passed(text: str) -> bool: + raw = _first_match(_RAW_HEAD_RESULT_RE, text).lower() + return raw in {"passed", "pass", "success"} + + +def assess_validation_status_vocabulary( + report_text: str, + *, + command_log: list | None = None, + baseline_proof: dict | None = None, +) -> dict[str, Any]: + """Bind validation-status labels to the proof path that actually ran (#406).""" + text = report_text or "" + reasons: list[str] = [] + status_raw = _first_match(_STATUS_FIELD_RE, text) + status = _normalize_status_label(status_raw) if status_raw else "" + + if status_raw and status not in ALLOWED_VALIDATION_STATUSES: + reasons.append( + f"unknown validation status {status_raw!r}; use one of " + f"{sorted(ALLOWED_VALIDATION_STATUSES)}" + ) + + if status == STATUS_BASELINE_EQUIVALENT: + if not _baseline_proof_complete(text, baseline_proof): + reasons.append( + "baseline-equivalent failure accepted requires baseline " + "worktree path, baseline target SHA, and matching failure " + "signatures (#406)" + ) + + if status == STATUS_MERGE_SIM_RESOLVED: + if not _raw_head_failed(text): + reasons.append( + "raw-head failure resolved by merge simulation requires " + "raw PR-head validation result: failed (#406)" + ) + if not _merge_simulation_passed(text, command_log): + reasons.append( + "raw-head failure resolved by merge simulation requires " + "passing merge simulation with worktree/index mutation proof " + "(#317/#406)" + ) + + if status == STATUS_TRANSIENT_PASS: + if not _TRANSIENT_HISTORY_RE.search(text): + reasons.append( + "passed after transient failure investigation requires " + "documented earlier validation failure history (#396/#406)" + ) + + if status == STATUS_PASSED and _raw_head_failed(text): + reasons.append( + "validation status passed contradicts raw PR-head validation " + "failure; use a precise status (#406)" + ) + + if status == STATUS_BASELINE_EQUIVALENT and _merge_simulation_passed( + text, command_log + ) and not _baseline_proof_complete(text, baseline_proof): + reasons.append( + "baseline-equivalent failure accepted is misleading when only " + "merge simulation resolved the failure; use " + "'raw-head failure resolved by merge simulation' (#406)" + ) + + if status == STATUS_FAILED and _merge_simulation_passed(text, command_log): + reasons.append( + "validation status failed contradicts passing merge simulation; " + "report the precise resolution status (#406)" + ) + + block = bool(reasons) + return { + "block": block, + "proven": not block, + "status_claimed": status or None, + "raw_status_label": status_raw or None, + "raw_head_failed": _raw_head_failed(text), + "raw_head_passed": _raw_head_passed(text), + "merge_simulation_passed": _merge_simulation_passed(text, command_log), + "baseline_proof_complete": _baseline_proof_complete(text, baseline_proof), + "reasons": reasons, + "safe_next_action": ( + "use a validation status that matches the proof path executed " + "(baseline worktree, merge simulation, or transient history)" + if reasons + else "proceed" + ), + } \ No newline at end of file diff --git a/webui/app.py b/webui/app.py index 4557f7b..efe0125 100644 --- a/webui/app.py +++ b/webui/app.py @@ -16,6 +16,8 @@ from webui.prompt_library import find_prompt, library_to_dict from webui.prompt_views import render_prompt_detail, render_prompts_page from webui.gated_actions import attempt_action, load_action_registry, preview_action from webui.gated_action_views import render_actions_page +from webui.lease_loader import load_lease_snapshot, snapshot_to_dict as lease_snapshot_to_dict +from webui.lease_views import render_leases_page from webui.queue_loader import load_queue_snapshot, snapshot_to_dict from webui.queue_views import render_queue_page @@ -144,10 +146,12 @@ async def worktrees(_request: Request) -> HTMLResponse: async def leases(_request: Request) -> HTMLResponse: - return _stub_page( - "Leases", - "Lease visibility will show active issue and reviewer PR leases.", - ) + snapshot = load_lease_snapshot() + return HTMLResponse(render_leases_page(snapshot)) + + +async def api_leases(_request: Request) -> JSONResponse: + return JSONResponse(lease_snapshot_to_dict(load_lease_snapshot())) async def actions(_request: Request) -> HTMLResponse: @@ -225,6 +229,7 @@ def create_app() -> Starlette: api_action_attempt, methods=["POST"], ), + Route("/api/leases", api_leases, methods=["GET"]), ], exception_handlers={405: method_not_allowed}, ) \ No newline at end of file diff --git a/webui/lease_loader.py b/webui/lease_loader.py new file mode 100644 index 0000000..717feef --- /dev/null +++ b/webui/lease_loader.py @@ -0,0 +1,330 @@ +"""Lease and collision visibility for the web UI (#433).""" + +from __future__ import annotations + +import os +import re +import subprocess +from dataclasses import dataclass +from typing import Any, Callable +from urllib.parse import urlparse + +from gitea_auth import api_fetch_page, get_auth_header, repo_api_url +from issue_claim_heartbeat import build_claim_inventory +from merged_cleanup_reconcile import ISSUE_LOCK_FILE, read_issue_lock + +from webui.project_registry import ProjectRecord, load_registry +from webui.queue_loader import _extract_linked_issue, _fetch_issues, _fetch_prs + +_REVIEWER_LEASE_MARKER = "" +_REVIEWER_FIELD_RE = re.compile( + r"^\s*([a-z_]+)\s*:\s*(.+?)\s*$", + re.IGNORECASE | re.MULTILINE, +) +_ISSUE_BRANCH_RE = re.compile(r"issue-(\d+)", re.IGNORECASE) + +_COLLISION_HISTORY = ( + {"number": 267, "title": "Author work leases"}, + {"number": 268, "title": "Issue claim heartbeat leases"}, + {"number": 400, "title": "Early duplicate-work detection"}, + {"number": 407, "title": "Per-PR reviewer leases"}, +) + + +@dataclass(frozen=True) +class CollisionWarning: + kind: str + message: str + issue_number: int | None = None + pr_numbers: tuple[int, ...] = () + + +@dataclass(frozen=True) +class LeaseSnapshot: + project_id: str + repo_label: str + issue_lock: dict[str, Any] | None + claim_inventory: dict[str, Any] + reviewer_leases: tuple[dict[str, Any], ...] + duplicate_prs: tuple[CollisionWarning, ...] + duplicate_branches: tuple[CollisionWarning, ...] + collision_history: tuple[dict[str, Any], ...] + fetch_error: str | None = None + + +def _repo_root() -> str: + override = (os.environ.get("WEBUI_REPO_ROOT") or "").strip() + if override: + return os.path.realpath(override) + return os.path.realpath(os.path.join(os.path.dirname(__file__), "..")) + + +def _host_from_url(remote_host: str) -> str: + parsed = urlparse(remote_host.strip()) + return parsed.netloc or remote_host.strip().rstrip("/") + + +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_reviewer_lease_comment(body: str) -> dict[str, Any] | None: + text = body or "" + if _REVIEWER_LEASE_MARKER not in text: + return None + fields: dict[str, str] = {} + for match in _REVIEWER_FIELD_RE.finditer(text): + fields[match.group(1).strip().lower()] = match.group(2).strip() + if not fields: + return None + return { + "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"), + "phase": (fields.get("phase") or "").strip().lower() or None, + "expires_at": fields.get("expires_at"), + "blocker": fields.get("blocker"), + } + + +def _fetch_comments( + host: str, + org: str, + repo: str, + auth: str, + *, + issue_number: int, +) -> list[dict]: + url = f"{repo_api_url(host, org, repo)}/issues/{issue_number}/comments" + comments: list[dict] = [] + page = 1 + while page <= 10: + raw_page, meta = api_fetch_page(url, auth, page=page, limit=50) + comments.extend(raw_page) + if meta.get("is_final_page"): + break + page += 1 + return comments + + +def _list_local_branch_names(project_root: str) -> list[str]: + result = subprocess.run( + ["git", "-C", project_root, "branch", "--list", "--format=%(refname:short)"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return [] + return [line.strip() for line in (result.stdout or "").splitlines() if line.strip()] + + +def _duplicate_pr_warnings(raw_prs: list[dict]) -> list[CollisionWarning]: + issue_to_prs: dict[int, list[int]] = {} + for pr in raw_prs: + linked = _extract_linked_issue(pr.get("title"), pr.get("body")) + if linked is None: + continue + issue_to_prs.setdefault(linked, []).append(int(pr["number"])) + warnings: list[CollisionWarning] = [] + for issue_number, pr_numbers in sorted(issue_to_prs.items()): + if len(pr_numbers) > 1: + warnings.append( + CollisionWarning( + kind="duplicate-pr", + issue_number=issue_number, + pr_numbers=tuple(sorted(pr_numbers)), + message=( + f"Issue #{issue_number} has {len(pr_numbers)} open PRs: " + f"{', '.join(f'#{n}' for n in sorted(pr_numbers))} (#400)" + ), + ) + ) + return warnings + + +def _duplicate_branch_warnings(branch_names: list[str]) -> list[CollisionWarning]: + issue_to_branches: dict[int, list[str]] = {} + for name in branch_names: + match = _ISSUE_BRANCH_RE.search(name) + if not match: + continue + issue_num = int(match.group(1)) + issue_to_branches.setdefault(issue_num, []).append(name) + warnings: list[CollisionWarning] = [] + for issue_number, branches in sorted(issue_to_branches.items()): + if len(branches) > 1: + warnings.append( + CollisionWarning( + kind="duplicate-branch", + issue_number=issue_number, + message=( + f"Issue #{issue_number} has {len(branches)} local branches: " + f"{', '.join(branches)}" + ), + ) + ) + return warnings + + +def _extract_reviewer_leases( + raw_prs: list[dict], + *, + fetch_comments: Callable[[int], list[dict]], +) -> list[dict[str, Any]]: + leases: list[dict[str, Any]] = [] + for pr in raw_prs: + pr_number = int(pr["number"]) + for comment in fetch_comments(pr_number): + parsed = parse_reviewer_lease_comment(comment.get("body") or "") + if not parsed: + continue + leases.append( + { + **parsed, + "pr_number": parsed.get("pr_number") or pr_number, + "comment_id": comment.get("id"), + "author": (comment.get("user") or {}).get("login"), + "created_at": comment.get("created_at"), + } + ) + active_phases = {"claimed", "validating", "approved", "request-changes", "merging"} + return [lease for lease in leases if (lease.get("phase") or "") in active_phases] + + +def load_lease_snapshot( + *, + project_id: str | None = None, + project_root: str | None = None, + issue_lock_path: str | None = None, + fetch_prs: Callable | None = None, + fetch_issues: Callable | None = None, + fetch_comments: Callable[[str, str, str, str, int], list[dict]] | None = None, +) -> LeaseSnapshot: + registry = load_registry() + project: ProjectRecord | None = None + if project_id: + project = next((p for p in registry.projects if p.id == project_id), None) + else: + project = registry.projects[0] if registry.projects else None + + root = project_root or _repo_root() + lock = read_issue_lock(issue_lock_path if issue_lock_path is not None else ISSUE_LOCK_FILE) + + if project is None: + return LeaseSnapshot( + project_id=project_id or "", + repo_label="", + issue_lock=lock, + claim_inventory={"entries": [], "counts": {}}, + reviewer_leases=(), + duplicate_prs=(), + duplicate_branches=(), + collision_history=_COLLISION_HISTORY, + fetch_error="project not found in registry", + ) + + host = _host_from_url(project.remote_host) + pr_fetch = fetch_prs or _fetch_prs + issue_fetch = fetch_issues or _fetch_issues + comment_fetch = fetch_comments or _fetch_comments + using_live = fetch_prs is None or fetch_issues is None + auth = get_auth_header(host) if using_live else "test-auth" + + if using_live and not auth: + return LeaseSnapshot( + project_id=project.id, + repo_label=f"{project.gitea_owner}/{project.repo_name}", + issue_lock=lock, + claim_inventory={"entries": [], "counts": {}}, + reviewer_leases=(), + duplicate_prs=(), + duplicate_branches=_duplicate_branch_warnings(_list_local_branch_names(root)), + collision_history=_COLLISION_HISTORY, + fetch_error=( + f"Gitea credentials unavailable for {host}; " + "remote lease artifacts cannot be loaded" + ), + ) + + try: + raw_prs, _ = pr_fetch(host, project.gitea_owner, project.repo_name, auth) + raw_issues, _ = issue_fetch(host, project.gitea_owner, project.repo_name, auth) + except Exception as exc: # noqa: BLE001 + return LeaseSnapshot( + project_id=project.id, + repo_label=f"{project.gitea_owner}/{project.repo_name}", + issue_lock=lock, + claim_inventory={"entries": [], "counts": {}}, + reviewer_leases=(), + duplicate_prs=(), + duplicate_branches=_duplicate_branch_warnings(_list_local_branch_names(root)), + collision_history=_COLLISION_HISTORY, + fetch_error=f"Gitea fetch failed: {exc}", + ) + + comments_by_issue: dict[int, list[dict]] = {} + for issue in raw_issues: + if not any(lb.get("name") == "status:in-progress" for lb in issue.get("labels", [])): + continue + number = int(issue["number"]) + comments_by_issue[number] = comment_fetch( + host, project.gitea_owner, project.repo_name, auth, issue_number=number + ) + + branch_names = _list_local_branch_names(root) + inventory = build_claim_inventory( + issues=raw_issues, + comments_by_issue=comments_by_issue, + open_prs=raw_prs, + branch_names=branch_names, + ) + + def _pr_comments(pr_number: int) -> list[dict]: + return comment_fetch( + host, project.gitea_owner, project.repo_name, auth, issue_number=pr_number + ) + + reviewer_leases = tuple(_extract_reviewer_leases(raw_prs, fetch_comments=_pr_comments)) + + return LeaseSnapshot( + project_id=project.id, + repo_label=f"{project.gitea_owner}/{project.repo_name}", + issue_lock=lock, + claim_inventory=inventory, + reviewer_leases=reviewer_leases, + duplicate_prs=tuple(_duplicate_pr_warnings(raw_prs)), + duplicate_branches=tuple(_duplicate_branch_warnings(branch_names)), + collision_history=_COLLISION_HISTORY, + ) + + +def snapshot_to_dict(snapshot: LeaseSnapshot) -> dict[str, Any]: + return { + "project_id": snapshot.project_id, + "repo_label": snapshot.repo_label, + "issue_lock": snapshot.issue_lock, + "claim_inventory": snapshot.claim_inventory, + "reviewer_leases": list(snapshot.reviewer_leases), + "duplicate_prs": [ + { + "kind": w.kind, + "message": w.message, + "issue_number": w.issue_number, + "pr_numbers": list(w.pr_numbers), + } + for w in snapshot.duplicate_prs + ], + "duplicate_branches": [ + { + "kind": w.kind, + "message": w.message, + "issue_number": w.issue_number, + } + for w in snapshot.duplicate_branches + ], + "collision_history": list(snapshot.collision_history), + "fetch_error": snapshot.fetch_error, + } \ No newline at end of file diff --git a/webui/lease_views.py b/webui/lease_views.py new file mode 100644 index 0000000..a998dea --- /dev/null +++ b/webui/lease_views.py @@ -0,0 +1,130 @@ +"""HTML views for lease and collision visibility (#433).""" + +from __future__ import annotations + +import html +import json + +from webui.layout import render_page +from webui.lease_loader import LeaseSnapshot + + +def _escape(text: str) -> str: + return html.escape(text, quote=True) + + +def _warnings_block(snapshot: LeaseSnapshot) -> str: + warnings = list(snapshot.duplicate_prs) + list(snapshot.duplicate_branches) + if not warnings: + return "

No duplicate PR/branch collisions detected in current inventory.

" + items = "".join(f"
  • {_escape(w.message)}
  • " for w in warnings) + return f"
      {items}
    " + + +def _lock_block(snapshot: LeaseSnapshot) -> str: + lock = snapshot.issue_lock + if not lock: + return "

    No active local issue lock file.

    " + return ( + "
    "
    +        f"{_escape(json.dumps(lock, indent=2, sort_keys=True))}"
    +        "
    " + ) + + +def _claims_table(snapshot: LeaseSnapshot) -> str: + entries = snapshot.claim_inventory.get("entries") or [] + if not entries: + return "

    No in-progress issue claims in fetched inventory.

    " + rows = [] + for entry in entries: + rows.append( + "" + f"#{entry.get('issue_number')}" + f"" + f"{_escape(str(entry.get('status')))}" + f"{_escape(str(entry.get('linked_open_pr') or '—'))}" + f"{entry.get('heartbeat_count', 0)}" + f"{_escape(', '.join(entry.get('matching_branches') or []) or '—')}" + f"{_escape('; '.join(entry.get('reasons') or []))}" + "" + ) + return ( + "" + "" + "" + f"{''.join(rows)}
    IssueClaim statusOpen PRHeartbeatsBranchesNotes
    " + ) + + +def _reviewer_leases_table(snapshot: LeaseSnapshot) -> str: + if not snapshot.reviewer_leases: + return ( + "

    No active reviewer PR lease comments found " + "(marker <!-- mcp-review-lease:v1 -->; see #407).

    " + ) + rows = [] + for lease in snapshot.reviewer_leases: + rows.append( + "" + f"#{lease.get('pr_number')}" + f"{_escape(str(lease.get('reviewer_identity') or '—'))}" + f"{_escape(str(lease.get('profile') or '—'))}" + f"{_escape(str(lease.get('phase') or '—'))}" + f"{_escape(str(lease.get('expires_at') or '—'))}" + "" + ) + return ( + "" + "" + "" + f"{''.join(rows)}
    PRReviewerProfilePhaseExpires
    " + ) + + +def _history_links(snapshot: LeaseSnapshot) -> str: + items = "".join( + f"
  • #{item['number']} — {_escape(item['title'])}
  • " + for item in snapshot.collision_history + ) + return f"
      {items}
    " + + +LEASE_PAGE_STYLES = """ + +""" + + +def render_leases_page(snapshot: LeaseSnapshot) -> str: + error_block = "" + if snapshot.fetch_error: + error_block = ( + '

    Partial load: ' + f"{_escape(snapshot.fetch_error)}

    " + ) + body = ( + "

    Leases & collisions

    " + "

    Read-only visibility for issue claims, reviewer PR leases, and " + "duplicate-work risks. Does not acquire or release leases.

    " + f"{error_block}" + f"

    Project: {_escape(snapshot.repo_label)}

    " + "

    Collision warnings

    " + f"{_warnings_block(snapshot)}" + "

    Local issue lock

    " + f"{_lock_block(snapshot)}" + "

    In-progress issue claims (#268)

    " + f"{_claims_table(snapshot)}" + "

    Reviewer PR leases (#407)

    " + f"{_reviewer_leases_table(snapshot)}" + "

    Collision / lease history issues

    " + f"{_history_links(snapshot)}" + "

    JSON API

    " + f"{LEASE_PAGE_STYLES}" + ) + return render_page(title="Leases", body_html=body) \ No newline at end of file diff --git a/webui/queue_views.py b/webui/queue_views.py index 8294b8e..e5e6dd7 100644 --- a/webui/queue_views.py +++ b/webui/queue_views.py @@ -38,7 +38,13 @@ def _queue_table( title: str, items: tuple[QueueItem, ...], columns: tuple[tuple[str, str], ...], + fetch_failed: bool = False, ) -> str: + if fetch_failed: + return ( + f"

    {html.escape(title)}

    " + "

    Not loaded — queue fetch did not complete.

    " + ) if not items: return f"

    {html.escape(title)}

    No open items.

    " @@ -73,9 +79,11 @@ def render_queue_page(snapshot: QueueSnapshot) -> str: f"{html.escape(snapshot.fetch_error)}

    " ) + fetch_failed = bool(snapshot.fetch_error) pr_section = _queue_table( title="Open pull requests", items=snapshot.prs, + fetch_failed=fetch_failed, columns=( ("number", "#"), ("title", "Title"), @@ -88,6 +96,7 @@ def render_queue_page(snapshot: QueueSnapshot) -> str: issue_section = _queue_table( title="Open issues", items=snapshot.issues, + fetch_failed=fetch_failed, columns=( ("number", "#"), ("title", "Title"), diff --git a/worktree_cleanup_audit.py b/worktree_cleanup_audit.py new file mode 100644 index 0000000..6ede83b --- /dev/null +++ b/worktree_cleanup_audit.py @@ -0,0 +1,1023 @@ +"""Session-owned worktree cleanup audit, TTL enforcement, and integrity (#401, #404). + +LLM workflows create many session-owned worktrees under ``branches/`` +(review, baseline, merge-simulation, issue, and conflict-fix worktrees). +When a run stops early, races a sibling session, hits a validation failure, +or loses shell/cwd state, those worktrees are left behind and later workflow +decisions get harder and riskier. + +This module provides: + +* ``build_worktree_metadata`` — ownership/purpose metadata for a worktree. +* ``classify_worktree`` — safety-first classification into the audit + vocabulary (active open PR, active issue work, dirty, clean stale + removable, detached review leftover, unsafe/unknown). +* ``assess_worktree_removal`` — a per-worktree removal decision with an + explicit proof and block reasons. +* git-shelling helpers (``list_worktrees``, ``read_worktree_dirty``, + ``git_worktree_list``, ``remove_worktree``) and ``audit_branches_directory`` + that classify every entry under ``branches/``. +* ``capture_cleanup_snapshot`` / ``reconcile_cleanup_audit`` — before/after + reconciliation for bulk cleanup audits (#404). + +Pure assessment functions take explicit state so they are unit-testable +without a filesystem or network. Only the thin git helpers shell out, and +removal is only ever executed after ``assess_worktree_removal`` proves the +worktree is safe to delete. +""" + +from __future__ import annotations + +import os +import re +import subprocess +from datetime import datetime, timezone +from typing import Any + +from merged_cleanup_reconcile import branch_worktree_folder, read_local_worktree_state +from reviewer_worktree import parse_dirty_tracked_files + +PROTECTED_BRANCHES = frozenset({"master", "main", "dev"}) +DEFAULT_TTL_HOURS = float(os.environ.get("GITEA_WORKTREE_TTL_HOURS", "24") or 24) + +# Workflow types that can create session-owned worktrees. +WORKFLOW_REVIEW = "review" +WORKFLOW_BASELINE = "baseline" +WORKFLOW_MERGE_SIMULATION = "merge_simulation" +WORKFLOW_ISSUE_WORK = "issue_work" +WORKFLOW_CONFLICT_FIX = "conflict_fix" +WORKFLOW_UNKNOWN = "unknown" + +# Workflow types whose worktrees are transient and removed automatically at +# successful workflow completion (acceptance criterion 2). +AUTO_REMOVE_ON_SUCCESS = frozenset( + {WORKFLOW_REVIEW, WORKFLOW_BASELINE, WORKFLOW_MERGE_SIMULATION} +) + +# Cleanup-audit classification vocabulary (acceptance criterion 4). +CLASS_ACTIVE_OPEN_PR = "active_open_pr" +CLASS_ACTIVE_ISSUE_WORK = "active_issue_work" +CLASS_DIRTY_LOCAL = "dirty_local_worktree" +CLASS_CLEAN_STALE_REMOVABLE = "clean_stale_removable" +CLASS_DETACHED_REVIEW_LEFTOVER = "detached_review_leftover" +CLASS_UNSAFE_UNKNOWN = "unsafe_unknown" + +# Only these two classifications may ever be removed automatically. +REMOVABLE_CLASSES = frozenset( + {CLASS_CLEAN_STALE_REMOVABLE, CLASS_DETACHED_REVIEW_LEFTOVER} +) + +_ISSUE_REF_RE = re.compile(r"issue-(\d+)", re.IGNORECASE) +_ISSUE_BRANCH_PREFIXES = ("feat/", "fix/", "docs/", "chore/") + + +def infer_workflow_type(path: str | None, branch: str | None = None) -> str: + """Infer the creating workflow type from a worktree path or branch name.""" + text = f"{path or ''} {branch or ''}".lower() + if "baseline" in text: + return WORKFLOW_BASELINE + if "merge-sim" in text or "merge_sim" in text or "mergesim" in text: + return WORKFLOW_MERGE_SIMULATION + if "review" in text or "review-pr" in text: + return WORKFLOW_REVIEW + if "conflict" in text: + return WORKFLOW_CONFLICT_FIX + if "issue-" in text or (branch or "").startswith(_ISSUE_BRANCH_PREFIXES): + return WORKFLOW_ISSUE_WORK + return WORKFLOW_UNKNOWN + + +def _extract_issue_number(path: str | None, branch: str | None) -> int | None: + match = _ISSUE_REF_RE.search(f"{path or ''} {branch or ''}") + return int(match.group(1)) if match else None + + +def build_worktree_metadata( + *, + path: str, + branch: str | None = None, + head_sha: str | None = None, + workflow_type: str | None = None, + issue_number: int | None = None, + pr_number: int | None = None, + creator: str | None = None, + profile: str | None = None, + created_at: str | None = None, + last_used_at: str | None = None, + cleanup_eligibility: str | None = None, +) -> dict[str, Any]: + """Return ownership/purpose metadata for a session-owned worktree. + + Covers acceptance criterion 1: path, workflow type, issue/PR number, + branch/head SHA, creator identity/profile, created and last-used + timestamps, and cleanup eligibility. + """ + wt = workflow_type or infer_workflow_type(path, branch) + issue = issue_number + if issue is None: + issue = _extract_issue_number(path, branch) + return { + "path": path, + "workflow_type": wt, + "issue_number": issue, + "pr_number": pr_number, + "branch": branch, + "head_sha": head_sha, + "creator": creator, + "profile": profile, + "created_at": created_at, + "last_used_at": last_used_at, + "auto_remove_on_success": wt in AUTO_REMOVE_ON_SUCCESS, + "cleanup_eligibility": cleanup_eligibility, + } + + +def _parse_timestamp(value: str | None) -> datetime | None: + if not value: + return None + text = str(value).strip() + if not text: + return None + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is None: + return parsed.replace(tzinfo=timezone.utc) + return parsed + + +def is_ttl_expired( + *, + last_used_at: str | None, + now: datetime | str | None, + ttl_hours: float = DEFAULT_TTL_HOURS, +) -> bool: + """Return True only when the age is known and exceeds ``ttl_hours``. + + Unknown or unparseable timestamps fail safe (not expired) so a worktree + is never treated as removable merely because its age is unknown. + """ + last = _parse_timestamp(last_used_at) + now_dt = now if isinstance(now, datetime) else _parse_timestamp(now) + if last is None or now_dt is None: + return False + if now_dt.tzinfo is None: + now_dt = now_dt.replace(tzinfo=timezone.utc) + return (now_dt - last).total_seconds() > ttl_hours * 3600.0 + + +def classify_worktree( + *, + workflow_type: str, + is_dirty: bool, + has_open_pr: bool = False, + has_active_lease: bool = False, + has_active_issue_lock: bool = False, + is_detached: bool = False, + branch_gone: bool = False, + ttl_expired: bool = False, + is_protected: bool = False, + metadata_known: bool = True, +) -> str: + """Classify a worktree, safety-first: any preservation signal wins. + + Dirty, open-PR, leased, active-lock, protected, and unknown states are + all non-removable and are checked before any removable classification, + so nothing removable can shadow a preservation signal (criteria 6-8). + """ + if is_protected: + # The main checkout / a protected base branch is never removable. + return CLASS_UNSAFE_UNKNOWN + if is_dirty: + return CLASS_DIRTY_LOCAL # never auto-deleted (criterion 6) + if has_open_pr: + return CLASS_ACTIVE_OPEN_PR # never auto-deleted (criterion 7) + if has_active_lease: + return CLASS_ACTIVE_ISSUE_WORK # never auto-deleted (criterion 8) + if has_active_issue_lock: + return CLASS_ACTIVE_ISSUE_WORK + if not metadata_known or workflow_type == WORKFLOW_UNKNOWN: + return CLASS_UNSAFE_UNKNOWN # never auto-deleted without proof + + # Clean, no PR, no lease, no lock, known workflow type. + if workflow_type in AUTO_REMOVE_ON_SUCCESS: + if is_detached or branch_gone: + return CLASS_DETACHED_REVIEW_LEFTOVER + return CLASS_CLEAN_STALE_REMOVABLE + # issue_work / conflict_fix: only removable once the TTL has expired. + if ttl_expired: + return CLASS_CLEAN_STALE_REMOVABLE + return CLASS_ACTIVE_ISSUE_WORK + + +def is_removable(classification: str) -> bool: + """Return True only for the two auto-removable classifications.""" + return classification in REMOVABLE_CLASSES + + +def assess_worktree_removal( + *, + path: str, + branch: str | None, + head_sha: str | None, + is_dirty: bool, + has_open_pr: bool, + has_active_lease: bool, + classification: str, +) -> dict[str, Any]: + """Return a per-worktree removal decision with proof (criterion 9). + + A worktree is only safe to remove when it is clean, has no active PR, + has no active lease, and its classification is auto-removable. + """ + block_reasons: list[str] = [] + if is_dirty: + block_reasons.append("worktree has uncommitted changes") + if has_open_pr: + block_reasons.append("worktree branch has an open PR") + if has_active_lease: + block_reasons.append("worktree has an active lease") + if not is_removable(classification): + block_reasons.append( + f"classification '{classification}' is not auto-removable" + ) + return { + "path": path, + "branch": branch, + "head_sha": head_sha, + "classification": classification, + "clean": not is_dirty, + "no_active_pr": not has_open_pr, + "no_active_lease": not has_active_lease, + "safe_to_remove": not block_reasons, + "block_reasons": block_reasons, + } + + +def plan_success_cleanup( + *, + metadata: dict[str, Any], + is_dirty: bool, + has_open_pr: bool, + has_active_lease: bool, +) -> dict[str, Any]: + """Decide whether a just-completed worktree is removed at success. + + Review/baseline/merge-simulation worktrees are removed automatically at + successful completion (criterion 2); everything else is preserved and + reported. Dirty/PR/leased worktrees are always preserved (criteria 6-8). + """ + workflow_type = metadata.get("workflow_type", WORKFLOW_UNKNOWN) + if not metadata.get("auto_remove_on_success"): + return { + "remove": False, + "reason": f"workflow type '{workflow_type}' is preserved by policy", + } + classification = classify_worktree( + workflow_type=workflow_type, + is_dirty=is_dirty, + has_open_pr=has_open_pr, + has_active_lease=has_active_lease, + ) + decision = assess_worktree_removal( + path=metadata.get("path", ""), + branch=metadata.get("branch"), + head_sha=metadata.get("head_sha"), + is_dirty=is_dirty, + has_open_pr=has_open_pr, + has_active_lease=has_active_lease, + classification=classification, + ) + return { + "remove": decision["safe_to_remove"], + "reason": "clean transient worktree removable at success completion" + if decision["safe_to_remove"] + else "; ".join(decision["block_reasons"]), + "classification": classification, + "decision": decision, + } + + +def cleanup_failure_report(path: str, reason: str) -> dict[str, Any]: + """Structured leftover-worktree record for the final report (criterion 3).""" + return {"path": path, "removed": False, "reason": reason} + + +# -------------------------------------------------------------------------- +# git-shelling helpers (only these touch the filesystem) +# -------------------------------------------------------------------------- + + +def parse_worktree_porcelain(text: str) -> list[dict[str, Any]]: + """Parse ``git worktree list --porcelain`` output into entries.""" + entries: list[dict[str, Any]] = [] + current: dict[str, Any] = {} + for raw in (text or "").splitlines(): + line = raw.rstrip("\n") + if not line: + if current: + entries.append(current) + current = {} + continue + if line.startswith("worktree "): + if current: + entries.append(current) + current = { + "path": line[len("worktree ") :].strip(), + "head": None, + "branch": None, + "detached": False, + "bare": False, + } + elif line.startswith("HEAD "): + current["head"] = line[len("HEAD ") :].strip() + elif line.startswith("branch "): + ref = line[len("branch ") :].strip() + current["branch"] = ref.replace("refs/heads/", "", 1) + elif line == "detached": + current["detached"] = True + elif line == "bare": + current["bare"] = True + if current: + entries.append(current) + return entries + + +def list_worktrees(project_root: str) -> list[dict[str, Any]]: + """Return parsed ``git worktree list`` entries for ``project_root``.""" + result = subprocess.run( + ["git", "-C", project_root, "worktree", "list", "--porcelain"], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return [] + return parse_worktree_porcelain(result.stdout) + + +def git_worktree_list(project_root: str) -> str: + """Return plain ``git worktree list`` output for final verification (criterion 10).""" + result = subprocess.run( + ["git", "-C", project_root, "worktree", "list"], + capture_output=True, + text=True, + check=False, + ) + return (result.stdout or "").strip() + + +def read_worktree_dirty(path: str) -> dict[str, Any]: + """Return dirty state for a worktree path via ``git status --porcelain``.""" + if not path or not os.path.isdir(path): + return {"exists": False, "dirty": None, "dirty_files": []} + result = subprocess.run( + ["git", "-C", path, "status", "--porcelain"], + capture_output=True, + text=True, + check=False, + ) + files = [ln for ln in (result.stdout or "").splitlines() if ln.strip()] + return {"exists": True, "dirty": bool(files), "dirty_files": files} + + +def remove_worktree(project_root: str, path: str) -> dict[str, Any]: + """Remove a single worktree via ``git worktree remove`` (no ``--force``).""" + result = subprocess.run( + ["git", "-C", project_root, "worktree", "remove", path], + capture_output=True, + text=True, + check=False, + ) + ok = result.returncode == 0 + return { + "path": path, + "removed": ok, + "reason": None if ok else (result.stderr or "git worktree remove failed").strip(), + } + + +def _is_under_branches(project_root: str, path: str) -> bool: + branches_root = os.path.join(os.path.abspath(project_root), "branches") + return os.path.abspath(path or "").startswith(branches_root + os.sep) + + +def audit_branches_directory( + project_root: str, + *, + open_pr_branches: set[str] | None = None, + leased_branches: set[str] | None = None, + active_issue_branches: set[str] | None = None, + now: datetime | str | None = None, + ttl_hours: float = DEFAULT_TTL_HOURS, +) -> dict[str, Any]: + """Classify every session-owned worktree under ``branches/``. + + Read-only: shells out to git for discovery and dirty state, then applies + the pure classifier. Returns per-worktree classifications, counts, the + list of removable candidates, and the ``git worktree list`` proof. + """ + open_pr_branches = open_pr_branches or set() + leased_branches = leased_branches or set() + active_issue_branches = active_issue_branches or set() + + worktrees: list[dict[str, Any]] = [] + for entry in list_worktrees(project_root): + path = entry.get("path") or "" + branch = entry.get("branch") + is_protected = (branch in PROTECTED_BRANCHES) or not _is_under_branches( + project_root, path + ) + dirty_state = read_worktree_dirty(path) + is_dirty = bool(dirty_state.get("dirty")) + metadata = build_worktree_metadata( + path=path, branch=branch, head_sha=entry.get("head") + ) + has_open_pr = bool(branch) and branch in open_pr_branches + has_active_lease = bool(branch) and branch in leased_branches + has_active_lock = bool(branch) and branch in active_issue_branches + ttl_expired = is_ttl_expired( + last_used_at=metadata.get("last_used_at"), now=now, ttl_hours=ttl_hours + ) + classification = classify_worktree( + workflow_type=metadata["workflow_type"], + is_dirty=is_dirty, + has_open_pr=has_open_pr, + has_active_lease=has_active_lease, + has_active_issue_lock=has_active_lock, + is_detached=bool(entry.get("detached")), + branch_gone=branch is None and not entry.get("detached"), + ttl_expired=ttl_expired, + is_protected=is_protected, + ) + metadata["cleanup_eligibility"] = classification + worktrees.append( + { + **metadata, + "detached": bool(entry.get("detached")), + "dirty": is_dirty, + "dirty_files": dirty_state.get("dirty_files", []), + "has_open_pr": has_open_pr, + "has_active_lease": has_active_lease, + "has_active_issue_lock": has_active_lock, + "is_protected": is_protected, + "classification": classification, + "removable": is_removable(classification), + } + ) + + counts: dict[str, int] = {} + for wt in worktrees: + counts[wt["classification"]] = counts.get(wt["classification"], 0) + 1 + removable = [wt for wt in worktrees if wt["removable"]] + + return { + "project_root": project_root, + "worktrees": worktrees, + "counts": counts, + "removable_candidates": removable, + "removable_count": len(removable), + "total": len(worktrees), + "git_worktree_list": git_worktree_list(project_root), + } +CLASSIFICATIONS = frozenset({ + "active_open_pr", + "active_issue_work", + "dirty_local_worktree", + "clean_stale_removable", + "detached_review_leftover", + "orphan_directory", + "unsafe_unknown", +}) + +PRESERVE_CLASSIFICATIONS = frozenset({ + "active_open_pr", + "active_issue_work", + "dirty_local_worktree", + "unsafe_unknown", +}) + +DISPOSITIONS = frozenset({ + "removed_intentionally", + "preserved_exists", + "preserved_missing_explained", + "not_registered_worktree", + "unsafe_unknown", +}) + +REVIEW_WORKTREE_RE = re.compile( + r"branches/(?:review-pr\d+|merge-simulation-pr\d+|review-[\w-]+)", + re.IGNORECASE, +) + + +def normalize_path(path: str) -> str: + return os.path.normpath((path or "").strip()) + + +def relative_branches_path(project_root: str, path: str) -> str: + root = normalize_path(project_root) + normalized = normalize_path(path) + if normalized.startswith(root + os.sep): + return normalized[len(root) + 1 :] + return normalized.replace("\\", "/") + + +def parse_worktree_list_porcelain(porcelain: str) -> list[dict[str, Any]]: + """Parse ``git worktree list --porcelain`` into worktree records.""" + entries: list[dict[str, Any]] = [] + current: dict[str, Any] = {} + for raw in (porcelain or "").splitlines(): + line = raw.strip() + if not line: + if current: + entries.append(current) + current = {} + continue + if line.startswith("worktree "): + if current: + entries.append(current) + current = {"path": line.split(" ", 1)[1].strip()} + elif line.startswith("HEAD "): + current["head_sha"] = line.split(" ", 1)[1].strip() + elif line.startswith("branch "): + current["branch"] = line.split(" ", 1)[1].strip().removeprefix("refs/heads/") + elif line == "detached": + current["detached"] = True + elif line == "bare": + current["bare"] = True + if current: + entries.append(current) + return entries + + +def list_branches_directories(project_root: str, dir_names: list[str] | None = None) -> list[str]: + """Return relative ``branches/`` paths for first-level directories.""" + branches_root = os.path.join(project_root, "branches") + if dir_names is not None: + return sorted( + f"branches/{name}" + for name in dir_names + if name and not name.startswith(".") + ) + if not os.path.isdir(branches_root): + return [] + names: list[str] = [] + for entry in sorted(os.listdir(branches_root)): + full = os.path.join(branches_root, entry) + if entry.startswith(".") or not os.path.isdir(full): + continue + names.append(f"branches/{entry}") + return names + + +def _untracked_dirty(porcelain: str) -> bool: + return any(line.startswith("??") for line in (porcelain or "").splitlines()) + + +def classify_branches_entry( + *, + rel_path: str, + worktree_record: dict[str, Any] | None, + worktree_state: dict[str, Any] | None, + open_pr_branches: set[str] | None = None, + active_lock_branches: set[str] | None = None, + active_issue_branches: set[str] | None = None, +) -> str: + """Classify a ``branches/`` directory for cleanup policy.""" + open_pr_branches = open_pr_branches or set() + active_lock_branches = active_lock_branches or set() + active_issue_branches = active_issue_branches or set() + state = worktree_state or {} + record = worktree_record or {} + + branch_name = (record.get("branch") or "").strip() + folder_name = rel_path.split("/", 1)[-1] if "/" in rel_path else rel_path + inferred_branch = folder_name.replace("-", "/") if "/" not in folder_name else folder_name + + candidate_branches = {b for b in (branch_name, inferred_branch) if b} + open_folder_names = {branch_worktree_folder(b) for b in open_pr_branches} + if folder_name in open_folder_names or any( + b in open_pr_branches for b in candidate_branches + ): + return "active_open_pr" + if any(b in active_lock_branches or b in active_issue_branches for b in candidate_branches): + return "active_issue_work" + + dirty_tracked = bool(state.get("dirty_files")) + dirty_untracked = bool(state.get("dirty_untracked")) + if dirty_tracked or dirty_untracked: + return "dirty_local_worktree" + + if not record: + return "orphan_directory" + + if record.get("detached") and REVIEW_WORKTREE_RE.search(rel_path): + return "detached_review_leftover" + + if state.get("exists") and state.get("clean"): + return "clean_stale_removable" + + return "unsafe_unknown" + + +def capture_cleanup_snapshot( + project_root: str, + *, + branch_dirs: list[str] | None = None, + worktree_porcelain: str | None = None, + open_pr_branches: set[str] | None = None, + active_lock_branches: set[str] | None = None, + active_issue_branches: set[str] | None = None, + issue_lock_path: str | None = None, +) -> dict[str, Any]: + """Capture audit snapshot for ``branches/`` dirs and registered worktrees.""" + root = normalize_path(project_root) + rel_dirs = list_branches_directories(root, branch_dirs) + worktrees = parse_worktree_list_porcelain(worktree_porcelain or "") + worktree_by_rel: dict[str, dict[str, Any]] = {} + for wt in worktrees: + rel = relative_branches_path(root, wt.get("path") or "") + if rel.startswith("branches/"): + worktree_by_rel[rel] = wt + + lock_branches = set(active_lock_branches or []) + lock = None + if issue_lock_path: + from merged_cleanup_reconcile import read_issue_lock + + lock = read_issue_lock(issue_lock_path) + if lock and lock.get("branch_name"): + lock_branches.add(str(lock["branch_name"])) + + entries: list[dict[str, Any]] = [] + for rel_path in rel_dirs: + abs_path = os.path.join(root, rel_path) + wt_record = worktree_by_rel.get(rel_path) + state = read_local_worktree_state(abs_path) if os.path.isdir(abs_path) else { + "exists": False, + "clean": None, + "dirty_files": [], + } + if state.get("exists"): + status_res = state.get("porcelain_status") + if status_res is None and os.path.isdir(abs_path): + import subprocess + + proc = subprocess.run( + ["git", "-C", abs_path, "status", "--porcelain"], + capture_output=True, + text=True, + check=False, + ) + status_res = proc.stdout or "" + state["dirty_untracked"] = _untracked_dirty(status_res or "") + state["dirty_files"] = state.get("dirty_files") or parse_dirty_tracked_files( + status_res or "" + ) + state["clean"] = not state["dirty_files"] and not state["dirty_untracked"] + + classification = classify_branches_entry( + rel_path=rel_path, + worktree_record=wt_record, + worktree_state=state, + open_pr_branches=open_pr_branches, + active_lock_branches=lock_branches, + active_issue_branches=active_issue_branches, + ) + entries.append( + { + "path": rel_path, + "absolute_path": abs_path, + "classification": classification, + "registered_worktree": bool(wt_record), + "worktree_record": wt_record or None, + "worktree_state": state, + "preserve": classification in PRESERVE_CLASSIFICATIONS, + } + ) + + return { + "project_root": root, + "branch_directory_count": len(rel_dirs), + "registered_branches_worktree_count": len(worktree_by_rel), + "entries": entries, + "worktrees": worktrees, + } + + +def _index_snapshot_entries(snapshot: dict[str, Any]) -> dict[str, dict[str, Any]]: + return {entry["path"]: entry for entry in snapshot.get("entries") or []} + + +def _removal_paths(removal_log: list[dict[str, Any]] | None) -> dict[str, dict[str, Any]]: + indexed: dict[str, dict[str, Any]] = {} + for item in removal_log or []: + rel = (item.get("path") or "").strip().replace("\\", "/") + if rel: + indexed[rel] = item + return indexed + + +def reconcile_cleanup_audit( + before: dict[str, Any], + after: dict[str, Any], + removal_log: list[dict[str, Any]] | None = None, + *, + explained_missing: dict[str, str] | None = None, + concurrent_mutations: list[str] | None = None, +) -> dict[str, Any]: + """Reconcile before/after snapshots to exactly one disposition per path.""" + before_index = _index_snapshot_entries(before) + after_index = _index_snapshot_entries(after) + removals = _removal_paths(removal_log) + explained = { + (k or "").strip().replace("\\", "/"): (v or "").strip() + for k, v in (explained_missing or {}).items() + } + concurrent = { + (p or "").strip().replace("\\", "/") + for p in (concurrent_mutations or []) + } + + rows: list[dict[str, Any]] = [] + for path, before_entry in sorted(before_index.items()): + after_entry = after_index.get(path) + after_exists = bool(after_entry and after_entry.get("worktree_state", {}).get("exists")) + classification = before_entry.get("classification") or "unsafe_unknown" + preserve = bool(before_entry.get("preserve")) or classification in PRESERVE_CLASSIFICATIONS + removal = removals.get(path) + explanation = explained.get(path, "") + + if removal: + disposition = "removed_intentionally" + reasons = [] + elif after_exists: + disposition = "preserved_exists" + reasons = [] + elif explanation: + disposition = "preserved_missing_explained" + reasons = [explanation] + elif not before_entry.get("registered_worktree"): + disposition = "not_registered_worktree" + reasons = ["directory was not a registered git worktree at audit start"] + elif path in concurrent: + disposition = "preserved_missing_explained" + reasons = ["removed or mutated by another session during cleanup"] + elif preserve: + disposition = "unsafe_unknown" + reasons = [ + f"preserved classification '{classification}' disappeared without " + "removal log or explanation" + ] + else: + disposition = "unsafe_unknown" + reasons = [ + "clean/removable path disappeared without removal log entry" + ] + + rows.append( + { + "path": path, + "classification": classification, + "preserve": preserve, + "disposition": disposition, + "removed_intentionally": disposition == "removed_intentionally", + "removal_record": removal, + "after_exists": after_exists, + "reasons": reasons, + } + ) + + for path, removal in removals.items(): + if path not in before_index: + rows.append( + { + "path": path, + "classification": "unsafe_unknown", + "preserve": False, + "disposition": "unsafe_unknown", + "removed_intentionally": True, + "removal_record": removal, + "after_exists": path in after_index, + "reasons": ["removal log references path absent from before snapshot"], + } + ) + + counts = { + "initial_count": len(before_index), + "removed_count": sum(1 for r in rows if r["disposition"] == "removed_intentionally"), + "preserved_count": sum(1 for r in rows if r["disposition"] == "preserved_exists"), + "missing_unexplained_count": sum( + 1 + for r in rows + if r["disposition"] == "unsafe_unknown" + and r.get("preserve") + and not r.get("after_exists") + ), + "missing_explained_count": sum( + 1 for r in rows if r["disposition"] == "preserved_missing_explained" + ), + "final_count": len(after_index), + "orphan_directory_count": sum( + 1 for r in rows if r["disposition"] == "not_registered_worktree" + ), + } + expected_final = ( + counts["initial_count"] + - counts["removed_count"] + - counts["missing_explained_count"] + ) + counts["count_reconciles"] = counts["final_count"] == expected_final + + return { + "rows": rows, + "counts": counts, + "removal_log_complete": _removal_log_complete(before_index, after_index, removals), + } + + +def _removal_log_complete( + before_index: dict[str, dict[str, Any]], + after_index: dict[str, dict[str, Any]], + removals: dict[str, dict[str, Any]], +) -> bool: + for path, before_entry in before_index.items(): + if path in after_index: + continue + classification = before_entry.get("classification") or "" + if classification == "clean_stale_removable" and path not in removals: + return False + return True + + +def assess_cleanup_audit_integrity(reconciliation: dict[str, Any]) -> dict[str, Any]: + """Fail closed when preserved worktrees vanish or counts do not reconcile.""" + reasons: list[str] = [] + counts = reconciliation.get("counts") or {} + + if counts.get("missing_unexplained_count"): + reasons.append( + f"{counts['missing_unexplained_count']} preserved worktree(s) missing " + "without explanation" + ) + + for row in reconciliation.get("rows") or []: + if not row.get("preserve") or row.get("after_exists"): + continue + if row.get("disposition") == "removed_intentionally": + continue + message = ( + f"preserved worktree {row.get('path')} missing " + f"({row.get('disposition')})" + ) + if message not in reasons: + reasons.append(message) + for item in row.get("reasons") or []: + if item not in reasons: + reasons.append(item) + + if not counts.get("count_reconciles"): + reasons.append( + "final directory count does not reconcile with initial minus removed " + f"(initial={counts.get('initial_count')}, removed={counts.get('removed_count')}, " + f"final={counts.get('final_count')})" + ) + + if reconciliation.get("removal_log_complete") is False: + reasons.append("removal log omits one or more removed clean-stale worktrees") + + for row in reconciliation.get("rows") or []: + removal = row.get("removal_record") or {} + if row.get("disposition") == "removed_intentionally": + if not removal.get("method"): + reasons.append(f"removal log for {row.get('path')} missing method") + if not removal.get("pre_removal_proof"): + reasons.append(f"removal log for {row.get('path')} missing pre-removal proof") + + block = bool(reasons) + return { + "block": block, + "proven": not block, + "reasons": reasons, + "counts": counts, + "safe_next_action": ( + "capture before/after snapshots, record every removal with proof, and " + "explain any preserved path that disappears" + if block + else "proceed" + ), + } + + +def build_cleanup_reconciliation_table(reconciliation: dict[str, Any]) -> dict[str, Any]: + """Return the operator-facing reconciliation summary table.""" + counts = dict(reconciliation.get("counts") or {}) + return { + "initial_count": counts.get("initial_count", 0), + "removed_count": counts.get("removed_count", 0), + "preserved_count": counts.get("preserved_count", 0), + "missing_unexplained_count": counts.get("missing_unexplained_count", 0), + "missing_explained_count": counts.get("missing_explained_count", 0), + "final_count": counts.get("final_count", 0), + "count_reconciles": counts.get("count_reconciles", False), + } + + +def _read_worktree_porcelain(project_root: str) -> str: + import subprocess + + try: + res = subprocess.run( + ["git", "-C", project_root, "worktree", "list", "--porcelain"], + capture_output=True, + text=True, + check=False, + ) + except OSError: + return "" + return res.stdout if res.returncode == 0 else "" + + +def capture_branches_worktree_snapshot( + project_root: str, + *, + open_pr_branches: list[str] | None = None, + active_lock_branch: str | None = None, + leased_paths: list[str] | None = None, + issue_lock_path: str | None = None, +) -> dict[str, Any]: + """MCP-facing snapshot capture for live ``branches/`` cleanup audits.""" + root = normalize_path(project_root) + lock_branches = set() + if active_lock_branch: + lock_branches.add(active_lock_branch) + issue_branches = set() + for path in leased_paths or []: + rel = relative_branches_path(root, path) + if rel.startswith("branches/"): + issue_branches.add(rel.split("/", 1)[-1].replace("-", "/")) + return capture_cleanup_snapshot( + root, + worktree_porcelain=_read_worktree_porcelain(root), + open_pr_branches=set(open_pr_branches or []), + active_lock_branches=lock_branches, + active_issue_branches=issue_branches, + issue_lock_path=issue_lock_path, + ) + + +def assess_worktree_cleanup_integrity( + *, + before: dict[str, Any], + after: dict[str, Any], + removals: list[dict[str, Any]] | None = None, + explained_missing: dict[str, str] | None = None, +) -> dict[str, Any]: + """MCP-facing integrity assessment over before/after cleanup snapshots.""" + reconciliation = reconcile_cleanup_audit( + before, + after, + removals, + explained_missing=explained_missing, + ) + integrity = assess_cleanup_audit_integrity(reconciliation) + return { + **integrity, + "integrity_passed": integrity.get("proven", False), + "reconciliation": reconciliation, + "reconciliation_table": build_cleanup_reconciliation_table(reconciliation), + "rows": reconciliation.get("rows") or [], + } + + +_RECON_INITIAL_RE = re.compile(r"initial count\s*:\s*(\d+)", re.I) +_RECON_REMOVED_RE = re.compile(r"removed count\s*:\s*(\d+)", re.I) +_RECON_PRESERVED_RE = re.compile(r"preserved count\s*:\s*(\d+)", re.I) +_RECON_MISSING_RE = re.compile(r"missing-unexplained count\s*:\s*(\d+)", re.I) +_RECON_FINAL_RE = re.compile(r"final count\s*:\s*(\d+)", re.I) +_WORKTREE_LIST_RE = re.compile(r"git worktree list|worktree list proof", re.I) + + +def assess_cleanup_audit_final_report(report_text: str) -> dict[str, Any]: + """Validate cleanup final report includes reconciliation proof (#404).""" + text = report_text or "" + reasons: list[str] = [] + for pattern in ( + _RECON_INITIAL_RE, + _RECON_REMOVED_RE, + _RECON_PRESERVED_RE, + _RECON_MISSING_RE, + _RECON_FINAL_RE, + ): + if not pattern.search(text): + reasons.append( + f"cleanup report missing field matching /{pattern.pattern}/" + ) + if not _WORKTREE_LIST_RE.search(text): + reasons.append("final verification missing git worktree list proof") + proven = not reasons + return {"proven": proven, "block": not proven, "reasons": reasons} \ No newline at end of file