Compare commits

...
Author SHA1 Message Date
sysadmin ffa1ff95cc fix: import ISSUE_LOCK_FILE from issue_lock_provenance
The conflict-resolution rebase incorrectly kept ISSUE_LOCK_FILE in the
merged_cleanup_reconcile import, but that symbol was moved to
issue_lock_provenance on master. This broke webui import collection.

Fixes the reviewer-reported regression on PR #448.
2026-07-09 00:04:34 -04:00
sysadminandClaude Opus 4.8 95f77bb648 feat: add MCP runtime health dashboard to web UI (Closes #430)
Adds read-only /runtime and /api/runtime routes showing active profile,
identity, config model, git sync vs remote master, shell health,
workflow/schema hashes, and stale-runtime warnings with #420 guidance.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-09 00:04:11 -04:00
sysadmin 08957aeb2a Merge pull request 'feat: validate Selected PR: none in cleanup mode when queue is empty' (#576) from feat/issue-572-empty-queue-fallback into master 2026-07-08 23:01:11 -05:00
sysadmin 4d865666c7 Merge pull request 'feat: add server code parity gate for mutations (Closes #420)' (#444) from feat/issue-420-server-code-parity into master 2026-07-08 22:49:34 -05:00
sysadmin f04f61058f Merge branch 'master' into feat/issue-420-server-code-parity 2026-07-08 22:44:27 -05:00
sysadmin a82d35f8bd feat: validate Selected PR: none in cleanup mode when queue is empty 2026-07-08 23:33:51 -04:00
sysadmin 066eec8477 Merge pull request 'feat: require true pre-merge baseline proof for non-zero validation exits (Closes #533)' (#574) from feat/issue-533-premerge-baseline-proof into master 2026-07-08 22:21:30 -05:00
sysadmin f29df56a99 Merge pull request 'feat: authorized cleanup for reviewer scratch worktrees (Closes #534)' (#575) from feat/issue-534-reviewer-scratch-cleanup into master 2026-07-08 22:20:50 -05:00
sysadmin 8e9e4670c6 Merge pull request 'feat: look at approvals or issues next if the PR queue is empty (Closes #572)' (#573) from feat/issue-572-empty-queue-fallback into master 2026-07-08 22:18:14 -05:00
sysadmin e4f1a52a87 feat: authorized cleanup for reviewer scratch worktrees (Closes #534)
Discover branches/review-pr<N>[-submit] worktrees separately from author
cleanup, fail closed on dirty trees / open PRs / active reviewer leases,
and remove only assessed-safe scratch paths via reconcile.
2026-07-08 23:12:57 -04:00
sysadmin a2b7327447 feat: check approvals or issues next if the PR queue is empty (Closes #572) 2026-07-08 23:08:55 -04:00
sysadmin 631620d264 Merge pull request 'feat: add canonical state handoff ledger (Closes #494)' (#498) from feat/issue-494-state-handoff-ledger into master 2026-07-08 22:07:47 -05:00
sysadminandClaude Opus 4.8 7fb813446f feat: require true pre-merge baseline proof for non-zero validation exits (Closes #533)
Add premerge_baseline_proof.py distinguishing four validation outcomes
(clean pass / current-master failure reproduced / pre-merge baseline-proven
failure / unresolved regression risk). A non-zero exit may only be labeled
"baseline"/"pre-existing" with pre-merge proof: the failure reproduced on the
PR's pre-merge base commit, or a documented known-failure record predating the
PR. Current-master (post-merge) reproduction is explicitly rejected as
sufficient baseline proof.

- premerge_baseline_proof.py: assess_premerge_baseline_proof + labels
- final_report_validator.py: reviewer.premerge_baseline_proof rule on
  review_pr and reconcile_already_landed tasks
- skills/llm-project-workflow/templates/review-pr.md: baseline proof section
  requiring base commit, tested commit, command, exit status, failure signature
- tests/test_premerge_baseline_proof.py: reject current-master-only proof,
  accept pre-merge base proof and documented known-failure records

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 23:04:44 -04:00
sysadmin f6b2adac4d Merge pull request 'feat: discover non-canonical worktrees by branch matching (Closes #528)' (#568) from feat/issue-528-reconcile-worktree-naming into master 2026-07-08 22:03:44 -05:00
sysadmin 719de6fb7a Merge pull request 'fix: resolve broken ISSUE_LOCK_FILE import in webui/lease_loader.py (Closes #569)' (#570) from feat/issue-569-fix-webui-import into master 2026-07-08 21:59:03 -05:00
sysadmin 0ee89fe347 fix: resolve broken ISSUE_LOCK_FILE import in webui/lease_loader.py (Closes #569) 2026-07-08 22:56:55 -04:00
sysadmin 7a0de4a8e9 feat: discover non-canonical worktrees by branch matching (Closes #528) 2026-07-08 22:53:34 -04:00
sysadmin 4bcdd350f6 Merge pull request 'docs: define bootstrap review path for self-hosted MCP fixes (Closes #557)' (#564) from docs/issue-557-bootstrap-review-path into master 2026-07-08 21:52:43 -05:00
sysadmin e0c583db75 Merge branch 'master' into feat/issue-494-state-handoff-ledger 2026-07-08 21:52:36 -05:00
sysadmin 565363d5f4 docs: define bootstrap review path for self-hosted MCP fixes (Closes #557)
Document the narrow controller-authorized path for landing MCP workflow
fixes when live daemons cannot canonically review themselves, with
required audits, allowed/forbidden actions, and post-land restart steps.
2026-07-08 22:36:10 -04:00
sysadmin 9a2e585a9e Merge pull request 'feat: enforce reconciler PR-close proof fields in final reports (Closes #306)' (#423) from feat/issue-306-reconciler-close-proof into master 2026-07-08 21:32:32 -05:00
sysadmin 81822da04d Merge pull request 'feat: add controller issue-acceptance gate (Closes #500)' (#504) from feat/issue-500-controller-issue-acceptance-gate into master 2026-07-08 21:29:05 -05:00
sysadminandClaude Opus 4.8 e97b1d3642 fix: resolve conflicts for PR #444
Merge prgs/master into PR branch to restore mergeability.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 22:28:12 -04:00
sysadmin b7e0c537f3 Merge pull request 'feat: persist MCP workflow proof and decision lock across daemons (Closes #559)' (#562) from feat/issue-559-durable-session-state into master 2026-07-08 21:26:57 -05:00
sysadmin 2a3d30fb3a feat: persist MCP workflow proof and decision lock across daemons (Closes #559)
Share review-workflow-load and review-decision-lock state across MCP
daemon process pools via a user-private durable store keyed by profile
identity (not host-global /tmp), with TTL and fail-closed profile checks.
2026-07-08 22:22:34 -04:00
sysadmin 1ffa911dbc Merge pull request 'feat: add lease and collision visibility to web UI (Closes #433)' (#454) from feat/issue-433-lease-visibility into master 2026-07-08 21:22:21 -05:00
sysadmin 5ad764b359 Merge pull request 'Detect stale MCP runtime before mutation tools execute' (#545) from fix/issue-531-mcp-eof-defect into master 2026-07-08 21:20:16 -05:00
sysadmin 0e791325f8 feat: add canonical run-tests runner (Closes #473)
Merge PR #476 — root-level run-tests.sh full-validation runner (Closes #473).
2026-07-08 21:16:06 -05:00
sysadmin 826d632173 Merge pull request 'feat: require exact per-mutation capability proof in reviewer reports (Closes #405)' (#418) from feat/issue-405-mutation-capability-proof into master 2026-07-08 21:13:08 -05:00
sysadmin 9d86fc44e4 fix: return structured conflict-fix push assessment errors (Closes #519)
Merge PR #520 — structured conflict-fix push assessment errors (Closes #519).
2026-07-08 21:11:29 -05:00
sysadmin 8db26c9a15 fix: preserve actual reconciler role for comment_issue preflight (Closes #540)
Merge PR #541 — preserve actual reconciler role for #274/#475 exemptions during comment_issue preflight (Closes #540).
2026-07-08 21:09:33 -05:00
sysadmin ae9ed8a908 fix: bind issue comments to explicit worktree path (Closes #560)
Merge PR #561 — bind issue comments to explicit worktree path (Closes #560).
2026-07-08 21:09:01 -05:00
sysadmin f1449ff5c8 fix: isolate #519 PR lookup from shared lease comment listing
Keep _fetch_pr_lease_comments_safe for gitea_assess_conflict_fix_push
only. Restore _list_pr_lease_comments to a single comments GET so merge
approval feedback and #485 non-list handling keep stable API call order.

Closes #519
2026-07-08 22:07:59 -04:00
sysadmin 08bcc03f17 fix: return structured conflict-fix push assessment errors (Closes #519)
Resolve PR via pulls API before fetching lease comments so
gitea_assess_conflict_fix_push fails closed with actionable reasons
instead of surfacing HTTP 500 when Gitea returns not-found errors.

Add regression tests and document assessment-failure handoff in work-issue.
2026-07-08 22:07:59 -04:00
sysadmin 6e589e73ea fix: guard remote/repo mismatch vs local git remote (Closes #530)
Merge PR #555 — remote/repo mismatch guard vs local git remote (Closes #530).
2026-07-08 21:07:02 -05:00
sysadmin 8c171fb47b Merge pull request 'feat: add root MCP operator shell menu (#478)' (#479) from feat/issue-478-mcp-menu-shell into master 2026-07-08 21:04:35 -05:00
sysadmin 18e7d7b6e1 Merge pull request 'fix: remove duplicate verify_preflight_purity in gitea_adopt_merger_pr_lease (closes #548)' (#550) from issue-548-fix-duplicate-preflight into master 2026-07-08 20:54:40 -05:00
sysadmin bd22135906 fix: bind issue comments to explicit worktree path (Closes #560) 2026-07-08 16:13:34 -04:00
sysadmin 78fce0dbb6 Merge branch 'fix/issue-546-reviewer-deadlock' into master (bootstrap deadlock bypass) 2026-07-08 16:12:32 -04:00
sysadminandClaude Opus 4.8 fd2bc018ff fix: guard remote/repo mismatch vs local git remote (Closes #530)
Bare remote=prgs resolved to the hardcoded REMOTES default
Scaled-Tech-Consulting/Timesheet instead of the Gitea-Tools repository
the local git remote points at, causing false 404s and risking mutation
of a different repository.

Add remote_repo_guard.assess_remote_repo_match (pure) + formatter, wired
into gitea_mcp_server._resolve so every read/lookup/mutation tool fails
closed when the MCP-resolved org/repo disagrees with the local git remote
URL and the caller did not pass explicit org/repo. The guard is
best-effort: it skips when both org and repo are explicit, when the local
remote URL is unavailable, and is bypassed under pytest unless
GITEA_FORCE_REMOTE_REPO_CHECK is set.

Add tests/test_remote_repo_guard.py (pure-function cases + server wiring
proving a lookup tool cannot silently query a different repository), and
update author/reviewer/merger handoff templates to require explicit
remote/org/repo.

Closes #530

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 15:49:31 -04:00
sysadmin 535da1bc5a chore: remove trailing extra blank line at EOF in test_mcp_server.py 2026-07-08 15:40:12 -04:00
sysadmin dfbee45309 fix: resolve reviewer preflight capability/lease deadlock and add gitea_release_reviewer_pr_lease tool (closes #546) 2026-07-08 15:29:18 -04:00
sysadmin a4e014e62b fix: remove duplicate verify_preflight_purity in gitea_adopt_merger_pr_lease (#548)
The second direct call after _verify_role_mutation_workspace caused
_clear_preflight_capability_state() to run twice (or between checks),
clearing valid capability/preflight state. This forced temp local
patches during #542/#517 merge/reconcile.

- Remove the redundant verify call (the _verify already performs
  purity verification with correct worktree_path).
- Update test to reflect single invocation path and add explicit
  test proving state preservation, single call, no dupe clear,
  and no need for raw/temp fallbacks.
- Reconciler/controller flows stay fully MCP-native.

Fixes #548
2026-07-08 15:01:48 -04:00
sysadmin eeadb1bbea Merge pull request 'feat: require MCP-native post-merge cleanup proof (Closes #517)' (#542) from feat/issue-517-mcp-native-cleanup into master 2026-07-08 13:08:51 -05:00
sysadmin c64809134f Detect stale MCP runtime before mutation tools execute 2026-07-08 13:43:50 -04:00
sysadminandClaude Opus 4.8 749ca04388 fix: scope MCP cleanup proof to mutation ledgers (#517)
Raw branch/comment delete detection now scans only Cleanup mutations,
Git ref mutations, and Worktree mutations ledger fields so narrative
§28B citations and validation notes no longer false-positive.

Authorized cleanup tool proof also applies when cleanup is claimed under
Git ref or Worktree mutation ledgers while Cleanup mutations is none.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 13:43:39 -04:00
sysadminandClaude Opus 4.8 ea51b0196a feat: require MCP-native post-merge cleanup proof (Closes #517)
Add mcp_native_cleanup_proof verifier that blocks raw git branch deletion and
raw API comment deletion scripts as cleanup proof, requires authorized
reconciler MCP tools in cleanup mutation ledgers, and separates merge from
cleanup mutations in controller handoffs.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 12:54:37 -04:00
sysadmin cc63ab74e6 Merge pull request 'feat: add worktree cleanup audit integrity (Closes #404)' (#417) from feat/issue-404-worktree-cleanup-audit into master 2026-07-08 11:51:06 -05:00
sysadminandClaude Opus 4.8 9438855fdd fix: preserve actual reconciler role for #274/#475 role exemptions during comment_issue preflight (Closes #540)
resolve_task_capability("comment_issue") stamps _preflight_resolved_role
= "author" because comment_issue.required_role_kind = author.
_effective_workspace_role() prefers that stamp, so a genuine
prgs-reconciler session was classified 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.

Fix keys the role exemptions off the actual active profile role as well
as the effective workspace role:

- Add _actual_profile_role(): derives the workspace role from the active
  profile alone, never from _preflight_resolved_role.
- _enforce_branches_only_author_mutation: exempt when EITHER the
  effective role OR the actual profile role is a non-author role.
- _enforce_root_checkout_guard: pass actual_role; assess_root_checkout_guard
  now exempts a reconciler by resolved OR actual role.

Author blocking is preserved: an actual author profile classifies as
author in both signals, so it stays blocked on a contaminated control
checkout. Merger strictness stays keyed on the resolved task role so a
merger operating from its clean branches/ workspace under a non-merge
task is not over-tightened.

Regression tests (tests/test_issue_540_comment_role_poison.py) cover the
reconciler comment path, author-blocking path, reviewer/merger/reconciler
role classification under a poisoned author stamp, and the root guard
actual_role exemption.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 12:03:14 -04:00
sysadmin cfce823dd7 Merge pull request 'feat: guarded merger lease adoption flow (#536)' (#538) from feat/issue-536-merger-lease-adoption into master 2026-07-08 10:47:51 -05:00
sysadmin 7231f27c1c Merge pull request 'feat: add workflow-load session boundary proof (Closes #403)' (#422) from feat/issue-403-workflow-load-boundaries into master 2026-07-08 10:42:49 -05:00
sysadmin e27b6ddefb feat: add worktree cleanup audit integrity (Closes #404)
Reconcile before/after branches/ snapshots so preserved worktrees cannot
disappear without removal logs or explained state transitions.

- worktree_cleanup_audit.py: snapshot capture, disposition reconciliation
- gitea_capture_branches_worktree_snapshot, gitea_assess_worktree_cleanup_integrity
- Final-report rule author.worktree_cleanup_audit_proof
- worktree-cleanup.md bulk audit section
- tests/test_worktree_cleanup_audit.py (11 cases)
2026-07-08 11:15:55 -04:00
sysadmin cefebd7643 test: seed review workflow load and pass expected_head_sha in legacy test fixtures 2026-07-08 11:14:06 -04:00
sysadminandClaude Opus 4.8 3d540f6fba feat: add workflow-load session boundary proof (Closes #403)
Extend the review workflow load gate with pre-review command classification,
boundary violation tracking, and structured helper-result requirements in
final reports. Adds gitea_record_pre_review_command MCP tool.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 11:13:43 -04:00
sysadmin 2bb45c0f80 Merge pull request 'feat: add root checkout guard for contaminated control checkout (Closes #475)' (#488) from feat/issue-475-root-checkout-guard into master 2026-07-08 10:12:52 -05:00
sysadmin 0b62d4e06a Merge pull request 'feat: add session-owned worktree cleanup audit and TTL enforcement (Closes #401)' (#492) from feat/issue-401-worktree-cleanup-ttl into master 2026-07-08 10:11:15 -05:00
sysadmin a2aea5357d fix: resolve conflicts for PR #444
Merge prgs/master into feat/issue-420-server-code-parity and preserve PR intent
alongside compatible master guardrails.
2026-07-08 11:10:13 -04:00
sysadminandClaude Opus 4.8 7d5c7df82f fix: resolve conflicts for PR #418
Merge prgs/master into feat/issue-405-mutation-capability-proof and keep
both reviewer mutation-capability proof and post-merge cleanup proof rules.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 11:06:03 -04:00
sysadmin 08ea214526 feat: guarded merger lease adoption flow (#536)
Replace manual in-process _SESSION_LEASE seeding with an auditable
gitea_adopt_merger_pr_lease tool that posts durable adoption proof and
records sanctioned session provenance. Mutation gates now reject bare
record_session_lease calls without provenance from acquire/adopt/heartbeat.

Closes #536
2026-07-08 11:03:56 -04:00
sysadmin 6b45212659 Merge pull request 'feat: keep reconciliation audits read-only unless cleanup authorized (Closes #419)' (#421) from feat/issue-419-audit-readonly-mode into master 2026-07-08 04:16:45 -05:00
sysadmin ccdff1df27 Merge pull request 'Require canonical workflow load proof for reviewer mutations' (#493) from feat/issue-389-review-workflow-load-proof into master 2026-07-08 04:16:19 -05:00
sysadmin af938064cd Merge pull request 'feat: add safe post-merge moot lease cleanup for already-merged PRs (Closes #515)' (#527) from feat/issue-515-post-merge-moot-lease into master 2026-07-08 04:02:23 -05:00
sysadminandClaude Opus 4.8 2faf18a802 feat: add safe post-merge moot lease cleanup for already-merged PRs (Closes #515)
Refuse merge-oriented reviewer-lease acquisition/adoption on an already
merged/closed PR, and add a read-first cleanup path for a lingering moot lease.

- reviewer_pr_lease.assess_acquire_lease: new pr_merged_or_closed flag; fail
  closed with a post_merge_moot reason and no lease body when the PR already
  merged/closed.
- reviewer_pr_lease.assess_post_merge_moot_lease: pure assessment. Only treats a
  lease as moot when the PR is merged/closed; never proposes touching an active
  lease on an open PR; provides a terminal phase:released (blocker:
  post-merge-moot) body to neutralise the moot lease append-only; idempotent
  once the released marker is the newest entry.
- gitea_acquire_reviewer_pr_lease: fetch live PR state, pass the flag, surface
  post_merge_moot on refusal (no comment posted).
- gitea_cleanup_post_merge_moot_lease: new read-first tool. Reports merged
  state, merge_commit_sha, linked-issue closure, lease-moot, cleanup
  performed/skipped, and no_merge_or_adoption. apply=True posts the released
  marker only when merged/closed with an active lease; refuses on open PRs.
- tests/test_post_merge_moot_lease.py: 10 tests covering acquire refusal on
  merged PR (no post), safe/idempotent cleanup, and open-PR foreign-lease
  protection.

No relaxation of #407 reviewer-lease or #16 gated-merge gates for open PRs.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 04:51:10 -04:00
sysadmin 236c8b779f Merge pull request 'fix: register gitea_lock_issue as MCP tool (Closes #521)' (#526) from feat/issue-521-lock-issue-registration into master 2026-07-08 03:50:24 -05:00
sysadmin 564fee9639 Merge branch 'prgs/master' into feat/issue-389-review-workflow-load-proof 2026-07-08 04:44:58 -04:00
sysadmin d56baa635e fix: register gitea_lock_issue as MCP tool (Closes #521)
Move @mcp.tool() from internal _list_open_pulls helper onto
gitea_lock_issue so author sessions can discover and call the
issue lock required by gitea_create_pr.

Add regression tests for MCP registration and create_pr lock flow.
2026-07-08 04:42:35 -04:00
sysadmin c54ac0d4de Merge pull request 'feat: isolate MCP workspace binding across role namespaces (Closes #510)' (#512) from feat/issue-510-workspace-binding-isolation into master 2026-07-08 03:37:17 -05:00
sysadmin 4d24c34548 Merge pull request 'feat: require post-merge cleanup safety-gate proof in reviewer reports (Closes #402)' (#415) from feat/issue-402-post-merge-cleanup-proof into master 2026-07-08 03:13:43 -05:00
sysadminandClaude Opus 4.8 4561d7ad12 fix: resolve conflicts for PR #493
Merge prgs/master; keep review workflow load gate (#389) with explicit
merge_pr preflight task binding.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 04:09:31 -04:00
sysadmin 3d11e1f12b feat: isolate MCP workspace binding across role namespaces (Closes #510)
Namespace-scoped workspace resolution prevents GITEA_AUTHOR_WORKTREE from
poisoning reviewer, merger, and reconciler purity checks. Each role uses
its own worktree env var with actionable binding-source errors and safe
reconnect guidance. Preserves #274/#475 author and control-checkout guards.
2026-07-08 04:08:08 -04:00
sysadminandClaude Opus 4.8 182fcbf598 fix: resolve conflicts for PR #421
Merge prgs/master; keep audit-readonly gate (#419) and explicit delete_branch
preflight task binding from master.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 04:08:07 -04:00
sysadminandClaude Opus 4.8 33d01ba65e feat: add controller issue-acceptance gate (Closes #500)
Add issue_acceptance_gate validation for Controller Issue Acceptance
comments, final-report rules blocking merge-only issue completion claims,
documentation/templates, and regression tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 03:27:37 -04:00
sysadminandClaude Opus 4.8 d50328aea4 feat: add canonical state handoff ledger (#494)
Add templates, validators, and documentation for discussion/issue/PR state
comments and final-report next-action handoff fields. Wire enforcement into
assess_final_report_validator across all workflow task kinds.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 03:14:32 -04:00
sysadminandClaude Opus 4.8 cedf451a2b feat: add session-owned worktree cleanup audit and TTL enforcement (Closes #401)
Stale worktrees accumulate under branches/ when runs stop early, race a
sibling session, hit a validation failure, or lose cwd state, making later
workflow decisions harder and riskier.

Changes:
- worktree_cleanup_audit.py — ownership metadata, safety-first classifier
  (active_open_pr, active_issue_work, dirty_local_worktree,
  clean_stale_removable, detached_review_leftover, unsafe_unknown), TTL
  expiry, per-worktree removal proof, and success-completion cleanup plan.
  Only clean_stale_removable and detached_review_leftover are removable;
  dirty/PR/leased/protected/unknown worktrees are never auto-deleted.
- gitea_audit_worktree_cleanup — read-only MCP tool that classifies every
  branches/ entry, fetches live open-PR heads (fails closed if unavailable),
  and returns counts, removable candidates, and git worktree list proof.
- work-issue.md 22A + review-merge-pr.md 28 — cleanup/TTL workflow rules.
- tests/test_worktree_cleanup_audit.py — 30 cases covering all nine
  acceptance scenarios plus inference, metadata, TTL, and report accuracy.

Validation:
  /Users/jasonwalker/Development/Gitea-Tools/venv/bin/python \
    -m unittest tests.test_worktree_cleanup_audit tests.test_merged_cleanup_reconcile -q
  38 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 02:40:15 -04:00
sysadminandClaude Opus 4.8 be592d6d4d feat: add root checkout guard for contaminated control checkout (Closes #475)
Introduce root_checkout_guard helper and enforce it in verify_preflight_purity
so author, reviewer, and merger mutations fail closed when the stable control
checkout is on the wrong branch, detached, dirty, or diverged from prgs/master.
Isolated branches/... worktrees and reconciler paths remain allowed.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 02:23:27 -04:00
sysadmin 673cb77004 fix: resolve conflicts for PR #418 2026-07-08 02:20:04 -04:00
sysadmin d6b1e4be3c fix: resolve conflicts for PR #415 2026-07-08 02:20:04 -04:00
sysadmin 685e627984 fix: resolve conflicts for PR #392
Merge prgs/master into feat/issue-389-review-workflow-load-proof and
preserve workflow-load gates while adopting master test harness patterns
(_seed_ready_review_decision, reviewer leases, expected_head_sha).
2026-07-08 02:17:58 -04:00
sysadminandClaude Opus 4.8 831183308d feat: add root MCP operator shell menu (#478)
Add ./mcp-menu.sh for safe, read-only onboarding and workflow prompt
discovery. Includes root checkout health, role prompts, onboarding
checklist, Proxmox placeholders, and run-tests fallback. Document in
docs/mcp-menu.md with hermetic tests.

Closes #478.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 00:23:29 -04:00
sysadminandClaude Opus 4.8 b15494deb9 feat: add root-level run-tests.sh full-validation runner (Closes #473)
Adds a canonical, discoverable full-validation entry point so sessions stop
guessing between pytest invocations.

- run-tests.sh: executable root runner; runs `venv/bin/python -m pytest "$@"`;
  forwards args; fails closed with a clear setup message when the venv Python
  is missing (no silent wrong-interpreter fallback); set -euo pipefail; no
  network, no Gitea, no lock files.
- docs/developer-testing-guidelines.md: name ./run-tests.sh as the canonical
  full/focused validation command.
- tests/test_run_tests_script.py: contract checks (exists, executable, strict
  bash flags, venv pytest + arg forwarding, fail-closed guard, repo-local, and
  the guide naming the runner).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 17:44:22 -04:00
sysadmin 2c47208b0d fix: resolve conflicts for PR #392
Merge current prgs/master into feat/issue-389-review-workflow-load-proof.
Combine workflow-load session proof (#389) with reviewer PR lease test
fixtures from #407 so reviewer mutation gates remain fully tested.
2026-07-07 17:34:41 -04:00
sysadmin 551ee7d70c fix: resolve conflicts for PR #421 2026-07-07 17:20:53 -04:00
sysadmin ddaf380db2 feat: gate reconciliation audit mode from unauthorized cleanup (Closes #419)
Add audit vs cleanup phase tracking so reconciliation audits stay read-only
unless cleanup is explicitly authorized with delete capability proof, safety
proof, and before/after snapshots. Block gitea_delete_branch and merged-cleanup
execution during audit phase, validate final reports for false no-mutations
claims, and document the boundary in the reconcile-landed-pr workflow.
2026-07-07 17:20:36 -04:00
sysadmin 8b551c565f fix: resolve conflicts for PR #418 2026-07-07 17:20:10 -04:00
sysadminandClaude Opus 4.8 66d37dd3cb feat: require exact per-mutation capability proof in reviewer reports (Closes #405)
Adds mutation-capability table validation so review_pr cannot authorize
merge or delete mutations without their own exact capability rows.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 17:20:01 -04:00
sysadminandClaude Opus 4.8 44a7c62dc3 fix: resolve conflicts for PR #415
Rebase onto current prgs/master and fix cleanup-mutations regex so
"Cleanup mutations: none" does not false-positive the proof checklist.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 17:19:37 -04:00
sysadminandClaude Opus 4.8 d5dc9f2708 feat: require post-merge cleanup safety-gate proof in reviewer reports (Closes #402)
Adds post_merge_cleanup_proof validation so cleanup claims must carry the
branch deletion and worktree removal checklists, or report CLEANUP_SKIPPED
with an exact blocker.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 17:19:37 -04:00
sysadminandClaude Opus 4.8 09e83fd034 feat: add lease and collision visibility to web UI (Closes #433)
Adds read-only /leases and /api/leases routes for issue claim inventory,
reviewer PR lease comments, duplicate PR/branch warnings, and local issue
lock visibility without acquire/release mutations.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 15:29:56 -04:00
sysadmin 5512ba7710 test: seed workflow load proof in reviewer mutation paths (Closes #389) 2026-07-07 13:24:47 -04:00
sysadminandClaude Opus 4.8 934688a71d feat: add master-parity staleness gate for MCP mutations (Closes #420)
The MCP server loads capability-gate code into memory at startup, so a
newly merged gate (e.g. the branch-delete capability gate #408/#410) does
not take effect until the process restarts. A long-running server could
therefore still perform a mutation the updated codebase forbids.

Runtime profile/config data is already read live from disk each call
(gitea_config.load_config re-reads the JSON), so allowed_operations changes
apply without a restart. This closes the remaining gap: *code* parity.

- master_parity_gate.py: capture the process's startup commit and, at
  mutation time, compare it against the on-disk master HEAD; pure,
  injectable assessment plus block-reasons and a restart-required report.
- gitea_mcp_server.py: capture _STARTUP_PARITY at import; _profile_operation_gate
  fails closed for mutating ops when stale while leaving gitea.read allowed;
  gitea_get_runtime_context surfaces master_parity; new read-only
  gitea_assess_master_parity tool reports parity/restart-required.
- Escape hatches: GITEA_MCP_DISABLE_PARITY_GATE (ops), GITEA_TEST_CURRENT_HEAD (tests).
- tests/test_master_parity_gate.py: 18 cases (pure logic, block/report,
  read override, and server wiring: reads pass, mutations blocked when stale).

Validation: venv/bin/python -m pytest -q -> 1618 passed, 6 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 13:11:36 -04:00
sysadmin 0d41760ea3 Merge branch 'master' into feat/issue-389-review-workflow-load-proof 2026-07-07 12:05:17 -05:00
sysadminandClaude Opus 4.8 b9e5cd0f57 feat: enforce reconciler PR-close proof fields in final reports (Closes #306)
Closes #306.

The dedicated reconciler close path (profile, gated close tool, ancestor
proof, tests) already shipped across #301/#304/#309/#310, but the
final-report validator did not require a reconciler close to prove itself.
A handoff could report `PRs closed: #N` while omitting the close
capability, ancestor proof, or linked-issue result and still grade A.

This adds the missing report-time enforcement.

Changes:
- final_report_validator.py — new `reconcile.close_proof_fields` rule
  (`_rule_reconcile_close_proof`). Fires only when a PR close is reported
  (via the `PRs closed` field or a `reconciler_close_lock`), then blocks
  unless the handoff carries close capability proof (gitea.pr.close),
  ancestor proof, PR close result, and linked-issue result. Comment-only
  and blocked reconciliations are unaffected. New optional
  `reconciler_close_lock` kwarg on `assess_final_report_validator`.
- tests/test_final_report_validator.py — TestReconcilerCloseProof (6 cases):
  full-proof close passes; missing capability / ancestor / linked-issue
  result each block; session close lock requires proof; comment-only
  needs no close proof.
- reconcile-landed-pr.md — §18A documents the enforced gate.

Validation:

  venv/bin/python -m pytest tests/test_final_report_validator.py \
    tests/test_reconciler_close_gate.py tests/test_already_landed_reconcile.py \
    tests/test_reconcile_already_landed_pr_tool.py \
    tests/test_reconciliation_workflow.py tests/test_review_proofs.py -q

289 passed.

Worktree: branches/issue-306-reconciler-close-proof

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 13:03:23 -04:00
sysadmin 8a8cffbdf5 feat: gate reviewer mutations on canonical workflow load proof (Closes #389)
Add gitea_load_review_workflow and in-process session proof required before
gitea_submit_pr_review, gitea_mark_final_review_decision, and gitea_merge_pr.
Capability and runtime context report workflow-load status; stale or missing
proof fails closed with recovery handoff text that forbids approve/merge replay.
2026-07-07 09:57:50 -04:00
97 changed files with 15328 additions and 125 deletions
+9
View File
@@ -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
+344
View File
@@ -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, []
+2
View File
@@ -12,6 +12,8 @@ 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:
+190
View File
@@ -0,0 +1,190 @@
# Bootstrap Review Path for Self-Hosted MCP Workflow Fixes (#557)
## Purpose
Gitea-Tools MCP workflow fixes can create a **bootstrap deadlock**: the live
MCP daemons still run the broken code from `master`, so canonical reviewer /
merger tools cannot complete the review that would land the fix.
This document defines a **narrow, controller-authorized bootstrap path** so
such fixes can land without weakening normal review/merge gates.
It is **not** a general bypass. Normal PRs must still use the full
reviewer → merger MCP workflow.
## When this path applies
All of the following must be true:
1. The PR changes **Gitea-Tools MCP workflow / preflight / lease / gate** code
that the live daemon must load to review itself (self-hosted control plane).
2. Canonical review or merge tools **fail closed** because of that defect
(documented tool errors, not “inconvenience”).
3. A **controller** records an explicit bootstrap authorization on the PR or
linked issue (see below).
4. The PR source is clean, testable, and free of unrelated scope.
Example (historical): PR #553 / Issue #546 (reviewer preflight capability/lease
deadlock). Live daemons on unpatched `master` could not complete canonical
review of the fix PR.
## Durable authorization (required)
**No manual remote merge, force-merge, or API merge of a bootstrap PR is
allowed** unless a controller has posted a durable authorization record on
Gitea.
### Authorization record (issue or PR comment)
The controller comment **must** include a machine-readable marker and the
fields below:
```text
## BOOTSTRAP REVIEW AUTHORIZATION (#557)
Status: APPROVED
PR: <number>
Issue: <number>
Controller: <gitea username>
Reason: live MCP runtime cannot canonically review this self-hosted workflow fix
Scope: narrow bootstrap only — does not weaken normal PR gates
Validation evidence:
- git diff --check: clean
- targeted pytest: <command> → pass
- full suite attribution (if non-zero): baseline compared to prgs/master
Duplicate-PR audit: <none | list and disposition>
Mutation ledger audit: <summary or path to proof>
Contamination audit: <clean | list contaminated comment/lease IDs and disposition>
Allowed verification actions used: <list>
Forbidden actions NOT used: root checkout edits; raw curl mutation; direct
module import of gitea_mcp_server for mutations; in-memory gate restoration
Post-land plan:
- restart MCP daemons for author/reviewer/merger/reconciler profiles
- re-verify with canonical tools (see Post-bootstrap steps)
```
Without this record, bootstrap merge is **forbidden**. Agents must stop with
`BLOCKED + DIAGNOSE` rather than improvise a merge.
## Review gates and proof (still required)
Bootstrap does **not** skip technical review. It only allows verification and
landing when the **live MCP mutation path** cannot complete the review.
Before authorization, the controller (or a delegated read-only verifier in an
isolated worktree) must have:
| Gate | Requirement |
|------|-------------|
| Diff hygiene | `git diff --check` clean on the PR tip vs `prgs/master` |
| Tests | Targeted tests for the fix pass; full suite either green or failures attributed to baseline `prgs/master` |
| Duplicate PR | Open-PR inventory shows no competing open PR for the same issue (or supersession is documented) |
| Mutation ledger | Any claimed mutations are ledger-consistent; no silent root edits |
| Contamination audit | PR/issue comments and leases classified as clean vs workflow-contaminated |
| Scope | Diff limited to the bootstrap issue; no drive-by refactors |
### Contamination audit (comments / leases)
Comments or leases created via **raw curl**, **direct Python import of MCP
modules**, or **token extraction** are **workflow-contaminated**. They must be
listed and must **not** be treated as canonical review proof.
Only comments posted via **sanctioned MCP reviewer tools** (after the fix is
landed and daemons restarted, or during a clean path that did not bypass gates)
count as canonical review state.
Historical example on PR #553:
| Comment | Disposition |
|---------|-------------|
| #7247 test / raw API | contaminated |
| #7251 lease metadata / raw API | contaminated |
| #7252 lease metadata / direct import | contaminated |
| #7265 lease metadata / MCP reviewer tools | workflow-clean |
## Allowed verification actions
These may run **outside** the broken live mutation path to establish facts:
- Read-only MCP tools (`gitea_view_pr`, `gitea_list_prs`, `gitea_whoami`,
`gitea_get_profile`, inventory, eligibility **reads**).
- Isolated `branches/` worktree checkout of the PR head (never root).
- `git fetch`, `git log`, `git diff`, `git merge-tree` (read-only analysis).
- Targeted `pytest` / `py_compile` inside that worktree.
- Controller posting of the bootstrap authorization comment via a healthy
MCP profile **if available**; if comment mutation is also deadlocked, a
**human operator** posts the authorization in the Gitea UI (still durable on
the thread).
## Forbidden actions (always)
Even under bootstrap:
- Editing or committing in the **project root / control checkout**.
- Treating root as a worktree for implementation or review mutations.
- Raw `curl` / REST mutation with extracted tokens as a normal workflow.
- `import gitea_mcp_server` (or sibling modules) from a shell to call mutation
helpers and bypass preflight.
- In-memory restoration of capability / lease / decision state to “finish”
a blocked chain.
- Force-push, history rewrite, or deleting evidence comments.
- Weakening normal gates in code “temporarily” without a tracked issue/PR.
- Self-review or self-merge by the PR author identity.
## Landing procedure (after APPROVED authorization)
1. Confirm the authorization record is present and complete on the PR or issue.
2. Merge **only** with an independent merger identity when possible.
- Preferred: restart/replace the MCP merger daemon with a build that includes
the fix (or a one-shot process started from the PR worktree binary) so
`gitea_merge_pr` can run under normal gates.
- If the live merger daemon still cannot load the fix, a **human operator**
may merge via the Gitea UI **only** after the authorization record exists.
3. Never merge from contaminated proof alone.
## Post-bootstrap steps
### 1. Restart MCP daemons
After the fix lands on `prgs/master`:
1. Stop author / reviewer / merger / reconciler MCP server processes for this
repo (IDE MCP pool + any long-lived terminals).
2. Confirm no stale PID still serves the old code.
3. Restart each profile so it loads the updated `master` tree (or installed
package path).
4. Call `gitea_whoami` / `gitea_get_profile` on each namespace and record
profile name + identity.
### 2. Re-verify with canonical tools
Example pattern after PR #553-class fixes (lease release / #550-style follow-up):
1. From a **reviewer** MCP session: acquire/release or inspect the relevant
lease with canonical tools only.
2. Confirm no direct-import or raw-API path is required.
3. Post a short controller or reconciler note: bootstrap complete; normal gates
restored.
If verification still requires a bypass, open a new issue — do **not** extend
this bootstrap authorization silently.
## Explicit non-goals
- This path does **not** authorize skipping tests, duplicate audits, or
contamination audits.
- This path does **not** authorize root checkout implementation work.
- This path does **not** replace BLOCKED + DIAGNOSE when a non-bootstrap
failure occurs (#552).
- This path does **not** grant permanent “operator exception” culture.
## Related
- Root checkout policy: `docs/llm-workflow-runbooks.md` (Global LLM Worktree Rule, #475)
- BLOCKED + DIAGNOSE: issue #552 / skill workflows
- Reviewer lease / preflight deadlock class: issues #546, #548, #550
- Durable session proofs across daemon pools: issue #559
+19
View File
@@ -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
+52
View File
@@ -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
+135
View File
@@ -377,6 +377,59 @@ explicit control-checkout repair.
Portable wording: [`skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md).
### Root checkout guard (#475)
The MCP server enforces a fail-closed **root checkout guard** before author,
reviewer, and merger mutations when the active workspace is not an isolated
`branches/...` worktree (reconciler close paths remain exempt per #468).
The guard blocks when the **control checkout** (repository root) is:
- on a non-stable branch (`master` / `main` / `dev` expected),
- detached HEAD,
- dirty (tracked edits),
- or its `HEAD` does not match `prgs/master` when that ref is available.
**Remediation (never auto-reset or stash):**
> Root checkout is not on master. Preserve state, switch root back to master,
> and use `scripts/worktree-review` or the sanctioned issue worktree flow.
**Recovery after root hijack:**
1. Preserve any in-progress edits (copy paths, note branch name, or commit on a
rescue branch from a `branches/...` worktree).
2. From the repository root: `git checkout master` (or `main` / `dev` per repo
policy) and `git fetch prgs && git merge --ff-only prgs/master` when safe.
3. Confirm `git status` is clean and `git branch --show-current` is `master`.
4. Resume work only inside `branches/issue-<n>-<slug>` via `gitea_lock_issue` /
`git worktree add`.
`branches/...` directories are disposable role worktrees; the root checkout is
the stable orchestration surface only.
## Bootstrap Review Path for self-hosted MCP fixes (#557)
When a PR fixes Gitea-Tools MCP workflow code that the **live daemon** still
runs from broken `master`, canonical review can deadlock on itself.
Do **not** improvise raw API, direct imports, root edits, or gate bypasses.
Use the narrow controller-authorized path documented in:
- [`docs/bootstrap-review-path.md`](bootstrap-review-path.md)
Hard rules:
- No merge without a durable `BOOTSTRAP REVIEW AUTHORIZATION (#557)` record on
the PR or linked issue.
- Validation, duplicate-PR, mutation-ledger, and contamination audits remain
mandatory.
- Root checkout stays read-only orchestration only; verification runs in
`branches/` worktrees.
- After land: restart MCP daemons and re-verify with canonical tools only.
- This never weakens normal reviewer/merger gates for ordinary PRs.
## Shell Spawn Hard-Stop Rule
Symptom: a shell tool call returns `exit_code: -1` with empty stdout/stderr.
@@ -641,6 +694,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 (`<!-- mcp-review-lease-adoption:v1 -->`)
recording actor, profiles, adopted-from session/comment, adoption reason, and
timestamp, then records sanctioned in-session provenance.
4. Call `gitea_merge_pr` with the same pinned `expected_head_sha`.
**Forbidden:** Python one-liners or scripts that call `record_session_lease()`
without provenance from `gitea_acquire_reviewer_pr_lease`,
`gitea_adopt_merger_pr_lease`, or `gitea_heartbeat_reviewer_pr_lease`.
**Same-session review+merge:** the reviewer session may use
`gitea_acquire_reviewer_pr_lease` directly; adoption is only for cross-session
merger handoff.
### Close the issue after merge / Reconciliation
- **Profile:** issue-manager or merger.
@@ -823,6 +903,45 @@ scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push
```
## Namespace workspace binding (#510)
Each MCP namespace resolves its **own** active task workspace. Foreign role
worktree environment variables must not poison another namespace's purity
checks.
| Namespace | Workspace env vars (in priority under `GITEA_ACTIVE_WORKTREE`) | Allowed roots |
|-----------|------------------------------------------------------------------|---------------|
| author | `GITEA_AUTHOR_WORKTREE` | `branches/<task>` worktree only (#274) |
| reviewer | `GITEA_REVIEWER_WORKTREE` | clean `branches/<review>` worktree |
| merger | `GITEA_MERGER_WORKTREE` | clean `branches/<merge>` worktree **or** clean control checkout |
| reconciler | `GITEA_RECONCILER_WORKTREE` | clean `branches/<reconcile>` worktree **or** clean control checkout |
`GITEA_AUTHOR_WORKTREE` is **author-only**. Reviewer, merger, and reconciler
MCP processes ignore it even when it points at a dirty author WIP tree.
### Safe reconnect / rebind procedure
When a mutation blocks on workspace binding:
1. Read the error — it names the **resolved workspace path**, **role
namespace**, and **binding source** (tool arg, env var, or process root).
2. Reconnect or relaunch the correct namespace MCP server from the intended
workspace (or set the role-specific env var before launch).
3. Pass `worktree_path` on reviewer/merger mutation tools when the active
branches/ worktree differs from the MCP process root.
4. **Do not** clean, reset, or discard foreign role worktrees to unblock your
own namespace — that destroys another agent's WIP.
### CTH guidance for workspace binding blockers
When posting a Canonical Thread Handoff after a binding blocker:
- State which namespace was active (author / reviewer / merger / reconciler).
- Quote the resolved workspace path and binding source from the error.
- Name the safe reconnect action (relaunch MCP from `branches/...`, set
`GITEA_*_WORKTREE`, or pass `worktree_path`).
- Explicitly note that foreign worktrees must not be cleaned to unblock.
## Safety notes
- Never place raw tokens or passwords in any LLM MCP config; reference secrets
@@ -832,6 +951,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).
@@ -841,6 +961,21 @@ scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push
- [`credential-isolation.md`](credential-isolation.md) — credential handling.
- [`release-workflows.md`](release-workflows.md) — release/merge workflow.
- [`../README.md`](../README.md) — canonical config, thin launchers, the menu.
- [`state-handoff-ledger.md`](state-handoff-ledger.md) — canonical state comments and next-action handoff (#494).
## Canonical state handoff ledger (#494)
Gitea comments and final reports must make continuation obvious without chat
history. See [`state-handoff-ledger.md`](state-handoff-ledger.md) for:
- discussion → issue → PR → review → merge → reconcile lifecycle
- templates for discussion, issue, PR, and queue-controller state comments
- final-report requirements: Current status, Next actor, Next action, Next prompt
- queue-controller priority order and discussion ≥5-comment rule (urgent/trivial
exceptions)
Helpers live in `state_handoff_ledger.py`; final-report enforcement is wired
through `assess_final_report_validator`.
## PR-only queue cleanup mode (#390)
+67
View File
@@ -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`.
+86
View File
@@ -0,0 +1,86 @@
# Canonical state handoff ledger (#494)
Gitea is the system of record for continuation. Every discussion, issue, and PR
should answer: current state, last proof, who acts next, and the exact prompt for
the next role.
## Lifecycle
1. Controller opens a discussion.
2. Discussion accumulates substantive comments (default minimum: five).
3. Controller posts a discussion summary when ready.
4. Controller creates linked issues from the summary.
5. Author locks issue, implements, opens PR.
6. Reviewer reviews at current head.
7. Merger merges after formal approval.
8. Reconciler closes superseded or already-landed PRs.
9. Issue/PR/discussion state comments make the next action obvious at every step.
## Discussion rules
- Do **not** convert a discussion into issues until it has at least **five
substantive comments**, unless the discussion state comment marks
`URGENCY: urgent` or `URGENCY: trivial`.
- Substantive comment types: proposal, risk/concern, acceptance criteria,
implementation approach, dependency/sequence, summary.
- Before issue creation, post a **discussion summary** with decision, issues to
create, non-goals, unresolved questions, and next prompt.
- Created issues must link back to the discussion; the discussion must link
forward to created issues.
## State comment templates
Use `state_handoff_ledger.py` helpers or copy the canonical blocks:
- `render_discussion_state_comment(...)`
- `render_discussion_summary_comment(...)`
- `render_issue_state_comment(...)`
- `render_pr_state_comment(...)`
- `render_queue_controller_report(...)`
Post state comments as the **latest canonical update** on the object. Do not
bury state inside PR bodies only.
## Final report requirements
Every final report must include a `Controller Handoff` section with:
- **Current status** — live state after this session
- **Next actor** — `author`, `reviewer`, `merger`, `reconciler`, or `controller`
- **Next action** — one imperative step
- **Next prompt** — ready-to-paste prompt for the next role
`assess_final_report_next_action_handoff` and
`assess_contradictory_state_handoff` enforce these fields and reject
contradictory claims (for example, ready-to-merge without approval, issue done
without PR proof, discussion complete without summary).
## Queue controller selection (priority order)
1. Merge clean approved PRs at current head.
2. Review PRs needing review.
3. Reconcile superseded or already-landed duplicates.
4. Continue blocked-but-now-unblocked issues.
5. Create issues from completed discussions.
6. Start new author work only when higher-priority queue items are clear.
For each candidate object, read the **latest canonical state comment** before
choosing an action. Output the exact next-role prompt in the controller report.
## Example workflow states
| State | Next actor | Typical next action |
|-------|------------|---------------------|
| Discussion needs more comments | controller | Facilitate discussion until five substantive comments or urgent/trivial exception |
| Issue ready for author | author | Lock issue and implement in `branches/` worktree |
| PR needs review | reviewer | Review at pinned head in reviewer worktree |
| PR approved at head | merger | Merge with merger profile after eligibility proof |
| PR superseded | reconciler | Close duplicate/already-landed PR with proof |
| Issue blocked | controller | Post issue state comment with blockers and next prompt |
## Non-goals
- Chat history is not the source of truth.
- Do not weaken author/reviewer/merger/reconciler separation.
- Do not replace Gitea issues, PRs, or canonical workflow files under
`skills/llm-project-workflow/`.
+22 -3
View File
@@ -43,10 +43,12 @@ Optional environment variables:
| `/api/projects` | JSON registry export |
| `/prompts` | Prompt library with per-prompt copy buttons (#428) |
| `/api/prompts` | JSON prompt export with workflow hashes |
| `/runtime` | Stub — MCP runtime health (#430) |
| `/runtime` | MCP runtime health and stale detection (#430) |
| `/api/runtime` | JSON runtime health export |
| `/audit` | Stub — report audit paste (#431) |
| `/worktrees` | Stub — hygiene dashboard (#432) |
| `/leases` | Stub — lease visibility (#433) |
| `/leases` | Lease and collision visibility (#433) |
| `/api/leases` | JSON lease/collision export |
All routes are GET-only. POST/PUT/PATCH/DELETE return `405` with
`read-only-mvp`.
@@ -82,8 +84,25 @@ credentials. The UI surfaces pagination proof (returned count, pages fetched,
If credentials are missing or the fetch fails, the page shows an explicit error
instead of an empty queue (fail closed).
## Lease visibility (#433)
`/leases` surfaces read-only lease and collision state: local issue lock file,
in-progress claim inventory (#268), reviewer PR lease comments when present
(`<!-- mcp-review-lease:v1 -->`, #407), duplicate open PRs per issue (#400),
and duplicate local branches per issue. Links to collision-history backend
issues (#267, #268, #400, #407) are included. No lease acquire/release from UI.
## Runtime health (#430)
`/runtime` surfaces read-only MCP/runtime diagnostics for the default registry
project: active profile and role kind, authenticated identity (when credentials
are available), config model/mode, local vs remote `master` SHA sync, shell
health, workflow/schema SHA-256 hashes, and stale-runtime warnings when the
checkout is behind merged safety-gate changes. Restart guidance links to #420;
no tokens or MCP restart actions are exposed.
## Tests
```bash
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py -q
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py tests/test_webui_lease_visibility.py tests/test_webui_runtime_health.py -q
```
+2
View File
@@ -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
+303
View File
@@ -11,7 +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,
@@ -21,6 +24,7 @@ from review_proofs import (
assess_review_mutation_final_report,
assess_validation_report,
)
from state_handoff_ledger import assess_state_handoff_ledger_report
from validation_status_vocabulary import assess_validation_status_vocabulary
FINAL_REPORT_TASK_KINDS = frozenset({
@@ -117,6 +121,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",
@@ -316,6 +336,38 @@ def _rule_shared_controller_handoff(
)
def _rule_shared_state_handoff_next_action(report_text: str) -> list[dict[str, str]]:
result = assess_state_handoff_ledger_report(report_text)
if result.get("complete"):
return []
findings: list[dict[str, str]] = []
next_action = result.get("next_action") or {}
contradictions = result.get("contradictions") or {}
if next_action.get("block"):
findings.extend(
_findings_from_reasons(
"shared.state_handoff_next_action",
next_action.get("reasons") or [],
field="Next action handoff",
severity="block",
safe_next_action=next_action.get("safe_next_action")
or "add next-action handoff fields to Controller Handoff",
)
)
if contradictions.get("block"):
findings.extend(
_findings_from_reasons(
"shared.state_handoff_contradiction",
contradictions.get("reasons") or [],
field="State handoff",
severity="block",
safe_next_action=contradictions.get("safe_next_action")
or "resolve contradictory state claims before submission",
)
)
return findings
def _rule_shared_email_disclosure(report_text: str) -> list[dict[str, str]]:
result = assess_email_disclosure(report_text)
if result.get("proven"):
@@ -567,6 +619,27 @@ def _rule_conflict_fix_push_proof(report_text: str) -> list[dict[str, str]]:
)
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):
@@ -675,6 +748,25 @@ def _rule_reviewer_main_checkout_baseline(report_text: str) -> list[dict[str, st
]
def _rule_reviewer_premerge_baseline_proof(report_text: str) -> list[dict[str, str]]:
from premerge_baseline_proof import assess_premerge_baseline_proof
result = assess_premerge_baseline_proof(report_text)
if not result.get("block"):
return []
return _findings_from_reasons(
"reviewer.premerge_baseline_proof",
result.get("reasons") or [],
field="Pre-merge baseline proof",
severity="block",
safe_next_action=result.get("safe_next_action")
or (
"prove the failure on the PR pre-merge base commit or a known-failure "
"record predating the PR; current-master reproduction is not baseline proof"
),
)
def _rule_reviewer_validation_status_vocabulary(
report_text: str,
*,
@@ -921,6 +1013,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):
@@ -1007,6 +1162,22 @@ def _rule_shared_manual_lock_pr_override(report_text: str) -> list[dict[str, str
)
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"):
@@ -1023,6 +1194,70 @@ def _rule_shared_author_reviewer_same_run(report_text: str) -> list[dict[str, st
)
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,
*,
@@ -1042,15 +1277,64 @@ 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_state_handoff_next_action,
_rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
_rule_reviewer_legacy_workspace_mutations,
@@ -1063,54 +1347,71 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_reviewer_validation_structured,
_rule_reviewer_linked_issue,
_rule_reviewer_baseline_on_failure,
_rule_reviewer_premerge_baseline_proof,
_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_state_handoff_next_action,
_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_premerge_baseline_proof,
_rule_reviewer_git_fetch_readonly,
_rule_reviewer_legacy_workspace_mutations,
_rule_reviewer_vague_mutations_none,
_rule_audit_reconciliation_boundary,
],
"author_issue": [
_rule_shared_controller_handoff,
_rule_shared_state_handoff_next_action,
_rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
_rule_reviewer_vague_mutations_none,
],
"work_issue": [
_rule_shared_controller_handoff,
_rule_shared_state_handoff_next_action,
_rule_shared_email_disclosure,
*_SHARED_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_state_handoff_next_action,
_rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
],
"inventory": [
_rule_shared_controller_handoff,
_rule_shared_state_handoff_next_action,
_rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
_rule_reconcile_pagination_proof,
],
"issue_selection": [
_rule_shared_controller_handoff,
_rule_shared_state_handoff_next_action,
_rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
],
@@ -1176,6 +1477,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).
@@ -1232,6 +1534,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, ()):
+1509 -68
View File
File diff suppressed because it is too large Load Diff
+300
View File
@@ -0,0 +1,300 @@
"""Controller issue-acceptance gate helpers (#500).
Pure validation for controller acceptance comments and final-report claims
that an issue is complete. Does not post comments or close issues.
"""
from __future__ import annotations
import re
ACCEPTANCE_HEADING = "controller issue acceptance"
REQUIRED_FIELDS = (
"STATE",
"WHO_IS_NEXT",
"NEXT_ACTION",
"NEXT_PROMPT",
"ISSUE",
"MERGED_PR",
"MERGE_COMMIT",
"ACCEPTANCE_CRITERIA_CHECKED",
"VALIDATION_REVIEWED",
"CONTROLLER_DECISION",
"WHY",
)
ACCEPTED_STATES = frozenset({"accepted"})
REJECTION_STATES = frozenset({
"more-work-required",
"more_work_required",
"needs-tests",
"needs_tests",
"needs-docs",
"needs_docs",
"needs-feature-enhancement",
"needs_feature_enhancement",
"needs-follow-up-issue",
"needs_follow_up_issue",
"blocked",
})
ALLOWED_NEXT_ACTORS = frozenset({
"controller",
"author",
"reviewer",
"merger",
"reconciler",
"user",
})
_FIELD_RE = re.compile(r"^\s*(?:[-*]\s*)?([A-Z][A-Z0-9_ ]+)\s*:\s*(.*)$")
_FULL_SHA_RE = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE)
_ISSUE_REF_RE = re.compile(r"#\d+")
_PR_REF_RE = re.compile(r"#\d+")
_CHECKED_ITEM_RE = re.compile(r"\[[xX]\]")
_UNCHECKED_ITEM_RE = re.compile(r"\[[\s]\]")
_CLAIMS_ISSUE_COMPLETE_RE = re.compile(
r"\bissue\s+(?:is\s+)?(?:complete|completed|accepted|closed\s+as\s+complete|fully\s+satisfied)\b|"
r"\bissue\s+acceptance\s*:\s*accepted\b|"
r"\bcontroller\s+acceptance\s*:\s*(?:accepted|complete)\b",
re.IGNORECASE,
)
_MERGE_ONLY_COMPLETE_RE = re.compile(
r"(?:pr\s+merged|merged\s+pr|merge\s+result\s*:\s*merged).{0,120}"
r"(?:issue\s+(?:is\s+)?(?:complete|closed|accepted)|issue\s+complete)",
re.IGNORECASE | re.DOTALL,
)
_CLAIMS_CONTROLLER_ACCEPTANCE_RE = re.compile(
r"controller\s+issue\s+acceptance|controller\s+acceptance\s+(?:posted|complete|pending)",
re.IGNORECASE,
)
_PENDING_ACCEPTANCE_RE = re.compile(
r"controller\s+acceptance\s+(?:pending|required|not\s+(?:yet\s+)?(?:performed|complete))",
re.IGNORECASE,
)
def render_controller_acceptance_template() -> str:
"""Return the canonical controller issue-acceptance comment template."""
return """## Controller Issue Acceptance
STATE:
<accepted | more-work-required | needs-tests | needs-docs | needs-feature-enhancement | needs-follow-up-issue | blocked>
WHO_IS_NEXT:
<author | reviewer | merger | reconciler | controller | user>
NEXT_ACTION:
<one sentence>
NEXT_PROMPT:
<paste-ready prompt for the next LLM>
ISSUE:
#...
MERGED_PR:
#...
MERGE_COMMIT:
<40-character SHA>
ACCEPTANCE_CRITERIA_CHECKED:
- [x] ...
- [ ] ...
VALIDATION_REVIEWED:
<tests/proofs reviewed>
CONTROLLER_DECISION:
<accepted or rejected>
WHY:
<reasoning>
MISSING_WORK:
<none, or exact missing work>
FOLLOW_UP_ISSUES:
<none, or issue list to create>
BLOCKERS:
<none, or exact blockers>
LAST_UPDATED_BY:
<identity/profile/date>
"""
def extract_acceptance_fields(text: str | None) -> dict[str, str]:
"""Return upper-case labeled fields from a controller acceptance block."""
fields: dict[str, str] = {}
current_key: str | None = None
for line in (text or "").splitlines():
match = _FIELD_RE.match(line)
if match:
current_key = match.group(1).strip().upper().replace(" ", "_")
fields[current_key] = match.group(2).strip()
continue
stripped = line.strip()
if current_key and stripped:
existing = fields.get(current_key, "")
fields[current_key] = (
f"{existing}\n{stripped}" if existing else stripped
)
return fields
def contains_acceptance_block(text: str | None) -> bool:
return ACCEPTANCE_HEADING in (text or "").lower()
def _empty_or_placeholder(value: str | None) -> bool:
value = (value or "").strip().lower()
return not value or value in {"none", "n/a", "unknown", "tbd", "<...>", "..."}
def _normalize_state(value: str | None) -> str:
return (value or "").strip().lower().replace(" ", "_").replace("-", "_")
def validate_controller_acceptance_comment(text: str | None) -> dict:
"""Validate a controller issue-acceptance comment."""
body = text or ""
if not contains_acceptance_block(body):
return {
"valid": False,
"fields": {},
"reasons": ["missing Controller Issue Acceptance heading"],
}
fields = extract_acceptance_fields(body)
reasons: list[str] = []
for field in REQUIRED_FIELDS:
if _empty_or_placeholder(fields.get(field)):
reasons.append(f"missing required controller acceptance field: {field}")
state = _normalize_state(fields.get("STATE"))
if state and state not in ACCEPTED_STATES and state not in REJECTION_STATES:
reasons.append(
"STATE must be accepted or a rejection path "
"(more-work-required, needs-tests, needs-docs, "
"needs-feature-enhancement, needs-follow-up-issue, blocked)"
)
actor = (fields.get("WHO_IS_NEXT") or "").strip().lower()
if actor and actor not in ALLOWED_NEXT_ACTORS:
reasons.append(
"WHO_IS_NEXT must be one of: "
+ ", ".join(sorted(ALLOWED_NEXT_ACTORS))
)
if not _ISSUE_REF_RE.search(fields.get("ISSUE") or ""):
reasons.append("ISSUE must cite an issue number (#N)")
if not _PR_REF_RE.search(fields.get("MERGED_PR") or ""):
reasons.append("MERGED_PR must cite a merged PR number (#N)")
if not _FULL_SHA_RE.search(fields.get("MERGE_COMMIT") or ""):
reasons.append("MERGE_COMMIT must include a full 40-character SHA")
criteria = fields.get("ACCEPTANCE_CRITERIA_CHECKED") or ""
if not _CHECKED_ITEM_RE.search(criteria) and not _UNCHECKED_ITEM_RE.search(criteria):
reasons.append(
"ACCEPTANCE_CRITERIA_CHECKED must list checked/unchecked criteria items"
)
decision = (fields.get("CONTROLLER_DECISION") or "").strip().lower()
if state in ACCEPTED_STATES:
if decision not in {"accepted", "accept"}:
reasons.append("accepted STATE requires CONTROLLER_DECISION: accepted")
if not _CHECKED_ITEM_RE.search(criteria):
reasons.append(
"accepted STATE requires at least one checked acceptance criterion"
)
if _empty_or_placeholder(fields.get("WHY")):
reasons.append("accepted STATE requires WHY with acceptance rationale")
elif state in REJECTION_STATES:
if decision not in {"rejected", "reject", "more_work_required"}:
reasons.append(
"rejection STATE requires CONTROLLER_DECISION: rejected"
)
if _empty_or_placeholder(fields.get("NEXT_PROMPT")):
reasons.append(
"rejection STATE requires a paste-ready NEXT_PROMPT for the next actor"
)
missing = fields.get("MISSING_WORK") or ""
if _empty_or_placeholder(missing):
reasons.append(
"rejection STATE requires MISSING_WORK describing what is still needed"
)
return {
"valid": not reasons,
"fields": fields,
"reasons": reasons,
}
def claims_issue_complete(text: str | None) -> bool:
"""Return True when text claims an issue is complete/accepted."""
return bool(_CLAIMS_ISSUE_COMPLETE_RE.search(text or ""))
def claims_merge_only_issue_complete(text: str | None) -> bool:
"""Return True when text treats PR merge as issue completion."""
return bool(_MERGE_ONLY_COMPLETE_RE.search(text or ""))
def claims_controller_acceptance_update(text: str | None) -> bool:
return bool(_CLAIMS_CONTROLLER_ACCEPTANCE_RE.search(text or ""))
def notes_controller_acceptance_pending(text: str | None) -> bool:
return bool(_PENDING_ACCEPTANCE_RE.search(text or ""))
def validate_final_report_issue_acceptance(report_text: str | None) -> dict:
"""Validate issue-completion and controller-acceptance claims in final reports."""
text = report_text or ""
reasons: list[str] = []
complete_claim = claims_issue_complete(text)
merge_only = claims_merge_only_issue_complete(text)
acceptance_claim = claims_controller_acceptance_update(text)
pending_noted = notes_controller_acceptance_pending(text)
has_block = contains_acceptance_block(text)
if merge_only and not (has_block and validate_controller_acceptance_comment(text)["valid"]):
reasons.append(
"final report treats PR merge as issue completion without controller acceptance proof"
)
if complete_claim and not pending_noted:
if not has_block:
reasons.append(
"final report claims issue complete but includes no Controller Issue Acceptance block"
)
else:
result = validate_controller_acceptance_comment(text)
if not result["valid"]:
reasons.extend(result["reasons"])
else:
state = _normalize_state(result["fields"].get("STATE"))
if state not in ACCEPTED_STATES:
reasons.append(
"final report claims issue complete but controller STATE is not accepted"
)
if acceptance_claim and has_block:
result = validate_controller_acceptance_comment(text)
if not result["valid"]:
reasons.extend(result["reasons"])
applicable = complete_claim or merge_only or acceptance_claim or pending_noted
return {
"applicable": applicable,
"valid": not reasons,
"reasons": reasons,
}
+168
View File
@@ -0,0 +1,168 @@
"""Master-parity staleness gate (#420).
The Gitea MCP server loads its capability-gate code and workflow logic into
memory when the process starts. When ``master`` advances -- for example a newly
merged security gate such as the branch-delete capability gate (#408/#410) --
the running process keeps executing the *old* code until it is restarted. A
stale server can therefore still perform a mutation that the updated codebase
would forbid.
Runtime profile/config data is already read live from disk on every call
(``gitea_config.load_config`` re-reads the JSON file each time), so profile
``allowed_operations`` changes take effect immediately without a restart. The
gap this module closes is *code* parity: it captures the server process's
source-tree commit at startup and detects, at mutation time, when the on-disk
``master`` HEAD has advanced past it. Detected staleness fails closed with a
restart-required recovery report, while read-only operations stay allowed so a
stale server can still be inspected.
The core assessment is pure -- callers inject the observed HEAD SHAs -- so the
logic is fully unit-testable without a git checkout.
"""
from __future__ import annotations
import os
import subprocess
# Environment escape hatches (ops + tests):
# GITEA_MCP_DISABLE_PARITY_GATE -> disable enforcement entirely (fail open).
# GITEA_TEST_CURRENT_HEAD -> force the "current" HEAD read, for tests.
ENV_DISABLE = "GITEA_MCP_DISABLE_PARITY_GATE"
ENV_TEST_CURRENT_HEAD = "GITEA_TEST_CURRENT_HEAD"
def read_git_head(root: str) -> str | None:
"""Return the current ``HEAD`` commit SHA of *root*, or ``None``.
``None`` means the SHA could not be determined (not a git checkout, git
unavailable, or an error). A test override via ``GITEA_TEST_CURRENT_HEAD``
takes precedence so the gate can be exercised deterministically.
"""
forced = os.environ.get(ENV_TEST_CURRENT_HEAD)
if forced is not None:
return forced.strip() or None
if not root:
return None
try:
res = subprocess.run(
["git", "-C", root, "rev-parse", "HEAD"],
capture_output=True,
text=True,
check=False,
)
except Exception:
return None
if res.returncode != 0:
return None
return (res.stdout or "").strip() or None
def capture_startup_parity(root: str, head: str | None = None) -> dict:
"""Capture the process source-tree baseline once at server startup.
*head* may be injected (tests); otherwise it is read from *root*. The result
is an opaque baseline handed back to :func:`assess_master_parity`.
"""
startup_head = head if head is not None else read_git_head(root)
return {"root": root, "startup_head": startup_head}
def _short(sha: str | None) -> str:
return sha[:12] if sha else "unknown"
def assess_master_parity(startup: dict | None, current_head: str | None) -> dict:
"""Compare the startup baseline against the current on-disk ``HEAD``.
Pure: both HEADs are supplied by the caller. Returns a structured result:
- ``in_parity`` -- server code matches the on-disk master (or parity
could not be determined, which is not treated as stale).
- ``stale`` -- the on-disk master has definitively advanced past the
running process.
- ``restart_required`` -- alias of ``stale``; the recovery action.
- ``determinable`` -- whether both HEADs were known well enough to compare.
- ``startup_head`` / ``current_head`` / ``reasons``.
"""
startup_head = (startup or {}).get("startup_head")
reasons: list[str] = []
if startup_head is None:
reasons.append(
"startup commit was not captured; code parity cannot be enforced")
return _result(True, False, False, startup_head, current_head, reasons)
if current_head is None:
reasons.append(
"current workspace HEAD could not be read; code parity cannot be "
"enforced")
return _result(True, False, False, startup_head, current_head, reasons)
if startup_head == current_head:
return _result(True, False, True, startup_head, current_head, reasons)
reasons.append(
f"MCP server started at commit {_short(startup_head)} but the workspace "
f"master is now {_short(current_head)}; restart the server to load the "
f"current capability gates")
return _result(False, True, True, startup_head, current_head, reasons)
def _result(in_parity, stale, determinable, startup_head, current_head, reasons):
return {
"in_parity": in_parity,
"stale": stale,
"restart_required": stale,
"determinable": determinable,
"startup_head": startup_head,
"current_head": current_head,
"reasons": list(reasons),
}
def gate_disabled() -> bool:
"""Whether the parity gate is disabled by env escape hatch."""
return bool((os.environ.get(ENV_DISABLE) or "").strip())
def parity_block_reasons(assessment: dict) -> list[str]:
"""Block reasons for a mutation gate (empty when the mutation may proceed).
A disabled gate or an in-parity / non-determinable assessment yields no
reasons; only a definitively stale server blocks.
"""
if gate_disabled():
return []
if assessment.get("stale"):
return list(assessment.get("reasons") or
["server code is stale relative to master (fail closed)"])
return []
def parity_report(assessment: dict) -> dict:
"""Structured stale-server report for permission-block payloads."""
return {
"kind": "server_stale",
"restart_required": True,
"startup_head": assessment.get("startup_head"),
"current_head": assessment.get("current_head"),
"reasons": list(assessment.get("reasons") or []),
"recovery": [
"The running MCP server is executing code older than the current "
"master and may not enforce newly merged capability gates.",
"Restart the Gitea MCP server so it reloads master's capability "
"gates and execution profiles before retrying the mutation.",
],
}
def format_parity(assessment: dict) -> str:
"""One-line human summary for logs / runtime context."""
if assessment.get("stale"):
return (f"STALE: started {_short(assessment.get('startup_head'))}, "
f"master now {_short(assessment.get('current_head'))} "
f"(restart required)")
if not assessment.get("determinable"):
return "parity indeterminate (baseline or current HEAD unknown)"
return f"in parity at {_short(assessment.get('current_head'))}"
Executable
+232
View File
@@ -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 <org>/<repo>.
Goal: implement issue #<N> only.
Workflow:
1. Preflight: prove identity, work_issue/create_pr capability, clean session worktree under branches/.
2. gitea_lock_issue for issue #<N> and branch feat/issue-<N>-<short-desc>.
3. Implement in the locked worktree only — never mutate the root control checkout.
4. Validate, commit, push, gitea_create_pr. Final report with issue, branch, SHA, PR, tests, mutation ledger."
;;
2)
print_prompt_block "Author — conflict-fix session" \
"You are the AUTHOR session for <org>/<repo> in conflict-fix mode.
Goal: resolve merge conflicts on PR #<P> / branch <branch> only.
Workflow:
1. Preflight: prove author identity and exact push/commit capability for the locked PR branch.
2. Confirm conflict-fix lease and stale-head protection before pushing.
3. Work only in the session-owned worktree under branches/ — never the root checkout.
4. Rebase or merge target branch, run tests, push, update PR. No force-push without explicit operator approval.
5. Final report: conflict resolution proof, new HEAD SHA, tests, mutation ledger."
;;
3)
print_prompt_block "Author — root checkout recovery" \
"You are a RECOVERY session for <org>/<repo>.
Goal: restore the stable root control checkout to clean master/prgs/master.
Workflow:
1. Inspect root checkout: branch, git status --short --branch, HEAD vs prgs/master.
2. Do not implement features from the root checkout. Stash or move work to branches/<session> first.
3. Return root to master (or main/dev per project policy) matching prgs/master with no dirty tracked files.
4. Report before/after branch, SHA, dirty state, and safe next action for author worktree creation."
;;
0) return ;;
*) printf 'Invalid choice.\n' ;;
esac
done
}
show_reviewer_prompts() {
print_prompt_block "Reviewer — PR review" \
"You are the REVIEWER session for <org>/<repo>.
Goal: review PR #<P> only — do not merge unless explicitly switched to merger mode.
Workflow:
1. Load canonical workflow: skills/llm-project-workflow/workflows/review-merge-pr.md
2. Preflight: prove reviewer identity, review_pr capability, clean review worktree.
3. gitea_view_pr, validate scope, run required checks in the correct worktree.
4. gitea_review_pr with approve, request-changes, or comment as warranted.
5. Final report: PR head SHA, verdict, validation evidence, mutation ledger. No merge in reviewer-only runs."
}
show_merger_prompts() {
print_prompt_block "Merger — PR merge" \
"You are the MERGER session for <org>/<repo>.
Goal: merge PR #<P> only after every gate passes.
Workflow:
1. Load canonical workflow: skills/llm-project-workflow/workflows/review-merge-pr.md
2. Preflight: prove merger identity and exact merge_pr capability for the current PR head SHA.
3. Confirm approval pins the current head SHA; re-validate if the branch moved.
4. gitea_merge_pr only on explicit operator approval after all gates pass.
5. Final report: merged SHA, cleanup handoff, mutation ledger."
}
show_reconciler_prompts() {
print_prompt_block "Reconciler — already-landed / closed PR cleanup" \
"You are the RECONCILER session for <org>/<repo>.
Goal: reconcile already-landed open PRs — close or comment only when exact capability is proven.
Workflow:
1. Load canonical workflow: skills/llm-project-workflow/workflows/reconcile-landed-pr.md
2. Preflight: prove reconciler identity and gitea.pr.close (or authorized close) capability.
3. Do not review, merge, implement code, or create branches.
4. gitea_scan_already_landed_open_prs / gitea_reconcile_already_landed_pr as appropriate.
5. Final report: PR numbers handled, close proof, mutation ledger."
}
show_onboarding_prompt() {
print_prompt_block "Onboarding — new project to MCP workflow" \
"Onboard <org>/<repo> into the MCP Control Plane workflow.
Checklist:
1. Prove identity and task capability via gitea_whoami and gitea_resolve_task_capability.
2. Configure separate MCP namespaces/profiles: author, reviewer, merger/reconciler as needed.
3. Register gitea-tools (and jenkins-mcp / glitchtip-mcp if applicable) in the client MCP config.
4. Copy skills/llm-project-workflow/SKILL.md guidance into the target repo or ECC install.
5. Verify gitea_get_runtime_context, gitea_lock_issue, and worktree rules under branches/.
6. Run ./mcp-menu.sh for day-to-day prompts; use docs/mcp-menu.md and docs/llm-workflow-runbooks.md.
Canonical router: skills/llm-project-workflow/SKILL.md"
}
show_proxmox_placeholder() {
printf '\n--- Proxmox deployment (placeholder) ---\n\n'
printf 'Push this project to Proxmox — TODO / issue-backed\n'
printf 'Create Proxmox LXC — TODO / issue-backed\n\n'
printf 'These actions are NOT implemented yet.\n'
printf 'Track deployment automation in dedicated Gitea issues before enabling here.\n'
printf 'This menu will not run deploy scripts until sanctioned tooling exists.\n'
pause
}
run_tests() {
printf '\n--- Run tests ---\n\n'
if [[ -x "$REPO_ROOT/run-tests.sh" ]]; then
printf 'Running ./run-tests.sh ...\n\n'
(cd "$REPO_ROOT" && ./run-tests.sh)
pause
return
fi
if [[ -x "$REPO_ROOT/venv/bin/python" ]]; then
printf 'run-tests.sh not found; falling back to venv/bin/python -m pytest\n\n'
(cd "$REPO_ROOT" && ./venv/bin/python -m pytest)
pause
return
fi
printf 'ERROR: No test runner available (fail closed).\n' >&2
printf 'Expected ./run-tests.sh or venv/bin/python for pytest fallback.\n' >&2
exit 1
}
main_menu() {
while true; do
print_banner
printf ' 1) Project status / root checkout health\n'
printf ' 2) Author workflow prompts\n'
printf ' 3) Reviewer workflow prompts\n'
printf ' 4) Merger workflow prompts\n'
printf ' 5) Reconciler workflow prompts\n'
printf ' 6) Onboarding new project to this MCP workflow\n'
printf ' 7) Proxmox deployment menu placeholder\n'
printf ' 8) Create Proxmox LXC placeholder\n'
printf ' 9) Run tests\n'
printf ' 0) Exit\n'
read -r -p 'Choice: ' choice
case "$choice" in
1) show_root_checkout_health ;;
2) show_author_prompts ;;
3) show_reviewer_prompts ;;
4) show_merger_prompts ;;
5) show_reconciler_prompts ;;
6) show_onboarding_prompt ;;
7|8) show_proxmox_placeholder ;;
9) run_tests ;;
0) printf 'Goodbye.\n'; exit 0 ;;
*) printf 'Invalid choice.\n'; pause ;;
esac
done
}
main_menu
+359
View File
@@ -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)
),
}
+391
View File
@@ -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,
)
+269 -2
View File
@@ -17,6 +17,11 @@ import issue_lock_store
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
CLOSES_FIXES_RE = re.compile(r"\b(?:closes|fixes)\s+#(\d+)\b", re.IGNORECASE)
# Reviewer scratch folders: review-pr<N> or review-pr<N>-submit (#534).
REVIEWER_SCRATCH_NAME_RE = re.compile(
r"^review-pr(?P<pr>\d+)(?:-submit)?$",
re.IGNORECASE,
)
def extract_linked_issue(title: str, body: str) -> int | None:
@@ -36,6 +41,148 @@ def resolve_worktree_path(project_root: str, branch: str) -> str:
return os.path.join(project_root, "branches", branch_worktree_folder(branch))
def _git_worktree_porcelain(project_root: str) -> list[dict[str, str | None]]:
"""Parse ``git worktree list --porcelain`` into structured records."""
res = subprocess.run(
["git", "-C", project_root, "worktree", "list", "--porcelain"],
capture_output=True,
text=True,
check=False,
)
if res.returncode != 0:
return []
records: list[dict[str, str | None]] = []
current: dict[str, str | None] = {}
for line in res.stdout.splitlines():
line = line.strip()
if not line:
if current.get("worktree"):
records.append(current)
current = {}
continue
if line.startswith("worktree "):
if current.get("worktree"):
records.append(current)
current = {"worktree": line[9:].strip(), "branch": None, "head": None}
elif line.startswith("branch ") and current:
ref = line[7:].strip()
current["branch"] = (
ref[11:] if ref.startswith("refs/heads/") else ref
)
elif line.startswith("HEAD ") and current:
current["head"] = line[5:].strip()
elif line == "detached" and current:
current["branch"] = None
if current.get("worktree"):
records.append(current)
return records
def discover_local_worktrees(project_root: str) -> dict[str, str]:
"""Return a mapping from branch name to absolute worktree path (#528)."""
mapping: dict[str, str] = {}
for record in _git_worktree_porcelain(project_root):
branch_name = record.get("branch")
path = record.get("worktree")
if branch_name and path:
mapping[str(branch_name)] = str(path)
return mapping
def parse_reviewer_scratch_folder(folder_name: str) -> int | None:
"""Return PR number for a reviewer scratch folder name, else None (#534)."""
match = REVIEWER_SCRATCH_NAME_RE.match((folder_name or "").strip())
if not match:
return None
return int(match.group("pr"))
def discover_reviewer_scratch_worktrees(project_root: str) -> list[dict[str, Any]]:
"""Discover detached reviewer scratch worktrees under ``branches/`` (#534).
Matches folder names ``review-pr<N>`` and ``review-pr<N>-submit`` only.
These are never treated as author worktree paths.
"""
root = os.path.realpath(project_root)
branches_root = os.path.realpath(os.path.join(root, "branches"))
found: list[dict[str, Any]] = []
for record in _git_worktree_porcelain(project_root):
path = record.get("worktree")
if not path:
continue
real = os.path.realpath(path)
parent = os.path.dirname(real)
if parent != branches_root:
continue
folder = os.path.basename(real)
pr_number = parse_reviewer_scratch_folder(folder)
if pr_number is None:
continue
found.append(
{
"pr_number": pr_number,
"worktree_path": real,
"folder_name": folder,
"current_branch": record.get("branch"),
"head_sha": record.get("head"),
"worktree_kind": "reviewer_scratch",
}
)
return found
def assess_reviewer_scratch_cleanup(
*,
pr_number: int,
worktree_path: str,
folder_name: str,
pr_merged: bool,
pr_closed: bool,
worktree_state: dict[str, Any],
active_reviewer_lease: bool,
) -> dict[str, Any]:
"""Assess whether a reviewer scratch worktree is safe to remove (#534)."""
reasons: list[str] = []
exists = bool(worktree_state.get("exists"))
if not (pr_merged or pr_closed):
reasons.append("associated PR is still open")
if not exists:
reasons.append("reviewer scratch worktree not present")
if exists and worktree_state.get("clean") is False:
dirty = worktree_state.get("dirty_files") or []
reasons.append(
"reviewer scratch worktree has tracked edits"
+ (f" ({', '.join(dirty)})" if dirty else "")
)
if active_reviewer_lease:
reasons.append("active reviewer lease still requires this worktree")
current_branch = worktree_state.get("current_branch")
if current_branch:
# Scratch trees are expected detached; a named branch is ambiguous ownership.
reasons.append(
f"reviewer scratch worktree is on branch '{current_branch}' "
"(expected detached HEAD for review-pr scratch trees)"
)
safe = exists and not reasons
return {
"pr_number": pr_number,
"worktree_path": worktree_path,
"folder_name": folder_name,
"worktree_kind": "reviewer_scratch",
"worktree_exists": exists,
"worktree_clean": worktree_state.get("clean"),
"safe_to_remove_worktree": safe,
"block_reasons": reasons,
"recommended_action": (
"remove_reviewer_scratch_worktree" if safe else "keep_reviewer_scratch_worktree"
),
# Never treat as author worktree cleanup.
"author_worktree_cleanup": False,
}
def read_issue_lock(path: str | None = None) -> dict[str, Any] | None:
if path:
return issue_lock_store.read_lock_file(path.strip())
@@ -216,6 +363,7 @@ def build_pr_cleanup_entry(
delete_capability_allowed: bool,
issue_lock_path: str | None = None,
protected_branches: frozenset[str] | None = None,
worktree_map: dict[str, str] | None = None,
) -> dict[str, Any]:
pr_number = int(pr["number"])
head_branch = pr.get("head") or ""
@@ -224,7 +372,16 @@ def build_pr_cleanup_entry(
title = pr.get("title") or ""
body = pr.get("body") or ""
merged = bool(pr.get("merged_at"))
worktree_path = resolve_worktree_path(project_root, head_branch)
discovered_path = worktree_map.get(head_branch) if worktree_map else None
derived_path = resolve_worktree_path(project_root, head_branch)
if discovered_path:
worktree_path = discovered_path
match_type = "branch"
else:
worktree_path = derived_path
match_type = "path"
worktree_state = read_local_worktree_state(worktree_path)
worktree_state["worktree_path"] = worktree_path
active_lock = has_active_issue_lock(head_branch, issue_lock_path)
@@ -247,6 +404,8 @@ def build_pr_cleanup_entry(
worktree_state=worktree_state,
active_lock=active_lock,
)
local["match_type"] = match_type
return {
"pr_number": pr_number,
"issue_number": extract_linked_issue(title, body),
@@ -270,8 +429,11 @@ def build_reconciliation_report(
delete_capability_allowed: bool,
issue_lock_path: str | None = None,
protected_branches: frozenset[str] | None = None,
active_reviewer_leases: dict[int, bool] | None = None,
pr_states: dict[int, dict[str, Any]] | None = None,
) -> dict[str, Any]:
open_heads = collect_open_pr_heads(open_prs)
worktree_map = discover_local_worktrees(project_root)
entries: list[dict[str, Any]] = []
for pr in closed_prs or []:
if not pr.get("merged_at"):
@@ -290,19 +452,63 @@ def build_reconciliation_report(
delete_capability_allowed=delete_capability_allowed,
issue_lock_path=issue_lock_path,
protected_branches=protected_branches,
worktree_map=worktree_map,
)
)
# #534: reviewer scratch worktrees are reported separately from author cleanup.
lease_map = active_reviewer_leases or {}
state_map = pr_states or {}
open_pr_numbers = {
int(pr["number"]) for pr in (open_prs or []) if pr.get("number") is not None
}
closed_by_number = {
int(pr["number"]): pr for pr in (closed_prs or []) if pr.get("number") is not None
}
scratch_entries: list[dict[str, Any]] = []
for scratch in discover_reviewer_scratch_worktrees(project_root):
pr_number = int(scratch["pr_number"])
state_info = state_map.get(pr_number) or {}
closed_pr = closed_by_number.get(pr_number)
if closed_pr is not None:
pr_merged = bool(closed_pr.get("merged_at") or closed_pr.get("merged"))
pr_closed = True
elif pr_number in open_pr_numbers:
pr_merged = False
pr_closed = False
else:
pr_merged = bool(state_info.get("merged"))
pr_closed = bool(state_info.get("closed") or state_info.get("merged"))
worktree_path = scratch["worktree_path"]
worktree_state = read_local_worktree_state(worktree_path)
worktree_state["worktree_path"] = worktree_path
# Prefer live branch from state (git) over porcelain branch field.
assessment = assess_reviewer_scratch_cleanup(
pr_number=pr_number,
worktree_path=worktree_path,
folder_name=scratch["folder_name"],
pr_merged=pr_merged,
pr_closed=pr_closed,
worktree_state=worktree_state,
active_reviewer_lease=bool(lease_map.get(pr_number)),
)
scratch_entries.append(assessment)
return {
"project_root": os.path.realpath(project_root),
"merged_pr_count": len(entries),
"entries": entries,
"reviewer_scratch_entries": scratch_entries,
"reviewer_scratch_count": len(scratch_entries),
"dry_run": True,
"executed": False,
}
def remove_local_worktree(project_root: str, branch: str) -> dict[str, Any]:
worktree_path = resolve_worktree_path(project_root, branch)
worktree_map = discover_local_worktrees(project_root)
discovered_path = worktree_map.get(branch)
worktree_path = discovered_path or resolve_worktree_path(project_root, branch)
if not os.path.isdir(worktree_path):
return {
"success": False,
@@ -326,4 +532,65 @@ def remove_local_worktree(project_root: str, branch: str) -> dict[str, Any]:
"performed": True,
"message": f"removed worktree {worktree_path}",
"worktree_path": worktree_path,
}
def remove_reviewer_scratch_worktree(
project_root: str,
worktree_path: str,
) -> dict[str, Any]:
"""Remove a reviewer scratch worktree by absolute path (#534).
Fail closed unless the path is a direct ``branches/review-pr*`` child of
*project_root*. Never routes through author branch-name derivation.
"""
root = os.path.realpath(project_root)
branches_root = os.path.realpath(os.path.join(root, "branches"))
real = os.path.realpath((worktree_path or "").strip())
folder = os.path.basename(real)
if os.path.dirname(real) != branches_root:
return {
"success": False,
"performed": False,
"worktree_kind": "reviewer_scratch",
"message": (
"reviewer scratch path must be a direct child of "
f"{branches_root}; got {real}"
),
}
if parse_reviewer_scratch_folder(folder) is None:
return {
"success": False,
"performed": False,
"worktree_kind": "reviewer_scratch",
"message": f"path is not a reviewer scratch folder: {folder}",
}
if not os.path.isdir(real):
return {
"success": False,
"performed": False,
"worktree_kind": "reviewer_scratch",
"message": f"worktree not found: {real}",
}
res = subprocess.run(
["git", "-C", project_root, "worktree", "remove", real],
capture_output=True,
text=True,
check=False,
)
if res.returncode != 0:
return {
"success": False,
"performed": False,
"worktree_kind": "reviewer_scratch",
"message": (res.stderr or res.stdout or "worktree remove failed").strip(),
"worktree_path": real,
}
return {
"success": True,
"performed": True,
"worktree_kind": "reviewer_scratch",
"message": f"removed reviewer scratch worktree {real}",
"worktree_path": real,
"author_worktree_cleanup": False,
}
+253
View File
@@ -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 = "<!-- mcp-review-lease-adoption:v1 -->"
DEFAULT_ADOPTION_REASON = "merger-handoff-approved-head"
SOURCE_ADOPT = "gitea_adopt_merger_pr_lease"
SOURCE_ACQUIRE = "gitea_acquire_reviewer_pr_lease"
SOURCE_HEARTBEAT = "gitea_heartbeat_reviewer_pr_lease"
SANCTIONED_PROVENANCE_SOURCES = frozenset({
SOURCE_ADOPT,
SOURCE_ACQUIRE,
SOURCE_HEARTBEAT,
})
_MERGER_ADOPTABLE_FRESHNESS = frozenset({"active", "stale_warning"})
def format_adoption_body(
*,
repo: str,
pr_number: int,
issue_number: int | None,
adopter_identity: str,
adopter_profile: str,
adopter_session_id: str,
worktree: str,
candidate_head: str | None,
target_branch: str,
target_branch_sha: str | None,
adopted_from_session_id: str,
adopted_from_profile: str,
adopted_from_reviewer_identity: str,
adopted_from_comment_id: int | None,
adoption_reason: str = DEFAULT_ADOPTION_REASON,
adopted_at: datetime | None = None,
) -> str:
"""Render a durable merger adoption proof comment."""
adopted_at = adopted_at or datetime.now(timezone.utc)
adopted_text = adopted_at.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
"+00:00", "Z"
)
lease_body = leases.format_lease_body(
repo=repo,
pr_number=pr_number,
issue_number=issue_number,
reviewer_identity=adopter_identity,
profile=adopter_profile,
session_id=adopter_session_id,
worktree=worktree,
phase="adopted",
candidate_head=candidate_head,
target_branch=target_branch,
target_branch_sha=target_branch_sha,
last_activity=adopted_at,
blocker="none",
)
lines = [
ADOPTION_MARKER,
f"adopted_at: {adopted_text}",
f"adopted_by_identity: {adopter_identity}",
f"adopted_by_profile: {adopter_profile}",
f"adopted_from_session_id: {adopted_from_session_id}",
f"adopted_from_profile: {adopted_from_profile}",
f"adopted_from_reviewer_identity: {adopted_from_reviewer_identity}",
f"adopted_from_comment_id: {adopted_from_comment_id or 'none'}",
f"adoption_reason: {adoption_reason}",
lease_body,
]
return "\n".join(lines)
def is_adoption_comment(body: str) -> bool:
return ADOPTION_MARKER in (body or "")
def build_lease_provenance(
*,
source: str,
comment_id: int | None = None,
adopted_from_session_id: str | None = None,
adopted_from_profile: str | None = None,
adopted_from_reviewer_identity: str | None = None,
adoption_reason: str | None = None,
recorded_at: datetime | None = None,
) -> dict[str, Any]:
recorded_at = recorded_at or datetime.now(timezone.utc)
recorded_text = recorded_at.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
"+00:00", "Z"
)
proof = {
"source": source,
"recorded_at": recorded_text,
}
if comment_id is not None:
proof["comment_id"] = comment_id
if adopted_from_session_id:
proof["adopted_from_session_id"] = adopted_from_session_id
if adopted_from_profile:
proof["adopted_from_profile"] = adopted_from_profile
if adopted_from_reviewer_identity:
proof["adopted_from_reviewer_identity"] = adopted_from_reviewer_identity
if adoption_reason:
proof["adoption_reason"] = adoption_reason
return proof
def is_sanctioned_session_lease(session: dict[str, Any] | None) -> bool:
if not session:
return False
provenance = session.get("lease_provenance") or {}
source = (provenance.get("source") or "").strip()
if source not in SANCTIONED_PROVENANCE_SOURCES:
return False
if source == SOURCE_ADOPT:
return bool(provenance.get("comment_id")) and bool(
provenance.get("adopted_from_session_id")
)
if source in {SOURCE_ACQUIRE, SOURCE_HEARTBEAT}:
return bool(session.get("comment_id") or provenance.get("comment_id"))
return False
def 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,
}
+307
View File
@@ -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 [],
}
+239
View File
@@ -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"
),
}
+15 -1
View File
@@ -132,6 +132,13 @@ def check_cleanup_task_allowed(task: str) -> tuple[bool, list[str]]:
_SELECTED_PR_RE = re.compile(
r"^\s*[-*]?\s*selected pr\s*:\s*#?(\d+)", re.IGNORECASE | re.MULTILINE
)
_SELECTED_PR_NONE_RE = re.compile(
r"^\s*[-*]?\s*selected pr\s*:\s*(?:none|n/a)\b", re.IGNORECASE | re.MULTILINE
)
_EMPTY_QUEUE_RE = re.compile(
r"\b0 open pr|\bno open pr|\bno eligible pr|\bempty (?:review )?queue|\binventory empty|\btrusted_empty\b",
re.IGNORECASE,
)
_NEXT_SUGGESTED_RE = re.compile(
r"^\s*[-*]?\s*next suggested pr\s*:", re.IGNORECASE | re.MULTILINE
)
@@ -176,7 +183,11 @@ def assess_pr_queue_cleanup_report(report_text: str) -> dict:
selected = _SELECTED_PR_RE.findall(text)
if len(selected) == 0:
reasons.append("report must name exactly one Selected PR")
if _SELECTED_PR_NONE_RE.search(text):
if not _EMPTY_QUEUE_RE.search(text):
reasons.append("Selected PR is 'none' but no valid empty-queue claim found")
else:
reasons.append("report must name exactly one Selected PR")
elif len(set(selected)) > 1:
reasons.append(
"cleanup run selected multiple PRs "
@@ -184,6 +195,9 @@ def assess_pr_queue_cleanup_report(report_text: str) -> dict:
"PR per run"
)
if len(selected) > 0 and _SELECTED_PR_NONE_RE.search(text):
reasons.append("report contains both a selected PR and a claim of none selected")
terminal = _TERMINAL_MUTATION_RE.findall(text)
if len(terminal) > 1:
reasons.append(
+176
View File
@@ -0,0 +1,176 @@
"""Pre-merge baseline proof verifier for non-zero validation exits (#533).
A controller/reviewer may reproduce a failing test on *current* master and
describe it as proof of a "known baseline failure". Reproducing a failure on
post-merge master only proves the failure exists on current master — it does
NOT prove the failure pre-existed the PR under review. A PR can introduce or
preserve a regression, then once merged the failure appears on master and gets
mislabeled "baseline", weakening validation gates.
This module distinguishes four canonical validation outcomes:
- ``CLEAN_PASS`` — the validation command exited zero.
- ``CURRENT_MASTER_FAILURE_REPRODUCED`` — the same failure reproduces on
current (post-merge) master. Not sufficient as baseline proof.
- ``PREMERGE_BASELINE_PROVEN_FAILURE`` — the failure is proven on the PR's
pre-merge base commit, or by a documented known-failure record that predates
the PR.
- ``UNRESOLVED_REGRESSION_RISK`` — a non-zero exit that is neither a clean pass
nor proven pre-existing.
A report may only call a non-zero validation exit a "baseline"/"pre-existing"
failure when it carries pre-merge proof.
"""
from __future__ import annotations
import re
from typing import Any
CLEAN_PASS = "clean pass"
CURRENT_MASTER_FAILURE_REPRODUCED = "current-master failure reproduced"
PREMERGE_BASELINE_PROVEN_FAILURE = "pre-merge baseline-proven failure"
UNRESOLVED_REGRESSION_RISK = "unresolved regression risk"
# A non-zero validation exit is claimed somewhere in the report.
_NONZERO_EXIT_RE = re.compile(
r"(?:exit(?:\s*(?:status|code))?\s*[:=]?\s*(?:[1-9]\d*)"
r"|\b\d+\s+failed\b"
r"|\bnon[- ]zero\s+(?:exit|validation))",
re.IGNORECASE,
)
# The report claims the failure is pre-existing / a baseline failure.
_BASELINE_CLAIM_RE = re.compile(
r"(?:pre[- ]?existing(?:\s+(?:baseline|failure))?"
r"|baseline(?:[- ]proven)?\s+failure"
r"|known[- ]baseline"
r"|failure\s+is\s+baseline"
r"|already\s+fail(?:s|ing)\s+on\s+master)",
re.IGNORECASE,
)
# Only-current-master reproduction language (insufficient on its own).
_CURRENT_MASTER_RE = re.compile(
r"(?:current[- ]master\s+(?:reproduc|failure)"
r"|reproduc\w*\s+on\s+(?:current\s+)?master"
r"|post[- ]merge\s+master"
r"|fails\s+on\s+(?:current\s+)?master\s+(?:now|too|as\s+well))",
re.IGNORECASE,
)
# Pre-merge base proof: a base commit tested before the PR landed.
_PREMERGE_BASE_RE = re.compile(
r"(?:pre[- ]merge\s+base(?:\s+commit)?"
r"|pr\s+base\s+commit"
r"|merge[- ]base(?:\s+commit)?"
r"|base\s+commit\s+before\s+(?:the\s+)?pr)",
re.IGNORECASE,
)
# Documented known-failure record predating the PR.
_KNOWN_FAILURE_RECORD_RE = re.compile(
r"known[- ]failure\s+(?:record|reference|ledger)\b.{0,80}?"
r"(?:predat|before\s+(?:the\s+)?pr|pre[- ]existing|prior\s+to\s+(?:the\s+)?pr)",
re.IGNORECASE | re.DOTALL,
)
# Required proof fields for a pre-merge baseline claim.
_FIELD_PATTERNS: dict[str, re.Pattern[str]] = {
"base commit": re.compile(
r"(?:pre[- ]merge\s+)?base\s+commit\s*[:=]\s*([0-9a-f]{7,40})", re.IGNORECASE
),
"tested commit": re.compile(
r"tested\s+commit\s*[:=]\s*([0-9a-f]{7,40})", re.IGNORECASE
),
"command": re.compile(r"command\s*[:=]\s*\S", re.IGNORECASE),
"exit status": re.compile(r"exit\s*(?:status|code)?\s*[:=]\s*\d+", re.IGNORECASE),
"failure signature": re.compile(
r"failure\s+signature\s*[:=]\s*\S", re.IGNORECASE
),
}
def assess_premerge_baseline_proof(report_text: str) -> dict[str, Any]:
"""Assess whether a claimed baseline failure carries pre-merge proof.
Returns a dict with ``proven``, ``block``, ``label``, ``reasons``,
``skipped`` and ``safe_next_action``. Only blocks when the report claims a
non-zero validation exit is baseline/pre-existing without valid pre-merge
proof.
"""
text = report_text or ""
has_failure = bool(_NONZERO_EXIT_RE.search(text))
claims_baseline = bool(_BASELINE_CLAIM_RE.search(text))
# No failure claimed, or no baseline assertion — nothing to enforce here.
if not has_failure and not claims_baseline:
return {
"proven": True,
"block": False,
"label": CLEAN_PASS,
"reasons": [],
"skipped": True,
"safe_next_action": "",
}
if not claims_baseline:
# A non-zero exit not asserted as baseline — surface as regression risk,
# but do not block (the report never claimed a clean/baseline pass).
return {
"proven": True,
"block": False,
"label": UNRESOLVED_REGRESSION_RISK,
"reasons": [],
"skipped": False,
"safe_next_action": "",
}
has_premerge_base = bool(_PREMERGE_BASE_RE.search(text))
has_known_record = bool(_KNOWN_FAILURE_RECORD_RE.search(text))
only_current_master = bool(_CURRENT_MASTER_RE.search(text))
reasons: list[str] = []
if not (has_premerge_base or has_known_record):
if only_current_master:
reasons.append(
"baseline failure claimed from current-master reproduction only; "
"that proves the failure on current master, not that it pre-existed "
"the PR — provide pre-merge base proof or a known-failure record"
)
else:
reasons.append(
"baseline/pre-existing failure claimed without pre-merge proof "
"(no pre-merge base commit and no documented known-failure record "
"predating the PR)"
)
# Require the concrete proof fields regardless of proof source.
missing = [name for name, pat in _FIELD_PATTERNS.items() if not pat.search(text)]
if missing:
reasons.append(
"pre-merge baseline claim missing required proof field(s): "
+ ", ".join(missing)
)
if reasons:
return {
"proven": False,
"block": True,
"label": UNRESOLVED_REGRESSION_RISK,
"reasons": reasons,
"skipped": False,
"safe_next_action": (
"prove the failure on the PR pre-merge base commit (or cite a "
"known-failure record predating the PR) and state base commit, "
"tested commit, command, exit status, and failure signature; "
"current-master reproduction alone is not baseline proof"
),
}
return {
"proven": True,
"block": False,
"label": PREMERGE_BASELINE_PROVEN_FAILURE,
"reasons": [],
"skipped": False,
"safe_next_action": "",
}
+98
View File
@@ -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,
}
+18
View File
@@ -5115,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(
@@ -5608,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)
+259
View File
@@ -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": [],
}
+321
View File
@@ -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.",
]
+129
View File
@@ -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"
),
}
+139 -6
View File
@@ -23,6 +23,7 @@ _ACTIVE_PHASES = frozenset({
"approved",
"request-changes",
"merging",
"adopted",
})
DEFAULT_LEASE_TTL_MINUTES = 120
@@ -217,12 +218,24 @@ def assess_acquire_lease(
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."""
"""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)
@@ -270,12 +283,120 @@ def assess_acquire_lease(
"existing_lease": existing,
"lease_body": body,
"session_id": session_id,
"post_merge_moot": post_merge_moot,
}
def record_session_lease(lease: dict[str, Any]) -> dict[str, Any]:
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
_SESSION_LEASE = dict(lease)
stored = dict(lease)
if lease_provenance:
stored["lease_provenance"] = dict(lease_provenance)
_SESSION_LEASE = stored
return dict(_SESSION_LEASE)
@@ -308,13 +429,25 @@ def assess_mutation_lease_gate(
if not session:
reasons.append(
f"no in-session reviewer lease recorded; acquire via "
f"gitea_acquire_reviewer_pr_lease before {mutation}"
f"gitea_acquire_reviewer_pr_lease or adopt via "
f"gitea_adopt_merger_pr_lease before {mutation}"
)
elif session.get("pr_number") != pr_number:
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.get("session_id") or "") != (session_id or session.get("session_id")):
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:
+148
View File
@@ -0,0 +1,148 @@
"""Root checkout guard (#475).
The project root checkout is the stable control checkout on master/prgs/master.
Author/reviewer/merge flows must fail closed when the control checkout is
contaminated (wrong branch, detached HEAD, dirty, or HEAD behind/ahead of
prgs/master). Isolated ``branches/...`` worktrees remain allowed.
"""
from __future__ import annotations
import os
import subprocess
from author_mutation_worktree import is_path_under_branches
from reviewer_worktree import parse_dirty_tracked_files
REMEDIATION = (
"Root checkout is not on master. Preserve state, switch root back to master, "
"and use scripts/worktree-review or the sanctioned issue worktree flow."
)
BASE_BRANCHES = frozenset({"master", "main", "dev"})
REMOTE_MASTER_REFS = ("prgs/master", "refs/remotes/prgs/master")
def resolve_remote_master_sha(
canonical_repo_root: str,
*,
remote_refs: tuple[str, ...] | None = None,
) -> str | None:
"""Return the commit SHA for the tracking master ref when available."""
root = (canonical_repo_root or "").strip()
if not root:
return None
for ref in remote_refs or REMOTE_MASTER_REFS:
res = subprocess.run(
["git", "-C", root, "rev-parse", "--verify", ref],
capture_output=True,
text=True,
check=False,
)
if res.returncode == 0:
sha = (res.stdout or "").strip()
if sha:
return sha
return None
resolve_tracking_master_sha = resolve_remote_master_sha
def assess_root_checkout_guard(
*,
workspace_path: str,
canonical_repo_root: str,
current_branch: str | None,
head_sha: str | None,
porcelain_status: str,
remote_master_sha: str | None,
resolved_role: str | None = None,
actual_role: str | None = None,
) -> dict:
"""Fail closed when the control checkout is not clean master/prgs/master.
``resolved_role`` is the preflight-resolved *task* role and ``actual_role``
is the *active profile* role (#540). The reconciler exemption honours either
signal so a ``comment_issue`` preflight (which stamps the task role as
``author``) cannot strip a genuine reconciler of its exemption. An actual
author profile classifies as ``author`` in both signals, so author blocking
on a contaminated control checkout is preserved.
Merger *strictness* (a merger must not be auto-exempted by working from a
``branches/`` worktree) stays keyed on the resolved task role: merge
operations resolve their own task role, and widening the merger test with
``actual_role`` would wrongly subject a merger operating from its clean
workspace under a non-merge task to full control-checkout checks.
"""
reasons: list[str] = []
root = os.path.realpath(canonical_repo_root)
workspace = os.path.realpath(workspace_path)
branch = (current_branch or "").strip()
dirty_files = parse_dirty_tracked_files(porcelain_status)
if resolved_role == "reconciler" or actual_role == "reconciler":
return _assessment(True, [], root, workspace, branch, head_sha, dirty_files)
if resolved_role != "merger" and is_path_under_branches(workspace, root):
return _assessment(True, [], root, workspace, branch, head_sha, dirty_files)
if dirty_files:
reasons.append(
"control checkout has tracked local edits before role work "
f"(dirty files: {', '.join(dirty_files)})"
)
if not branch:
reasons.append("control checkout is detached HEAD; expected branch 'master'")
elif branch not in BASE_BRANCHES:
reasons.append(
f"control checkout branch '{branch}' is not a stable base branch "
f"({'/'.join(sorted(BASE_BRANCHES))})"
)
if remote_master_sha and head_sha and head_sha != remote_master_sha:
reasons.append(
"control checkout HEAD does not match prgs/master "
f"(HEAD {head_sha[:12]}, prgs/master {remote_master_sha[:12]})"
)
proven = not reasons
return _assessment(proven, reasons, root, workspace, branch or None, head_sha, dirty_files)
def format_root_checkout_guard_error(assessment: dict) -> str:
"""Single RuntimeError message for MCP preflight gates."""
root = assessment.get("canonical_repo_root") or "(unknown)"
workspace = assessment.get("workspace_path") or "(unknown)"
reasons = "; ".join(assessment.get("reasons") or ["unknown root checkout violation"])
return (
f"Root checkout guard (#475): {reasons}. "
f"canonical repository root: {root}; workspace: {workspace}. "
f"{REMEDIATION}"
)
def _assessment(
proven: bool,
reasons: list[str],
canonical_repo_root: str,
workspace_path: str,
current_branch: str | None,
head_sha: str | None,
dirty_files: list[str],
) -> dict:
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"canonical_repo_root": canonical_repo_root,
"workspace_path": workspace_path,
"current_branch": current_branch,
"head_sha": head_sha,
"dirty_files": dirty_files,
"remediation": REMEDIATION,
}
assess_root_checkout = assess_root_checkout_guard
Executable
+13
View File
@@ -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 "$@"
+14 -1
View File
@@ -182,4 +182,17 @@ Ready-to-copy task prompts live in [`templates/`](templates/):
Releases follow SemVer from remote `master` only, after full test suite passes.
See [`templates/release-tag.md`](templates/release-tag.md) and
`scripts/release-tag`.
`scripts/release-tag`.
## Bootstrap Review Path (#557)
Self-hosted MCP workflow fixes can deadlock live review daemons. Do not bypass
gates with raw API, direct imports, or root checkout edits.
If and only if a controller posts a durable `BOOTSTRAP REVIEW AUTHORIZATION
(#557)` record, follow:
`docs/bootstrap-review-path.md`
Otherwise stop with BLOCKED + DIAGNOSE. Bootstrap never weakens normal PR gates.
@@ -63,8 +63,14 @@ Do not use legacy fields: `Pinned reviewed head`, `Scratch worktree used`,
- Current status:
- Safe next action:
- Safety statement:
- Workflow-load helper result:
```
The **Workflow-load helper result** field must carry structured output from
`gitea_load_review_workflow` (workflow_hash, final_report_schema_hash,
boundary_status). Narrative claims that workflow files were viewed locally are
not sufficient (#403).
### Already-landed handoff overrides
When eligibility class is `ALREADY_LANDED_RECONCILE_REQUIRED`:
@@ -0,0 +1,68 @@
# Controller issue-acceptance prompt
Use after a PR merges when auditing whether the linked issue is truly complete.
```text
Audit issue #<N> against its acceptance criteria after merged PR #<PR>.
Post a Controller Issue Acceptance comment with checked criteria, validation
reviewed, controller decision, next actor, and paste-ready next prompt.
Do not mark the issue accepted unless every required criterion is satisfied.
```
## Comment template
```text
## Controller Issue Acceptance
STATE:
<accepted | more-work-required | needs-tests | needs-docs | needs-feature-enhancement | needs-follow-up-issue | blocked>
WHO_IS_NEXT:
<author | reviewer | merger | reconciler | controller | user>
NEXT_ACTION:
<one sentence>
NEXT_PROMPT:
<paste-ready prompt for the next LLM>
ISSUE:
#...
MERGED_PR:
#...
MERGE_COMMIT:
<40-character SHA>
ACCEPTANCE_CRITERIA_CHECKED:
- [x] ...
- [ ] ...
VALIDATION_REVIEWED:
<tests/proofs reviewed>
CONTROLLER_DECISION:
<accepted or rejected>
WHY:
<reasoning>
MISSING_WORK:
<none, or exact missing work>
FOLLOW_UP_ISSUES:
<none, or issue list to create>
BLOCKERS:
<none, or exact blockers>
LAST_UPDATED_BY:
<identity/profile/date>
```
## Rejection paths
When rejecting completion, `STATE` must name the gap (`needs-tests`,
`needs-docs`, `more-work-required`, etc.), `MISSING_WORK` must be explicit,
and `NEXT_PROMPT` must be ready for the next author session.
@@ -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 <remote> --prune; git checkout master; git pull <remote> master --ff-only
- Verify that the expected files added/modified in the PR are present on master (or absent if deleted).
@@ -0,0 +1,27 @@
# Template: post-merge cleanup (reconciler only)
Copy, fill the `<...>` fields, and paste as the task prompt. Run only after merge
is confirmed on remote master. Merger sessions must hand off here — never perform
this cleanup inline (#517).
```text
Task: MCP-native post-merge cleanup for PR #<pr> / issue #<n>.
Rules:
- Active profile must be reconciler (`prgs-reconciler`) with `gitea.branch.delete`.
- Use MCP tools only — no raw git branch delete, no API comment deletion scripts.
- Record cleanup mutations separately from merge mutations in the handoff.
Steps:
1. `gitea_resolve_task_capability(task="reconcile_merged_cleanups", remote=prgs)`
2. Confirm PR #<pr> merged on <remote>/master.
3. `gitea_cleanup_post_merge_moot_lease` if a reviewer lease remains (append-only).
4. `gitea_reconcile_merged_cleanups` for branch/worktree cleanup with dry-run first.
5. Report authorized cleanup tools used and reconciler capability proof.
Handoff ledger (required fields):
- Merge mutations: (none — merger already recorded gitea_merge_pr)
- Cleanup mutations: list exact MCP tools invoked
- Reconciler capability: profile + gitea.branch.delete proof
- Next actor: controller acceptance or none
```
@@ -16,7 +16,7 @@ Rules:
reviewing a second PR after a terminal mutation in this run.
- After REQUEST_CHANGES: stop. After APPROVED: merge only this PR and only if
operator explicitly authorized merge for this PR in this run.
- Report Next suggested PR without continuing to it.
- Report Next suggested PR without continuing to it. If the PR queue is empty, look at approvals, or issues next.
Operator PR list (optional): <pr numbers or "oldest eligible from inventory">
Merge authorized for selected PR in this run: <true|false>
@@ -28,13 +28,18 @@ Repo name disambiguation (Gitea-Tools blind review hardening):
`pr_inventory_trust_gate.status`, trust-gate reasons, corroboration,
remote/owner/repo/state filter, and the inventory MCP profile. A recent merge
commit is not valid corroboration. Author-bound sessions must not present
reviewer queue inventory as a reviewer decision.
reviewer queue inventory as a reviewer decision. If the PR queue is empty,
look at approvals, or issues next.
Load the canonical workflow first:
`skills/llm-project-workflow/workflows/review-merge-pr.md` (task mode: review-merge-pr).
Final report schema: `schemas/review-merge-final-report.md`.
Rules (llm-project-workflow):
- 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
@@ -122,4 +127,14 @@ including the review/merge role fields: Selected PR, Reviewer eligibility,
Pinned reviewed head, Review decision, Merge result, Linked issue status,
Cleanup status. If you could not merge, name the exact gate. Reports missing
the handoff are downgraded (review_proofs.assess_controller_handoff).
Baseline failure proof (#533): if a validation command exits non-zero, do NOT
call it a clean pass. Only label a failure "baseline"/"pre-existing" with
pre-merge proof — the failure reproduced on the PR's pre-merge base commit, or a
documented known-failure record predating the PR. Reproducing on current
(post-merge) master is "current-master failure reproduced", NOT baseline proof.
State: base commit, tested commit, command, exit status, failure signature.
Use one label: clean pass / current-master failure reproduced / pre-merge
baseline-proven failure / unresolved regression risk
(final_report_validator: reviewer.premerge_baseline_proof).
```
@@ -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
@@ -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.
@@ -55,6 +55,10 @@ claim. Select exactly one PR according to project queue ordering rules
PRs only with live per-PR proof. No multi-PR validation and no batch report
may substitute for per-PR proof.
If the open PR queue is empty:
* First, look at **Approvals** next: check if there are open PRs with pending/completed approvals requiring attention or merge.
* Next, look at **Issues** next: check if there are unresolved open issues requiring action/fixes.
## 4. Terminal mutation chain
`pr_queue_cleanup.resolve_cleanup_run_state` is the authority:
@@ -304,6 +304,40 @@ If any required mutation capability is missing:
* include safe next action (profile switch, human close, or dedicated reconciler
profile)
## 15A. Audit vs cleanup phase (#419)
Reconciliation audits are **read-only** unless a separate cleanup phase is
explicitly authorized.
**Audit phase forbids** (``audit_reconciliation_mode.check_audit_mutation_allowed``
fails closed):
* ``gitea_delete_branch``
* ``git branch -D``
* ``git worktree remove``
* pushes
* issue/PR mutations
* file edits
Dry-run merged-cleanup reconciliation (``gitea_reconcile_merged_cleanups`` with
``dry_run=True``) stays in audit phase. Execution requires:
1. Operator approval or workflow authorization
2. Exact ``delete_branch`` capability proof (``gitea.branch.delete``)
3. Proof branch/worktree is safe to remove
4. Before/after state snapshot
Call ``gitea_authorize_reconciliation_cleanup_phase`` before any cleanup
mutation. Final reports must not claim ``no mutations`` if cleanup occurred.
Classify cleanup mutations as:
* remote branch deletion → **External-state mutations**
* local branch deletion → **Git ref mutations**
* worktree removal → **Cleanup mutations**
``audit_reconciliation_mode.assess_audit_reconciliation_report`` validates
these boundaries in final reports.
## 16. Mutation classification
Use precise mutation categories in the final report:
@@ -341,6 +375,24 @@ Include:
* confirmation that no normal review, approval, request-changes, or merge was
performed
## 18A. Reconciler close proof is enforced (#306)
When a reconciler run closes a PR, the final-report validator
(`final_report_validator` rule `reconcile.close_proof_fields`) **blocks** the
handoff unless it carries all four close proofs. The prompt is guidance; the
MCP validator is the authority.
A close is detected from `PRs closed: #<n>` (or a session close lock). Once a
close is reported, the handoff must include:
* `Capabilities proven:` naming `gitea.pr.close` — the exact close capability
* `Ancestor proof:` — the landed/ancestor proof for the closed PR
* `PRs closed:` — the PR close result (the closed PR number)
* `Linked issue live status:` (or `Issues closed:`) — the linked-issue result
Comment-only and blocked reconciliations (no PR close) are unaffected: the rule
returns no finding when nothing was closed.
## 19. Local artifact and report consistency rule
Do not create local walkthrough, notes, markdown, JSON, or report artifacts
@@ -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:
@@ -234,6 +272,10 @@ If queue ordering cannot be proven, stop and produce a recovery handoff.
## 10. Select the next actionable PR using project rules
If the open PR queue is empty:
* First, look at approvals next to see if there are pending approvals or approved PRs that need attention/merge.
* Next, look at issues next to see if there are open issues requiring action/fixes.
Do not review your own PR.
Do not review stale, draft, blocked, duplicate, already-owned, dependency-blocked, already-landed, already-requested-changes work unless the rules explicitly allow it.
@@ -796,6 +838,21 @@ 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:
@@ -871,6 +928,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.
@@ -881,6 +948,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:
@@ -979,6 +1101,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.
@@ -626,9 +626,14 @@ When pushing to an existing PR branch to resolve merge conflicts:
* session worktree path
* push cwd
* whether the push is fast-forward
3. Do not push when a reviewer holds an active lease on the same PR.
4. Do not force-push.
5. Do not push from the main checkout or wrong cwd.
* 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:
@@ -699,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:
+374
View File
@@ -0,0 +1,374 @@
"""Canonical state handoff ledger for discussions, issues, and PRs (#494)."""
from __future__ import annotations
import re
from typing import Any
HANDOFF_HEADING = "Controller Handoff"
ISSUE_STATE_FIELDS = (
"STATE",
"NEXT_ACTOR",
"NEXT_ACTION",
"NEXT_PROMPT",
"STATUS",
"RELATED_PRS",
"BRANCH",
"HEAD_SHA",
"VALIDATION",
"BLOCKERS",
"DUPLICATES_OR_SUPERSEDES",
"LAST_UPDATED_BY",
)
PR_STATE_FIELDS = (
"STATE",
"NEXT_ACTOR",
"NEXT_ACTION",
"NEXT_PROMPT",
"ISSUE",
"BASE",
"HEAD",
"HEAD_SHA",
"REVIEW_STATUS",
"VALIDATION",
"BLOCKERS",
"SUPERSEDES",
"SUPERSEDED_BY",
"MERGE_READY",
"LAST_UPDATED_BY",
)
DISCUSSION_STATE_FIELDS = (
"STATE",
"NEXT_ACTOR",
"NEXT_ACTION",
"NEXT_PROMPT",
"COMMENT_COUNT",
"SUBSTANTIVE_COMMENTS",
"URGENCY",
"BLOCKERS",
"LAST_UPDATED_BY",
)
DISCUSSION_SUMMARY_FIELDS = (
"DECISION",
"ISSUES_TO_CREATE",
"NON_GOALS",
"UNRESOLVED_QUESTIONS",
"NEXT_PROMPT",
"LAST_UPDATED_BY",
)
QUEUE_CONTROLLER_FIELDS = (
"QUEUE_STATE",
"SELECTED_OBJECT",
"SELECTED_OBJECT_TYPE",
"NEXT_ACTOR",
"NEXT_ACTION",
"NEXT_PROMPT",
"EVIDENCE",
"LAST_UPDATED_BY",
)
FINAL_REPORT_NEXT_ACTION_FIELDS = (
("Current status", ("current status",)),
("Next actor", ("next actor", "next_actor")),
("Next action", ("next action", "next_action")),
("Next prompt", ("next prompt", "next_prompt")),
)
STATE_COMMENT_KINDS = frozenset({
"issue_state",
"pr_state",
"discussion_state",
"discussion_summary",
"queue_controller",
})
_FIELDS_BY_KIND = {
"issue_state": ISSUE_STATE_FIELDS,
"pr_state": PR_STATE_FIELDS,
"discussion_state": DISCUSSION_STATE_FIELDS,
"discussion_summary": DISCUSSION_SUMMARY_FIELDS,
"queue_controller": QUEUE_CONTROLLER_FIELDS,
}
_FIELD_LINE_RE = re.compile(
r"^([A-Z][A-Z0-9_]+)\s*:\s*(.*)$",
re.MULTILINE,
)
_HANDOFF_SECTION_RE = re.compile(r"^##\s*Controller Handoff\s*$", re.I | re.M)
_READY_TO_MERGE_RE = re.compile(
r"\b(?:ready[_ ]to[_ ]merge|merge[_ ]ready\s*:\s*(?:yes|true))\b",
re.IGNORECASE,
)
_APPROVAL_PROOF_RE = re.compile(
r"\b(?:review decision\s*:\s*approve|approved at|approval at current head|"
r"formal approval|review_status\s*:\s*approved)\b",
re.IGNORECASE,
)
_EXTERNAL_NONE_RE = re.compile(
r"^\s*[-*]?\s*external[- ]state mutations\s*:\s*none\s*$",
re.IGNORECASE | re.MULTILINE,
)
_LOCK_MUTATION_RE = re.compile(
r"\b(?:gitea_lock_issue|issue-locks/|lock file edited)\b",
re.IGNORECASE,
)
_ISSUE_DONE_RE = re.compile(
r"\b(?:issue (?:done|closed)\b|current status\s*:\s*(?:done|closed)\b|"
r"current status\s*:\s*issue complete\b)",
re.IGNORECASE,
)
_PR_MERGE_PROOF_RE = re.compile(
r"\b(?:pr (?:merged|number)|merge result\s*:\s*merged|pr mutations\s*:\s*created)\b",
re.IGNORECASE,
)
_DISCUSSION_COMPLETE_RE = re.compile(
r"\b(?:discussion complete|discussion ready for issue creation)\b",
re.IGNORECASE,
)
_DISCUSSION_SUMMARY_PROOF_RE = re.compile(
r"\b(?:discussion summary|issues to create|issues created|linked issues)\b",
re.IGNORECASE,
)
MIN_DISCUSSION_COMMENTS_DEFAULT = 5
def _render_fields(fields: tuple[str, ...], values: dict[str, Any]) -> str:
lines = ["```text"]
for name in fields:
raw = values.get(name, values.get(name.lower(), "none"))
text = str(raw).strip() if raw is not None else "none"
lines.append(f"{name}: {text or 'none'}")
lines.append("```")
return "\n".join(lines)
def render_issue_state_comment(**values: Any) -> str:
"""Render canonical issue state comment body."""
return _render_fields(ISSUE_STATE_FIELDS, values)
def render_pr_state_comment(**values: Any) -> str:
"""Render canonical PR state comment body."""
return _render_fields(PR_STATE_FIELDS, values)
def render_discussion_state_comment(**values: Any) -> str:
"""Render canonical discussion state comment body."""
return _render_fields(DISCUSSION_STATE_FIELDS, values)
def render_discussion_summary_comment(**values: Any) -> str:
"""Render discussion-to-issue conversion summary comment."""
return _render_fields(DISCUSSION_SUMMARY_FIELDS, values)
def render_queue_controller_report(**values: Any) -> str:
"""Render queue-controller selection report."""
return _render_fields(QUEUE_CONTROLLER_FIELDS, values)
def parse_state_comment(text: str) -> dict[str, str]:
"""Parse KEY: value lines from a canonical state comment."""
parsed: dict[str, str] = {}
for match in _FIELD_LINE_RE.finditer(text or ""):
parsed[match.group(1)] = match.group(2).strip()
return parsed
def _handoff_field_map(report_text: str) -> dict[str, str]:
section_match = _HANDOFF_SECTION_RE.search(report_text or "")
if not section_match:
return {}
section = (report_text or "")[section_match.end():]
fields: dict[str, str] = {}
for line in section.splitlines():
stripped = line.strip().lstrip("-*").strip()
if ":" not in stripped:
continue
key, value = stripped.split(":", 1)
fields[key.strip().lower()] = value.strip()
return fields
def assess_state_comment(text: str, *, kind: str) -> dict[str, Any]:
"""Validate a canonical Gitea state comment for *kind*."""
normalized = (kind or "").strip().lower()
if normalized not in STATE_COMMENT_KINDS:
return {
"complete": False,
"block": True,
"missing_fields": [],
"reasons": [f"unknown state comment kind '{kind}'"],
"safe_next_action": "use a supported state comment kind",
}
required = _FIELDS_BY_KIND[normalized]
parsed = parse_state_comment(text)
missing = [name for name in required if name not in parsed or not parsed[name].strip()]
reasons = [f"state comment missing field: {name}" for name in missing]
if normalized == "discussion_state":
urgency = parsed.get("URGENCY", "").strip().lower()
count_raw = parsed.get("COMMENT_COUNT", "").strip()
try:
count = int(count_raw)
except (TypeError, ValueError):
count = -1
if (
urgency not in {"urgent", "trivial"}
and count >= 0
and count < MIN_DISCUSSION_COMMENTS_DEFAULT
):
reasons.append(
f"discussion has {count} comments; need at least "
f"{MIN_DISCUSSION_COMMENTS_DEFAULT} substantive comments "
"or URGENCY: urgent|trivial"
)
block = bool(missing or reasons)
return {
"complete": not block,
"block": block,
"missing_fields": missing,
"parsed_fields": parsed,
"reasons": reasons,
"safe_next_action": (
"fill all required state comment fields before posting"
if block
else "proceed"
),
}
def assess_final_report_next_action_handoff(report_text: str) -> dict[str, Any]:
"""Require current status plus next actor/action/prompt in Controller Handoff."""
fields = _handoff_field_map(report_text)
if not fields:
return {
"complete": False,
"block": True,
"missing_fields": [name for name, _ in FINAL_REPORT_NEXT_ACTION_FIELDS],
"reasons": [f"final report has no section titled exactly '{HANDOFF_HEADING}'"],
"safe_next_action": f"add a complete {HANDOFF_HEADING} section",
}
missing = []
for name, aliases in FINAL_REPORT_NEXT_ACTION_FIELDS:
value = ""
for alias in aliases:
if alias in fields:
value = fields[alias]
break
if not value or value.lower() in {"none", "n/a", "not verified in this session", ""}:
missing.append(name)
reasons = [f"handoff missing next-action field: {name}" for name in missing]
block = bool(missing)
return {
"complete": not block,
"block": block,
"missing_fields": missing,
"reasons": reasons,
"safe_next_action": (
"add Current status, Next actor, Next action, and Next prompt to Controller Handoff"
if block
else "proceed"
),
}
def assess_contradictory_state_handoff(report_text: str) -> dict[str, Any]:
"""Fail closed on contradictory queue/state claims in final reports."""
text = report_text or ""
fields = _handoff_field_map(text)
reasons: list[str] = []
review_decision = fields.get("review decision", fields.get("review status", ""))
has_approval = (
review_decision.lower() in {"approve", "approved"}
or bool(_APPROVAL_PROOF_RE.search(text))
)
if _READY_TO_MERGE_RE.search(text) and not has_approval:
reasons.append(
"report claims ready to merge without approval-at-current-head proof"
)
lock_val = fields.get("external-state mutations", "")
mutation_lines = " ".join(
fields.get(key, "")
for key in (
"issue mutations",
"mcp/gitea mutations",
"external-state mutations",
"claim/lock state",
)
)
if (
(_EXTERNAL_NONE_RE.search(text) or lock_val.lower() == "none")
and _LOCK_MUTATION_RE.search(mutation_lines)
):
reasons.append(
"report claims External-state mutations: none while lock "
"activity is documented in mutation fields"
)
if _ISSUE_DONE_RE.search(text) and not _PR_MERGE_PROOF_RE.search(text):
reasons.append(
"report claims issue done/complete without PR or merge proof"
)
if _DISCUSSION_COMPLETE_RE.search(text) and not _DISCUSSION_SUMMARY_PROOF_RE.search(text):
reasons.append(
"report claims discussion complete without summary or issue-creation proof"
)
review_status = fields.get("review status", fields.get("review decision", ""))
merge_ready = fields.get("merge ready", "")
if (
merge_ready.lower() in {"yes", "true", "ready"}
and review_status.lower() not in {"approve", "approved"}
and not _APPROVAL_PROOF_RE.search(text)
):
reasons.append(
"MERGE_READY or merge-ready claim without approved review status"
)
block = bool(reasons)
return {
"complete": not block,
"block": block,
"reasons": reasons,
"safe_next_action": (
"correct contradictory state claims or add missing proof"
if block
else "proceed"
),
}
def assess_state_handoff_ledger_report(report_text: str) -> dict[str, Any]:
"""Composite #494 assessment for final reports."""
next_action = assess_final_report_next_action_handoff(report_text)
contradictions = assess_contradictory_state_handoff(report_text)
reasons = list(next_action.get("reasons") or []) + list(contradictions.get("reasons") or [])
block = bool(next_action.get("block") or contradictions.get("block"))
return {
"complete": not block,
"block": block,
"next_action": next_action,
"contradictions": contradictions,
"reasons": reasons,
"safe_next_action": (
next_action.get("safe_next_action")
if next_action.get("block")
else contradictions.get("safe_next_action")
if contradictions.get("block")
else "proceed"
),
}
+8
View File
@@ -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",
+129
View File
@@ -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()
+21
View File
@@ -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()
+139
View File
@@ -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": "<!-- mcp-conflict-fix-lease:v1 -->"}
def _api(method, url, auth, *args, **kwargs):
if "/pulls/" in url:
return OPEN_PR
return [comment]
mock_api.side_effect = _api
result = _fetch_pr_lease_comments_safe(
508, remote="prgs", host=None, org=None, repo=None)
self.assertTrue(result["success"])
self.assertEqual(result["comments"], [comment])
self.assertEqual(result["head_sha"], OPEN_PR["head"]["sha"])
class TestAssessConflictFixPushTool(unittest.TestCase):
@patch("mcp_server._fetch_pr_lease_comments_safe")
@patch("mcp_server._profile_operation_gate", return_value=None)
def test_returns_structured_block_when_pr_lookup_fails(
self, _gate, mock_fetch,
):
mock_fetch.return_value = {
"success": False,
"comments": [],
"reasons": ["PR lookup failed"],
"pr_lookup": "failed",
"resolved_repo": "Scaled-Tech-Consulting/Gitea-Tools",
"remote": "prgs",
"pr_number": 508,
}
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
result = gitea_assess_conflict_fix_push(
pr_number=508,
branch_head_before=HEAD_BEFORE,
branch_head_after=HEAD_AFTER,
worktree_path="/proj/branches/fix-pr508",
push_cwd="/proj/branches/fix-pr508",
is_fast_forward=True,
remote="prgs",
)
self.assertFalse(result["push_allowed"])
self.assertTrue(result.get("assessment_failed"))
self.assertEqual(result["pr_lookup"], "failed")
@patch("mcp_server._fetch_pr_lease_comments_safe")
@patch("mcp_server._profile_operation_gate", return_value=None)
def test_valid_push_assessment_when_pr_and_comments_resolve(
self, _gate, mock_fetch,
):
mock_fetch.return_value = {
"success": True,
"comments": [],
"reasons": [],
"pr_lookup": "ok",
"resolved_repo": "Scaled-Tech-Consulting/Gitea-Tools",
"remote": "prgs",
"pr_number": 508,
"head_sha": HEAD_AFTER,
}
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
result = gitea_assess_conflict_fix_push(
pr_number=508,
branch_head_before=HEAD_BEFORE,
branch_head_after=HEAD_AFTER,
worktree_path="/proj/branches/fix-pr508",
push_cwd="/proj/branches/fix-pr508",
is_fast_forward=True,
remote="prgs",
)
self.assertTrue(result["push_allowed"])
self.assertEqual(result.get("live_pr_head_sha"), HEAD_AFTER)
if __name__ == "__main__":
unittest.main()
+10 -4
View File
@@ -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):
@@ -291,12 +292,15 @@ class TestGatedToolAudit(_AuditWiringBase):
def setUp(self):
super().setUp()
from mcp_server import init_review_decision_lock
from tests.test_mcp_server import _install_owned_reviewer_lease
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
# init_review_decision_lock clears any prior session lease (#407).
init_review_decision_lock("prgs", "review_pr")
# 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(
@@ -337,6 +341,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",
expected_head_sha="abc123", remote="prgs")
self.assertTrue(r["performed"])
@@ -356,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()
+291
View File
@@ -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()
+81
View File
@@ -0,0 +1,81 @@
"""Doc-contract checks for the bootstrap review path (#557).
Self-hosted MCP workflow fixes can deadlock live review daemons. These tests
pin the narrow controller-authorized bootstrap path so it cannot silently
regress into a general gate bypass.
"""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
DOC = REPO_ROOT / "docs" / "bootstrap-review-path.md"
RUNBOOK = REPO_ROOT / "docs" / "llm-workflow-runbooks.md"
SKILL = REPO_ROOT / "skills" / "llm-project-workflow" / "SKILL.md"
def _normalize(text: str) -> str:
return " ".join(text.split())
def test_bootstrap_doc_exists_and_names_authorization_marker():
text = DOC.read_text(encoding="utf-8")
assert "BOOTSTRAP REVIEW AUTHORIZATION (#557)" in text
assert "bootstrap deadlock" in text.lower() or "Bootstrap deadlock" in text
def test_bootstrap_doc_requires_validation_and_audits():
text = _normalize(DOC.read_text(encoding="utf-8"))
for phrase in (
"git diff --check",
"Duplicate-PR audit",
"Mutation ledger audit",
"Contamination audit",
"workflow-contaminated",
):
assert phrase in text, f"bootstrap doc missing: {phrase!r}"
def test_bootstrap_doc_lists_allowed_and_forbidden_actions():
text = _normalize(DOC.read_text(encoding="utf-8"))
lower = text.lower()
for phrase in (
"Allowed verification actions",
"Forbidden actions",
"project root",
"direct Python import",
"branches/",
):
assert phrase in text, f"bootstrap doc missing: {phrase!r}"
assert "curl" in lower
assert "raw" in lower
def test_bootstrap_doc_forbids_general_gate_weakening():
text = _normalize(DOC.read_text(encoding="utf-8")).lower()
assert "never weakens normal" in text or "does not weaken normal" in text
assert "general bypass" in text or "general gate" in text
def test_bootstrap_doc_post_land_restart_and_verify():
text = _normalize(DOC.read_text(encoding="utf-8"))
for phrase in (
"Restart MCP daemons",
"canonical tools",
"gitea_whoami",
):
assert phrase in text, f"post-bootstrap guidance missing: {phrase!r}"
def test_runbook_links_bootstrap_path():
text = _normalize(RUNBOOK.read_text(encoding="utf-8"))
assert "Bootstrap Review Path for self-hosted MCP fixes (#557)" in text
assert "bootstrap-review-path.md" in text
assert "BOOTSTRAP REVIEW AUTHORIZATION (#557)" in text
def test_skill_links_bootstrap_path():
text = _normalize(SKILL.read_text(encoding="utf-8"))
assert "Bootstrap Review Path (#557)" in text
assert "bootstrap-review-path.md" in text
assert "BLOCKED + DIAGNOSE" in text or "BLOCKED" in text
+12 -2
View File
@@ -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):
+81
View File
@@ -35,6 +35,9 @@ def _review_handoff(**overrides):
"- External-state mutations: none",
"- Read-only diagnostics: issue and PR metadata inspected",
"- Current status: PR open",
"- Next actor: author",
"- Next action: address review feedback",
"- Next prompt: Fix PR #203 per reviewer request_changes and push updated head",
"- Blockers: none",
"- Next: await author",
"- Safety: no self-review; no self-merge",
@@ -92,6 +95,9 @@ def _reconcile_handoff(drop=(), **overrides):
"- Read-only diagnostics: gitea_view_pr, gitea_view_issue",
"- Reconciliation mutations: PR comment posted",
"- Current status: reconciliation complete",
"- Next actor: reconciler",
"- Next action: close PR #99 after capability proof",
"- Next prompt: Reconcile already-landed PR #99 with prgs-reconciler profile",
"- Blocker: missing gitea.pr.close capability",
"- Safe next action: hand off to a profile with gitea.pr.close",
"- No review/merge confirmation: confirmed",
@@ -498,6 +504,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")
+271
View File
@@ -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()
+183
View File
@@ -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()
+257
View File
@@ -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()
+1
View File
@@ -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,
+8
View File
@@ -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():
+127
View File
@@ -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)
+152
View File
@@ -0,0 +1,152 @@
"""Tests for the master-parity staleness gate (#420).
Covers the pure assessment logic, the mutation-block helper, the env escape
hatches, and the server-side wiring: reads are never blocked by staleness, a
mutation is refused when the on-disk master has advanced, and the read-only
gitea_assess_master_parity tool reports the stale state.
"""
import os
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import master_parity_gate as mp # noqa: E402
SHA_A = "a" * 40
SHA_B = "b" * 40
class TestAssessMasterParity(unittest.TestCase):
def test_in_parity_when_heads_match(self):
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
self.assertTrue(res["in_parity"])
self.assertFalse(res["stale"])
self.assertFalse(res["restart_required"])
self.assertTrue(res["determinable"])
def test_stale_when_master_advanced(self):
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B)
self.assertFalse(res["in_parity"])
self.assertTrue(res["stale"])
self.assertTrue(res["restart_required"])
self.assertTrue(res["determinable"])
self.assertTrue(any("restart" in r for r in res["reasons"]))
def test_missing_startup_head_not_determinable(self):
res = mp.assess_master_parity({"startup_head": None}, SHA_B)
self.assertFalse(res["determinable"])
self.assertFalse(res["stale"])
self.assertTrue(res["in_parity"])
def test_missing_current_head_not_determinable(self):
res = mp.assess_master_parity({"startup_head": SHA_A}, None)
self.assertFalse(res["determinable"])
self.assertFalse(res["stale"])
self.assertTrue(res["in_parity"])
def test_none_startup_baseline(self):
res = mp.assess_master_parity(None, SHA_A)
self.assertFalse(res["determinable"])
self.assertFalse(res["stale"])
class TestBlockReasonsAndReport(unittest.TestCase):
def test_stale_produces_block_reasons(self):
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B)
self.assertTrue(mp.parity_block_reasons(res))
def test_in_parity_no_block_reasons(self):
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
self.assertEqual(mp.parity_block_reasons(res), [])
def test_disable_env_suppresses_block(self):
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B)
with patch.dict(os.environ, {mp.ENV_DISABLE: "1"}):
self.assertTrue(mp.gate_disabled())
self.assertEqual(mp.parity_block_reasons(res), [])
def test_report_shape_when_stale(self):
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B)
report = mp.parity_report(res)
self.assertEqual(report["kind"], "server_stale")
self.assertTrue(report["restart_required"])
self.assertEqual(report["startup_head"], SHA_A)
self.assertEqual(report["current_head"], SHA_B)
self.assertTrue(report["recovery"])
class TestReadGitHead(unittest.TestCase):
def test_test_override_takes_precedence(self):
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
self.assertEqual(mp.read_git_head("/nonexistent"), SHA_B)
def test_blank_override_is_none(self):
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: " "}):
self.assertIsNone(mp.read_git_head("/nonexistent"))
def test_empty_root_is_none(self):
# No override set; empty root cannot resolve a HEAD.
env = {k: v for k, v in os.environ.items()
if k != mp.ENV_TEST_CURRENT_HEAD}
with patch.dict(os.environ, env, clear=True):
self.assertIsNone(mp.read_git_head(""))
class TestServerWiring(unittest.TestCase):
"""Integration with the gate choke point in the server namespace."""
def setUp(self):
import mcp_server
self.srv = mcp_server
# Pin a known startup baseline so the current-HEAD override can diverge.
self._saved = self.srv._STARTUP_PARITY
self.srv._STARTUP_PARITY = {"root": self.srv.PROJECT_ROOT,
"startup_head": SHA_A}
def tearDown(self):
self.srv._STARTUP_PARITY = self._saved
def test_reads_not_blocked_when_stale(self):
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
self.assertEqual(self.srv._master_parity_block("gitea.read"), [])
def test_mutation_blocked_when_stale(self):
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
reasons = self.srv._master_parity_block("gitea.pr.create")
self.assertTrue(reasons)
# The profile-operation choke point surfaces the same block.
self.assertTrue(self.srv._profile_operation_gate("gitea.pr.create"))
def test_mutation_allowed_when_in_parity(self):
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A}):
self.assertEqual(
self.srv._master_parity_block("gitea.pr.create"), [])
def test_disable_env_lets_mutation_through(self):
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B,
mp.ENV_DISABLE: "1"}):
self.assertEqual(
self.srv._master_parity_block("gitea.pr.create"), [])
def test_assess_tool_reports_stale(self):
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
out = self.srv.gitea_assess_master_parity(remote="prgs")
self.assertTrue(out["stale"])
self.assertTrue(out["restart_required"])
self.assertEqual(out["startup_head"], SHA_A)
self.assertEqual(out["current_head"], SHA_B)
self.assertIn("report", out)
def test_assess_tool_reports_in_parity(self):
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A}):
out = self.srv.gitea_assess_master_parity(remote="prgs")
self.assertFalse(out["stale"])
self.assertTrue(out["in_parity"])
self.assertNotIn("report", out)
if __name__ == "__main__":
unittest.main()
+124
View File
@@ -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()
+251
View File
@@ -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()
+190 -10
View File
@@ -58,6 +58,12 @@ _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'."""
@@ -121,6 +127,7 @@ def _install_owned_reviewer_lease(
session_id=_DEFAULT_LEASE_SESSION,
head_sha="abc123",
):
import merger_lease_adoption as mla
import reviewer_pr_lease
reviewer_pr_lease.clear_session_lease()
@@ -129,7 +136,11 @@ def _install_owned_reviewer_lease(
"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=[
@@ -179,6 +190,7 @@ def _seed_ready_review_decision(
"correction_authorized": False,
"correction_reason": None,
})
_m.gitea_load_review_workflow()
# Issue-write tools are profile-gated (#69).
@@ -671,6 +683,7 @@ class TestMergePR(unittest.TestCase):
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(
@@ -1049,7 +1062,8 @@ 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), 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"))
@@ -1876,7 +1890,7 @@ class TestReviewDecisionValidationGate(unittest.TestCase):
def setUp(self):
import reviewer_pr_lease
init_review_decision_lock("prgs", "review_pr")
_init_reviewer_session("prgs")
self._lease_patch = _install_owned_reviewer_lease(
self.PR, head_sha=self.SHA,
)
@@ -1996,7 +2010,7 @@ class TestSubmitPrReview(unittest.TestCase):
def setUp(self):
import reviewer_pr_lease
init_review_decision_lock("prgs", "review_pr")
_init_reviewer_session("prgs")
self._lease_patch = _install_owned_reviewer_lease(8)
self._lease_patch.start()
self._auth_identity_patch = patch(
@@ -2416,7 +2430,7 @@ class TestSubmitPrReview(unittest.TestCase):
os.remove(spoof_path)
def test_mark_final_decision_rejects_remote_mismatch(self):
init_review_decision_lock("prgs", "review_pr")
_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"]))
@@ -2500,6 +2514,7 @@ 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()
@@ -3879,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.
@@ -3945,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"])
+207
View File
@@ -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()
+71
View File
@@ -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()
+285 -1
View File
@@ -4,7 +4,7 @@ import os
import sys
import tempfile
import unittest
from unittest.mock import patch
from unittest.mock import patch, MagicMock
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
@@ -154,6 +154,290 @@ class TestMergedCleanupReport(unittest.TestCase):
finally:
os.remove(lock_path)
@patch("subprocess.run")
def test_discover_local_worktrees(self, mock_run):
mock_output = (
"worktree /repo/Gitea-Tools\n"
"bare\n\n"
"worktree /repo/Gitea-Tools/branches/custom-name\n"
"branch refs/heads/feat/issue-11-x\n"
"HEAD cafebabe\n\n"
)
mock_run.return_value = MagicMock(returncode=0, stdout=mock_output)
res = mcr.discover_local_worktrees("/repo/Gitea-Tools")
self.assertEqual(res, {"feat/issue-11-x": "/repo/Gitea-Tools/branches/custom-name"})
@patch("subprocess.run")
@patch("merged_cleanup_reconcile.read_local_worktree_state")
def test_build_report_discovers_non_canonical_worktree_by_branch(self, mock_state, mock_run):
mock_output = (
"worktree /repo/Gitea-Tools\n"
"bare\n\n"
"worktree /repo/Gitea-Tools/branches/custom-name\n"
"branch refs/heads/feat/issue-11-x\n"
"HEAD cafebabe\n\n"
)
mock_run.return_value = MagicMock(returncode=0, stdout=mock_output)
mock_state.return_value = {
"exists": True,
"clean": True,
"current_branch": "feat/issue-11-x",
"head_sha": "cafebabe",
"dirty_files": [],
}
report = mcr.build_reconciliation_report(
project_root="/repo/Gitea-Tools",
closed_prs=[
{
"number": 11,
"title": "merged",
"body": "Closes #11",
"head": {"ref": "feat/issue-11-x"},
"merged_at": "2026-07-06T12:00:00Z",
"merge_commit_sha": "abc123",
}
],
open_prs=[],
remote_branch_exists={"feat/issue-11-x": True},
head_on_master={11: True},
delete_capability_allowed=True,
)
self.assertEqual(report["merged_pr_count"], 1)
entry = report["entries"][0]
local = entry["local_worktree"]
self.assertEqual(local["worktree_path"], "/repo/Gitea-Tools/branches/custom-name")
self.assertEqual(local["match_type"], "branch")
@patch("subprocess.run")
def test_remove_local_worktree_uses_discovered_path(self, mock_run):
mock_output = (
"worktree /repo/Gitea-Tools\n"
"bare\n\n"
"worktree /repo/Gitea-Tools/branches/custom-name\n"
"branch refs/heads/feat/issue-11-x\n"
"HEAD cafebabe\n\n"
)
# First call is discover_local_worktrees, second is git worktree remove
mock_run.side_effect = [
MagicMock(returncode=0, stdout=mock_output),
MagicMock(returncode=0, stdout="", stderr=""),
]
with patch("os.path.isdir", return_value=True):
result = mcr.remove_local_worktree("/repo/Gitea-Tools", "feat/issue-11-x")
self.assertTrue(result["success"])
self.assertTrue(result["performed"])
self.assertEqual(result["worktree_path"], "/repo/Gitea-Tools/branches/custom-name")
# Assert git worktree remove was called with the custom path
mock_run.assert_any_call(
["git", "-C", "/repo/Gitea-Tools", "worktree", "remove", "/repo/Gitea-Tools/branches/custom-name"],
capture_output=True,
text=True,
check=False,
)
class TestReviewerScratchCleanup(unittest.TestCase):
"""#534: authorized cleanup path for reviewer scratch worktrees."""
def test_parse_reviewer_scratch_folder(self):
self.assertEqual(mcr.parse_reviewer_scratch_folder("review-pr512-submit"), 512)
self.assertEqual(mcr.parse_reviewer_scratch_folder("review-pr99"), 99)
self.assertIsNone(mcr.parse_reviewer_scratch_folder("feat-issue-11-x"))
self.assertIsNone(mcr.parse_reviewer_scratch_folder("review-feat-issue-11"))
self.assertIsNone(mcr.parse_reviewer_scratch_folder("review-pr512-extra-tail"))
def test_assess_safe_clean_detached_after_merge(self):
assessment = mcr.assess_reviewer_scratch_cleanup(
pr_number=512,
worktree_path="/repo/Gitea-Tools/branches/review-pr512-submit",
folder_name="review-pr512-submit",
pr_merged=True,
pr_closed=True,
worktree_state={
"exists": True,
"clean": True,
"current_branch": None,
"head_sha": "abc",
"dirty_files": [],
},
active_reviewer_lease=False,
)
self.assertTrue(assessment["safe_to_remove_worktree"])
self.assertFalse(assessment["author_worktree_cleanup"])
self.assertEqual(
assessment["recommended_action"], "remove_reviewer_scratch_worktree"
)
self.assertEqual(assessment["worktree_kind"], "reviewer_scratch")
def test_assess_blocks_dirty_scratch(self):
assessment = mcr.assess_reviewer_scratch_cleanup(
pr_number=512,
worktree_path="/repo/Gitea-Tools/branches/review-pr512-submit",
folder_name="review-pr512-submit",
pr_merged=True,
pr_closed=True,
worktree_state={
"exists": True,
"clean": False,
"current_branch": None,
"head_sha": "abc",
"dirty_files": ["foo.py"],
},
active_reviewer_lease=False,
)
self.assertFalse(assessment["safe_to_remove_worktree"])
self.assertTrue(
any("tracked edits" in r for r in assessment["block_reasons"])
)
def test_assess_blocks_active_lease(self):
assessment = mcr.assess_reviewer_scratch_cleanup(
pr_number=512,
worktree_path="/repo/Gitea-Tools/branches/review-pr512-submit",
folder_name="review-pr512-submit",
pr_merged=True,
pr_closed=True,
worktree_state={
"exists": True,
"clean": True,
"current_branch": None,
"head_sha": "abc",
"dirty_files": [],
},
active_reviewer_lease=True,
)
self.assertFalse(assessment["safe_to_remove_worktree"])
self.assertTrue(
any("active reviewer lease" in r for r in assessment["block_reasons"])
)
def test_assess_blocks_open_pr(self):
assessment = mcr.assess_reviewer_scratch_cleanup(
pr_number=512,
worktree_path="/repo/Gitea-Tools/branches/review-pr512-submit",
folder_name="review-pr512-submit",
pr_merged=False,
pr_closed=False,
worktree_state={
"exists": True,
"clean": True,
"current_branch": None,
"head_sha": "abc",
"dirty_files": [],
},
active_reviewer_lease=False,
)
self.assertFalse(assessment["safe_to_remove_worktree"])
self.assertTrue(any("still open" in r for r in assessment["block_reasons"]))
@patch("subprocess.run")
@patch("merged_cleanup_reconcile.read_local_worktree_state")
def test_report_lists_scratch_separately_from_author(self, mock_state, mock_run):
mock_output = (
"worktree /repo/Gitea-Tools\n"
"bare\n\n"
"worktree /repo/Gitea-Tools/branches/feat-issue-11-x\n"
"branch refs/heads/feat/issue-11-x\n"
"HEAD deadbeef\n\n"
"worktree /repo/Gitea-Tools/branches/review-pr512-submit\n"
"HEAD abcdef0\n"
"detached\n\n"
)
mock_run.return_value = MagicMock(returncode=0, stdout=mock_output)
def _state(path):
if "review-pr512" in path:
return {
"exists": True,
"clean": True,
"current_branch": None,
"head_sha": "abcdef0",
"dirty_files": [],
}
return {
"exists": True,
"clean": True,
"current_branch": "feat/issue-11-x",
"head_sha": "deadbeef",
"dirty_files": [],
}
mock_state.side_effect = _state
report = mcr.build_reconciliation_report(
project_root="/repo/Gitea-Tools",
closed_prs=[
{
"number": 11,
"title": "merged author",
"body": "Closes #11",
"head": {"ref": "feat/issue-11-x"},
"merged_at": "2026-07-06T12:00:00Z",
},
{
"number": 512,
"title": "merged reviewed",
"body": "Closes #512",
"head": {"ref": "feat/issue-512-x"},
"merged_at": "2026-07-06T12:00:00Z",
},
],
open_prs=[],
remote_branch_exists={
"feat/issue-11-x": False,
"feat/issue-512-x": False,
},
head_on_master={11: True, 512: True},
delete_capability_allowed=True,
active_reviewer_leases={512: False},
)
self.assertEqual(report["merged_pr_count"], 2)
self.assertEqual(report["reviewer_scratch_count"], 1)
scratch = report["reviewer_scratch_entries"][0]
self.assertEqual(scratch["pr_number"], 512)
self.assertEqual(scratch["worktree_kind"], "reviewer_scratch")
self.assertFalse(scratch["author_worktree_cleanup"])
self.assertTrue(scratch["safe_to_remove_worktree"])
# Author entries must not swallow the scratch path as local_worktree.
author_paths = {
(e.get("local_worktree") or {}).get("worktree_path")
for e in report["entries"]
}
self.assertNotIn(
"/repo/Gitea-Tools/branches/review-pr512-submit", author_paths
)
@patch("subprocess.run")
def test_remove_reviewer_scratch_worktree_path_guard(self, mock_run):
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with patch("os.path.isdir", return_value=True):
bad = mcr.remove_reviewer_scratch_worktree(
"/repo/Gitea-Tools",
"/repo/Gitea-Tools/branches/feat-issue-11-x",
)
self.assertFalse(bad["performed"])
good = mcr.remove_reviewer_scratch_worktree(
"/repo/Gitea-Tools",
"/repo/Gitea-Tools/branches/review-pr512-submit",
)
self.assertTrue(good["success"])
self.assertTrue(good["performed"])
self.assertFalse(good["author_worktree_cleanup"])
mock_run.assert_any_call(
[
"git",
"-C",
"/repo/Gitea-Tools",
"worktree",
"remove",
"/repo/Gitea-Tools/branches/review-pr512-submit",
],
capture_output=True,
text=True,
check=False,
)
if __name__ == "__main__":
unittest.main()
+431
View File
@@ -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": "<!-- mcp-review-lease-adoption:v1 --> 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()
+240
View File
@@ -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))
+7
View File
@@ -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()
@@ -250,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",
+169
View File
@@ -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()
+257
View File
@@ -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()
@@ -26,6 +26,11 @@ class TestPrLeaseCommentsNonListGuard(unittest.TestCase):
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)
@@ -73,4 +78,4 @@ class TestPrLeaseCommentsNonListGuard(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
unittest.main()
+33
View File
@@ -265,5 +265,38 @@ class TestCleanupReportVerifier(unittest.TestCase):
self.assertEqual(direct["proven"], wrapped["proven"])
class TestCleanupEmptyQueue(unittest.TestCase):
def test_empty_queue_report_passes(self):
report = _clean_report(
selected="Selected PR: none",
decision="Review decision: comment",
next="Next suggested PR: approvals (queue empty)",
pagination="PR inventory pagination proof: inventory_complete=true, total_count 0",
)
report += "\npr_inventory_trust_gate.status: trusted_empty"
result = assess_pr_queue_cleanup_report(report)
self.assertTrue(result["proven"], result["reasons"])
def test_empty_queue_without_evidence_fails(self):
report = _clean_report(
selected="Selected PR: none",
)
result = assess_pr_queue_cleanup_report(report)
self.assertFalse(result["proven"])
self.assertTrue(
any("no valid empty-queue claim" in r for r in result["reasons"]),
result["reasons"]
)
def test_contradictory_selected_pr_fails(self):
report = _clean_report() + "\nSelected PR: none"
result = assess_pr_queue_cleanup_report(report)
self.assertFalse(result["proven"])
self.assertTrue(
any("contains both a selected PR and a claim of none selected" in r for r in result["reasons"]),
result["reasons"]
)
if __name__ == "__main__":
unittest.main()
+1
View File
@@ -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
+112
View File
@@ -0,0 +1,112 @@
import unittest
from premerge_baseline_proof import (
CLEAN_PASS,
PREMERGE_BASELINE_PROVEN_FAILURE,
UNRESOLVED_REGRESSION_RISK,
assess_premerge_baseline_proof,
)
from final_report_validator import assess_final_report_validator
_FIELDS = (
"Pre-merge base commit: 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b\n"
"Tested commit: 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b\n"
"Command: venv/bin/python -m pytest tests/test_foo.py -q\n"
"Exit status: 1\n"
"Failure signature: AssertionError: None is not true\n"
)
# Current-master-only reproduction carries no pre-merge base proof (that is the
# whole defect #533 guards against): a command/exit/signature but no base commit.
_CURRENT_ONLY_FIELDS = (
"Command: venv/bin/python -m pytest tests/test_foo.py -q\n"
"Exit status: 1\n"
"Failure signature: AssertionError: None is not true\n"
)
class TestPremergeBaselineProof(unittest.TestCase):
def test_clean_pass_not_blocked(self):
result = assess_premerge_baseline_proof(
"Validation: full suite passed, exit status 0. Clean pass."
)
self.assertFalse(result["block"])
self.assertTrue(result["skipped"])
self.assertEqual(result["label"], CLEAN_PASS)
def test_nonzero_exit_not_claimed_baseline_not_blocked(self):
result = assess_premerge_baseline_proof(
"Full suite: 1 failed. Investigating the regression; not yet classified."
)
self.assertFalse(result["block"])
self.assertEqual(result["label"], UNRESOLVED_REGRESSION_RISK)
def test_current_master_only_reproduction_rejected(self):
report = (
"Full suite: 1 failed. This is a pre-existing baseline failure — "
"reproduced on current master after the PR merged.\n" + _CURRENT_ONLY_FIELDS
)
result = assess_premerge_baseline_proof(report)
self.assertTrue(result["block"])
self.assertTrue(
any("current-master reproduction only" in r for r in result["reasons"])
)
def test_baseline_claim_missing_fields_blocked(self):
report = (
"Full suite: 1 failed. This failure is baseline / pre-existing. "
"Verified against the pre-merge base commit."
)
result = assess_premerge_baseline_proof(report)
self.assertTrue(result["block"])
self.assertTrue(
any("missing required proof field" in r for r in result["reasons"])
)
def test_premerge_base_proof_accepted(self):
report = (
"Full suite: 1 failed. This is a pre-existing baseline failure, proven "
"on the PR pre-merge base commit.\n" + _FIELDS
)
result = assess_premerge_baseline_proof(report)
self.assertFalse(result["block"])
self.assertTrue(result["proven"])
self.assertEqual(result["label"], PREMERGE_BASELINE_PROVEN_FAILURE)
def test_documented_known_failure_record_accepted(self):
report = (
"Full suite: 1 failed. Baseline failure: cited known-failure record #529 "
"which predates the PR.\n" + _FIELDS
)
result = assess_premerge_baseline_proof(report)
self.assertFalse(result["block"])
self.assertEqual(result["label"], PREMERGE_BASELINE_PROVEN_FAILURE)
class TestPremergeBaselineProofValidatorIntegration(unittest.TestCase):
def _has_rule(self, report):
result = assess_final_report_validator(report, "review_pr")
return any(
f["rule_id"] == "reviewer.premerge_baseline_proof"
for f in result["findings"]
)
def test_review_report_current_master_only_flagged(self):
report = (
"## Reviewer final report\n"
"Full suite: 1 failed. Pre-existing baseline failure — reproduced on "
"post-merge master.\n" + _CURRENT_ONLY_FIELDS
)
self.assertTrue(self._has_rule(report))
def test_review_report_premerge_proof_clean(self):
report = (
"## Reviewer final report\n"
"Full suite: 1 failed. Pre-existing baseline failure proven on the "
"pre-merge base commit.\n" + _FIELDS
)
self.assertFalse(self._has_rule(report))
if __name__ == "__main__":
unittest.main()
+178
View File
@@ -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()
+138
View File
@@ -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()
+179
View File
@@ -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()
@@ -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()
+11 -2
View File
@@ -7,6 +7,7 @@ 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
@@ -123,7 +124,11 @@ class TestReviewerLeaseMutationGate(unittest.TestCase):
"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,
@@ -143,7 +148,11 @@ class TestReviewerLeaseMutationGate(unittest.TestCase):
"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,
+161
View File
@@ -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()
+65
View File
@@ -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"
)
+243
View File
@@ -0,0 +1,243 @@
"""Tests for canonical state handoff ledger (#494)."""
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 state_handoff_ledger import ( # noqa: E402
ISSUE_STATE_FIELDS,
assess_contradictory_state_handoff,
assess_final_report_next_action_handoff,
assess_state_comment,
assess_state_handoff_ledger_report,
parse_state_comment,
render_issue_state_comment,
render_pr_state_comment,
render_queue_controller_report,
)
def _work_issue_handoff(**overrides):
lines = [
"## Controller Handoff",
"",
"- Task: work-issue",
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
"- Role: author",
"- Identity: jcwalker3 / prgs-author",
"- Selected issue: #494",
"- Current status: implementation complete; PR opened awaiting review",
"- Next actor: reviewer",
"- Next action: review PR at pinned head",
"- Next prompt: Review PR #500 for issue #494 at current head in reviewer worktree",
"- Safe next action: switch to reviewer session",
"- External-state mutations: none",
]
text = "\n".join(lines)
for key, value in overrides.items():
needle = f"- {key}:"
if needle in text:
prefix = text.split(needle)[0]
rest = text.split(needle, 1)[1]
suffix = rest.split("\n", 1)[1] if "\n" in rest else ""
text = f"{prefix}{needle} {value}\n{suffix}".rstrip()
else:
text += f"\n- {key}: {value}"
return text
class TestStateCommentTemplates(unittest.TestCase):
def test_render_issue_state_includes_all_fields(self):
body = render_issue_state_comment(
STATE="issue_ready_for_author",
NEXT_ACTOR="author",
NEXT_ACTION="lock and implement",
NEXT_PROMPT="Find issue #494 and open a PR",
LAST_UPDATED_BY="jcwalker3",
)
parsed = parse_state_comment(body)
for field in ISSUE_STATE_FIELDS:
self.assertIn(field, parsed)
def test_render_pr_state_parses(self):
body = render_pr_state_comment(
STATE="needs_review",
NEXT_ACTOR="reviewer",
NEXT_ACTION="review at head",
NEXT_PROMPT="Review PR #500",
ISSUE="#494",
HEAD_SHA="a" * 40,
LAST_UPDATED_BY="sysadmin",
)
parsed = parse_state_comment(body)
self.assertEqual(parsed["STATE"], "needs_review")
self.assertEqual(parsed["NEXT_ACTOR"], "reviewer")
def test_queue_controller_template(self):
body = render_queue_controller_report(
QUEUE_STATE="open_prs_need_review",
SELECTED_OBJECT="PR #500",
SELECTED_OBJECT_TYPE="pr",
NEXT_ACTOR="reviewer",
NEXT_ACTION="start review session",
NEXT_PROMPT="Review PR #500",
LAST_UPDATED_BY="controller",
)
result = assess_state_comment(body, kind="queue_controller")
self.assertTrue(result["complete"])
class TestStateCommentValidation(unittest.TestCase):
def test_complete_issue_state_comment(self):
body = render_issue_state_comment(
STATE="in_progress",
NEXT_ACTOR="author",
NEXT_ACTION="push branch",
NEXT_PROMPT="Continue issue #494",
STATUS="open",
RELATED_PRS="none",
BRANCH="feat/issue-494-state-handoff-ledger",
HEAD_SHA="b" * 40,
VALIDATION="pytest passed",
BLOCKERS="none",
DUPLICATES_OR_SUPERSEDES="none",
LAST_UPDATED_BY="jcwalker3",
)
result = assess_state_comment(body, kind="issue_state")
self.assertTrue(result["complete"])
self.assertFalse(result["block"])
def test_missing_fields_blocked(self):
body = "STATE: open\nNEXT_ACTOR: author"
result = assess_state_comment(body, kind="issue_state")
self.assertFalse(result["complete"])
self.assertIn("NEXT_ACTION", result["missing_fields"])
def test_discussion_requires_five_comments_by_default(self):
body = render_issue_state_comment(
STATE="collecting_feedback",
NEXT_ACTOR="controller",
NEXT_ACTION="facilitate discussion",
NEXT_PROMPT="Add acceptance criteria",
STATUS="open",
RELATED_PRS="none",
BRANCH="none",
HEAD_SHA="none",
VALIDATION="none",
BLOCKERS="none",
DUPLICATES_OR_SUPERSEDES="none",
LAST_UPDATED_BY="controller",
)
# Re-map to discussion fields manually for comment-count test
from state_handoff_ledger import render_discussion_state_comment
disc = render_discussion_state_comment(
STATE="collecting_feedback",
NEXT_ACTOR="controller",
NEXT_ACTION="wait for comments",
NEXT_PROMPT="Continue discussion",
COMMENT_COUNT="2",
SUBSTANTIVE_COMMENTS="yes",
URGENCY="normal",
BLOCKERS="none",
LAST_UPDATED_BY="controller",
)
result = assess_state_comment(disc, kind="discussion_state")
self.assertFalse(result["complete"])
self.assertTrue(any("5" in reason for reason in result["reasons"]))
def test_discussion_urgent_exception(self):
from state_handoff_ledger import render_discussion_state_comment
disc = render_discussion_state_comment(
STATE="ready_for_summary",
NEXT_ACTOR="controller",
NEXT_ACTION="summarize",
NEXT_PROMPT="Post summary",
COMMENT_COUNT="2",
SUBSTANTIVE_COMMENTS="yes",
URGENCY="urgent",
BLOCKERS="none",
LAST_UPDATED_BY="controller",
)
result = assess_state_comment(disc, kind="discussion_state")
self.assertTrue(result["complete"])
class TestFinalReportNextAction(unittest.TestCase):
def test_complete_next_action_handoff(self):
report = _work_issue_handoff()
result = assess_final_report_next_action_handoff(report)
self.assertTrue(result["complete"])
self.assertFalse(result["block"])
def test_missing_next_prompt_blocked(self):
report = _work_issue_handoff(**{"Next prompt": "none"})
result = assess_final_report_next_action_handoff(report)
self.assertFalse(result["complete"])
self.assertIn("Next prompt", result["missing_fields"])
def test_missing_handoff_section_blocked(self):
result = assess_final_report_next_action_handoff("no handoff here")
self.assertTrue(result["block"])
class TestContradictoryState(unittest.TestCase):
def test_ready_to_merge_without_approval_blocked(self):
report = _work_issue_handoff(
**{
"Current status": "PR ready to merge",
"Review decision": "not attempted",
}
)
result = assess_contradictory_state_handoff(report)
self.assertTrue(result["block"])
def test_ready_to_merge_with_approval_allowed(self):
report = _work_issue_handoff(
**{
"Current status": "PR ready to merge",
"Review decision": "approve",
}
)
result = assess_contradictory_state_handoff(report)
self.assertFalse(result["block"])
def test_issue_done_without_pr_proof_blocked(self):
report = _work_issue_handoff(**{"Current status": "issue complete"})
result = assess_contradictory_state_handoff(report)
self.assertTrue(result["block"])
def test_external_none_with_lock_activity_blocked(self):
report = _work_issue_handoff(
**{
"External-state mutations": "none",
}
)
report += "\n- Issue mutations: gitea_lock_issue adopted branch"
result = assess_contradictory_state_handoff(report)
self.assertTrue(result["block"])
def test_discussion_complete_without_summary_blocked(self):
report = _work_issue_handoff(**{"Current status": "discussion complete"})
result = assess_contradictory_state_handoff(report)
self.assertTrue(result["block"])
class TestFinalReportValidatorIntegration(unittest.TestCase):
def test_work_issue_validator_flags_missing_next_prompt(self):
report = _work_issue_handoff(**{"Next prompt": ""})
result = assess_final_report_validator(report, "work_issue")
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertIn("shared.state_handoff_next_action", rule_ids)
def test_work_issue_validator_accepts_complete_handoff(self):
report = _work_issue_handoff()
result = assess_state_handoff_ledger_report(report)
self.assertTrue(result["complete"])
if __name__ == "__main__":
unittest.main()
+14
View File
@@ -36,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,
@@ -48,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)
@@ -92,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])
@@ -113,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])
@@ -145,7 +156,10 @@ def _mark(action, pr_number=6, **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()
+94
View File
@@ -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 = (
"<!-- mcp-review-lease:v1 -->\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()
+88
View File
@@ -0,0 +1,88 @@
"""Tests for web UI runtime health dashboard (#430)."""
import sys
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from starlette.testclient import TestClient
from webui.app import create_app
from webui.runtime_health import _role_kind, load_runtime_snapshot, snapshot_to_dict
def _mock_identity(_host: str):
return "jcwalker3", None
def _mock_git_sync(_repo: Path, _remote: str, _branch: str):
return "localsha123456", "remoteshaabcdef", 2
class TestRuntimeHealthLoader(unittest.TestCase):
def test_role_kind_author(self):
allowed = ["gitea.pr.create", "gitea.branch.push", "gitea.read"]
forbidden = ["gitea.pr.merge", "gitea.pr.approve"]
self.assertEqual(_role_kind(allowed, forbidden), "author")
def test_snapshot_with_mocks(self):
snapshot = load_runtime_snapshot(
resolve_username=_mock_identity,
git_sync=_mock_git_sync,
)
self.assertEqual(snapshot.project_id, "gitea-tools")
self.assertEqual(snapshot.authenticated_username, "jcwalker3")
self.assertEqual(snapshot.commits_behind_master, 2)
self.assertIsNotNone(snapshot.stale_runtime_warning)
self.assertGreaterEqual(len(snapshot.workflow_hashes), 3)
self.assertGreaterEqual(len(snapshot.schema_hashes), 2)
self.assertIn("shell_use_allowed", snapshot.shell_health)
def test_snapshot_dict_export(self):
snapshot = load_runtime_snapshot(
resolve_username=_mock_identity,
git_sync=_mock_git_sync,
)
data = snapshot_to_dict(snapshot)
self.assertEqual(data["authenticated_username"], "jcwalker3")
self.assertEqual(data["commits_behind_master"], 2)
self.assertTrue(data["stale_runtime_warning"])
class TestRuntimeRoutes(unittest.TestCase):
def setUp(self):
self.client = TestClient(create_app())
self.snapshot = load_runtime_snapshot(
resolve_username=_mock_identity,
git_sync=_mock_git_sync,
)
self._patch = mock.patch(
"webui.app.load_runtime_snapshot",
return_value=self.snapshot,
)
self._patch.start()
def tearDown(self):
self._patch.stop()
def test_runtime_page_renders_profile_and_warning(self):
response = self.client.get("/runtime")
self.assertEqual(response.status_code, 200)
self.assertIn("Runtime health", response.text)
self.assertIn("Active profile", response.text)
self.assertIn("Stale runtime warning", response.text)
self.assertIn("Workflow hashes", response.text)
self.assertNotIn("child issue", response.text.lower())
def test_api_runtime_json(self):
response = self.client.get("/api/runtime")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["project_id"], "gitea-tools")
self.assertEqual(data["role_kind"], self.snapshot.role_kind)
self.assertEqual(data["commits_behind_master"], 2)
if __name__ == "__main__":
unittest.main()
+15 -6
View File
@@ -29,8 +29,13 @@ class TestWebuiSkeleton(unittest.TestCase):
self.assertIn("Operator console", response.text)
self.assertIn("Read-only MVP", response.text)
def test_runtime_is_implemented(self):
response = self.client.get("/runtime")
self.assertEqual(response.status_code, 200)
self.assertIn("Runtime health", response.text)
def test_route_stubs_render(self):
for path in ("/runtime", "/audit"):
for path in ("/audit",):
with self.subTest(path=path):
response = self.client.get(path)
self.assertEqual(response.status_code, 200)
@@ -46,10 +51,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_post_is_rejected(self):
response = self.client.post("/health")
@@ -62,10 +71,10 @@ 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"):
for path in ("/", "/queue", "/projects", "/prompts", "/runtime", "/audit", "/leases"):
with self.subTest(path=path):
text = self.client.get(path).text
for href in ("/queue", "/projects", "/prompts", "/runtime", "/audit"):
for href in ("/queue", "/projects", "/prompts", "/runtime", "/audit", "/leases"):
self.assertIn(f'href="{href}"', text)
+22 -2
View File
@@ -126,17 +126,37 @@ class TestRuntimeContextGuardAlignment(unittest.TestCase):
with mock.patch.dict("os.environ", {"GITEA_TEST_PORCELAIN": ""}, clear=False):
srv.verify_preflight_purity(worktree_path=BRANCHES_WORKTREE)
def test_stable_checkout_still_rejected(self):
@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, *_exists):
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,
+532
View File
@@ -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()
+20 -10
View File
@@ -14,8 +14,12 @@ from webui.project_registry import find_project, load_registry, registry_to_dict
from webui.project_views import render_project_detail, render_projects_list
from webui.prompt_library import find_prompt, library_to_dict
from webui.prompt_views import render_prompt_detail, render_prompts_page
from webui.queue_loader import load_queue_snapshot, snapshot_to_dict
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 as queue_snapshot_to_dict
from webui.queue_views import render_queue_page
from webui.runtime_health import load_runtime_snapshot, snapshot_to_dict as runtime_snapshot_to_dict
from webui.runtime_views import render_runtime_page
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
@@ -61,7 +65,7 @@ async def queue(_request: Request) -> HTMLResponse:
async def api_queue(_request: Request) -> JSONResponse:
return JSONResponse(snapshot_to_dict(load_queue_snapshot()))
return JSONResponse(queue_snapshot_to_dict(load_queue_snapshot()))
async def projects(_request: Request) -> HTMLResponse:
@@ -120,10 +124,12 @@ async def api_prompts(_request: Request) -> JSONResponse:
async def runtime(_request: Request) -> HTMLResponse:
return _stub_page(
"Runtime",
"Runtime health will report MCP profile, preflight, and stale-server signals.",
)
snapshot = load_runtime_snapshot()
return HTMLResponse(render_page(title="Runtime", body_html=render_runtime_page(snapshot)))
async def api_runtime(_request: Request) -> JSONResponse:
return JSONResponse(runtime_snapshot_to_dict(load_runtime_snapshot()))
async def audit(_request: Request) -> HTMLResponse:
@@ -141,10 +147,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 method_not_allowed(request: Request, _exc: Exception) -> Response:
@@ -172,9 +180,11 @@ def create_app() -> Starlette:
Route("/prompts/{prompt_id}", prompt_detail, methods=["GET"]),
Route("/api/prompts", api_prompts, methods=["GET"]),
Route("/runtime", runtime, methods=["GET"]),
Route("/api/runtime", api_runtime, methods=["GET"]),
Route("/audit", audit, methods=["GET"]),
Route("/worktrees", worktrees, methods=["GET"]),
Route("/leases", leases, methods=["GET"]),
Route("/api/leases", api_leases, methods=["GET"]),
],
exception_handlers={405: method_not_allowed},
)
+331
View File
@@ -0,0 +1,331 @@
"""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 issue_lock_provenance import ISSUE_LOCK_FILE
from merged_cleanup_reconcile import 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 = "<!-- mcp-review-lease:v1 -->"
_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,
}
+130
View File
@@ -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 "<p class='muted'>No duplicate PR/branch collisions detected in current inventory.</p>"
items = "".join(f"<li>{_escape(w.message)}</li>" for w in warnings)
return f"<ul class='collision-warnings'>{items}</ul>"
def _lock_block(snapshot: LeaseSnapshot) -> str:
lock = snapshot.issue_lock
if not lock:
return "<p class='muted'>No active local issue lock file.</p>"
return (
"<pre class='prompt-text'>"
f"{_escape(json.dumps(lock, indent=2, sort_keys=True))}"
"</pre>"
)
def _claims_table(snapshot: LeaseSnapshot) -> str:
entries = snapshot.claim_inventory.get("entries") or []
if not entries:
return "<p class='muted'>No in-progress issue claims in fetched inventory.</p>"
rows = []
for entry in entries:
rows.append(
"<tr>"
f"<td>#{entry.get('issue_number')}</td>"
f"<td><span class='badge badge-{_escape(str(entry.get('status')))}'>"
f"{_escape(str(entry.get('status')))}</span></td>"
f"<td>{_escape(str(entry.get('linked_open_pr') or ''))}</td>"
f"<td>{entry.get('heartbeat_count', 0)}</td>"
f"<td>{_escape(', '.join(entry.get('matching_branches') or []) or '')}</td>"
f"<td class='muted'>{_escape('; '.join(entry.get('reasons') or []))}</td>"
"</tr>"
)
return (
"<table class='registry leases'>"
"<thead><tr><th>Issue</th><th>Claim status</th><th>Open PR</th>"
"<th>Heartbeats</th><th>Branches</th><th>Notes</th></tr></thead>"
f"<tbody>{''.join(rows)}</tbody></table>"
)
def _reviewer_leases_table(snapshot: LeaseSnapshot) -> str:
if not snapshot.reviewer_leases:
return (
"<p class='muted'>No active reviewer PR lease comments found "
"(marker <code>&lt;!-- mcp-review-lease:v1 --&gt;</code>; see #407).</p>"
)
rows = []
for lease in snapshot.reviewer_leases:
rows.append(
"<tr>"
f"<td>#{lease.get('pr_number')}</td>"
f"<td>{_escape(str(lease.get('reviewer_identity') or ''))}</td>"
f"<td><code>{_escape(str(lease.get('profile') or ''))}</code></td>"
f"<td>{_escape(str(lease.get('phase') or ''))}</td>"
f"<td><code>{_escape(str(lease.get('expires_at') or ''))}</code></td>"
"</tr>"
)
return (
"<table class='registry leases'>"
"<thead><tr><th>PR</th><th>Reviewer</th><th>Profile</th>"
"<th>Phase</th><th>Expires</th></tr></thead>"
f"<tbody>{''.join(rows)}</tbody></table>"
)
def _history_links(snapshot: LeaseSnapshot) -> str:
items = "".join(
f"<li><a href=\"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools/issues/"
f"{item['number']}\">#{item['number']}</a> — {_escape(item['title'])}</li>"
for item in snapshot.collision_history
)
return f"<ul>{items}</ul>"
LEASE_PAGE_STYLES = """
<style>
.collision-warnings li { color: #f0c4c4; margin: 0.35rem 0; }
table.leases td:nth-child(6) { font-size: 0.85rem; }
.badge-active, .badge-awaiting_review { color: #8fd19e; border-color: #3d6b4a; }
.badge-stale, .badge-phantom, .badge-reclaimable { color: #f0a8a8; border-color: #7a3b3b; }
</style>
"""
def render_leases_page(snapshot: LeaseSnapshot) -> str:
error_block = ""
if snapshot.fetch_error:
error_block = (
'<div class="stub"><p><strong>Partial load:</strong> '
f"{_escape(snapshot.fetch_error)}</p></div>"
)
body = (
"<h2>Leases &amp; collisions</h2>"
"<p>Read-only visibility for issue claims, reviewer PR leases, and "
"duplicate-work risks. Does not acquire or release leases.</p>"
f"{error_block}"
f"<p class='meta'>Project: <code>{_escape(snapshot.repo_label)}</code></p>"
"<h3>Collision warnings</h3>"
f"{_warnings_block(snapshot)}"
"<h3>Local issue lock</h3>"
f"{_lock_block(snapshot)}"
"<h3>In-progress issue claims (#268)</h3>"
f"{_claims_table(snapshot)}"
"<h3>Reviewer PR leases (#407)</h3>"
f"{_reviewer_leases_table(snapshot)}"
"<h3>Collision / lease history issues</h3>"
f"{_history_links(snapshot)}"
"<p><a href=\"/api/leases\">JSON API</a></p>"
f"{LEASE_PAGE_STYLES}"
)
return render_page(title="Leases", body_html=body)
+320
View File
@@ -0,0 +1,320 @@
"""Read-only MCP/runtime health snapshot for the web UI (#430)."""
from __future__ import annotations
import hashlib
import os
import subprocess
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable
from urllib.parse import urlparse
import gitea_config
import reconciler_profile
from gitea_auth import REMOTES, api_request, get_auth_header, get_profile, gitea_url
from native_mcp_preference import shell_health_status
from webui.project_registry import ProjectRecord, load_registry
_RESTART_DOCS = "docs/llm-workflow-runbooks.md#issue-420-mcp-hot-reload"
_HOT_RELOAD_ISSUE = "#420"
@dataclass(frozen=True)
class FileHash:
label: str
path: str
sha256: str | None
error: str | None = None
@dataclass(frozen=True)
class RuntimeSnapshot:
project_id: str
repo_root: str
remote: str
host: str
profile_name: str
role_kind: str
config_model: str
profile_mode: str
profile_source: str
authenticated_username: str | None
identity_error: str | None
repo_sha: str | None
remote_master_sha: str | None
commits_behind_master: int | None
stale_runtime_warning: str | None
shell_health: dict[str, Any]
workflow_hashes: tuple[FileHash, ...]
schema_hashes: tuple[FileHash, ...]
restart_guidance: str
fetch_error: str | None = None
def _repo_root() -> Path:
override = (os.environ.get("WEBUI_REPO_ROOT") or "").strip()
if override:
return Path(override).resolve()
return Path(__file__).resolve().parent.parent
def _host_from_url(remote_host: str) -> str:
parsed = urlparse(remote_host.strip())
return parsed.netloc or remote_host.strip().rstrip("/")
def _remote_name_for_host(host: str) -> str:
for name, profile in REMOTES.items():
if profile.get("host") == host:
return name
return "custom"
def _config_model(config: dict | None) -> str:
if config is None:
return "env-only"
version = config.get("version")
if version == 1:
return "v1"
if version == 2:
if "contexts" in config or config.get("shape") == "contexts":
return "v2-contexts"
return "v2-environments"
return f"unknown-version-{version}"
def _profile_source() -> str:
if os.environ.get("GITEA_PROFILE_NAME"):
return "env var"
if gitea_config.selected_profile_name():
return "config file profile"
return "default"
def _role_kind(allowed: list[str], forbidden: list[str]) -> str:
if reconciler_profile.is_reconciler_profile(allowed, forbidden):
return "reconciler"
def can(op: str) -> bool:
return gitea_config.check_operation(op, allowed, forbidden)[0]
review = can("gitea.pr.approve") or can("gitea.pr.merge")
author = can("gitea.pr.create") or can("gitea.branch.push")
reconciler = can("gitea.pr.close") and not review and not author
if review and author:
return "mixed"
if review:
return "reviewer"
if reconciler:
return "reconciler"
if author:
return "author"
return "limited"
def _sha256_file(path: Path) -> FileHash:
label = path.name
try:
digest = hashlib.sha256(path.read_bytes()).hexdigest()
return FileHash(label=label, path=str(path), sha256=digest)
except OSError as exc:
return FileHash(label=label, path=str(path), sha256=None, error=str(exc))
def _run_git(repo: Path, *args: str) -> str | None:
try:
completed = subprocess.run(
["git", *args],
cwd=repo,
check=True,
capture_output=True,
text=True,
)
return completed.stdout.strip() or None
except (OSError, subprocess.CalledProcessError):
return None
def _git_sync_status(repo: Path, remote: str, branch: str) -> tuple[str | None, str | None, int | None]:
local_sha = _run_git(repo, "rev-parse", "HEAD")
remote_sha = _run_git(repo, "rev-parse", f"{remote}/{branch}")
behind = None
if local_sha and remote_sha and local_sha != remote_sha:
count = _run_git(repo, "rev-list", "--count", f"HEAD..{remote}/{branch}")
if count is not None and count.isdigit():
behind = int(count)
return local_sha, remote_sha, behind
def _resolve_username(host: str) -> tuple[str | None, str | None]:
auth = get_auth_header(host)
if not auth:
return None, f"Gitea credentials unavailable for {host}"
try:
data = api_request("GET", gitea_url(host, "/api/v1/user"), auth)
except Exception as exc: # noqa: BLE001 — operator-visible identity errors
return None, f"Identity lookup failed: {exc}"
username = (data or {}).get("login")
if not username:
return None, "Authenticated identity could not be resolved"
return str(username), None
def _stale_warning(commits_behind: int | None) -> str | None:
if commits_behind is None or commits_behind <= 0:
return None
return (
f"Local checkout is {commits_behind} commit(s) behind remote master. "
"Merged safety-gate changes may require an MCP server restart or profile "
f"reload — see {_HOT_RELOAD_ISSUE} / {_RESTART_DOCS}."
)
def load_runtime_snapshot(
project_id: str | None = None,
*,
resolve_username: Callable[[str], tuple[str | None, str | None]] | None = None,
git_sync: Callable[[Path, str, str], tuple[str | None, str | None, int | None]] | None = None,
) -> RuntimeSnapshot:
"""Build a read-only runtime health snapshot for the default registry project."""
registry = load_registry()
project: ProjectRecord | None = None
if project_id:
for entry in registry.projects:
if entry.id == project_id:
project = entry
break
else:
project = registry.projects[0] if registry.projects else None
if project is None:
return RuntimeSnapshot(
project_id=project_id or "",
repo_root=str(_repo_root()),
remote="",
host="",
profile_name="",
role_kind="",
config_model="",
profile_mode="",
profile_source="",
authenticated_username=None,
identity_error="project not found in registry",
repo_sha=None,
remote_master_sha=None,
commits_behind_master=None,
stale_runtime_warning=None,
shell_health={},
workflow_hashes=(),
schema_hashes=(),
restart_guidance=_RESTART_DOCS,
fetch_error="project not found in registry",
)
repo = _repo_root()
host = _host_from_url(project.remote_host)
remote = _remote_name_for_host(host)
profile = get_profile()
allowed = profile.get("allowed_operations") or []
forbidden = profile.get("forbidden_operations") or []
config = None
try:
config = gitea_config.load_config()
except gitea_config.ConfigError as exc:
return RuntimeSnapshot(
project_id=project.id,
repo_root=str(repo),
remote=remote,
host=host,
profile_name=profile.get("profile_name") or "",
role_kind=_role_kind(allowed, forbidden),
config_model="config-error",
profile_mode="unknown",
profile_source=_profile_source(),
authenticated_username=None,
identity_error=str(exc),
repo_sha=None,
remote_master_sha=None,
commits_behind_master=None,
stale_runtime_warning=None,
shell_health=shell_health_status(),
workflow_hashes=(),
schema_hashes=(),
restart_guidance=_RESTART_DOCS,
fetch_error=str(exc),
)
identity_fn = resolve_username or _resolve_username
username, identity_error = identity_fn(host)
git_fn = git_sync or _git_sync_status
remote_ref = "prgs" if remote == "prgs" else "origin"
local_sha, remote_sha, behind = git_fn(repo, remote_ref, project.default_branch)
workflow_hashes = tuple(
_sha256_file(repo / rel_path)
for rel_path in project.workflow_paths.values()
)
schema_hashes = tuple(
_sha256_file(repo / rel_path)
for rel_path in project.schema_paths.values()
)
switching = gitea_config.is_runtime_switching_enabled()
return RuntimeSnapshot(
project_id=project.id,
repo_root=str(repo),
remote=remote,
host=host,
profile_name=str(profile.get("profile_name") or ""),
role_kind=_role_kind(allowed, forbidden),
config_model=_config_model(config),
profile_mode="dynamic-profile" if switching else "static-profile",
profile_source=_profile_source(),
authenticated_username=username,
identity_error=identity_error,
repo_sha=local_sha,
remote_master_sha=remote_sha,
commits_behind_master=behind,
stale_runtime_warning=_stale_warning(behind),
shell_health=shell_health_status(),
workflow_hashes=workflow_hashes,
schema_hashes=schema_hashes,
restart_guidance=_RESTART_DOCS,
)
def snapshot_to_dict(snapshot: RuntimeSnapshot) -> dict[str, Any]:
def _hash_row(item: FileHash) -> dict[str, Any]:
return {
"label": item.label,
"path": item.path,
"sha256": item.sha256,
"error": item.error,
}
return {
"project_id": snapshot.project_id,
"repo_root": snapshot.repo_root,
"remote": snapshot.remote,
"host": snapshot.host,
"profile_name": snapshot.profile_name,
"role_kind": snapshot.role_kind,
"config_model": snapshot.config_model,
"profile_mode": snapshot.profile_mode,
"profile_source": snapshot.profile_source,
"authenticated_username": snapshot.authenticated_username,
"identity_error": snapshot.identity_error,
"repo_sha": snapshot.repo_sha,
"remote_master_sha": snapshot.remote_master_sha,
"commits_behind_master": snapshot.commits_behind_master,
"stale_runtime_warning": snapshot.stale_runtime_warning,
"shell_health": snapshot.shell_health,
"workflow_hashes": [_hash_row(item) for item in snapshot.workflow_hashes],
"schema_hashes": [_hash_row(item) for item in snapshot.schema_hashes],
"restart_guidance": snapshot.restart_guidance,
"fetch_error": snapshot.fetch_error,
}
+92
View File
@@ -0,0 +1,92 @@
"""HTML views for MCP runtime health (#430)."""
from __future__ import annotations
import html
from webui.runtime_health import FileHash, RuntimeSnapshot
def _hash_rows(items: tuple[FileHash, ...]) -> str:
if not items:
return "<p class='muted'>No hashes available.</p>"
rows = []
for item in items:
digest = item.sha256 or item.error or "unavailable"
rows.append(
"<tr>"
f"<td>{html.escape(item.label)}</td>"
f"<td><code>{html.escape(item.path)}</code></td>"
f"<td><code>{html.escape(digest[:16] if item.sha256 else digest)}</code></td>"
"</tr>"
)
return (
"<table class='registry'><thead><tr>"
"<th>Artifact</th><th>Path</th><th>SHA-256</th>"
"</tr></thead><tbody>"
f"{''.join(rows)}</tbody></table>"
)
def render_runtime_page(snapshot: RuntimeSnapshot) -> str:
error_block = ""
if snapshot.fetch_error:
error_block = (
'<div class="stub"><p><strong>Runtime snapshot incomplete:</strong> '
f"{html.escape(snapshot.fetch_error)}</p></div>"
)
identity = snapshot.authenticated_username or "unresolved"
if snapshot.identity_error:
identity = f"unresolved ({snapshot.identity_error})"
stale_block = ""
if snapshot.stale_runtime_warning:
stale_block = (
'<div class="stub"><p><strong>Stale runtime warning:</strong> '
f"{html.escape(snapshot.stale_runtime_warning)}</p></div>"
)
shell = snapshot.shell_health or {}
shell_summary = (
f"shell_use_allowed={shell.get('shell_use_allowed')} · "
f"failures={shell.get('consecutive_spawn_failures')} · "
f"hard_stopped={shell.get('hard_stopped')}"
)
return (
"<h2>Runtime health</h2>"
f"<p class='meta'>Project <code>{html.escape(snapshot.project_id)}</code> · "
f"host <code>{html.escape(snapshot.host)}</code> · "
f"repo root <code>{html.escape(snapshot.repo_root)}</code></p>"
f"{error_block}"
f"{stale_block}"
"<h3>Active profile</h3>"
"<table class='detail'>"
f"<tr><th>Profile</th><td><code>{html.escape(snapshot.profile_name)}</code></td></tr>"
f"<tr><th>Role kind</th><td>{html.escape(snapshot.role_kind)}</td></tr>"
f"<tr><th>Identity</th><td>{html.escape(identity)}</td></tr>"
f"<tr><th>Config model</th><td>{html.escape(snapshot.config_model)}</td></tr>"
f"<tr><th>Profile mode</th><td>{html.escape(snapshot.profile_mode)} "
f"({html.escape(snapshot.profile_source)})</td></tr>"
"</table>"
"<h3>Server code sync</h3>"
"<table class='detail'>"
f"<tr><th>Local HEAD</th><td><code>{html.escape(snapshot.repo_sha or 'unknown')}</code></td></tr>"
f"<tr><th>Remote {html.escape(snapshot.remote)}/{html.escape('master')}</th>"
f"<td><code>{html.escape(snapshot.remote_master_sha or 'unknown')}</code></td></tr>"
f"<tr><th>Commits behind</th><td>{snapshot.commits_behind_master if snapshot.commits_behind_master is not None else 'unknown'}</td></tr>"
"</table>"
"<h3>Shell health</h3>"
f"<p class='meta'>{html.escape(shell_summary)}</p>"
f"<p class='muted'>{html.escape(str(shell.get('safe_next_action') or ''))}</p>"
"<h3>Workflow hashes</h3>"
f"{_hash_rows(snapshot.workflow_hashes)}"
"<h3>Schema hashes</h3>"
f"{_hash_rows(snapshot.schema_hashes)}"
"<h3>Restart / reload</h3>"
"<p class='muted'>MVP is read-only — restart MCP servers from your IDE/operator "
"workflow. Related issue: <code>#420</code>. Guidance: "
f"<code>{html.escape(snapshot.restart_guidance)}</code></p>"
"<p class='muted'>This page does not expose tokens or perform MCP restarts.</p>"
)
File diff suppressed because it is too large Load Diff