Compare commits

..
Author SHA1 Message Date
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 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
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
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 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
sysadmin ce61e424f6 Merge pull request 'fix: preserve capability preflight across safe reads (Closes #469)' (#502) from fix/issue-469-preflight-read-survival into master 2026-07-08 03:01:48 -05:00
sysadmin f89949cea9 Merge pull request 'fix: exempt reconciler close_pr from branches-only worktree guard (Closes #468)' (#472) from feat/issue-468-reconciler-close-guard into master 2026-07-08 02:48:57 -05:00
sysadmin b3edd53185 Merge pull request 'fix: guard PR/issue comment listing against non-list API payloads (Closes #485)' (#487) from feat/issue-485-lease-comments-non-list-guard into master 2026-07-08 02:46:03 -05:00
sysadmin 9ada4762c5 Merge pull request 'feat: expose explicit adoption proof in gitea_lock_issue response (Closes #477)' (#481) from feat/issue-477-lock-adoption-proof into master 2026-07-08 02:31:47 -05:00
sysadminandClaude Opus 4.8 3bce0a55fb test: reset preflight globals in read-survival setUp (#469)
Prevents cross-test leakage that masked the missing-capability fail-closed case.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 03:23:12 -04:00
sysadminandClaude Opus 4.8 afb4ba563c fix: preserve capability preflight across interleaved read-only whoami (#469)
Read-only gitea_whoami calls no longer clear a valid capability proof when
whoami was already verified clean. Capability is consumed on mutation (one
resolve per mutation), and verify_preflight_purity rejects task mismatches.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 03:23:12 -04:00
sysadmin 48cf3a334b Merge pull request 'feat: require validation cwd and HEAD proof in reviewer reports (Closes #398)' (#411) from feat/issue-398-validation-cwd-proof into master 2026-07-08 02:09:11 -05:00
sysadmin 974463163d Merge pull request 'feat: add first-class stacked PR support to author workflow (Closes #484)' (#489) from feat/issue-484-stacked-pr-support into master 2026-07-08 01:41:19 -05: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
sysadminandClaude Opus 4.8 0cbd1fc801 feat: add first-class stacked PR support to author workflow (#484)
Normal author work is unchanged: gitea_lock_issue still requires the worktree
to be base-equivalent to master/main/dev, and gitea_create_pr still targets a
normal base branch. A *stacked* PR (based on another unmerged PR's branch) is a
new explicit, proof-backed path — it never bypasses the issue lock.

- stacked_pr_support.py: pure policy. Approve a non-master base only when it is
  declared AND owned by a live OPEN PR whose number matches; reject arbitrary,
  mismatched, or stale (merged/closed) branches; require the PR body to document
  the stack (base branch, stacked-on PR, merge ordering).
- issue_lock_worktree.py: read_worktree_git_state / _find_matching_base_ref gain
  an opt-in extra_bases arg so an approved stacked base can anchor
  base-equivalence in addition to master/main/dev (empty by default = unchanged).
- gitea_mcp_server.py:
  - gitea_lock_issue gains stacked_base_branch + stacked_base_pr. When declared,
    it verifies the open PR owns the branch, anchors base-equivalence to it, and
    records approved_stacked_base = {branch, pr_number, verified_open} on the lock.
  - gitea_create_pr validates a non-master base against the lock's approved
    stacked base, re-checks the dependency PR is still open, and enforces the
    stacked-PR body fields. Master-based path untouched.
- Docs: llm-workflow-runbooks.md and work-issue.md document when stacked PRs are
  allowed and the required PR-body wording.

Tests: test_stacked_pr_support.py (18 policy cases), stacked base-equivalence in
test_issue_lock_worktree.py, approved_stacked_base persistence round-trip in
test_issue_lock_store.py. Existing lock/create_pr regressions still pass.

The #482 -> #479/#478 case motivates this: create_pr previously could not open a
stacked PR because the lock required a master-equivalent worktree.

Closes #484.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 02:22:54 -04:00
sysadmin d6b1e4be3c fix: resolve conflicts for PR #415 2026-07-08 02:20:04 -04:00
sysadmin 28f0f84de8 fix: resolve conflicts for PR #411 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 08202f7eaa fix: guard PR/issue comment listing against non-list API payloads (Closes #485)
_list_pr_lease_comments and gitea_list_issue_comments now fail safe to an
empty list when the comments API returns a non-list payload (e.g. an error
object), preventing KeyError: slice on the lease and listing paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 02:17:37 -04:00
sysadmin 7af73e1539 Merge pull request 'feat: add precise validation status vocabulary for reviewer reports (Closes #406)' (#412) from feat/issue-406-validation-status-vocabulary into master 2026-07-08 00:48:04 -05:00
sysadmin ae0478bf54 Merge pull request 'feat: add conflict-fix leases and stale-head protection (Closes #399)' (#416) from feat/issue-399-conflict-fix-leases into master 2026-07-08 00:34:20 -05:00
sysadmin cc5686b896 Merge pull request 'feat: require approval at current PR head for merge (#471)' (#480) from feat/issue-471-merge-head-approval into master 2026-07-08 00:29:17 -05:00
sysadmin d4dc9aa854 fix: align test mocks with #399 lease gates and head SHA requirements
PR #416 introduced pr_work_lease reviewer blocks and mandatory full
40-hex expected_head_sha on mark/merge paths. Update audit, review,
permission-report, terminal hard-stop, inventory, and schema tests to
patch lease comment fetches, seed ready review decisions, and use
consistent HEAD_SHA constants so the full suite passes again.
2026-07-08 01:26:36 -04:00
jcwalker3andClaude Opus 4.8 3496fc3952 feat: surface explicit adoption proof in gitea_lock_issue response (Closes #477)
When gitea_lock_issue adopts an existing own branch, the live tool
response now carries explicit, citable adoption-proof fields so #473-style
recovery sessions can quote the lock output directly instead of inferring
adoption from separate offline checks.

- issue_lock_adoption.py: add DECISION_LABELS + decision_label()/
  safe_next_action() helpers; extend build_adoption_proof() with
  adoption_decision, adopted, adopted_branch, adopted_branch_head,
  matcher_summary (boundary-safe reason), competing_branch_check, and
  safe_next_action for all outcomes; add build_non_adoption_lock_proof()
  for adoption-free NO_MATCH metadata.
- gitea_mcp_server.py: attach adoption-free adoption_check block to normal
  (non-adopt) lock responses so they cannot be misread as claiming adoption.
- docs/llm-workflow-runbooks.md: document the adoption/adoption_check
  response blocks and instruct recovery reports to cite them directly.
- tests: explicit-field proof for ADOPT/BLOCK_COMPETING/NO_MATCH,
  substring-collision boundary safety (issue-42 vs issue-420), non-adoption
  proof, and MCP-level live-response assertions.

BLOCK_COMPETING lock attempts still fail closed (raise); no raw API
fallback, branch deletion, force-push, or manual lock seeding introduced.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 23:40:12 -05:00
sysadminandClaude Opus 4.8 89582a0f2a feat: require approval at current PR head for merge (#471)
Add merge_approval_gate assessment so gitea_merge_pr fails closed when
visible APPROVED reviews do not pin the live head SHA. Expose
approval_at_current_head and stale_approval_block_reason in review
feedback. Document re-review requirement in canonical merge workflow.

Closes #471.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 00:37:54 -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
sysadmin a1de4ab8f9 Merge pull request 'feat: harden issue lock store against concurrent session clobbering (Closes #438)' (#464) from feat/issue-438-lock-hardening into master 2026-07-07 17:37:46 -05:00
sysadmin 3c41d2af5c Merge pull request 'feat: add own-branch lock adoption recovery to gitea_lock_issue (Closes #442)' (#461) from feat/issue-442-lock-adoption into master 2026-07-07 17:16:33 -05:00
sysadminandClaude Opus 4.8 d306c0a5ec feat: harden keyed issue lock store with flock serialization (#438)
Rebase onto master (#443 keyed store) and port #438 hardening: per-issue
fcntl.flock around bind_session_lock, assess_lock_freshness with dead-PID
detection, verify_lock_for_mutation before create_pr, and live lock
inventory for claim visibility.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 18:07:44 -04:00
sysadminandClaude Opus 4.8 85532059e2 fix: use exact issue-number boundary in own-branch adoption (#442)
Replace substring issue-marker matching with a numeric word-boundary
regex so issue-42 adoption is not false-blocked by unrelated issue-420
branches. Add regression tests for the #42 vs #420 collision.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 18:05:19 -04:00
sysadmin 54a75153a9 Merge pull request 'feat: keyed issue-lock store replaces global lock file (Closes #443)' (#465) from feat/issue-443-lock-store into master 2026-07-07 16:57:54 -05:00
sysadmin 5dfc00319f Merge pull request 'feat: align mutation guard with runtime_context workspace resolution (Closes #460)' (#462) from feat/issue-460-workspace-guard-alignment into master 2026-07-07 16:53:07 -05:00
sysadmin f51dc95153 Merge pull request 'feat: harden #274 branches-only guard for gitea_create_issue (Closes #274)' (#449) from feat/issue-274-branches-only-guard-hardening into master 2026-07-07 16:48:00 -05:00
sysadmin 55b3658d93 fix: resolve conflicts for PR #416
Merge current prgs/master into feat/issue-399-conflict-fix-leases.
Preserve reviewer PR lease gate (#407) before conflict-fix stale-head
gate (#399); document both as §26B and §26C. Align MCP and validator
tests with mandatory expected_head_sha and stale-head proof fields.
2026-07-07 17:38:45 -04:00
sysadminandClaude Opus 4.8 c7b462a034 fix: exempt reconciler close_pr from branches-only worktree guard (#468)
Reconciler profiles perform Gitea metadata mutations (close_pr) and must
not be blocked when the MCP workspace resolves to the stable control
checkout without GITEA_AUTHOR_WORKTREE. Author file mutations remain
guarded. Add regression tests for reconciler close vs author create_issue.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 17:36:33 -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
sysadminandClaude Opus 4.8 e956040b6b test: align duplicate-gate and artifact tests with keyed lock store (#443)
Update lock-issue and duplicate-recheck tests to mock branch listing,
use GITEA_ISSUE_LOCK_DIR session binding, and satisfy create_pr
provenance/worktree guards after the keyed persistent lock store landed.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 17:30:50 -04:00
sysadmin 43a17a7e8e resolve conflicts for PR #465 2026-07-07 17:30:35 -04:00
sysadminandClaude Opus 4.8 d042a9ca24 feat: replace global issue lock with keyed persistent store (Closes #443)
Store per remote/org/repo/issue locks under GITEA_ISSUE_LOCK_DIR with
atomic writes and per-session binding. Integrate own-branch adoption for
lock recovery, update worktree-start and cleanup reconcile, and add tests
documenting the ban on manual global lock seeding.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 17:30:35 -04:00
sysadmin f061fee18d Merge pull request 'feat: add per-PR reviewer leases for parallel review (Closes #407)' (#424) from feat/issue-407-reviewer-pr-leases into master 2026-07-07 16:27:19 -05: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
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 3e4b721d60 test: align duplicate gate lock fixtures with #447 provenance (#407)
Rebase onto master (#413/#463) requires sanctioned lock_provenance in
create_pr duplicate-recheck tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 17:18:00 -04:00
sysadminandClaude Opus 4.8 1830360850 test: fix reviewer lease gate regressions in MCP integration tests (#407)
Align merge/review/audit mocks with the per-PR reviewer lease gate introduced
in #407: install owned session leases after init_review_decision_lock, stub
_fetch_pr_comments and _authenticated_username to preserve mock ordering, and
update head-SHA mismatch expectations for lease-first fail-closed paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 17:16:06 -04:00
sysadminandClaude Opus 4.8 06c2069234 feat: add per-PR reviewer leases for parallel review (Closes #407)
Structured PR-thread leases with acquire/heartbeat MCP tools and
mutation-time enforcement so reviewers cannot race the same PR queue.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 17:16:06 -04:00
sysadmin cc4741a4ce Merge pull request 'feat: block manual issue-lock seeding and require lock disclosure (Closes #447)' (#463) from feat/issue-447-lock-provenance into master 2026-07-07 15:46:09 -05:00
sysadmin d95ea323d7 Merge pull request 'feat: move duplicate-work detection before author mutations (Closes #400)' (#413) from feat/issue-400-duplicate-work-preflight into master 2026-07-07 15:40:23 -05:00
sysadminandClaude Opus 4.8 e4adccd82a fix: inject duplicate-work context fetcher for testable lock/create_pr paths (#400)
Expose issue_duplicate_context_fetcher on the MCP server so lock_issue,
commit_files, and create_pr duplicate rechecks avoid live Gitea calls in
unit tests. Update affected test suites to patch the fetcher without
weakening duplicate-work gate assertions.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 16:33:31 -04:00
sysadmin 89a7d4dbfc Merge pull request 'fix(webui): suppress misleading empty queue copy on fail-closed fetch (#458)' (#459) from feat/issue-458-queue-fail-closed-ux into master 2026-07-07 15:19:13 -05:00
sysadminandClaude Opus 4.8 795f544047 feat: block manual issue-lock seeding and require lock disclosure (#447)
Add lock_provenance metadata on gitea_lock_issue writes and fail closed at
gitea_create_pr when provenance is missing. Final-report validation now
requires explicit External-state mutations disclosure for issue-lock
read/write/delete and blocks mixed author PR creation with reviewer approval.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 16:18:42 -04:00
sysadminandClaude Opus 4.8 b33844d74a feat: align mutation guard with runtime_context workspace resolution (Closes #460)
Share canonical workspace/repo-root resolution between gitea_get_runtime_context
and verify_preflight_purity so valid branches/ worktrees are not rejected when
the MCP process root differs from the stable control checkout. Fix relative
git-common-dir resolution and add regression tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 16:11:23 -04:00
sysadmin 22a1c4c2df fix(webui): suppress empty queue copy on fetch failure (#458)
When Gitea credentials are missing or the queue fetch fails closed,
show "Not loaded" instead of "No open items." and keep pagination
marked unavailable. Successful empty inventories still show the empty
state with complete pagination proof.

Closes #458
2026-07-07 15:53:10 -04:00
sysadmin ee8e9a0247 Merge pull request 'feat: add live PR/issue queue dashboard to web UI (Closes #429)' (#445) from feat/issue-429-queue-dashboard into master 2026-07-07 14:13:27 -05:00
sysadminandClaude Opus 4.8 f5370a94d3 test: harden queue dashboard classification and route coverage (#429)
Expand queue dashboard tests for stale/duplicate badges, title-linked
issues, and nav coverage; drop unused import in queue_loader.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 15:05:42 -04:00
sysadminandClaude Opus 4.8 c91e3642de feat: add live PR/issue queue dashboard to web UI (Closes #429)
Adds read-only /queue and /api/queue routes that load open PRs and issues
from the default registry project via gitea_auth, surface pagination proof,
and classify items with claimed/blocked/in-review/duplicate badges.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 15:05:41 -04:00
sysadmin 3599a9e12a Merge pull request 'feat: add canonical workflow prompt library to web UI (Closes #428)' (#441) from feat/issue-428-prompt-library into master 2026-07-07 13:58:57 -05:00
sysadmin f845864889 feat: add web UI prompt library from canonical workflows (#428)
Surface short copy/paste operator prompts derived from canonical workflow
files with workflow path and SHA-256 citations. Adds /prompts, /prompts/{id},
and /api/prompts with one-click copy.

Closes #428
2026-07-07 14:53:16 -04:00
sysadminandClaude Opus 4.8 6670c72e65 feat: add canonical workflow prompt library to web UI (Closes #428)
Expose short copy/paste operator prompts on /prompts derived from canonical
workflow files with workflow path and SHA-256 hash. Adds JSON export at
/api/prompts, copy buttons, and tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 14:53:16 -04:00
sysadmin 1529b9ff6b Merge pull request 'feat: thread worktree_path through gitea_create_issue (Closes #450)' (#451) from feat/issue-450-create-issue-worktree-guard into master 2026-07-07 13:51:00 -05:00
sysadmin 4a95c65f8b Merge pull request 'feat: add web UI project registry and onboarding (Closes #427)' (#439) from feat/issue-427-project-registry into master 2026-07-07 13:48:06 -05:00
sysadmin 16dcf65825 feat: add web UI project registry and onboarding (#427)
Load projects from versioned webui/data/projects.registry.json with profile
mappings, workflow/schema paths, and read-only onboarding checklist UI.
Seeds Gitea-Tools; exposes /projects, /projects/{id}, and /api/projects.

Closes #427
2026-07-07 14:44:27 -04:00
sysadmin 81fcdb09fd feat: thread worktree_path through gitea_create_issue (#450)
Hardens #274 branches-only guard for gitea_create_issue:

- verify_preflight_purity: validate resolved worktree exists, is a directory,
  and belongs to the target repository before author mutations.
- gitea_create_issue: add worktree_path threaded into preflight guard.
- tests/test_create_issue_workspace_guard.py: stable-control-checkout rejection
  and invalid-path fail-closed cases (PROJECT_ROOT simulated as control checkout).

Preserves WIP commit 8530109 implementation; test fix completes stable-checkout path.

Closes #450
2026-07-07 14:41:06 -04:00
sysadmin a9265efa82 feat: harden #274 branches-only guard for gitea_create_issue
Follow-up on the branches-only worktree guard (#274):

- verify_preflight_purity: validate resolved worktree exists, is a directory,
  and belongs to the target repository before author mutations.
- gitea_create_issue: add worktree_path for explicit branches/ workspace proof.
- tests/test_create_issue_workspace_guard.py: six regression cases including
  stable-control-checkout rejection with explicit PROJECT_ROOT simulation.

Closes #274
2026-07-07 14:38:10 -04:00
sysadmin 30ded9b712 Merge pull request 'feat: add internal web UI server skeleton (Closes #426)' (#437) from feat/issue-426-internal-web-ui-skeleton into master 2026-07-07 13:38:07 -05: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 a8fcf0e01c feat: add internal web UI server skeleton (Closes #426)
Starlette read-only MVP with shared layout, /health JSON liveness, and
route stubs for projects, prompts, runtime, audit, worktrees, and leases.
Includes scripts/run-webui, docs/webui-local-dev.md, and tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 13:23:09 -04:00
sysadminandClaude Opus 4.8 1320a55a7e feat: require validation cwd and HEAD proof in reviewer reports (Closes #398)
Rebased onto current prgs/master; merged with #396 validation failure
history by keeping both validator rules and workflow handoff fields.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 13:09:58 -04:00
sysadminandClaude Opus 4.8 0bad26230b fix: resolve conflicts for PR #413
Rebase onto current prgs/master and patch create_pr duplicate-gate test
with auth header mock to match post-rebase recheck path.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 13:08:53 -04:00
sysadminandClaude Opus 4.8 cf057d7829 feat: move duplicate-work detection before author mutations (Closes #400)
Extract issue_work_duplicate_gate for open PR, remote branch, and active
claim checks; wire it into lock_issue, commit_files recheck, and create_pr
recheck. Add gitea_assess_work_issue_duplicate read-only preflight, work-issue
report outcome validation, and workflow section 10A.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 13:08:21 -04:00
sysadmin 87beb44394 feat: add conflict-fix leases and stale-head protection (Closes #399)
Introduce structured PR work leases so author conflict-fix pushes cannot
race reviewer validation, approval, or merge on a moving head SHA.

- pr_work_lease.py: parse/acquire leases, push and reviewer mutation gates
- gitea_acquire_conflict_fix_lease, gitea_assess_conflict_fix_push MCP tools
- Enforce reviewed head SHA on mark_final_review_decision, submit_pr_review, merge_pr
- Final-report rules and workflow sections 20A / 26B
- tests/test_pr_work_lease.py (14 cases)
2026-07-07 13:06:07 -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 db032ef93c feat: add precise validation status vocabulary for reviewer reports (Closes #406)
Introduce validation_status_vocabulary with proof-path binding for baseline-
equivalent, merge-simulation-resolved, and transient-pass labels. Wire the
assessor into final_report_validator and document the taxonomy in the
review-merge workflow.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 12:53:55 -04:00
sysadmin 1a1e679246 Merge pull request 'feat: gate gitea_delete_branch on gitea.branch.delete capability (Closes #408)' (#410) from feat/issue-408-delete-branch-capability-gate into master 2026-07-07 10:48:51 -05:00
sysadmin 9fde4f3e76 Merge pull request 'feat: require transient validation failure history in reviewer reports (Closes #396)' (#409) from feat/issue-396-transient-validation-failure-history into master 2026-07-07 10:42:56 -05:00
sysadmin 6b5d2ad080 Merge pull request 'feat: require proof-backed claims in reviewer handoff reports (Closes #395)' (#397) from feat/issue-395-proof-backed-review-handoff into master 2026-07-07 10:34:35 -05:00
sysadmin f5654963eb Merge pull request 'feat: add prgs-reconciler profile model and role detection (Closes #304)' (#388) from feat/issue-304-reconciler-profile into master 2026-07-07 10:27:12 -05:00
sysadminandClaude Opus 4.8 af7131abf1 feat: gate gitea_delete_branch on gitea.branch.delete capability (Closes #408)
Add fail-closed profile gate at tool entry before preflight or API calls,
return structured permission reports on block, and record required_permission
in delete_branch audit metadata. Regression tests prove resolver-denied
sessions cannot bypass the gate through the raw tool.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 11:26:37 -04:00
sysadmin 027a3cb92c Merge pull request 'feat: register work-issue task routing for role-aware MCP (Closes #139)' (#385) from feat/issue-139-role-aware-work-issue-routing into master 2026-07-07 10:24:04 -05:00
sysadminandClaude Opus 4.8 71ccbca10f feat: require transient validation failure history in reviewer reports (Closes #396)
Add assess_validation_failure_history_report so reviewer final reports must
document every validation failure observed during a session, not only the
final passing result. Wire the verifier into final_report_validator,
gitea_validate_review_final_report, and the review-merge workflow template.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 11:22:00 -04:00
sysadmin 6220304f8c fix: resolve conflicts for PR #397
Merge prgs/master into feat/issue-395-proof-backed-review-handoff; keep
both proof-backed handoff (#395) and already-landed classification (#295)
downgrade gates in review_proofs.py.
2026-07-07 11:21:22 -04:00
sysadmin 6a37f7c8d5 fix: resolve conflicts for PR #388
Merge prgs/master into feat/issue-304-reconciler-profile; keep both
reconciler_profile and reconciliation_workflow imports in gitea_mcp_server.py.
2026-07-07 11:19:57 -04:00
sysadmin fcb9944378 Merge pull request 'feat: add reviewer final-report schema MCP validator (Closes #391)' (#394) from feat/issue-391-review-final-report-schema into master 2026-07-07 10:15:41 -05:00
sysadmin f75f2327f5 fix: resolve conflicts for PR #385
Merge prgs/master into feat/issue-139-role-aware-work-issue-routing and
keep both work-issue task routing entries and reconcile-landed-pr entries
in task_capability_map.py.
2026-07-07 11:13:45 -04:00
sysadmin 9f4dd4c39b Merge pull request 'feat: enforce canonical reconciliation handoff schema (Closes #307)' (#387) from feat/issue-307-canonical-reconciliation-handoff into master 2026-07-07 10:09:50 -05:00
sysadmin 6e212c0de7 Merge pull request 'feat: enforce author work leases in issue lock preflight (Closes #267)' (#386) from feat/issue-267-work-leases into master 2026-07-07 09:52:37 -05:00
sysadmin d446a31442 Merge pull request 'feat: add reconciliation workflow MCP tools for already-landed PRs (Closes #301)' (#384) from feat/issue-301-reconciliation-workflow into master 2026-07-07 09:42:31 -05:00
sysadmin b11160f236 Merge pull request 'feat: classify already-landed PRs as reconciliation-only (Closes #295)' (#382) from feat/issue-295-already-landed-classification into master 2026-07-07 09:37:23 -05:00
sysadmin d814a9ca81 fix: resolve conflicts for PR #388
Merge prgs/master; unify reconciler profile docs (#304) with master's
already-landed close tool guidance (#310).
2026-07-07 10:34:57 -04:00
sysadmin 10e64f6683 fix: resolve conflicts for PR #385
Merge prgs/master; keep work-issue author routing (#139) alongside
reconciler task entries from master (#309/#310).
2026-07-07 10:34:23 -04:00
sysadmin 5513bdf2aa fix: resolve conflicts for PR #384
Merge prgs/master; keep read-only reconciliation tools (#301) alongside
master's gitea_reconcile_already_landed_pr close path (#310). Unify task
routing: reconcile_landed_pr stays author/read-only; reconcile-landed-pr
and reconcile_already_landed_pr stay reconciler.
2026-07-07 10:33:42 -04:00
sysadmin 273eba7fb3 Merge pull request 'feat: enforce branches-only author mutation worktree guard (Closes #274)' (#374) from feat/issue-274-branches-only-worktrees into master 2026-07-07 09:33:18 -05:00
sysadmin cd61272837 fix: resolve conflicts for PR #374
Merge prgs/master into feat/issue-274-branches-only-worktrees; keep both
already_landed_reconcile and author_mutation_worktree imports. Allow
branches/ worktrees when PROJECT_ROOT is the session worktree; align
commit-payload tests with branches-only guard (#274).
2026-07-07 10:31:21 -04:00
sysadmin 7fbb1bf34a Merge pull request 'feat: require pagination proof in reconciliation PR inventory (Closes #308)' (#376) from feat/issue-308-reconcile-inventory-pagination into master 2026-07-07 09:26:02 -05:00
sysadmin c1d85b621a feat: require proof-backed claims in reviewer handoff reports (Closes #395) 2026-07-07 10:24:54 -04:00
sysadmin 4243b60ca3 Merge pull request 'feat: add prgs-reconciler profile and gated already-landed PR close (Closes #310)' (#381) from feat/issue-310-prgs-reconciler-profile into master 2026-07-07 09:22:55 -05:00
sysadmin f5953549aa fix: resolve conflicts for PR #376
Merge prgs/master; keep reconcile-inventory pagination verifier (#308)
alongside linked-issue live-proof gates from master (#300).
2026-07-07 10:22:35 -04:00
sysadmin ab6bb593bc fix: resolve conflicts for PR #382
Merge prgs/master; keep already-landed oldest-eligible-PR test from
#295 alongside master already-landed gate and target-branch freshness tests.
2026-07-07 10:20:52 -04:00
sysadmin 4cf75bcbc9 fix: resolve conflicts for PR #384
Merge prgs/master; keep reconciliation_workflow import alongside
review_merge_state_machine and preserve both reconciliation and
pr-queue-cleanup workflow split tests.
2026-07-07 10:20:14 -04:00
sysadmin 9ff861a1f3 fix: resolve conflicts for PR #388
Merge prgs/master into feat/issue-304-reconciler-profile; keep both
reconciler_profile and review_merge_state_machine imports.

Closes conflict with master landing review_merge_state_machine (#389 stack).
2026-07-07 10:19:40 -04:00
sysadmin 8d5fa06351 Merge commit 'b5ee6567aade4fdce4c57d826184bfd01ebb522e' into HEAD
# Conflicts:
#	tests/test_llm_workflow_split.py
2026-07-07 10:19:18 -04:00
sysadmin 7db8298cdd Merge pull request 'feat: require live linked issue proof in reconciliation handoffs (Closes #300)' (#372) from feat/issue-300-linked-issue-live-proof into master 2026-07-07 09:19:01 -05:00
sysadmin 167257b8a3 Merge commit 'b5ee6567aade4fdce4c57d826184bfd01ebb522e' into HEAD
# Conflicts:
#	tests/test_resolve_task_capability.py
2026-07-07 10:18:20 -04:00
sysadmin 23380d27fe Merge commit 'b5ee6567aade4fdce4c57d826184bfd01ebb522e' into HEAD
# Conflicts:
#	review_proofs.py
#	tests/test_llm_workflow_split.py
2026-07-07 10:16:59 -04:00
sysadmin bb525a8f94 Merge pull request 'Enforce already-landed review report state gates' (#383) from feat/issue-294-already-landed-review-gate into master 2026-07-07 09:15:19 -05:00
sysadmin d5b185ce38 Merge commit 'b5ee6567aade4fdce4c57d826184bfd01ebb522e' into HEAD
# Conflicts:
#	review_proofs.py
#	tests/test_llm_workflow_split.py
2026-07-07 10:15:10 -04:00
sysadmin b5ee6567aa Merge pull request 'feat: verify canonical workflow citation and version in review reports (Closes #296)' (#380) from feat/issue-296-workflow-version-proof into master 2026-07-07 09:11:13 -05:00
sysadmin 103364aa8d Merge pull request 'feat: add PR-only queue cleanup mode with per-PR dispatch gates (Closes #390)' (#393) from feat/issue-390-pr-queue-cleanup-mode into master 2026-07-07 09:07:51 -05:00
sysadminandClaude Opus 4.8 a2bd23da14 test: expand PR-queue-cleanup coverage to full acceptance matrix (#390)
Superset of the initial test slice: adds capability-map and router
routing cases (reviewer-role enforcement, author wrong_role_stop), the
full terminal-chain matrix (comment/skip stop, gates-failed stop, merge
completed/blocker stop, unknown-decision fail-closed), and report
verifier cases for two terminal mutations, missing next-suggested-PR,
branch mutations, and missing workflow citation.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 10:06:50 -04:00
sysadmin a164700ab9 feat: add reviewer final-report schema MCP validator (Closes #391)
Expose gitea_validate_review_final_report composing the composable
final-report validator with review-specific schema gates for legacy
fields, merge/issue claims, blocked handoff replay, and narrative drift.
2026-07-07 10:04:06 -04:00
sysadminandClaude Opus 4.8 91c67930ec feat: add PR-only queue cleanup mode with per-PR dispatch gates (Closes #390)
Adds canonical pr-queue-cleanup workflow, report verifier, reviewer-only task
routing, and runbook guidance so operators can drain open PRs one canonical
review at a time with fail-closed boundaries on batching and unauthorized merges.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 10:02:49 -04:00
sysadmin 0c18d0f54f Merge pull request 'feat: enforce PR review/merge workflow state machine gates (Closes #290)' (#378) from feat/issue-290-review-merge-state-machine into master 2026-07-07 09:02:42 -05:00
sysadmin ae566d5cd1 Merge pull request 'feat: reject stale already-landed controller handoff fields (Closes #299)' (#371) from feat/issue-299-already-landed-handoff into master 2026-07-07 09:00:07 -05:00
sysadmin d66d465e31 fix: resolve conflicts for PR #385
Merge prgs/master and keep work-issue author routing (#139) alongside
reconciler close task routing (#309).
2026-07-07 09:58:45 -04:00
sysadmin cf94112e96 fix: resolve conflicts for PR #384
Merge prgs/master and keep read-only reconciliation workflow tasks (#301)
alongside reconciler close capabilities (#309).
2026-07-07 09:58:45 -04:00
sysadmin 5a5bc8846f fix: resolve conflicts for PR #381
Merge prgs/master and keep already-landed reconcile close tool routing (#310)
alongside dedicated reconciler close tasks (#309).
2026-07-07 09:58:45 -04:00
sysadmin 7ef4c53cd8 Merge remote-tracking branch 'prgs/master' into feat/issue-304-reconciler-profile 2026-07-07 09:57:58 -04:00
sysadmin 60040e789b Merge remote-tracking branch 'prgs/master' into feat/issue-290-review-merge-state-machine 2026-07-07 09:57:58 -04:00
sysadmin 769f387267 Merge remote-tracking branch 'prgs/master' into feat/issue-308-reconcile-inventory-pagination 2026-07-07 09:57:54 -04:00
sysadmin 202be15764 Merge remote-tracking branch 'prgs/master' into feat/issue-300-linked-issue-live-proof 2026-07-07 09:57:54 -04:00
sysadmin fbfcc08640 Merge remote-tracking branch 'prgs/master' into feat/issue-299-already-landed-handoff 2026-07-07 09:57:54 -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
sysadmin 0bb6cae95a Merge pull request 'feat: dedicated reconciler capability for closing landed PRs (Closes #309)' (#379) from feat/issue-309-reconciler-close-capability into master 2026-07-07 08:57:04 -05:00
sysadmin 180490c640 fix: resolve conflicts for PR #384
Merge prgs/master and keep both reconciliation workflow (#301) and
native MCP preference (#270) imports in gitea_mcp_server.
2026-07-07 09:57:01 -04:00
sysadmin b604b468b5 fix: resolve conflicts for PR #388
Merge prgs/master and keep reconciler profile runtime fields (#304)
alongside native MCP shell health (#270).
2026-07-07 09:57:01 -04:00
sysadmin af064dd06b fix: resolve conflicts for PR #378
Merge prgs/master and keep both review-merge state machine (#290) and
native MCP preference (#270) imports, skill entries, and MCP tools.
2026-07-07 09:57:01 -04:00
sysadmin 6100187274 fix: resolve conflicts for PR #376
Merge prgs/master and keep both reconcile-inventory (#308) and
inventory-worktree (#293) verifiers wired in build_final_report.
2026-07-07 09:55:33 -04:00
sysadmin bf002aee2c fix: resolve conflicts for PR #372
Merge prgs/master and keep both reconcile-linked-issue (#300) and
inventory-worktree (#293) verifiers wired in build_final_report.
2026-07-07 09:55:33 -04:00
sysadmin b53e3de7cc fix: resolve conflicts for PR #371
Merge prgs/master and keep both already-landed-handoff (#299) and
inventory-worktree (#293) verifiers wired in build_final_report.
2026-07-07 09:55:33 -04:00
sysadmin 47b4ee4791 Merge pull request 'feat: add native MCP preference gate and shell circuit breaker (Closes #270)' (#377) from feat/issue-270-native-mcp-preference into master 2026-07-07 08:54:03 -05:00
sysadmin 1c506fa779 Merge pull request 'feat: comment-then-stop policy for partial PR reconciliation (Closes #302)' (#375) from feat/issue-302-partial-reconciliation-policy into master 2026-07-07 08:51:12 -05:00
sysadmin e7a1e183a0 Merge pull request 'feat: prove inventory pagination and worktree state in reviewer reports (Closes #293)' (#369) from feat/issue-293-inventory-worktree-proof into master 2026-07-07 08:47:05 -05:00
sysadminandClaude Opus 4.8 72f3bc8db8 fix: resolve conflicts for PR #376 against current master
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 09:45:21 -04:00
sysadminandClaude Opus 4.8 e0a1717db2 fix: resolve conflicts for PR #372 against current master
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 09:45:03 -04:00
sysadminandClaude Opus 4.8 3c2dd055fa fix: resolve conflicts for PR #371 against current master
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 09:44:44 -04:00
sysadminandClaude Opus 4.8 56f8f9eeb4 fix: resolve conflicts for PR #369 against current master
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 09:44:12 -04:00
sysadmin b6cf32c845 Merge pull request 'feat: add dedicated reconciliation controller-handoff schema (Closes #303)' (#373) from feat/issue-303-reconciliation-handoff into master 2026-07-07 08:42:40 -05:00
sysadminandClaude Opus 4.8 21ff12cef7 feat: add prgs-reconciler profile model and role detection (Closes #304)
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 09:39:52 -04:00
sysadmin a17e8561d0 Merge pull request 'feat: require infra_stop repair handoff for blocked reviewers (Closes #289)' (#366) from feat/issue-289-infra-stop-handoff into master 2026-07-07 08:39:35 -05:00
sysadmin e2771dfe9a fix: resolve conflicts for PR #385 against latest master 2026-07-07 09:38:36 -04:00
sysadmin 7635a17599 fix: resolve conflicts for PR #384 against latest master 2026-07-07 09:38:34 -04:00
sysadmin f9bf830067 fix: resolve conflicts for PR #381 against latest master 2026-07-07 09:38:31 -04:00
sysadmin 85c5d24b0e fix: resolve conflicts for PR #379 against latest master 2026-07-07 09:38:28 -04:00
sysadmin dc51e9a91b fix: resolve conflicts for PR #374 against latest master 2026-07-07 09:38:25 -04:00
sysadmin f749312b45 fix: resolve conflicts for PR #376 against latest master 2026-07-07 09:37:24 -04:00
sysadmin fa922cf8e3 fix: resolve conflicts for PR #373 against latest master 2026-07-07 09:37:21 -04:00
sysadmin 208dc40041 fix: resolve conflicts for PR #372 against latest master 2026-07-07 09:37:18 -04:00
sysadmin 8b555043e3 fix: resolve conflicts for PR #371 against latest master 2026-07-07 09:37:16 -04:00
sysadmin faf4484657 fix: resolve conflicts for PR #369 against latest master 2026-07-07 09:37:13 -04:00
sysadmin 93230457d8 fix: resolve conflicts for PR #366 against latest master 2026-07-07 09:37:10 -04:00
sysadmin b4c79fdbbc Merge pull request 'feat: add issue claim heartbeat leases and stale-claim reconciliation (Closes #268)' (#370) from feat/issue-268-heartbeat-leases into master 2026-07-07 08:36:55 -05:00
sysadmin 8b9a0705b6 Merge pull request 'feat: enforce precise mutation categories in controller handoffs (Closes #319)' (#365) from feat/issue-319-mutation-categories into master 2026-07-07 08:33:56 -05:00
sysadminandClaude Opus 4.8 bc8e2b7928 fix: resolve conflicts for PR #382 against latest master
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 09:32:37 -04:00
sysadminandClaude Opus 4.8 9bb9bad4c7 fix: resolve conflicts for PR #382 against latest master
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 09:31:55 -04:00
sysadminandClaude Opus 4.8 ab126da479 fix: resolve conflicts for PR #376 against latest master
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 09:31:52 -04:00
sysadminandClaude Opus 4.8 ba4cf2e93d fix: resolve conflicts for PR #375 against latest master
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 09:31:50 -04:00
sysadminandClaude Opus 4.8 4507457560 fix: resolve conflicts for PR #372 against latest master
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 09:31:47 -04:00
sysadminandClaude Opus 4.8 dc24fe3afe feat: enforce canonical reconciliation handoff schema (Closes #307)
Reconciliation final reports must emit only the canonical reconciliation
handoff schema:

- Expand _RECONCILE_REQUIRED_FIELDS from 5 to the full 27-field canonical
  schema (Task through No review/merge confirmation), so incomplete
  reconciliation handoffs downgrade with per-field reasons.
- Add 'issue lock proof', 'claim/comment status', and 'workspace mutations'
  to _RECONCILE_STALE_FIELDS so stale author/reviewer fields block.
- Make the 'PR number opened' rejection conditional on a new
  session_pr_opened proof kwarg: the field is allowed only when a PR was
  actually opened in the session.
- Close the git-fetch reporting gap: a fetch observed only in the action
  log (not report text) now requires a Git ref mutations entry as well.
- Update the reconciliation handoff fixture to the canonical schema and
  add TestCanonicalReconcileSchema covering blocked, successful-close,
  and comment-only reconciliation plus missing-close-capability,
  stale-field, and conditional PR-number-opened paths.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 09:31:45 -04:00
sysadminandClaude Opus 4.8 3c9841760c fix: resolve conflicts for PR #371 against latest master
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 09:31:44 -04:00
sysadminandClaude Opus 4.8 51d0c14009 fix: resolve conflicts for PR #369 against latest master
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 09:31:42 -04:00
sysadminandClaude Opus 4.8 8a6cb6ac2c fix: resolve conflicts for PR #366 against latest master
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 09:31:21 -04:00
sysadmin ce39fb52c9 Merge pull request 'Add validation integrity verifier for PR-head vs diagnostic tests (#316)' (#358) from feat/issue-316-validation-integrity into master 2026-07-07 08:31:02 -05:00
sysadminandClaude Opus 4.8 c260ef15fb feat: register work-issue task routing for role-aware MCP (#139)
- Map work_issue/work-issue to author role and gitea.pr.create in resolver
- Route work-issue sessions through role_session_router before mutations
- Expose work_issue in runtime context task capabilities
- Add work-issue workflow source verifier and canonical routing docs
- Tests for resolver, router, and final-report verifier

Closes #139

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 09:30:38 -04:00
sysadmin e823000301 fix: resolve conflicts for PR #365 against latest master 2026-07-07 09:28:23 -04:00
sysadmin 7b71113b4d Merge pull request 'feat: hard-stop review mutations on already-landed PR heads (Closes #292)' (#368) from feat/issue-292-already-landed-gate into master 2026-07-07 08:28:04 -05:00
sysadminandClaude Opus 4.8 c2226177b6 fix: resolve conflicts for PR #358 against latest master
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 09:27:29 -04:00
sysadmin 749e480baf feat: add reconciliation workflow MCP tools for already-landed PRs (Closes #301)
Expose read-only assess/scan tools and capability planning so already-landed
open PRs can be reconciled without review or merge. Register the
gitea-reconcile-landed-pr skill and workflow-source verification.
2026-07-07 09:25:55 -04:00
sysadmin b41d9d6006 Merge pull request 'feat: reject eligible/reviewed wording for already-landed PRs (Closes #298)' (#367) from feat/issue-298-already-landed-wording into master 2026-07-07 08:25:44 -05:00
sysadmin 2577489c2a feat: enforce already-landed review report states 2026-07-07 09:24:30 -04:00
sysadminandClaude Opus 4.8 f89019f804 feat: classify already-landed PRs as reconciliation-only (Closes #295)
Add reviewer_already_landed_classification verifier to block oldest/next
eligible PR wording, review-eligible claims, and pinned reviewed head
usage when a PR is already landed. Wire the check into build_final_report
and tighten final_report_validator regex coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 09:23:50 -04:00
sysadmin e26398b7a9 Merge pull request 'feat: block edits in PR validation worktrees (Closes #315)' (#363) from feat/issue-315-validation-worktree-no-edits into master 2026-07-07 08:22:57 -05:00
sysadmin 9cf1b41b77 feat: add prgs-reconciler profile and gated already-landed PR close (Closes #310)
Introduce a dedicated reconciler role with gitea_reconcile_already_landed_pr,
which closes open PRs only after live fetch and ancestor proof against a fresh
target branch. Document the gitea-reconciler namespace and extend task routing.
2026-07-07 09:20:50 -04:00
sysadmin 0fe8f57dda Merge pull request 'feat: classify merge simulation as worktree/index mutations (Closes #317)' (#356) from feat/issue-317-merge-simulation-mutations into master 2026-07-07 08:20:40 -05:00
sysadminandClaude Opus 4.8 6946534f3e feat: verify canonical workflow citation and version in review reports (Closes #296)
The canonical PR review/merge workflow already lives in
skills/llm-project-workflow/workflows/review-merge-pr.md and is exposed
through mcp_list_project_skills / mcp_get_skill_guide (#333-#335), and
its final-report schema already demands a workflow version. What was
missing is enforcement: add compute_workflow_hash (short sha256
version hash of workflow text) and assess_review_workflow_source to
review_proofs. Review reports must cite the canonical workflow file
and carry a populated Workflow version field; when the current
canonical hash is supplied, a mismatched citation fails as a stale
workflow copy, directing the agent to reload via mcp_get_skill_guide.
Fails closed. Tests cover hash determinism and content sensitivity,
matching-hash pass, missing citation, missing version field, stale
hash, version none, and offline validation without a canonical hash.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 09:19:33 -04:00
sysadmin 1487ff60b4 Merge pull request 'feat: enforce validation environment proof in reviewer reports (Closes #311)' (#352) from feat/issue-311-validation-environment-proof into master 2026-07-07 08:18:29 -05:00
sysadmin d0f52cbf19 fix: resolve conflicts for PR #376 against current master 2026-07-07 09:17:33 -04:00
sysadmin 17c51eaa86 fix: resolve conflicts for PR #372 against current master 2026-07-07 09:17:33 -04:00
sysadmin 2900add223 fix: resolve conflicts for PR #371 against current master 2026-07-07 09:17:33 -04:00
sysadmin a5fb7d2472 fix: resolve conflicts for PR #366 against current master 2026-07-07 09:17:25 -04:00
sysadmin c5ab43ed5a fix: resolve conflicts for PR #366 against current master 2026-07-07 09:17:25 -04:00
sysadmin a237b15559 fix: resolve conflicts for PR #365 against current master 2026-07-07 09:17:15 -04:00
sysadmin 07b23950ab fix: resolve conflicts for PR #363 against current master 2026-07-07 09:17:00 -04:00
sysadmin bbf5ee6d59 fix: resolve conflicts for PR #375 against current master 2026-07-07 09:15:26 -04:00
sysadmin 5131fc595a fix: resolve conflicts for PR #368 against current master 2026-07-07 09:15:26 -04:00
sysadmin e017d80256 Merge pull request 'fix: seed profile gate for TestIssueLockArtifactWarning (Closes #359)' (#364) from fix/issue-359-artifact-warning-test into master 2026-07-07 08:15:18 -05:00
sysadminandClaude Opus 4.8 958d470f98 feat: dedicated reconciler capability for closing landed PRs (Closes #309)
Already-landed open PRs block the review queue: the already-landed gate
correctly stops approval/merge, but no task path could close the
redundant PR. Add the reconciler close path:

- task_capability_map: new reconcile_close_landed_pr (gitea.pr.close)
  and reconcile_close_landed_issue (gitea.issue.close) tasks under a
  dedicated "reconciler" role. Exact close capabilities only — the
  role grants no review, approve, request-changes, or merge authority,
  and normal reviewer profiles keep no broad PR-close authority.
- role_session_router: reconciler tasks route wrong_role_stop in
  author/reviewer sessions with a dedicated recovery message; matching
  reconciler sessions route allowed_current_session.
- review_proofs.assess_reconciler_close_gate: PR close allowed only
  when every proof passes — PR live-verified open, candidate head SHA
  pinned and matching the live head, target branch freshly fetched
  with a full SHA, head an ancestor of the target, exact
  gitea.pr.close proven. Non-landed PRs return
  NOT_LANDED_CLOSE_BLOCKED; missing close capability returns
  RECOVERY_HANDOFF_REQUIRED; incomplete proof fails closed as
  GATE_NOT_PROVEN. Linked-issue close is skipped when the issue is
  already closed and allowed only for an open issue resolved by the
  landed commits with exact gitea.issue.close capability. The gate
  also returns the required final-report field list.

17 new tests cover the capability map, wrong-role routing, and every
gate path (landed close, non-landed block, stale target, changed head,
missing capabilities, linked-issue variants, review-mutation denial).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 09:15:13 -04:00
sysadmin af806919b3 fix: resolve conflicts for PR #358 against current master 2026-07-07 09:14:47 -04:00
sysadmin 42cd0f9f4a fix: resolve conflicts for PR #356 against current master 2026-07-07 09:14:30 -04:00
sysadmin 8d32af14d6 feat: enforce PR review/merge workflow state machine gates (Closes #290)
Add fail-closed review/merge state progression helpers, pre-merge gate
requirements, blocked recovery handoff checks, and MCP assessment tool.

Closes #290
2026-07-07 09:14:29 -04:00
sysadmin 764e636c07 fix: resolve conflicts for PR #352 against current master 2026-07-07 09:14:04 -04:00
sysadmin c21e756a30 Merge pull request 'feat: prove reviewer worktree ownership before reset (Closes #312)' (#362) from feat/issue-312-worktree-ownership into master 2026-07-07 08:12:56 -05:00
sysadmin 6743cc11b9 feat: add native MCP preference gate and shell circuit breaker (Closes #270)
Enforce native MCP-first paths for Gitea mutations with fail-closed
assessment helpers, shell spawn circuit breaker tracking, and MCP tools
for operation-path assessment and shell health reporting.

Closes #270
2026-07-07 09:11:15 -04:00
sysadmin c20a93602d Merge pull request 'feat: block approval on full-suite failure without baseline proof (Closes #323)' (#361) from feat/issue-323-full-suite-approval-gate into master 2026-07-07 08:09:13 -05:00
sysadminandClaude Opus 4.8 6e774e191a feat: require pagination proof in reconciliation PR inventory (#308)
Add assess_reconcile_inventory_report verifier to block complete queue scan
and all-already-landed claims without final-page metadata, requested page size,
pages fetched, per-page counts, and no-next-page proof. Wire into
build_final_report and export from review_proofs.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 09:08:38 -04:00
sysadminandClaude Opus 4.8 852ac1f56b feat: comment-then-stop policy for partial PR reconciliation (Closes #302)
Already-landed PR reconciliation could prove a PR should be closed but
stop silently when gitea.pr.close was unavailable, even with comment
capability present. Adopt Option B (comment-then-stop) as the durable
policy and enforce it:

- resolve_partial_reconciliation_plan maps proven capabilities to one
  of four outcomes: FULL_RECONCILE_CLOSE_ALLOWED (close_pr proven),
  PARTIAL_RECONCILE_COMMENT_THEN_STOP (comment_pr proven, close_pr
  missing; one reconciliation comment with PR head SHA, target branch
  SHA, ancestor proof, linked issue status, and the missing capability,
  then stop), RECOVERY_HANDOFF_ONLY (no comment capability; no Gitea
  mutation), and GATE_NOT_PROVEN (no ancestry proof; no mutation
  regardless of capability). Missing capability dicts fail closed.
- assess_partial_reconciliation_report requires final reports to
  explain why the comment was or was not posted, name the missing
  capability, and state the close result or that no mutation ran.
- workflows/reconcile-landed-pr.md gains section 12A documenting the
  policy; the workflow contract test locks the new markers.

Tests cover close capability present, close missing with comment
allowed, comment capability missing, all capabilities missing,
unproven ancestry, fail-closed capability dict, and report checks for
each outcome.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:47:16 -04:00
sysadminandClaude Opus 4.8 012b8b387a feat: add dedicated reconciliation controller-handoff schema (Closes #303)
Add assess_reconciliation_handoff to review_proofs: already-landed PR
reconciliation runs must emit a dedicated handoff schema (selected PR,
live state, candidate head SHA, target branch + SHA, ancestor proof,
linked issue + live status, eligibility class
ALREADY_LANDED_RECONCILE_REQUIRED, capability proofs, per-category
mutation ledger, blocker, safe next action, no-review/merge
confirmation) instead of author or reviewer fields. Stale fields (PR
number opened, Pinned reviewed head, Scratch worktree used, Workspace
mutations, Issue lock proof, Claim/comment status) fail validation, a
wrong eligibility class fails, and an observed git fetch/pull must be
reported under Git ref mutations rather than claimed none. Fails
closed on a missing Controller Handoff section. Tests cover blocked
reconciliation, successful PR close, comment-only, no-op
already-closed, missing linked-issue proof, wrong eligibility class,
each stale field, and unreported observed fetch.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:45:51 -04:00
sysadminandClaude Opus 4.8 6779d903f2 feat: enforce branches-only author mutation worktree guard (Closes #274)
Add author_mutation_worktree assessment and integrate it into MCP
preflight so author mutations fail closed outside branches/ session
worktrees, with tests for control-checkout and branches-path cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:44:58 -04:00
sysadminandClaude Opus 4.8 3764ba0566 feat: require live linked issue proof in reconciliation handoffs (#300)
Add assess_reconcile_linked_issue_report verifier to block open/closed/resolved
linked issue status claims without gitea_view_issue proof in the current session,
require not-verified wording when the issue is missing, and gate recommended
issue close/reopen/comment actions on exact capability proof. Wire into
build_final_report and export from review_proofs.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:44:18 -04:00
sysadminandClaude Opus 4.8 2c65cf05ca feat: reject stale already-landed controller handoff fields (#299)
Add assess_already_landed_handoff_report verifier to block Pinned reviewed
head, Scratch worktree used, legacy Mutations/Workspace mutations None when
git fetch or other mutations occurred, and narrative/handoff drift after the
already-landed gate fires. Wire into build_final_report and review_proofs.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:42:08 -04:00
sysadminandClaude Opus 4.8 275a362a46 feat: add issue claim heartbeat leases and stale-claim reconciliation (Closes #268)
Structured claim/progress heartbeats post on issue claim and via
gitea_post_heartbeat. Read-only gitea_reconcile_issue_claims inventories
active, stale, phantom, and PR-backed claims; gitea_cleanup_stale_claims
supports dry-run cleanup of reclaimable labels.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:41:33 -04:00
sysadminandClaude Opus 4.8 1d3ee72274 feat: prove inventory pagination and worktree state in reviewer reports (#293)
Add assess_inventory_worktree_report verifier to reject partial PR inventory
claims, exactly-full first pages without finality proof, legacy scratch-worktree
flags that contradict branches/ review worktrees, and contradictory checkout
wording. Wire into build_final_report and export from review_proofs.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:39:43 -04:00
sysadminandClaude Opus 4.8 be1462dc03 feat: reject eligible/reviewed wording for already-landed PRs (Closes #298)
Add assess_already_landed_report_wording to review_proofs: when the
already-landed gate fires, final reports and controller handoffs must
use the reconciliation wording (Oldest open PR requiring action,
Eligibility class: ALREADY_LANDED_RECONCILE_REQUIRED, Candidate head
SHA, Reviewed head SHA: none, Review worktree used: false) and must not
use the legacy fields (oldest/next eligible PR, Pinned reviewed head,
Scratch worktree used) or claim a reviewed head SHA. In
non-already-landed reports, a populated Pinned reviewed head or
Reviewed head SHA fails unless validation and diff review actually
passed this session. Fails closed. Tests cover the correct
reconciliation wording, each forbidden legacy field, missing required
fields, populated reviewed SHA, normal reviewed reports, blocked-infra
reports, and validation-failed reports.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:38:32 -04:00
sysadmin 37dc3a38b8 fix: resolve conflicts for PR #352 2026-07-07 05:36:52 -04:00
sysadminandClaude Opus 4.8 876f1ee3a3 feat: hard-stop review mutations on already-landed PR heads (Closes #292)
A reviewer workflow approved and attempted to merge PR #278 although its
head commit was already an ancestor of master, ending in a Gitea 405 and
manual reconciliation. Add the missing pre-mutation gate:

- assess_already_landed_review_gate classifies the pinned candidate
  head against the fetched target branch: NORMAL_REVIEW_CANDIDATE,
  ALREADY_LANDED_RECONCILE_REQUIRED, or GATE_NOT_PROVEN (fail closed).
  Already-landed PRs block approval and the merge API, allow
  request-changes only for a real blocker, and require a reconciliation
  handoff (PR number/title, candidate head SHA, target branch + SHA,
  ancestor proof, linked issue status, recommended action, capability
  proof for close/comment mutations). Unchecked ancestry, invalid or
  missing SHAs, and a head changed since pinning all fail closed.
  Mergeability stays a separate gate.

- assess_already_landed_report_state rejects APPROVED / MERGED /
  READY_TO_MERGE wording and missing ALREADY_LANDED_RECONCILE_REQUIRED
  state when the session's gate fired, complementing the text-marker
  rules from #327 which need the report to admit the landed state.

Tests cover already-landed, normal, non-mergeable, changed-head,
unchecked-ancestry, and invalid-SHA gate paths plus all report-state
verdicts.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:36:10 -04:00
sysadmin 0cc3ed8868 feat: enforce author work leases (Closes #267) 2026-07-07 05:35:44 -04:00
sysadmin 15c9add3f4 fix: resolve conflicts for PR #358 2026-07-07 05:35:13 -04:00
sysadmin c278e16053 fix: resolve conflicts for PR #356 2026-07-07 05:35:13 -04:00
sysadmin 77b29fe417 fix: resolve conflicts for PR #362 2026-07-07 05:33:47 -04:00
sysadmin a0d68ef66e fix: resolve conflicts for PR #361 2026-07-07 05:33:47 -04:00
sysadmin 7c7aa1ac2d fix: resolve conflicts for PR #363 2026-07-07 05:33:14 -04:00
sysadminandClaude Opus 4.8 6be8f16351 Add infra-stop repair handoff verifier for blocked reviewers (#289)
Stop PR queue advancement when infra_stop blocks capability and require
CONTROL-CHECKOUT REPAIR MODE handoffs with infra diagnostics.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:33:12 -04:00
sysadminandClaude Opus 4.8 b71d3aeab3 Add precise mutation category verifier for controller handoffs (#319)
Reject legacy Workspace mutations wording and require explicit file,
worktree/index, and git ref mutation fields in reviewer controller handoffs.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:31:24 -04:00
sysadminandClaude Opus 4.8 ef7c15f84d fix: seed profile gate for TestIssueLockArtifactWarning (Closes #359)
gitea_lock_issue is profile-gated via task_capability_map
(gitea.issue.comment); without GITEA_ALLOWED_OPERATIONS the call
fail-closes before the artifact-warning logic runs, so the test
asserted success on a permission block. Seed the environment in
setUp/tearDown exactly like tests/test_mcp_server.py::TestIssueLocking
and complete the mocked git state with base_equivalent,
inspected_git_root, and base_branch so the newer lock-worktree
base-equivalence gate passes. The test still verifies the artifact
warning fires for '?? _emit_payload.py'. Full suite now passes with
zero failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:28:41 -04:00
sysadminandClaude Opus 4.8 b842a7f923 Add validation worktree no-edit verifier for reviewer reports (#315)
Block file edits in PR validation worktrees, require separate diagnostic
scratch worktrees with explicit labeling, and reject contradictory
File edits by reviewer: none claims.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:28:19 -04:00
sysadminandClaude Opus 4.8 ce3bd784c0 Add worktree ownership verifier for reviewer reset safety (#312)
Require session-owned review worktrees or documented safe-reuse proof
before destructive reset/validation. Block contradictory workspace-none
claims when git reset --hard occurred.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:26:37 -04:00
sysadminandClaude Opus 4.8 5fa5760dd0 feat: block approval on full-suite failure without baseline proof (Closes #323)
Approving a PR whose full test suite fails is now gated behind complete
baseline proof. New assess_full_suite_failure_approval_gate:

- Full-suite pass: approval allowed, no baseline required.
- Full-suite failure: default action is REQUEST_CHANGES; approval is
  blocked unless the #325 baseline validation proof passes (branches/
  worktree, pinned baseline/PR SHAs, matching failure signatures,
  clean before/after) and the proof also carries the PR and baseline
  full-suite commands, evidence that new/changed tests passed, and a
  non-empty explanation of why the failures are unrelated to the PR.
- Unknown/unproven full-suite result blocks approval.
- Main-checkout baseline comparison is rejected.
- Vague "same as master" claims without proof are rejected via the
  existing pre-existing-failure claim detection.
- Non-approve decisions are never blocked; the gate only reports the
  request-changes default.

Tests cover matching baseline failures, mismatched signatures,
incomplete proof (missing commands, new-test evidence, explanation),
main-checkout baseline, unknown suite result, vague claims, and
full-suite pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:24:07 -04:00
sysadminandClaude Opus 4.8 4ea618e8b4 Add validation integrity verifier for PR-head vs diagnostic tests (#316)
Separate official PR-head validation from post-edit diagnostic runs in
reviewer reports. Block treating diagnostic passes as official validation
and require contaminated sessions to report recovery-required status.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:19:22 -04:00
sysadminandClaude Opus 4.8 71b8d00897 feat: classify merge simulation as worktree/index mutations (Closes #317)
Add assess_merge_simulation_report to reject local merge simulations listed
as read-only diagnostics and require Worktree/index mutations documentation
with worktree path, pre/merge/abort/post-clean proof when simulation
commands appear in the session log.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:13:30 -04:00
sysadminandClaude Opus 4.8 da9c0ba54c feat: enforce validation environment proof in reviewer reports (Closes #311)
Add assess_validation_environment_proof requiring exact validation command,
working directory, result evidence, and bare-pytest executable/version/venv
proof before reviewer reports can claim validation success.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:08:42 -04:00
167 changed files with 31682 additions and 646 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
+142
View File
@@ -0,0 +1,142 @@
"""Already-landed open PR reconciliation gates (#310).
Reconciler workflows may close an open PR only when the PR head SHA is proven
an ancestor of a freshly fetched target branch. Arbitrary PR closure is denied.
"""
from __future__ import annotations
import subprocess
from typing import Any
from merged_cleanup_reconcile import extract_linked_issue, is_head_ancestor_of_ref
ELIGIBILITY_ALREADY_LANDED = "ALREADY_LANDED_RECONCILE_REQUIRED"
ELIGIBILITY_NOT_LANDED = "NOT_ALREADY_LANDED"
ELIGIBILITY_STALE_TARGET = "TARGET_BRANCH_UNVERIFIED"
ELIGIBILITY_PR_NOT_OPEN = "PR_NOT_OPEN"
def fetch_target_branch(project_root: str, remote: str, branch: str) -> dict[str, Any]:
"""Fetch *branch* from *remote* and return the resolved SHA."""
ref = f"{remote}/{branch}"
fetch = subprocess.run(
["git", "-C", project_root, "fetch", remote, branch],
capture_output=True,
text=True,
check=False,
)
if fetch.returncode != 0:
return {
"success": False,
"target_branch": branch,
"target_ref": ref,
"target_branch_sha": None,
"reasons": [
f"git fetch {remote} {branch} failed: "
f"{(fetch.stderr or fetch.stdout or '').strip()}"
],
}
rev = subprocess.run(
["git", "-C", project_root, "rev-parse", ref],
capture_output=True,
text=True,
check=False,
)
if rev.returncode != 0:
return {
"success": False,
"target_branch": branch,
"target_ref": ref,
"target_branch_sha": None,
"reasons": [
f"git rev-parse {ref} failed: "
f"{(rev.stderr or rev.stdout or '').strip()}"
],
}
return {
"success": True,
"target_branch": branch,
"target_ref": ref,
"target_branch_sha": (rev.stdout or "").strip(),
"reasons": [],
"git_fetch_command": f"git fetch {remote} {branch}",
}
def assess_already_landed_reconciliation(
*,
pr: dict[str, Any],
project_root: str,
remote: str,
target_branch: str,
target_fetch: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Return eligibility and proof for reconciling an open PR."""
pr_number = int(pr.get("number") or 0)
pr_state = (pr.get("state") or "").strip().lower()
head_sha = (pr.get("head") or {}).get("sha") if isinstance(pr.get("head"), dict) else None
if not head_sha and isinstance(pr.get("head"), str):
head_sha = None
head_ref = (pr.get("head") or {}).get("ref") if isinstance(pr.get("head"), dict) else pr.get("head")
base_ref = (pr.get("base") or {}).get("ref") if isinstance(pr.get("base"), dict) else pr.get("base")
title = pr.get("title") or ""
body = pr.get("body") or ""
fetch_result = target_fetch or fetch_target_branch(project_root, remote, target_branch)
linked_issue = extract_linked_issue(title, body)
result: dict[str, Any] = {
"pr_number": pr_number,
"pr_state": pr_state,
"candidate_head_sha": head_sha,
"head_ref": head_ref,
"base_ref": base_ref or target_branch,
"target_branch": target_branch,
"target_branch_sha": fetch_result.get("target_branch_sha"),
"linked_issue": linked_issue,
"git_ref_mutations": [],
"reasons": [],
}
if fetch_result.get("git_fetch_command"):
result["git_ref_mutations"].append(fetch_result["git_fetch_command"])
if pr_state != "open":
result["eligibility_class"] = ELIGIBILITY_PR_NOT_OPEN
result["ancestor_proof"] = None
result["close_allowed"] = False
result["reasons"].append(f"PR #{pr_number} state is {pr_state!r}, not open")
return result
if not fetch_result.get("success"):
result["eligibility_class"] = ELIGIBILITY_STALE_TARGET
result["ancestor_proof"] = None
result["close_allowed"] = False
result["reasons"].extend(fetch_result.get("reasons") or [])
return result
target_ref = fetch_result.get("target_ref") or f"{remote}/{target_branch}"
ancestor = is_head_ancestor_of_ref(project_root, head_sha, target_ref)
result["ancestor_proof"] = ancestor
if ancestor is None:
result["eligibility_class"] = ELIGIBILITY_STALE_TARGET
result["close_allowed"] = False
result["reasons"].append(
f"ancestor check failed for head {head_sha!r} against {target_ref}"
)
return result
if ancestor:
result["eligibility_class"] = ELIGIBILITY_ALREADY_LANDED
result["close_allowed"] = True
return result
result["eligibility_class"] = ELIGIBILITY_NOT_LANDED
result["close_allowed"] = False
result["reasons"].append(
f"PR head {head_sha} is not an ancestor of {target_ref}"
)
return result
+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, []
+234
View File
@@ -0,0 +1,234 @@
"""Branches-only author mutation worktree guard (#274).
Author/coder mutations must run from a session-owned worktree under the
project's ``branches/`` directory, never from the stable control checkout.
"""
from __future__ import annotations
import os
import subprocess
BASE_BRANCHES = frozenset({"master", "main", "dev"})
ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE"
AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE"
# Author-only: reviewer/merger/reconciler namespaces use role-specific env vars
# via namespace_workspace_binding (#510).
def _normalize_path(path: str) -> str:
return (path or "").replace("\\", "/").rstrip("/")
def is_path_under_branches(path: str, project_root: str | None = None) -> bool:
"""True when *path* resolves inside ``<project_root>/branches/``."""
normalized = _normalize_path(path)
if not normalized:
return False
if "/branches/" in f"{normalized}/":
return True
if normalized.endswith("/branches"):
return True
if project_root:
root = _normalize_path(os.path.realpath(project_root))
real = _normalize_path(os.path.realpath(path))
if real.startswith(f"{root}/"):
rel = real[len(root) + 1 :]
return rel == "branches" or rel.startswith("branches/")
return False
def resolve_mutation_workspace(
worktree_path: str | None,
project_root: str,
*,
active_worktree_env: str | None = None,
author_worktree_env: str | None = None,
) -> str:
"""Resolve the workspace path inspected before author mutations."""
for candidate in (worktree_path, active_worktree_env, author_worktree_env):
text = (candidate or "").strip()
if text:
return os.path.realpath(os.path.abspath(text))
return os.path.realpath(project_root)
def _realpath_git_common_dir(workspace_path: str, common_dir: str) -> str:
"""Resolve ``git rev-parse --git-common-dir`` relative to *workspace_path*."""
raw = (common_dir or "").strip()
if not raw:
return raw
if os.path.isabs(raw):
return os.path.realpath(raw)
return os.path.realpath(os.path.join(workspace_path, raw))
def resolve_canonical_repo_root(workspace_path: str, fallback_project_root: str) -> str:
"""Return the stable repository root for *workspace_path* via git metadata (#460)."""
path = (workspace_path or "").strip()
fallback = os.path.realpath(fallback_project_root)
if not path:
return fallback
try:
res = subprocess.run(
["git", "-C", path, "rev-parse", "--git-common-dir"],
capture_output=True,
text=True,
check=True,
)
common = _realpath_git_common_dir(path, res.stdout)
except Exception:
return fallback
if common.endswith(f"{os.sep}.git"):
return os.path.dirname(common)
if os.path.basename(common) == ".git":
return os.path.dirname(common)
return fallback
def resolve_author_mutation_context(
worktree_path: str | None,
process_project_root: str,
*,
active_worktree_env: str | None = None,
author_worktree_env: str | None = None,
) -> dict:
"""Shared workspace resolution for runtime_context and mutation guards (#460)."""
workspace = resolve_mutation_workspace(
worktree_path,
process_project_root,
active_worktree_env=active_worktree_env,
author_worktree_env=author_worktree_env,
)
process_root = os.path.realpath(process_project_root)
# Canonical repository identity comes from the MCP process checkout (#460),
# not from the declared task workspace being validated.
canonical_root = resolve_canonical_repo_root(process_root, process_root)
return {
"workspace_path": workspace,
"process_project_root": process_root,
"canonical_repo_root": canonical_root,
"roots_aligned": canonical_root == process_root,
}
def assess_workspace_repo_membership(
*,
workspace_path: str,
canonical_repo_root: str,
) -> dict:
"""Fail closed when *workspace_path* is not a git worktree of *canonical_repo_root*."""
workspace = os.path.realpath(workspace_path)
root = os.path.realpath(canonical_repo_root)
reasons: list[str] = []
if not os.path.exists(workspace):
reasons.append(f"worktree path '{workspace}' does not exist")
return _membership_assessment(False, reasons, workspace, root, None)
if not os.path.isdir(workspace):
reasons.append(f"worktree path '{workspace}' is not a directory")
return _membership_assessment(False, reasons, workspace, root, None)
try:
res = subprocess.run(
["git", "-C", workspace, "rev-parse", "--git-common-dir"],
capture_output=True,
text=True,
check=True,
)
common_dir = _realpath_git_common_dir(workspace, res.stdout)
except Exception:
reasons.append(f"worktree '{workspace}' is not a valid git repository")
return _membership_assessment(False, reasons, workspace, root, None)
expected_dir = os.path.realpath(os.path.join(root, ".git"))
if common_dir != expected_dir:
reasons.append(
f"worktree '{workspace}' does not belong to the target repository '{root}'"
)
return _membership_assessment(not reasons, reasons, workspace, root, common_dir)
def _membership_assessment(
proven: bool,
reasons: list[str],
workspace: str,
root: str,
common_dir: str | None,
) -> dict:
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"workspace_path": workspace,
"canonical_repo_root": root,
"git_common_dir": common_dir,
}
def format_workspace_repo_membership_error(assessment: dict) -> str:
workspace = assessment.get("workspace_path") or "(unknown)"
root = assessment.get("canonical_repo_root") or "(unknown)"
reasons = "; ".join(assessment.get("reasons") or ["unknown repository membership violation"])
return (
f"Branches-only mutation guard (#274): {reasons} (fail closed). "
f"canonical repository root: {root}; workspace: {workspace}."
)
def assess_author_mutation_worktree(
*,
workspace_path: str,
project_root: str,
current_branch: str | None = None,
base_branches: frozenset[str] | None = None,
) -> dict:
"""Fail closed when author mutations are not rooted under ``branches/``."""
bases = base_branches or BASE_BRANCHES
reasons: list[str] = []
root = os.path.realpath(project_root)
workspace = os.path.realpath(workspace_path)
branch = (current_branch or "").strip()
under_branches = is_path_under_branches(workspace, root)
if not under_branches:
if workspace == root:
reasons.append(
"author mutation blocked: workspace is the stable control checkout; "
"create or switch to a session-owned worktree under branches/"
)
else:
reasons.append(
f"author mutation blocked: workspace '{workspace}' is not under "
f"'{root}/branches/'; create a branches/<task> worktree first"
)
if not under_branches and workspace == root and branch and branch not in bases:
reasons.append(
f"control checkout drift: branch '{branch}' is not a stable base "
f"branch ({'/'.join(sorted(bases))})"
)
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"project_root": root,
"workspace_path": workspace,
"under_branches": is_path_under_branches(workspace, root),
"current_branch": branch or None,
}
def format_author_mutation_worktree_error(assessment: dict) -> str:
"""Single RuntimeError message for MCP preflight gates."""
workspace = assessment.get("workspace_path") or "(unknown)"
root = assessment.get("project_root") or "(unknown)"
reasons = "; ".join(assessment.get("reasons") or ["unknown branches-only violation"])
return (
f"Branches-only mutation guard (#274): {reasons}. "
f"project root: {root}; workspace: {workspace}. "
"Create a session-owned worktree under branches/ before mutating."
)
+2
View File
@@ -13,6 +13,8 @@ REVIEWER_CAPABILITY_TASKS = frozenset({
"review_pr",
"merge_pr",
"blind_pr_queue_review",
"pr_queue_cleanup",
"pr-queue-cleanup",
"request_changes_pr",
"approve_pr",
})
+1
View File
@@ -23,6 +23,7 @@ launched with exactly one static execution profile:
|-----------------------------|----------------|-------------|
| `gitea-author` | an author profile | implement issues, push branches, open PRs, comment |
| `gitea-reviewer` | a reviewer profile | review, approve/request changes, merge |
| `gitea-reconciler` | a reconciler profile | close already-landed open PRs after ancestry proof (#304 profile; #310 close tool) |
Properties:
+25
View File
@@ -226,6 +226,31 @@ explicit operator-directed closure of a contaminated PR). If `close_pr` ever
resolves as unknown, agents must fail closed rather than fall back to the
edit path.
## Reconciler profile for already-landed open PRs (#304 / #310)
Normal author and reviewer profiles must not gain broad `gitea.pr.close`
authority. Already-landed open PRs (head SHA is an ancestor of the target
branch) need a dedicated reconciler profile such as `prgs-reconciler` with a
narrow operation set:
- `gitea.read`
- `gitea.pr.comment`
- `gitea.issue.comment`
- `gitea.issue.close`
- `gitea.pr.close`
Forbidden on reconciler profiles: `gitea.pr.approve`, `gitea.pr.merge`,
`gitea.pr.review`, `gitea.pr.create`, `gitea.branch.push`, and
`gitea.repo.commit`.
Launch a static `gitea-reconciler` MCP namespace with
`GITEA_MCP_PROFILE=prgs-reconciler`. Profile shape is validated by
`reconciler_profile.assess_reconciler_profile` (#304). Use the
`gitea_reconcile_already_landed_pr` tool (#310). The resolver task is
`reconcile_already_landed_pr`. PR close is allowed only after live PR fetch,
fresh target-branch fetch, recorded target SHA, and ancestor proof. PRs whose
heads are not already landed cannot be closed through this path.
## Identity and fail-closed rules
Before **any** mutating action, a workflow must know both:
+209 -1
View File
@@ -169,6 +169,25 @@ To avoid the bottleneck of relaunching/restarting the MCP server to switch betwe
* `mcp__gitea-reviewer__*` (for reviewing PRs, approving, requesting changes, merging)
* **Trust Model:** Separate tokens remain separate in the keychain/environment. Each instance operates under its own `GITEA_MCP_PROFILE` and enforces its own `allowed_operations`. A runtime `whoami` identity check is still performed independently, and self-review/self-merge checks remain strictly mandatory. The dual-server pattern is a operational convenience and never a security bypass.
* **Reviewer-Identity PR Creation Deadlock:** Reviewer/merge identities must not create PRs or push branches. Doing so makes the reviewer identity the PR author in Gitea, blocking subsequent independent review and causing a review deadlock. Normally, PRs must be created by the author/work identity (`gitea-author`), leaving the reviewer identity (`gitea-reviewer`) clean and available for independent review and merge.
* **Reconciler namespace (#310):** Register a third static instance for
already-landed PR cleanup when review queues block on open PRs whose heads
already landed on `master`:
```json
"gitea-reconciler": {
"command": "/path/to/Gitea-Tools/venv/bin/python3",
"args": ["/path/to/Gitea-Tools/mcp_server.py"],
"env": {
"GITEA_MCP_CONFIG": "/path/to/.config/gitea-tools/profiles.json",
"GITEA_MCP_PROFILE": "prgs-reconciler"
}
}
```
The reconciler profile grants `gitea.pr.close` only for
`gitea_reconcile_already_landed_pr` after ancestry proof — not for normal
review or author workflows.
* **Fallback:** If the dual-profile MCP launcher pattern is not supported or configured in the client, the LLM must relaunch or restart the client/MCP with the correct profile environment variable before claiming or working on any tasks.
## Setup runbook — interactive menu
@@ -254,7 +273,80 @@ Never create a parallel branch or PR for the same issue unless the old branch
is proven abandoned and the takeover is recorded.
Gitea-Tools lease gates: `gitea_lock_issue` (fail-closed before author
mutations), `status:in-progress`, and claim comments. Full portable wording:
mutations), `status:in-progress`, and claim comments. `gitea_lock_issue`
records an `author_issue_work` lease in a keyed lock file under
`GITEA_ISSUE_LOCK_DIR` (default `~/.cache/gitea-tools/issue-locks`), one file
per `remote` + `org` + `repo` + `issue_number`. The current MCP session binds
its active lock through a per-process pointer so concurrent repos/issues never
share one overwrite-prone slot (#443).
Each lock payload includes issue number, optional PR number, branch, worktree
path, claimant identity/profile, created timestamp, expiry timestamp, and last
heartbeat timestamp. An active same-issue/same-operation lease blocks duplicate
work. An expired lease still blocks takeover until a recovery review records why
the prior work is abandoned, completed, or unsafe to continue.
**Stacked PRs (#484).** By default the lock worktree must be base-equivalent to
`master`/`main`/`dev` — ordinary work is unchanged. A *stacked* PR (deliberately
based on another unmerged PR's branch) is an explicit, opt-in path: pass
`stacked_base_branch` **and** `stacked_base_pr` to `gitea_lock_issue`. The lock
fails closed unless that branch is owned by a live **open** PR whose number
matches `stacked_base_pr`, so arbitrary or stale branches cannot be used as
bases. When approved, the lock payload records
`approved_stacked_base = {branch, pr_number, verified_open}` and the worktree may
be base-equivalent to that branch instead of master. `gitea_create_pr` then
allows `base = <that branch>` only when it matches the recorded approval, the
dependency PR is **still open**, and the PR body documents the stack:
- `Stacked on PR #<X> / issue #<Y>`
- `Base branch: <feature-branch>`
- `Head branch: <this-issue-branch>`
- `Do not merge before PR #<X>` (merge ordering)
- retarget/rebase to `master` after the dependency lands, if required
Stacked support never bypasses the issue lock — the base is recorded *on* the
lock and re-verified at PR time. A merged/closed dependency base fails closed;
retarget onto `master` or re-lock against a live base.
**Do not manually seed `/tmp/gitea_issue_lock.json` or any lock file as a normal
recovery path.** That global slot is deprecated and can clobber unrelated live
leases (#438). After an MCP restart, call `gitea_lock_issue` again — own-branch
adoption rebinds the session when the issue's exact branch already exists (#442).
`gitea_create_pr` resolves the durable keyed lock by session pointer or by
matching `head` branch without unsafe manual seeding.
**Issue-lock recovery (#447):** Do not manually seed, restore, or delete
`/tmp/gitea_issue_lock.json` as a normal recovery path. That file is global
shared state and manual writes can clobber another session's live lease. Use
`sanctioned recovery` instead:
1. `gitea_lock_issue` on a clean `branches/` worktree (normal path).
2. Own-branch adoption via #442 when the issue's exact branch is already pushed.
3. Operator override only when explicitly authorized — record
`External-state mutations` and `operator override proof` in the final report.
**Adoption proof in the live lock response (#477):** when `gitea_lock_issue`
adopts an existing own branch, the response carries an `adoption` block with
citable fields — `adoption_decision` (`ADOPT`), `adopted` (`true`),
`adopted_branch`, `adopted_branch_head`, `matcher_summary` (boundary-safe reason
the branch qualified), `competing_branch_check`, and `safe_next_action`. A normal
lock instead returns an `adoption_check` block with `adoption_decision`
(`NO_MATCH`) and `adopted: false`, so a non-adoption response can never be misread
as claiming adoption. Recovery reports should quote the live lock response
`adoption`/`adoption_check` block directly instead of inferring adoption from
separate offline checks.
`gitea_create_pr` rejects lock files that lack sanctioned `lock_provenance`
metadata. Final-report validation blocks handoffs that hide lock read/write/delete
under `External-state mutations: none` or mix author PR creation with reviewer
approval in one run. See also #438 (global lock redesign).
Remote branches matching the issue number are also treated as active work unless
the recovery review proves the branch is abandoned or superseded. Never delete
or clean up a branch when it has an active lease, dirty worktree, open PR, or is
the only copy of unmerged work.
Full portable wording:
[`skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md).
## Global LLM Worktree Rule
@@ -285,6 +377,37 @@ explicit control-checkout repair.
Portable wording: [`skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md).
### Root checkout guard (#475)
The MCP server enforces a fail-closed **root checkout guard** before author,
reviewer, and merger mutations when the active workspace is not an isolated
`branches/...` worktree (reconciler close paths remain exempt per #468).
The guard blocks when the **control checkout** (repository root) is:
- on a non-stable branch (`master` / `main` / `dev` expected),
- detached HEAD,
- dirty (tracked edits),
- or its `HEAD` does not match `prgs/master` when that ref is available.
**Remediation (never auto-reset or stash):**
> Root checkout is not on master. Preserve state, switch root back to master,
> and use `scripts/worktree-review` or the sanctioned issue worktree flow.
**Recovery after root hijack:**
1. Preserve any in-progress edits (copy paths, note branch name, or commit on a
rescue branch from a `branches/...` worktree).
2. From the repository root: `git checkout master` (or `main` / `dev` per repo
policy) and `git fetch prgs && git merge --ff-only prgs/master` when safe.
3. Confirm `git status` is clean and `git branch --show-current` is `master`.
4. Resume work only inside `branches/issue-<n>-<slug>` via `gitea_lock_issue` /
`git worktree add`.
`branches/...` directories are disposable role worktrees; the root checkout is
the stable orchestration surface only.
## Shell Spawn Hard-Stop Rule
Symptom: a shell tool call returns `exit_code: -1` with empty stdout/stderr.
@@ -549,6 +672,33 @@ loop and do **not** substitute WebFetch/Playwright/manual base64.
- **Prompt:** `Use any eligible merger profile to merge PR #N if checks pass and
it is mergeable. Confirm with "MERGE PR N". Do not force-merge.`
#### Merger lease adoption (#536)
When review and merge run in **separate sessions**, the merger must **not**
manually seed `reviewer_pr_lease._SESSION_LEASE` or equivalent in-process state.
That ad hoc pattern was used incidentally for PR #493 and PR #421; it is not
canonical proof and is rejected by mutation gates.
**Canonical merger handoff:**
1. Confirm a reviewer session holds an active PR lease and posted **APPROVED**
at the current live head (`gitea_get_pr_review_feedback`).
2. In a clean merger worktree under `branches/`, call
`gitea_adopt_merger_pr_lease` with `worktree`, `expected_head_sha`, and
optional `issue_number`.
3. The tool posts durable adoption proof on the PR thread (`<!-- 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.
@@ -731,6 +881,45 @@ scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md
scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push
```
## Namespace workspace binding (#510)
Each MCP namespace resolves its **own** active task workspace. Foreign role
worktree environment variables must not poison another namespace's purity
checks.
| Namespace | Workspace env vars (in priority under `GITEA_ACTIVE_WORKTREE`) | Allowed roots |
|-----------|------------------------------------------------------------------|---------------|
| author | `GITEA_AUTHOR_WORKTREE` | `branches/<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
@@ -749,3 +938,22 @@ scripts/release-tag v0.4.0 --notes-file /tmp/release-notes.md --push
- [`credential-isolation.md`](credential-isolation.md) — credential handling.
- [`release-workflows.md`](release-workflows.md) — release/merge workflow.
- [`../README.md`](../README.md) — canonical config, thin launchers, the menu.
## PR-only queue cleanup mode (#390)
Use `pr-queue-cleanup` mode when the queue holds many open PRs and the goal
is to drain reviews deterministically. One run = one PR = one canonical
review (`skills/llm-project-workflow/workflows/pr-queue-cleanup.md`).
* Cleanup runs are reviewer-role only (`pr_queue_cleanup` resolver task);
author sessions get `wrong_role_stop`.
* Forbidden in cleanup mode: issue claiming, branch creation, implementation
edits, new issue filing, and any second-PR review after a terminal
mutation.
* Terminal chain: stop after `REQUEST_CHANGES`; after `APPROVED` continue
only to same-PR merge with explicit per-PR operator authorization and
passing gates; stop after merge or merge blocker.
* Every run reports the next suggested PR without continuing to it; the next
PR requires a fresh run with fresh identity/capability/inventory proof.
* Use full `review-merge-pr` mode instead when the operator wants a single
targeted review, and `work-issue` mode for any authoring.
+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`.
+89
View File
@@ -0,0 +1,89 @@
# Internal web UI — local development (#426)
Read-only MVP skeleton for the MCP Control Plane operator console. Gitea,
MCP capability gates, and `skills/llm-project-workflow/` remain the source of
truth; this UI only provides route stubs and layout.
## Prerequisites
- Python 3.11+ with project dependencies installed (`pip install -r requirements.txt`)
- No secrets in repo, config, or client bundle
## Start the server
From the repository root (or an issue worktree):
```bash
./scripts/run-webui
```
Or directly:
```bash
python3 -m webui
```
Optional environment variables:
| Variable | Default | Purpose |
|----------|---------|---------|
| `WEBUI_HOST` | `127.0.0.1` | Bind address (keep local for MVP) |
| `WEBUI_PORT` | `8765` | Listen port |
## Routes (MVP)
| Path | Description |
|------|-------------|
| `/` | Home / operator overview |
| `/health` | JSON liveness (`status`, `service`, `mode`, `timestamp`) |
| `/queue` | Live PR and issue queue dashboard (#429) |
| `/api/queue` | JSON queue export with pagination metadata |
| `/projects` | Project registry list (#427) |
| `/projects/{id}` | Project detail + onboarding checklist |
| `/api/projects` | JSON registry export |
| `/prompts` | Prompt library with per-prompt copy buttons (#428) |
| `/api/prompts` | JSON prompt export with workflow hashes |
| `/runtime` | Stub — MCP runtime health (#430) |
| `/audit` | Stub — report audit paste (#431) |
| `/worktrees` | Stub — hygiene dashboard (#432) |
| `/leases` | Stub — lease visibility (#433) |
All routes are GET-only. POST/PUT/PATCH/DELETE return `405` with
`read-only-mvp`.
## Project registry (#427)
Versioned registry file: `webui/data/projects.registry.json` (schema version `1`).
Override path with `WEBUI_PROJECT_REGISTRY` when operators keep a machine-local
copy outside git. The registry stores repo identity, remotes, profile names,
workflow/schema path references, and onboarding checklist steps — never tokens
or credentials.
Seed entry: **Gitea-Tools** on `https://gitea.prgs.cc` with `prgs-author`,
`prgs-reviewer`, and `prgs-reconciler` profiles.
## Prompt library (#428)
Prompts are generated at load time from canonical workflow files under
`skills/llm-project-workflow/workflows/`. SHA-256 hashes are computed from
`WEBUI_REPO_ROOT` (defaults to the repository root). Prompt bodies are short
copy/paste starters; canonical workflow files remain the only full policy
source.
## Live queue dashboard (#429)
`/queue` loads open PRs and issues for the default registry project (seed:
**Gitea-Tools** on `https://gitea.prgs.cc`) using existing `gitea_auth` read
credentials. The UI surfaces pagination proof (returned count, pages fetched,
`has_more`, `inventory_complete`) and classification badges (`claimed`,
`blocked`, `in-review`, `duplicate`) when evidence exists.
If credentials are missing or the fetch fails, the page shows an explicit error
instead of an empty queue (fail closed).
## Tests
```bash
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py -q
```
+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
+5 -1
View File
@@ -17,6 +17,10 @@
0. Work Selection Rule — verify a work lease before any mutations (open PRs,
issue-linked PRs, branches, worktrees, dirty worktrees, active leases/
handoffs, merged-PR completion). Stop if another session owns the lease.
`gitea_lock_issue` records an operation-scoped `author_issue_work` lease
with issue, branch, worktree, claimant, created, expiry, and heartbeat
fields; active or expired same-operation leases require recovery review
before takeover.
0b. Global LLM Worktree Rule — main checkout on `master`/`main`/`dev` only;
mutate only from a `branches/` worktree after proving root, cwd, branch,
stable main-checkout branch, and session worktree path (no exceptions).
@@ -31,4 +35,4 @@
Wiki publication, credential provisioning, and cross-repo mirror operations are
operator-confirmed actions — see [Runbooks](Runbooks.md).
Full Gitea-specific runbooks: `docs/llm-workflow-runbooks.md` in the repository.
Full Gitea-specific runbooks: `docs/llm-workflow-runbooks.md` in the repository.
+523 -7
View File
@@ -11,6 +11,9 @@ import inspect
import re
from typing import Any, Callable
import issue_lock_provenance
from mcp_native_cleanup_proof import assess_mcp_native_cleanup_proof
from post_merge_cleanup_proof import assess_post_merge_cleanup_proof
from review_proofs import (
HANDOFF_HEADING,
assess_controller_handoff,
@@ -20,6 +23,7 @@ from review_proofs import (
assess_review_mutation_final_report,
assess_validation_report,
)
from validation_status_vocabulary import assess_validation_status_vocabulary
FINAL_REPORT_TASK_KINDS = frozenset({
"review_pr",
@@ -87,25 +91,88 @@ _ALREADY_LANDED_RE = re.compile(
r"already[- ]landed|ancestor proof\s*:\s*passed|eligibility class\s*:\s*already_landed",
re.IGNORECASE,
)
_ALREADY_LANDED_GATE_FIRED_RE = re.compile(
r"\b(?:fired|passed|true|yes|already_landed_reconcile_required)\b",
re.IGNORECASE,
)
_ELIGIBLE_REVIEW_RE = re.compile(
r"eligible for (?:review|merge)|ready for (?:review|merge)|next eligible pr",
r"eligible for (?:review|merge)|ready for (?:review|merge)|"
r"(?:next|oldest)\s+eligible\s+pr",
re.IGNORECASE,
)
_APPROVED_STATE_RE = re.compile(r"\bapproved?\b", re.IGNORECASE)
_MERGED_STATE_RE = re.compile(r"\bmerged\b", re.IGNORECASE)
_READY_TO_MERGE_STATE_RE = re.compile(r"\bready_to_merge\b|\bready to merge\b", re.IGNORECASE)
_NEGATED_STATE_RE = re.compile(
r"\b(?:not|no|none|n/a|never)\b|request_changes|not attempted",
re.IGNORECASE,
)
_REVIEWED_LANDED_RE = re.compile(
r"(?:reviewed|approved).{0,40}already[- ]landed|already[- ]landed.{0,40}(?:reviewed|eligible)",
re.IGNORECASE | re.DOTALL,
)
_TARGET_BRANCH_UP_TO_DATE_RE = re.compile(
r"\btarget branch (?:is )?up[- ]to[- ]date\b",
re.IGNORECASE,
)
_TARGET_BRANCH_SHA_RE = re.compile(
r"target branch sha\s*:\s*[0-9a-f]{40}",
re.IGNORECASE,
)
_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",
"pinned reviewed head",
"scratch worktree used",
"review decision",
"merge result",
"issue lock proof",
"claim/comment status",
"workspace mutations",
)
# Issue #307: reconciliation handoffs must carry the full canonical schema.
_RECONCILE_REQUIRED_FIELDS = (
"Task",
"Repo",
"Role/profile",
"Identity",
"Selected PR",
"Eligibility class",
"PR live state",
"Candidate head SHA",
"Target branch",
"Target branch SHA",
"Ancestor proof",
"Linked issue",
"Linked issue live status",
"Eligibility class",
"Capabilities proven",
"Missing capabilities",
"PR comments posted",
"Issue comments posted",
"PRs closed",
"Issues closed",
"Git ref mutations",
"Worktree mutations",
"MCP/Gitea mutations",
"External-state mutations",
"Read-only diagnostics",
"Blocker",
"Safe next action",
"No review/merge confirmation",
)
@@ -190,6 +257,54 @@ def _handoff_fields(report_text: str) -> dict[str, str]:
return fields
def _already_landed_gate_fired(report_text: str) -> bool:
text = report_text or ""
fields = _handoff_fields(text)
gate_value = fields.get("already-landed gate", "")
if gate_value and _ALREADY_LANDED_GATE_FIRED_RE.search(gate_value):
return True
ancestor_value = fields.get("ancestor proof", "")
if re.search(r"\bpassed\b", ancestor_value, re.IGNORECASE):
return True
eligibility_value = fields.get("eligibility class", "")
if "already_landed" in eligibility_value.lower():
return True
if re.search(
r"\bpr\b.{0,60}\balready[- ]landed\b|\balready[- ]landed\b.{0,60}\bpr\b",
text,
re.IGNORECASE | re.DOTALL,
):
return True
return bool(_ALREADY_LANDED_RE.search(text))
def _positive_review_state_terms(fields: dict[str, str]) -> list[str]:
terms: list[str] = []
for key, value in fields.items():
lowered_key = key.lower()
lowered_value = value.lower()
if _NEGATED_STATE_RE.search(value):
continue
if lowered_key == "review decision" and _APPROVED_STATE_RE.search(value):
terms.append("APPROVED")
elif lowered_key == "merge result" and _MERGED_STATE_RE.search(value):
terms.append("MERGED")
elif _READY_TO_MERGE_STATE_RE.search(value):
terms.append("READY_TO_MERGE")
elif lowered_value in {"approved", "approve"}:
terms.append("APPROVED")
elif lowered_value == "merged":
terms.append("MERGED")
elif lowered_value == "ready_to_merge":
terms.append("READY_TO_MERGE")
return sorted(set(terms))
def _rule_shared_controller_handoff(
report_text: str,
task_kind: str,
@@ -346,19 +461,151 @@ def _rule_reviewer_git_fetch_readonly(
"classify git fetch under Git ref mutations, not read-only diagnostics",
)
]
if not git_ref_reported and _GIT_FETCH_RE.search(text):
if not git_ref_reported:
# Issue #307: an observed git fetch (report text or action log)
# must be listed under Git ref mutations.
return [
validator_finding(
"reviewer.git_fetch_readonly",
"downgrade",
"Git ref mutations",
"git fetch mentioned without Git ref mutation classification",
"git fetch occurred without Git ref mutation classification",
"record git fetch under Git ref mutations",
)
]
return []
def _rule_reviewer_validation_failure_history(
report_text: str,
*,
validation_session: dict | None = None,
) -> list[dict[str, str]]:
from reviewer_validation_failure_history import (
assess_validation_failure_history_report,
)
session = validation_session or {}
observed = session.get("observed_failures") or []
if not observed:
return []
result = assess_validation_failure_history_report(
report_text,
validation_session=session,
)
if result.get("proven"):
return []
severity = "block" if result.get("violations") else "downgrade"
return [
validator_finding(
"reviewer.validation_failure_history",
severity,
"Validation failure history",
reason,
result.get("safe_next_action")
or "document every observed validation failure before claiming pass",
)
for reason in (result.get("violations") or result.get("reasons") or ["incomplete"])
]
def _rule_reviewer_validation_cwd_proof(
report_text: str,
*,
validation_session: dict | None = None,
) -> list[dict[str, str]]:
from reviewer_validation_cwd_proof import assess_validation_cwd_proof_report
session = validation_session or {}
claims = (
session.get("validation_ran")
or session.get("command")
or session.get("baseline_validation_ran")
)
if not claims and "validation command:" not in (report_text or "").lower():
return []
result = assess_validation_cwd_proof_report(
report_text,
validation_session=session,
)
if result.get("proven") or not result.get("claims_validation"):
return []
severity = "block" if result.get("violations") else "downgrade"
return [
validator_finding(
"reviewer.validation_cwd_proof",
severity,
"Validation cwd/HEAD proof",
reason,
result.get("safe_next_action")
or "document pwd, HEAD SHA, and explicit cwd before validation",
)
for reason in (result.get("violations") or result.get("reasons") or ["incomplete"])
]
def _rule_reviewer_stale_head_proof(report_text: str) -> list[dict[str, str]]:
from pr_work_lease import assess_reviewer_stale_head_final_report
result = assess_reviewer_stale_head_final_report(report_text)
if result.get("proven"):
return []
return _findings_from_reasons(
"reviewer.stale_head_proof",
result.get("reasons") or [],
field="Stale-head proof",
severity="block",
safe_next_action=(
"state reviewed head SHA, live head before approval/merge, and "
"whether any push occurred during validation"
),
)
def _rule_conflict_fix_push_proof(report_text: str) -> list[dict[str, str]]:
from pr_work_lease import assess_conflict_fix_final_report
text = report_text or ""
if "conflict-fix" not in text.lower() and "conflict fix" not in text.lower():
return []
result = assess_conflict_fix_final_report(text)
if result.get("proven"):
return []
return _findings_from_reasons(
"author.conflict_fix_push_proof",
result.get("reasons") or [],
field="Conflict-fix push proof",
severity="block",
safe_next_action=(
"state branch head before/after push, reviewer lease status, "
"fast-forward status, and whether any reviewer was active"
),
)
def _rule_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):
@@ -467,6 +714,34 @@ def _rule_reviewer_main_checkout_baseline(report_text: str) -> list[dict[str, st
]
def _rule_reviewer_validation_status_vocabulary(
report_text: str,
*,
action_log: list[dict] | None = None,
) -> list[dict[str, str]]:
text = report_text or ""
if not re.search(
r"validation status|pr-head validation status|official validation status",
text,
re.IGNORECASE,
):
return []
result = assess_validation_status_vocabulary(
text,
command_log=action_log,
)
if not result.get("block"):
return []
return _findings_from_reasons(
"reviewer.validation_status_vocabulary",
result.get("reasons") or [],
field="Validation status",
severity="block",
safe_next_action=result.get("safe_next_action")
or "use a validation status that matches the proof path executed",
)
def _rule_reviewer_main_checkout_path(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if "baseline worktree path" not in text.lower():
@@ -492,7 +767,7 @@ def _rule_reviewer_main_checkout_path(report_text: str) -> list[dict[str, str]]:
def _rule_reviewer_already_landed_eligible(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if not _ALREADY_LANDED_RE.search(text):
if not _already_landed_gate_fired(text):
return []
if not _ELIGIBLE_REVIEW_RE.search(text):
return []
@@ -507,6 +782,65 @@ def _rule_reviewer_already_landed_eligible(report_text: str) -> list[dict[str, s
]
def _rule_reviewer_already_landed_state(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if not _already_landed_gate_fired(text):
return []
terms = _positive_review_state_terms(_handoff_fields(text))
if not terms:
return []
return [
validator_finding(
"reviewer.already_landed_review_state",
"block",
"Review decision",
"already-landed gate fired but final report claims "
f"{', '.join(terms)} state",
"stop normal review/merge; classify as ALREADY_LANDED_RECONCILE_REQUIRED",
)
]
def _rule_reviewer_target_branch_freshness(
report_text: str,
*,
action_log: list[dict] | None = None,
) -> list[dict[str, str]]:
text = report_text or ""
if not _TARGET_BRANCH_UP_TO_DATE_RE.search(text):
return []
fields = _handoff_fields(text)
fetch_reported = bool(_GIT_FETCH_RE.search(text)) or any(
_GIT_FETCH_RE.search(str(e.get("command") or e.get("action") or ""))
for e in (action_log or [])
)
target_sha_reported = bool(_TARGET_BRANCH_SHA_RE.search(text)) or any(
"target branch" in key and "sha" in key and _FULL_SHA_RE.search(value)
for key, value in fields.items()
)
if fetch_reported and target_sha_reported:
return []
missing = []
if not fetch_reported:
missing.append("fresh git fetch")
if not target_sha_reported:
missing.append("target branch SHA")
return [
validator_finding(
"reviewer.target_branch_freshness_proof",
"block",
"Target branch SHA",
"target branch up-to-date claim lacks " + " and ".join(missing),
"fetch the target branch and report the exact target branch SHA before "
"claiming it is up to date",
)
]
def _rule_reconcile_controller_handoff(report_text: str) -> list[dict[str, str]]:
from review_proofs import _handoff_section_lines
@@ -541,10 +875,18 @@ def _rule_reconcile_controller_handoff(report_text: str) -> list[dict[str, str]]
return []
def _rule_reconcile_stale_author_fields(report_text: str) -> list[dict[str, str]]:
def _rule_reconcile_stale_author_fields(
report_text: str,
*,
session_pr_opened: bool = False,
) -> list[dict[str, str]]:
text = (report_text or "").lower()
findings = []
for field in _RECONCILE_STALE_FIELDS:
if field == "pr number opened" and session_pr_opened:
# Issue #307: allowed only when a PR was actually opened
# in this session.
continue
if f"{field}:" in text:
findings.append(
validator_finding(
@@ -672,6 +1014,118 @@ def _rule_reviewer_mutation_ledger(
)
def _rule_shared_issue_lock_external_state(report_text: str) -> list[dict[str, str]]:
result = issue_lock_provenance.assess_issue_lock_external_state_report(report_text)
if result.get("proven"):
return []
return _findings_from_reasons(
"shared.issue_lock_external_state",
result.get("reasons") or [],
field="External-state mutations",
severity="block",
safe_next_action=(
"disclose gitea_issue_lock.json read/write/delete under "
"External-state mutations; never claim none after lock seeding"
),
)
def _rule_shared_manual_lock_pr_override(report_text: str) -> list[dict[str, str]]:
result = issue_lock_provenance.assess_manual_lock_pr_without_override(report_text)
if result.get("proven"):
return []
return _findings_from_reasons(
"shared.manual_lock_pr_override",
result.get("reasons") or [],
field="External-state mutations",
severity="block",
safe_next_action=(
"use gitea_lock_issue or #442 adoption instead of manual lock seeding; "
"if operator override was authorized, cite override proof"
),
)
def _rule_shared_author_reviewer_same_run(report_text: str) -> list[dict[str, str]]:
result = issue_lock_provenance.assess_author_reviewer_same_run_report(report_text)
if result.get("proven"):
return []
return _findings_from_reasons(
"shared.author_reviewer_same_run",
result.get("reasons") or [],
field="Review mutations",
severity="block",
safe_next_action=(
"split author PR creation and reviewer approval across separate "
"sessions and handoffs"
),
)
def _rule_reviewer_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,
*,
@@ -691,27 +1145,77 @@ def _rule_reviewer_review_mutation(
)
def _rule_reviewer_post_merge_cleanup_proof(report_text: str) -> list[dict[str, str]]:
result = assess_post_merge_cleanup_proof(report_text)
if not result.get("block"):
return []
return _findings_from_reasons(
"reviewer.post_merge_cleanup_proof",
result.get("reasons") or [],
field="Cleanup status",
severity="block",
safe_next_action=result.get("safe_next_action")
or "report CLEANUP_SKIPPED with blocker or full cleanup checklist",
)
def _rule_shared_mcp_native_cleanup_proof(report_text: str) -> list[dict[str, str]]:
result = assess_mcp_native_cleanup_proof(report_text)
if not result.get("block"):
return []
return _findings_from_reasons(
"shared.mcp_native_cleanup_proof",
result.get("reasons") or [],
field="Cleanup mutations",
severity="block",
safe_next_action=result.get("safe_next_action")
or "use authorized reconciler MCP cleanup tools; never raw scripts",
)
_SHARED_ISSUE_LOCK_RULES = (
_rule_shared_issue_lock_external_state,
_rule_shared_manual_lock_pr_override,
_rule_shared_author_reviewer_same_run,
)
_SHARED_CLEANUP_PROOF_RULES = (
_rule_shared_mcp_native_cleanup_proof,
)
_RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
"review_pr": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
_rule_reviewer_legacy_workspace_mutations,
_rule_reviewer_vague_mutations_none,
_rule_reviewer_mutation_categories,
_rule_reviewer_git_fetch_readonly,
_rule_reviewer_validation_command,
_rule_reviewer_validation_failure_history,
_rule_reviewer_validation_cwd_proof,
_rule_reviewer_validation_structured,
_rule_reviewer_linked_issue,
_rule_reviewer_baseline_on_failure,
_rule_reviewer_validation_status_vocabulary,
_rule_reviewer_main_checkout_baseline,
_rule_reviewer_main_checkout_path,
_rule_reviewer_already_landed_eligible,
_rule_reviewer_already_landed_state,
_rule_reviewer_target_branch_freshness,
_rule_reviewer_workflow_load_boundary,
_rule_reviewer_mutation_ledger,
_rule_reviewer_review_mutation,
_rule_reviewer_post_merge_cleanup_proof,
*_SHARED_CLEANUP_PROOF_RULES,
_rule_reviewer_stale_head_proof,
],
"reconcile_already_landed": [
_rule_reconcile_controller_handoff,
_rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
*_SHARED_CLEANUP_PROOF_RULES,
_rule_reconcile_stale_author_fields,
_rule_reconcile_eligible_reviewed,
_rule_reconcile_linked_issue_live,
@@ -719,29 +1223,37 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_reviewer_git_fetch_readonly,
_rule_reviewer_legacy_workspace_mutations,
_rule_reviewer_vague_mutations_none,
_rule_audit_reconciliation_boundary,
],
"author_issue": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
_rule_reviewer_vague_mutations_none,
],
"work_issue": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
_rule_reviewer_vague_mutations_none,
_rule_conflict_fix_push_proof,
_rule_worktree_cleanup_audit_proof,
],
"issue_filing": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
],
"inventory": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
_rule_reconcile_pagination_proof,
],
"issue_selection": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
],
}
@@ -803,6 +1315,8 @@ def assess_final_report_validator(
mutations_observed: bool = False,
local_edits: bool = False,
issue_filing_lock: dict | None = None,
session_pr_opened: bool = False,
validation_session: dict | None = None,
) -> dict[str, Any]:
"""Validate final-report text against task-specific proof rules (#327).
@@ -857,6 +1371,8 @@ def assess_final_report_validator(
"action_log": action_log,
"mutations_observed": mutations_observed,
"local_edits": local_edits,
"session_pr_opened": session_pr_opened,
"validation_session": validation_session,
}
for rule in _RULES_BY_TASK.get(normalized_kind, ()):
@@ -875,4 +1391,4 @@ def assess_final_report_validator(
"safe_next_action": _primary_safe_next_action(findings),
"complete": grade == "A",
"handoff_heading": HANDOFF_HEADING,
}
}
+3236 -140
View File
File diff suppressed because it is too large Load Diff
+295
View File
@@ -0,0 +1,295 @@
"""Issue claim heartbeat leases and stale-claim reconciliation (#268).
Structured issue-thread comments prove live ownership beyond the
``status:in-progress`` label alone. Queue inventory can classify claims as
active, stale, reclaimable, PR-backed, or phantom.
"""
from __future__ import annotations
import re
from datetime import datetime, timedelta, timezone
from typing import Any
MARKER = "<!-- gitea-issue-claim-heartbeat:v1 -->"
IN_PROGRESS_LABEL = "status:in-progress"
_KIND_CLAIM = "claim"
_KIND_PROGRESS = "progress"
_KIND_CLEANUP = "cleanup"
_FIELD_RE = re.compile(
r"^\s*-\s*([a-z_]+)\s*:\s*(.+?)\s*$",
re.IGNORECASE | re.MULTILINE,
)
_ISSUE_REF_RE = re.compile(r"issue-(\d+)", re.IGNORECASE)
def _parse_timestamp(value: str | None) -> datetime | None:
if not value:
return None
text = value.strip()
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed
def format_heartbeat_body(
*,
kind: str,
issue_number: int,
branch: str,
phase: str,
profile: str | None = None,
pr: str = "none",
next_action: str = "none",
blocker: str = "none",
) -> str:
"""Return a structured, machine-parseable issue comment body."""
profile_value = (profile or "unknown").strip() or "unknown"
lines = [
MARKER,
"**Issue claim heartbeat**",
f"- kind: {kind}",
f"- issue: #{issue_number}",
f"- branch: {branch}",
f"- phase: {phase}",
f"- profile: {profile_value}",
f"- pr: {pr}",
f"- blocker: {blocker}",
f"- next_action: {next_action}",
]
return "\n".join(lines)
def parse_heartbeat_comment(body: str) -> dict[str, Any] | None:
"""Parse one structured heartbeat comment, or None when not a heartbeat."""
text = body or ""
if MARKER not in text:
return None
fields: dict[str, str] = {}
for match in _FIELD_RE.finditer(text):
fields[match.group(1).strip().lower()] = match.group(2).strip()
if not fields:
return None
issue_raw = fields.get("issue", "")
issue_digits = re.sub(r"[^\d]", "", issue_raw)
issue_number = int(issue_digits) if issue_digits.isdigit() else None
return {
"kind": fields.get("kind"),
"issue_number": issue_number,
"branch": fields.get("branch"),
"phase": fields.get("phase"),
"profile": fields.get("profile"),
"pr": fields.get("pr"),
"blocker": fields.get("blocker"),
"next_action": fields.get("next_action"),
"raw_fields": fields,
}
def extract_issue_heartbeats(
comments: list[dict],
*,
issue_number: int | None = None,
) -> list[dict]:
"""Return parsed heartbeats newest-last, optionally filtered to *issue_number*."""
heartbeats: list[dict] = []
for comment in comments or []:
parsed = parse_heartbeat_comment(comment.get("body") or "")
if not parsed:
continue
if issue_number is not None and parsed.get("issue_number") != issue_number:
continue
heartbeats.append(
{
**parsed,
"comment_id": comment.get("id"),
"author": (comment.get("user") or {}).get("login")
or comment.get("author"),
"created_at": comment.get("created_at"),
"updated_at": comment.get("updated_at"),
}
)
return heartbeats
def issue_has_in_progress_label(issue: dict) -> bool:
labels = issue.get("labels") or []
for label in labels:
name = label if isinstance(label, str) else label.get("name")
if name == IN_PROGRESS_LABEL:
return True
return False
def _linked_open_pr(issue_number: int, open_prs: list[dict]) -> dict | None:
pattern = f"issue-{issue_number}"
closes = f"closes #{issue_number}"
fixes = f"fixes #{issue_number}"
for pr in open_prs or []:
head = (pr.get("head") or {}).get("ref") or ""
text = f"{pr.get('title', '')} {pr.get('body', '')}".lower()
if pattern in head.lower():
return pr
if closes in text or fixes in text:
return pr
return None
def _matching_branch_names(issue_number: int, branch_names: list[str]) -> list[str]:
pattern = f"issue-{issue_number}"
return [name for name in branch_names if pattern in (name or "").lower()]
def classify_issue_claim(
*,
issue: dict,
comments: list[dict],
open_prs: list[dict] | None = None,
branch_names: list[str] | None = None,
now: datetime | None = None,
heartbeat_lease_minutes: int = 30,
reclaim_after_minutes: int = 60,
) -> dict[str, Any]:
"""Classify one issue's claim state from labels, heartbeats, PRs, and branches."""
issue_number = int(issue.get("number") or 0)
open_prs = open_prs or []
branch_names = branch_names or []
current = now or datetime.now(timezone.utc)
has_label = issue_has_in_progress_label(issue)
heartbeats = extract_issue_heartbeats(comments, issue_number=issue_number)
latest = heartbeats[-1] if heartbeats else None
latest_at = _parse_timestamp(
(latest or {}).get("updated_at") or (latest or {}).get("created_at")
)
age_minutes = None
if latest_at:
age_minutes = int((current - latest_at).total_seconds() // 60)
linked_pr = _linked_open_pr(issue_number, open_prs)
matching_branches = _matching_branch_names(issue_number, branch_names)
if not has_label:
status = "not_claimed"
reasons = ["issue lacks status:in-progress label"]
elif linked_pr:
status = "awaiting_review"
reasons = [f"open PR #{linked_pr.get('number')} covers this issue"]
elif not heartbeats:
status = "phantom"
reasons = [
"status:in-progress label present without structured claim heartbeat"
]
elif latest_at is None:
status = "phantom"
reasons = ["heartbeat comments present but timestamps could not be parsed"]
elif age_minutes is not None and age_minutes <= heartbeat_lease_minutes:
status = "active"
reasons = [f"heartbeat age {age_minutes}m within {heartbeat_lease_minutes}m lease"]
elif matching_branches and age_minutes is not None and age_minutes <= reclaim_after_minutes:
status = "active"
reasons = [
f"matching branch(es) {', '.join(matching_branches)} with heartbeat "
f"age {age_minutes}m"
]
elif age_minutes is not None and age_minutes > reclaim_after_minutes and not matching_branches:
status = "reclaimable"
reasons = [
f"no heartbeat within {reclaim_after_minutes}m and no matching branch"
]
elif age_minutes is not None and age_minutes > heartbeat_lease_minutes:
status = "stale"
reasons = [
f"heartbeat age {age_minutes}m exceeds {heartbeat_lease_minutes}m lease"
]
else:
status = "active"
reasons = ["claim has structured heartbeat proof"]
return {
"issue_number": issue_number,
"title": issue.get("title"),
"status": status,
"has_in_progress_label": has_label,
"heartbeat_count": len(heartbeats),
"latest_heartbeat": latest,
"heartbeat_age_minutes": age_minutes,
"linked_open_pr": linked_pr.get("number") if linked_pr else None,
"matching_branches": matching_branches,
"reasons": reasons,
"reclaimable": status == "reclaimable",
"stale": status in {"stale", "phantom", "reclaimable"},
}
def build_claim_inventory(
*,
issues: list[dict],
comments_by_issue: dict[int, list[dict]],
open_prs: list[dict],
branch_names: list[str],
now: datetime | None = None,
heartbeat_lease_minutes: int = 30,
reclaim_after_minutes: int = 60,
) -> dict[str, Any]:
"""Build a queue inventory report for in-progress issue claims."""
entries: list[dict] = []
for issue in issues or []:
if not issue_has_in_progress_label(issue):
continue
number = int(issue["number"])
entry = classify_issue_claim(
issue=issue,
comments=comments_by_issue.get(number, []),
open_prs=open_prs,
branch_names=branch_names,
now=now,
heartbeat_lease_minutes=heartbeat_lease_minutes,
reclaim_after_minutes=reclaim_after_minutes,
)
entries.append(entry)
counts: dict[str, int] = {}
for entry in entries:
counts[entry["status"]] = counts.get(entry["status"], 0) + 1
return {
"entries": entries,
"counts": counts,
"heartbeat_lease_minutes": heartbeat_lease_minutes,
"reclaim_after_minutes": reclaim_after_minutes,
"in_progress_total": len(entries),
}
def build_cleanup_plan(inventory: dict[str, Any]) -> list[dict]:
"""Return stale/reclaimable/phantom claims that may be cleaned up."""
plan: list[dict] = []
for entry in inventory.get("entries") or []:
if entry.get("status") in {"reclaimable", "phantom"}:
plan.append(
{
"issue_number": entry["issue_number"],
"status": entry["status"],
"action": "remove_status_in_progress_and_comment",
"reasons": list(entry.get("reasons") or []),
}
)
elif entry.get("status") == "stale" and not entry.get("linked_open_pr"):
plan.append(
{
"issue_number": entry["issue_number"],
"status": entry["status"],
"action": "report_only",
"reasons": list(entry.get("reasons") or []),
}
)
return plan
+272
View File
@@ -0,0 +1,272 @@
"""Own-branch lock adoption / recovery for ``gitea_lock_issue`` (#442 / #443).
When an issue's own already-pushed branch exists, lock reacquisition must be
allowed (adoption) instead of being treated as #400 duplicate competing work.
This module isolates the pure decision so it can be unit-tested apart from the
MCP server's live Gitea calls.
Adoption is granted only for the issue's *exact* requested branch. Any other
branch that merely contains the same ``issue-<n>`` marker is competing work and
stays fail-closed. Open-PR, competing-live-lock, capability, and worktree
safety checks are enforced by the caller before this decision is consulted;
this module additionally records whether they passed for proof purposes.
"""
from __future__ import annotations
import re
ADOPT = "adopt_existing_branch"
BLOCK_COMPETING = "block_competing_branch"
NO_MATCH = "no_matching_branch"
# Citable decision labels aligned with the ``assess_own_branch_adoption``
# outcomes, surfaced verbatim in the live ``gitea_lock_issue`` response so
# recovery reports (#473-style) can quote the lock tool output directly
# instead of inferring adoption from separate offline checks (#477).
DECISION_LABELS = {
ADOPT: "ADOPT",
BLOCK_COMPETING: "BLOCK_COMPETING",
NO_MATCH: "NO_MATCH",
}
_SAFE_NEXT_ACTIONS = {
ADOPT: (
"Own existing branch adopted for lock recovery; proceed to "
"gitea_create_pr for this issue and cite this adoption proof."
),
BLOCK_COMPETING: (
"Competing same-issue branch(es) exist; resolve branch ownership "
"before locking. No adoption performed (fail closed)."
),
NO_MATCH: (
"No existing branch carries this issue marker; normal lock path "
"applied. No adoption performed."
),
}
def decision_label(outcome: str) -> str:
"""Map an ``assess_own_branch_adoption`` outcome to its citable label."""
return DECISION_LABELS.get(outcome, "UNKNOWN")
def safe_next_action(outcome: str) -> str:
"""Return the safe next action string for an adoption *outcome*."""
return _SAFE_NEXT_ACTIONS.get(
outcome, "Unknown adoption outcome; treat as fail closed."
)
def _branch_name(entry) -> str:
if isinstance(entry, dict):
return str(entry.get("name") or "")
return str(entry or "")
def _branch_sha(entry) -> str | None:
if isinstance(entry, dict):
sha = entry.get("commit_sha")
if sha:
return str(sha)
return None
def _branch_carries_issue_marker(branch_name: str, issue_number: int) -> bool:
"""Return True when *branch_name* references issue *issue_number* exactly.
Uses a numeric word-boundary so ``issue-42`` does not match inside
``issue-420`` (AC6 / #440).
"""
name = (branch_name or "").strip()
if not name:
return False
pattern = rf"(?:^|/)issue-{int(issue_number)}(?![0-9])"
return re.search(pattern, name) is not None
def assess_own_branch_adoption(
*,
issue_number: int,
requested_branch: str,
existing_branches,
) -> dict:
"""Decide whether an existing matching branch is adoptable.
Args:
issue_number: The tracking issue number being locked.
requested_branch: The exact branch the caller wants to lock.
existing_branches: Iterable of remote branch entries — either names or
dicts with ``name`` and optional ``commit_sha``.
Returns:
dict with:
* ``outcome`` — one of ADOPT / BLOCK_COMPETING / NO_MATCH
* ``adopt`` (bool), ``block`` (bool)
* ``reason`` (str)
* ``matched_branch`` (str | None), ``matched_head_sha`` (str | None)
* ``competing_branches`` (list[str])
ADOPT: the issue's exact branch exists and no other same-issue branch does.
BLOCK_COMPETING: at least one same-issue branch is not the requested branch.
NO_MATCH: no branch carries the issue marker — normal lock path applies.
"""
requested = (requested_branch or "").strip()
matches: list[tuple[str, str | None]] = []
for entry in existing_branches or []:
name = _branch_name(entry).strip()
if _branch_carries_issue_marker(name, issue_number):
matches.append((name, _branch_sha(entry)))
competing = sorted({name for name, _ in matches if name != requested})
exact = [(name, sha) for name, sha in matches if name == requested]
# Fail closed whenever any non-requested same-issue branch exists, even if
# the requested branch is also present: ownership is then ambiguous.
if competing:
return {
"outcome": BLOCK_COMPETING,
"adopt": False,
"block": True,
"reason": (
f"issue #{issue_number} already has matching branch(es) "
f"{competing} that are not the requested branch "
f"'{requested}' (fail closed)"
),
"matched_branch": None,
"matched_head_sha": None,
"competing_branches": competing,
}
if exact:
name, sha = exact[0]
return {
"outcome": ADOPT,
"adopt": True,
"block": False,
"reason": (
f"existing branch '{name}' is the exact requested branch for "
f"issue #{issue_number}; adopting it for lock recovery"
),
"matched_branch": name,
"matched_head_sha": sha,
"competing_branches": [],
}
return {
"outcome": NO_MATCH,
"adopt": False,
"block": False,
"reason": f"no existing branch matches issue #{issue_number}",
"matched_branch": None,
"matched_head_sha": None,
"competing_branches": [],
}
def _matcher_summary(issue_number: int, assessment: dict) -> str:
"""Explain, citably, why the assessed branch did or did not qualify.
Names the numeric word-boundary rule so reports can show that
``issue-42`` was not matched inside ``issue-420`` (#440 / #477 AC3).
"""
outcome = assessment.get("outcome")
matched = assessment.get("matched_branch")
competing = assessment.get("competing_branches") or []
if outcome == ADOPT and matched:
return (
f"branch '{matched}' exactly matches the issue-{int(issue_number)} "
f"marker (numeric word-boundary; 'issue-{int(issue_number)}' is not "
f"matched inside 'issue-{int(issue_number)}0')"
)
if outcome == BLOCK_COMPETING:
return (
f"competing same-issue branch(es) {competing} carry the "
f"issue-{int(issue_number)} marker but are not the requested "
f"branch; ownership is ambiguous (fail closed)"
)
return (
f"no existing branch carries the issue-{int(issue_number)} marker "
f"under the numeric word-boundary rule"
)
def _competing_branch_check(assessment: dict) -> dict:
"""Structured competing-branch verdict for the proof block."""
competing = list(assessment.get("competing_branches") or [])
return {
"result": "blocked" if competing else "clear",
"competing_branches": competing,
}
def build_adoption_proof(
*,
issue_number: int,
branch_name: str,
assessment: dict,
open_pr_checked: bool,
competing_lock_checked: bool,
lock_file_path: str,
lock_file_status: str,
) -> dict:
"""Assemble the proof block returned by ``gitea_lock_issue`` on adoption.
Requirement #4: adoption results must carry issue number, branch name,
branch head commit, adoption reason, no-existing-PR proof, no-competing-
live-lock proof, and lock file path/status.
#477: additionally surface explicit, citable adoption-proof fields tied to
the ``assess_own_branch_adoption`` outcome (``adoption_decision``,
``adopted``, ``adopted_branch``, ``adopted_branch_head``,
``matcher_summary``, ``competing_branch_check``, ``safe_next_action``) so a
recovery session can quote the live lock response directly. The explicit
fields are populated for any outcome; ``adopted_branch`` /
``adopted_branch_head`` are set only when the outcome is ADOPT so a
non-adoption proof can never be misread as claiming adoption.
"""
outcome = assessment.get("outcome")
adopted = outcome == ADOPT
return {
"issue_number": issue_number,
"branch_name": branch_name,
"branch_head_commit": assessment.get("matched_head_sha"),
"adoption_reason": assessment.get("reason"),
"no_existing_pr_proof": bool(open_pr_checked),
"no_competing_live_lock_proof": bool(competing_lock_checked),
"lock_file_path": lock_file_path,
"lock_file_status": lock_file_status,
# Explicit citable fields (#477).
"adoption_decision": decision_label(outcome),
"adopted": adopted,
"adopted_branch": branch_name if adopted else None,
"adopted_branch_head": assessment.get("matched_head_sha") if adopted else None,
"matcher_summary": _matcher_summary(issue_number, assessment),
"competing_branch_check": _competing_branch_check(assessment),
"safe_next_action": safe_next_action(outcome),
}
def build_non_adoption_lock_proof(*, issue_number: int, branch_name: str) -> dict:
"""Safe, adoption-free proof metadata for a normal (NO_MATCH) lock.
Requirement #477 AC2: non-adoption lock responses must stay clear and must
not imply adoption. This returns explicit ``adopted: False`` metadata with
the ``NO_MATCH`` decision so a normal lock response can carry citable proof
without ever asserting a branch was adopted.
"""
return {
"issue_number": issue_number,
"branch_name": branch_name,
"adoption_decision": DECISION_LABELS[NO_MATCH],
"adopted": False,
"adopted_branch": None,
"adopted_branch_head": None,
"matcher_summary": (
f"no existing branch carries the issue-{int(issue_number)} marker; "
f"normal lock path (no adoption)"
),
"competing_branch_check": {"result": "clear", "competing_branches": []},
"safe_next_action": safe_next_action(NO_MATCH),
}
+269
View File
@@ -0,0 +1,269 @@
"""Issue-lock provenance and external-state disclosure (#447).
Sanctioned locks are written only by ``gitea_lock_issue`` (or adoption recovery
#442). Manual seeding of ``/tmp/gitea_issue_lock.json`` is unsafe and must be
blocked at PR creation unless explicit operator override proof is recorded.
"""
from __future__ import annotations
import os
import re
from datetime import datetime, timezone
ISSUE_LOCK_FILE = os.environ.get("GITEA_ISSUE_LOCK_FILE", "/tmp/gitea_issue_lock.json")
SOURCE_LOCK_ISSUE = "gitea_lock_issue"
SOURCE_LOCK_ADOPTION = "gitea_lock_issue_adoption"
SOURCE_OPERATOR_OVERRIDE = "operator_override"
SANCTIONED_LOCK_SOURCES = frozenset({
SOURCE_LOCK_ISSUE,
SOURCE_LOCK_ADOPTION,
SOURCE_OPERATOR_OVERRIDE,
})
_OPERATOR_OVERRIDE_ENV = "GITEA_ISSUE_LOCK_OPERATOR_OVERRIDE"
_ISSUE_LOCK_PATH_RE = re.compile(
r"(?:/tmp/)?gitea_issue_lock\.json",
re.IGNORECASE,
)
_LOCK_SEED_RE = re.compile(
r"(?:seed(?:ed|ing)?|restor(?:e|ed|ing)|wrote|written|write|programmatically|"
r"hand[- ]forg|manual(?:ly)?).{0,80}gitea_issue_lock",
re.IGNORECASE | re.DOTALL,
)
_LOCK_REMOVE_RE = re.compile(
r"(?:\brm\b|remove|deleted?|unlink).{0,80}gitea_issue_lock",
re.IGNORECASE | re.DOTALL,
)
_LOCK_READ_RE = re.compile(
r"(?:read|loaded?|parsed?).{0,80}gitea_issue_lock",
re.IGNORECASE | re.DOTALL,
)
_EXTERNAL_NONE_RE = re.compile(
r"external[- ]state mutations\s*:\s*none\b",
re.IGNORECASE,
)
_EXTERNAL_FIELD_RE = re.compile(
r"external[- ]state mutations\s*:\s*(.+)$",
re.IGNORECASE | re.MULTILINE,
)
_CLEANUP_ONLY_RE = re.compile(
r"cleanup mutations\s*:\s*(?:none|lock removed|removed issue lock)",
re.IGNORECASE,
)
_PR_CREATED_RE = re.compile(
r"(?:\bgitea_create_pr\b|PR\s*#\s*\d+\s+created|created\s+PR\s*#|opened\s+PR\s*#|"
r"PR\s+creation\s+(?:succeeded|complete))",
re.IGNORECASE,
)
_REVIEW_APPROVE_RE = re.compile(
r"(?:submitted\s+(?:['\"]approve['\"]|approve\s+review)|"
r"review decision\s*:\s*approve|approved\s+PR\s*#|gitea_review_pr.*approve)",
re.IGNORECASE,
)
_OVERRIDE_PROOF_RE = re.compile(
r"operator[- ]override\s+proof\s*:\s*(.+)$",
re.IGNORECASE | re.MULTILINE,
)
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def build_sanctioned_lock_provenance(
*,
tool: str,
source: str = SOURCE_LOCK_ISSUE,
claimant: dict | None = None,
adoption: dict | None = None,
) -> dict:
"""Return provenance metadata stored with a sanctioned lock write."""
entry = {
"source": source,
"written_at": _utc_now_iso(),
"written_by_tool": tool,
"lock_file_path": ISSUE_LOCK_FILE,
}
if claimant:
entry["claimant"] = claimant
if adoption:
entry["adoption"] = adoption
return entry
def operator_override_requested() -> bool:
return os.environ.get(_OPERATOR_OVERRIDE_ENV, "").strip().lower() in {
"1",
"true",
"yes",
}
def build_operator_override_provenance(*, reason: str, claimant: dict | None = None) -> dict:
text = (reason or "").strip()
if not text:
raise ValueError(
"operator override requires a non-empty override reason (fail closed)"
)
entry = build_sanctioned_lock_provenance(
tool="operator_override",
source=SOURCE_OPERATOR_OVERRIDE,
claimant=claimant,
)
entry["override_reason"] = text
return entry
def assess_lock_file_for_create_pr(lock_data: dict | None) -> dict:
"""Fail closed when lock file lacks sanctioned provenance (#447)."""
data = lock_data if isinstance(lock_data, dict) else {}
reasons: list[str] = []
provenance = data.get("lock_provenance")
if not isinstance(provenance, dict):
reasons.append(
"issue lock file lacks sanctioned lock_provenance; manual seeding is "
"not a normal recovery path — call gitea_lock_issue or use #442 adoption"
)
return _provenance_result(False, reasons, provenance)
source = str(provenance.get("source") or "").strip()
if source not in SANCTIONED_LOCK_SOURCES:
reasons.append(
f"issue lock provenance source '{source or '(missing)'}' is not sanctioned"
)
if source == SOURCE_OPERATOR_OVERRIDE and not str(
provenance.get("override_reason") or ""
).strip():
reasons.append(
"operator_override lock provenance requires override_reason proof"
)
if not data.get("work_lease"):
reasons.append("issue lock file missing work_lease metadata")
if not str(provenance.get("written_by_tool") or "").strip():
reasons.append("issue lock provenance missing written_by_tool")
proven = not reasons
return _provenance_result(proven, reasons, provenance)
def _provenance_result(proven: bool, reasons: list[str], provenance: dict | None) -> dict:
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"lock_provenance": provenance,
}
def format_lock_provenance_error(assessment: dict) -> str:
reasons = "; ".join(assessment.get("reasons") or ["unknown lock provenance violation"])
return f"Issue lock provenance guard (#447): {reasons} (fail closed)"
def _lock_activity_detected(text: str) -> dict[str, bool]:
body = text or ""
return {
"seed_or_restore": bool(_LOCK_SEED_RE.search(body)),
"remove": bool(_LOCK_REMOVE_RE.search(body)),
"read": bool(_LOCK_READ_RE.search(body)),
}
def _external_state_discloses_lock(text: str) -> bool:
match = _EXTERNAL_FIELD_RE.search(text or "")
if not match:
return False
value = (match.group(1) or "").strip().lower()
if value in {"", "none", "n/a"}:
return False
return "lock" in value or "gitea_issue_lock" in value or "issue-lock" in value
def assess_issue_lock_external_state_report(report_text: str) -> dict:
"""Require explicit external-state disclosure for issue-lock mutations (#447)."""
text = report_text or ""
activity = _lock_activity_detected(text)
if not any(activity.values()):
return {"proven": True, "block": False, "reasons": [], "activity": activity}
reasons: list[str] = []
disclosed = _external_state_discloses_lock(text)
if activity["seed_or_restore"] and _EXTERNAL_NONE_RE.search(text):
reasons.append(
"report mentions seeding/restoring gitea_issue_lock.json but claims "
"External-state mutations: none"
)
elif activity["seed_or_restore"] and not disclosed:
reasons.append(
"report mentions issue-lock file activity but External-state mutations "
"does not disclose read/write of gitea_issue_lock.json"
)
if activity["remove"]:
if _EXTERNAL_NONE_RE.search(text):
reasons.append(
"report mentions removing gitea_issue_lock.json but claims "
"External-state mutations: none"
)
elif not disclosed and _CLEANUP_ONLY_RE.search(text):
reasons.append(
"report removes issue lock but classifies it as cleanup only; "
"record under External-state mutations"
)
elif not disclosed:
reasons.append(
"report mentions deleting issue lock without External-state "
"mutation disclosure"
)
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"activity": activity,
}
def assess_manual_lock_pr_without_override(report_text: str) -> dict:
"""Block reports that created a PR via manual lock seed without override proof."""
text = report_text or ""
seeded = bool(_LOCK_SEED_RE.search(text))
created = bool(_PR_CREATED_RE.search(text))
if not (seeded and created):
return {"proven": True, "block": False, "reasons": []}
if _OVERRIDE_PROOF_RE.search(text):
return {"proven": True, "block": False, "reasons": []}
return {
"proven": False,
"block": True,
"reasons": [
"report created/opened a PR after manual issue-lock seeding without "
"operator override proof"
],
}
def assess_author_reviewer_same_run_report(report_text: str) -> dict:
"""Reviewer handoff must not create and approve the same PR in one run (#447)."""
text = report_text or ""
if not (_PR_CREATED_RE.search(text) and _REVIEW_APPROVE_RE.search(text)):
return {"proven": True, "block": False, "reasons": []}
return {
"proven": False,
"block": True,
"reasons": [
"report mixes author-side PR creation and reviewer approval in one "
"final handoff; split author and reviewer sessions"
],
}
+612
View File
@@ -0,0 +1,612 @@
"""Keyed, persistent issue-lock storage (#443) with flock hardening (#438).
Replaces the single global ``/tmp/gitea_issue_lock.json`` slot with per-issue
lock files under ``GITEA_ISSUE_LOCK_DIR`` (default
``~/.cache/gitea-tools/issue-locks``). Each MCP session binds its active lock
via a per-process pointer file so concurrent repos/issues never clobber each
other. Acquisition is serialized per issue with ``fcntl.flock``.
"""
from __future__ import annotations
import errno
import fcntl
import json
import os
import re
import tempfile
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from typing import Any
LOCK_DIR_ENV = "GITEA_ISSUE_LOCK_DIR"
DEFAULT_LOCK_DIR = os.path.expanduser("~/.cache/gitea-tools/issue-locks")
WORK_LEASE_TTL_HOURS = 4
AUTHOR_ISSUE_WORK_LEASE = "author_issue_work"
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
class LockContentionError(RuntimeError):
"""Raised when an exclusive per-issue lock cannot be acquired."""
def default_lock_dir() -> str:
raw = (os.environ.get(LOCK_DIR_ENV) or DEFAULT_LOCK_DIR).strip()
return raw or DEFAULT_LOCK_DIR
def _sanitize_segment(value: str) -> str:
text = (value or "").strip()
if not text:
return "_"
return _SAFE_SEGMENT_RE.sub("_", text)
def lock_key(
*,
remote: str,
org: str,
repo: str,
issue_number: int,
) -> str:
return "-".join(
_sanitize_segment(part)
for part in (remote, org, repo, str(issue_number))
)
def lock_file_path(
*,
remote: str,
org: str,
repo: str,
issue_number: int,
lock_dir: str | None = None,
) -> str:
root = (lock_dir or default_lock_dir()).strip()
return os.path.join(root, f"{lock_key(remote=remote, org=org, repo=repo, issue_number=issue_number)}.json")
def session_pointer_path(lock_dir: str | None = None) -> str:
root = (lock_dir or default_lock_dir()).strip()
return os.path.join(root, f"session-{os.getpid()}.json")
def _ensure_lock_dir(lock_dir: str | None = None) -> str:
root = (lock_dir or default_lock_dir()).strip()
os.makedirs(root, mode=0o700, exist_ok=True)
return root
def flock_path(json_path: str) -> str:
return f"{json_path}.lock"
def is_process_alive(pid: int | None) -> bool:
if not pid or pid <= 0:
return False
try:
os.kill(int(pid), 0)
return True
except OSError as exc:
return exc.errno != errno.ESRCH
except (TypeError, ValueError):
return False
@contextmanager
def _exclusive_file_lock(lock_path: str):
os.makedirs(os.path.dirname(lock_path) or ".", exist_ok=True)
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
try:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc:
raise LockContentionError(
f"could not acquire exclusive lock on '{lock_path}'"
) from exc
yield fd
finally:
try:
fcntl.flock(fd, fcntl.LOCK_UN)
finally:
os.close(fd)
def read_lock_file(path: str) -> dict[str, Any] | None:
lock_path = (path or "").strip()
if not lock_path or not os.path.exists(lock_path):
return None
try:
with open(lock_path, encoding="utf-8") as handle:
data = json.load(handle)
except (OSError, json.JSONDecodeError):
return None
return data if isinstance(data, dict) else None
def save_lock_file(path: str, data: dict[str, Any]) -> None:
lock_path = (path or "").strip()
if not lock_path:
raise ValueError("lock path is required (fail closed)")
parent = os.path.dirname(lock_path) or "."
os.makedirs(parent, mode=0o700, exist_ok=True)
payload = json.dumps(data, indent=2, sort_keys=True) + "\n"
fd, temp_path = tempfile.mkstemp(prefix=".lock-", suffix=".json", dir=parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp_path, lock_path)
finally:
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except OSError:
pass
def bind_session_lock(lock_data: dict[str, Any], lock_dir: str | None = None) -> str:
"""Persist a keyed lock and bind it to the current process session."""
remote = str(lock_data.get("remote") or "")
org = str(lock_data.get("org") or "")
repo = str(lock_data.get("repo") or "")
issue_number = int(lock_data.get("issue_number") or 0)
if not remote or not org or not repo or issue_number <= 0:
raise ValueError("lock record must include remote, org, repo, and issue_number")
root = _ensure_lock_dir(lock_dir)
path = lock_file_path(
remote=remote,
org=org,
repo=repo,
issue_number=issue_number,
lock_dir=root,
)
record = dict(lock_data)
record["lock_file_path"] = path
record["session_pid"] = os.getpid()
record.setdefault("pid", os.getpid())
pointer = {
"pid": os.getpid(),
"lock_file_path": path,
"issue_number": issue_number,
"branch_name": record.get("branch_name"),
"remote": remote,
"org": org,
"repo": repo,
}
sentinel = flock_path(path)
try:
with _exclusive_file_lock(sentinel):
existing = read_lock_file(path)
overwrite_block = assess_foreign_lock_overwrite(existing, record)
if overwrite_block:
raise RuntimeError(overwrite_block)
lease_block = assess_same_issue_lease_conflict(
existing,
issue_number=issue_number,
branch_name=str(record.get("branch_name") or ""),
worktree_path=str(record.get("worktree_path") or ""),
)
if lease_block:
raise RuntimeError(lease_block)
save_lock_file(path, record)
save_lock_file(session_pointer_path(root), pointer)
except LockContentionError as exc:
competing = read_lock_file(path)
if competing:
owner_pid = competing.get("session_pid") or competing.get("pid")
raise RuntimeError(
f"Issue #{issue_number} lock contention: {exc}; competing owner "
f"pid={owner_pid} (fail closed)"
) from exc
raise RuntimeError(f"Issue #{issue_number} lock contention: {exc} (fail closed)") from exc
return path
def read_session_issue_lock(lock_dir: str | None = None) -> dict[str, Any] | None:
root = (lock_dir or default_lock_dir()).strip()
pointer = read_lock_file(session_pointer_path(root))
if not pointer:
return None
lock_path = str(pointer.get("lock_file_path") or "").strip()
if not lock_path:
return None
return read_lock_file(lock_path)
def load_issue_lock(
*,
remote: str,
org: str,
repo: str,
issue_number: int,
lock_dir: str | None = None,
) -> dict[str, Any] | None:
return read_lock_file(
lock_file_path(
remote=remote,
org=org,
repo=repo,
issue_number=issue_number,
lock_dir=lock_dir,
)
)
def iter_lock_files(lock_dir: str | None = None) -> list[str]:
root = (lock_dir or default_lock_dir()).strip()
if not os.path.isdir(root):
return []
paths: list[str] = []
for name in os.listdir(root):
if not name.endswith(".json") or name.startswith("session-"):
continue
paths.append(os.path.join(root, name))
return sorted(paths)
def find_lock_for_branch(
*,
remote: str,
org: str,
repo: str,
branch_name: str,
lock_dir: str | None = None,
) -> dict[str, Any] | None:
target = (branch_name or "").strip()
if not target:
return None
for path in iter_lock_files(lock_dir):
lock = read_lock_file(path)
if not lock:
continue
if (
str(lock.get("remote") or "") == remote
and str(lock.get("org") or "") == org
and str(lock.get("repo") or "") == repo
and str(lock.get("branch_name") or "").strip() == target
):
lock = dict(lock)
lock.setdefault("lock_file_path", path)
return lock
return None
def _lease_now(now: datetime | None = None) -> datetime:
return now or datetime.now(timezone.utc)
def _parse_lease_timestamp(value: str | None) -> datetime | None:
text = (value or "").strip()
if not text:
return None
try:
return datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone(timezone.utc)
except ValueError:
return None
def lease_expires_at(lock: dict[str, Any] | None) -> datetime | None:
if not lock:
return None
lease = lock.get("work_lease")
if not isinstance(lease, dict):
return None
return _parse_lease_timestamp(lease.get("expires_at"))
def is_lease_expired(lock: dict[str, Any] | None, *, now: datetime | None = None) -> bool:
expires = lease_expires_at(lock)
if expires is None:
return False
return expires <= _lease_now(now)
def is_lease_live(lock: dict[str, Any] | None, *, now: datetime | None = None) -> bool:
return assess_lock_freshness(lock, now=now)["live"]
def assess_lock_freshness(
lock_data: dict[str, Any] | None,
*,
now: datetime | None = None,
) -> dict[str, Any]:
"""Classify a lock as live, expired, stale, or absent."""
current = _lease_now(now)
if not lock_data:
return {
"status": "absent",
"live": False,
"stale": False,
"reason": "no lock record",
}
expires_at = lease_expires_at(lock_data)
lease = lock_data.get("work_lease")
heartbeat_at = _parse_lease_timestamp(lock_data.get("last_heartbeat_at"))
if heartbeat_at is None and isinstance(lease, dict):
heartbeat_at = _parse_lease_timestamp(lease.get("last_heartbeat_at"))
pid = lock_data.get("session_pid")
if pid is None:
pid = lock_data.get("pid")
pid_alive = is_process_alive(pid) if pid is not None else False
if expires_at and expires_at <= current:
return {
"status": "expired",
"live": False,
"stale": True,
"reason": f"lease expired at {expires_at.isoformat()}",
"pid_alive": pid_alive,
}
if pid is not None and not pid_alive:
return {
"status": "stale",
"live": False,
"stale": True,
"reason": f"owner pid {pid} is not alive",
"pid_alive": False,
}
return {
"status": "live",
"live": True,
"stale": False,
"reason": "lock heartbeat and lease are fresh",
"pid_alive": pid_alive,
"heartbeat_at": heartbeat_at.isoformat() if heartbeat_at else None,
"expires_at": expires_at.isoformat() if expires_at else None,
}
def _same_realpath(left: str | None, right: str | None) -> bool:
if not left or not right:
return False
try:
return os.path.realpath(left) == os.path.realpath(right)
except OSError:
return left == right
def assess_same_issue_lease_conflict(
existing_lock: dict[str, Any] | None,
*,
issue_number: int,
branch_name: str,
worktree_path: str,
operation_type: str = AUTHOR_ISSUE_WORK_LEASE,
now: datetime | None = None,
) -> str | None:
"""Return a fail-closed error when a competing live lease blocks acquisition."""
if not existing_lock:
return None
existing_issue = existing_lock.get("issue_number")
lease = existing_lock.get("work_lease")
existing_operation = (
lease.get("operation_type")
if isinstance(lease, dict)
else AUTHOR_ISSUE_WORK_LEASE
)
if existing_issue != issue_number or existing_operation != operation_type:
return None
existing_branch = existing_lock.get("branch_name")
existing_worktree = existing_lock.get("worktree_path")
same_owner = (
existing_branch == branch_name
and _same_realpath(str(existing_worktree or ""), worktree_path)
)
if is_lease_expired(existing_lock, now=now):
return (
f"Issue #{issue_number} has an expired {operation_type} lease on "
f"branch '{existing_branch}' from worktree '{existing_worktree}'. "
"Recovery review is required before takeover (fail closed)"
)
if same_owner:
return None
return (
f"Issue #{issue_number} already has an active {operation_type} lease on "
f"branch '{existing_branch}' from worktree '{existing_worktree}' "
"(fail closed)"
)
def assess_foreign_lock_overwrite(
existing_lock: dict[str, Any] | None,
incoming_lock: dict[str, Any],
*,
now: datetime | None = None,
) -> str | None:
"""Block writes that would clobber an unrelated live lease on the same key."""
if not existing_lock:
return None
same_issue = existing_lock.get("issue_number") == incoming_lock.get("issue_number")
same_branch = existing_lock.get("branch_name") == incoming_lock.get("branch_name")
same_worktree = _same_realpath(
str(existing_lock.get("worktree_path") or ""),
str(incoming_lock.get("worktree_path") or ""),
)
if same_issue and same_branch and same_worktree:
return None
if not is_lease_live(existing_lock, now=now):
return None
return (
"Refusing to overwrite a live foreign issue lock "
f"(issue #{existing_lock.get('issue_number')}, "
f"branch '{existing_lock.get('branch_name')}', "
f"worktree '{existing_lock.get('worktree_path')}') (fail closed)"
)
def find_live_lock_for_branch(
branch_name: str,
lock_dir: str | None = None,
) -> dict[str, Any] | None:
target = (branch_name or "").strip()
if not target:
return None
for path in iter_lock_files(lock_dir):
lock = read_lock_file(path)
if not lock:
continue
if str(lock.get("branch_name") or "").strip() != target:
continue
if not is_lease_live(lock):
continue
record = dict(lock)
record.setdefault("lock_file_path", path)
return record
return None
def resolve_locked_branch_for_session(
branch_name: str | None = None,
lock_dir: str | None = None,
) -> str:
if branch_name:
lock = find_live_lock_for_branch(branch_name, lock_dir)
if lock:
return str(lock.get("branch_name") or "")
lock = read_session_issue_lock(lock_dir)
return str((lock or {}).get("branch_name") or "")
def has_active_issue_lock(
branch: str,
*,
lock_dir: str | None = None,
) -> bool:
target = (branch or "").strip()
if not target:
return False
for path in iter_lock_files(lock_dir):
lock = read_lock_file(path)
if not lock:
continue
if str(lock.get("branch_name") or "").strip() != target:
continue
if is_lease_live(lock):
return True
return False
def verify_lock_for_mutation(
lock_data: dict[str, Any] | None,
*,
issue_number: int | None = None,
branch_name: str | None = None,
worktree_path: str | None = None,
) -> dict[str, Any]:
"""Re-check lock ownership immediately before a mutation (#438)."""
reasons: list[str] = []
if not lock_data:
return {"proven": False, "block": True, "reasons": ["issue lock is missing (fail closed)"]}
freshness = assess_lock_freshness(lock_data)
if not freshness["live"]:
reasons.append(f"issue lock is not live: {freshness['reason']} (fail closed)")
if issue_number is not None and lock_data.get("issue_number") != issue_number:
reasons.append(
f"issue lock targets #{lock_data.get('issue_number')}, expected #{issue_number} (fail closed)"
)
if branch_name is not None and lock_data.get("branch_name") != branch_name:
reasons.append(
f"issue lock branch '{lock_data.get('branch_name')}' does not match "
f"'{branch_name}' (fail closed)"
)
if worktree_path is not None:
locked = os.path.realpath(str(lock_data.get("worktree_path") or ""))
declared = os.path.realpath(worktree_path)
if locked != declared:
reasons.append(
f"issue lock worktree '{locked}' does not match declared '{declared}' (fail closed)"
)
return {
"proven": not reasons,
"block": bool(reasons),
"reasons": reasons,
"freshness": freshness,
"lock_proof": format_lock_proof(lock_data, freshness=freshness),
}
def list_live_locks(
*,
lock_dir: str | None = None,
now: datetime | None = None,
) -> list[dict[str, Any]]:
"""Return live per-issue locks for queue visibility."""
live: list[dict[str, Any]] = []
for path in iter_lock_files(lock_dir):
record = read_lock_file(path)
if not record:
continue
freshness = assess_lock_freshness(record, now=now)
if not freshness["live"]:
continue
live.append(
{
"issue_number": record.get("issue_number"),
"branch_name": record.get("branch_name"),
"remote": record.get("remote"),
"org": record.get("org"),
"repo": record.get("repo"),
"worktree_path": record.get("worktree_path"),
"pid": record.get("session_pid") or record.get("pid"),
"claimant": (
record.get("claimant")
or (record.get("work_lease") or {}).get("claimant")
),
"freshness": freshness,
"lock_path": record.get("lock_file_path") or path,
}
)
return live
def format_lock_proof(
lock_data: dict[str, Any] | None,
*,
freshness: dict[str, Any] | None = None,
competing_live_locks: list[dict[str, Any]] | None = None,
released: bool | None = None,
) -> str:
"""Canonical issue-lock proof string for final reports."""
if not lock_data:
return "issue lock proof: not acquired"
fresh = freshness or assess_lock_freshness(lock_data)
owner = lock_data.get("claimant") or {}
if not owner and isinstance(lock_data.get("work_lease"), dict):
owner = lock_data["work_lease"].get("claimant") or {}
parts = [
"issue lock proof:",
f"acquired issue #{lock_data.get('issue_number')}",
f"branch {lock_data.get('branch_name')}",
f"owner {owner.get('profile') or 'unknown'}",
f"pid {lock_data.get('session_pid') or lock_data.get('pid')}",
f"freshness {fresh.get('status')}",
]
if competing_live_locks is not None:
parts.append(
"no competing live lock"
if not competing_live_locks
else f"competing live locks {len(competing_live_locks)}"
)
if released is True:
parts.append("lock released")
elif released is False:
parts.append("lock retained")
return "; ".join(parts)
+26 -5
View File
@@ -30,8 +30,16 @@ def resolve_author_worktree_path(
return os.path.realpath(os.path.abspath(path))
def read_worktree_git_state(worktree_path: str) -> dict:
"""Read branch name and porcelain status from a git worktree."""
def read_worktree_git_state(
worktree_path: str,
extra_bases: tuple[str, ...] | list[str] = (),
) -> dict:
"""Read branch name and porcelain status from a git worktree.
``extra_bases`` names additional branches (e.g. an approved stacked base)
that may anchor base-equivalence in addition to master/main/dev. When empty
(the default), only the normal base branches are considered.
"""
path = (worktree_path or "").strip()
if not path:
return {"current_branch": None, "porcelain_status": ""}
@@ -63,7 +71,7 @@ def read_worktree_git_state(worktree_path: str) -> dict:
check=False,
)
head_sha = (head_res.stdout or "").strip() if head_res.returncode == 0 else None
base_branch, base_sha = _find_matching_base_ref(path, head_sha)
base_branch, base_sha = _find_matching_base_ref(path, head_sha, extra_bases)
return {
"current_branch": current_branch,
"porcelain_status": status_res.stdout or "",
@@ -203,13 +211,26 @@ def _assessment(
}
def _find_matching_base_ref(path: str, head_sha: str | None) -> tuple[str | None, str | None]:
"""Return the stable branch ref whose commit matches HEAD, if any."""
def _find_matching_base_ref(
path: str,
head_sha: str | None,
extra_bases: tuple[str, ...] | list[str] = (),
) -> tuple[str | None, str | None]:
"""Return the stable branch ref whose commit matches HEAD, if any.
Normal base branches (master/main/dev) are always considered. ``extra_bases``
adds explicitly-approved stacked bases; each is checked as a local ref and via
the ``prgs``/``origin`` remotes.
"""
if not head_sha:
return None, None
candidates: list[str] = []
for branch in sorted(BASE_BRANCHES):
candidates.extend((f"origin/{branch}", branch))
for branch in extra_bases:
name = (branch or "").strip()
if name:
candidates.extend((f"prgs/{name}", f"origin/{name}", name))
for ref in candidates:
res = subprocess.run(
["git", "-C", path, "rev-parse", "--verify", ref],
+180
View File
@@ -0,0 +1,180 @@
"""Early duplicate-work detection for author work-issue sessions (#400)."""
from __future__ import annotations
from typing import Any
import issue_claim_heartbeat as claim_hb
PHASE_LOCK = "lock_issue"
PHASE_COMMIT = "commit"
PHASE_PUSH = "push"
PHASE_CREATE_PR = "create_pr"
OUTCOME_DUPLICATE_PR_PREVENTED = "duplicate_pr_prevented"
OUTCOME_DUPLICATE_BRANCH_PREVENTED = "duplicate_branch_prevented"
OUTCOME_DUPLICATE_COMMIT_PREVENTED = "duplicate_commit_prevented"
OUTCOME_DUPLICATE_WORK_NOT_PREVENTED = "duplicate_work_not_prevented"
_ACTIVE_CLAIM_STATUSES = frozenset({"active", "awaiting_review"})
def _issue_pattern(issue_number: int) -> str:
return f"issue-{int(issue_number)}"
def _linked_open_pr(issue_number: int, open_prs: list[dict]) -> dict | None:
return claim_hb._linked_open_pr(issue_number, open_prs)
def _matching_branches(
issue_number: int,
branch_names: list[str],
*,
locked_branch: str | None = None,
) -> list[str]:
pattern = _issue_pattern(issue_number)
matches = [
name for name in (branch_names or [])
if pattern in (name or "").lower()
]
if locked_branch:
locked = locked_branch.strip()
matches = [name for name in matches if name != locked]
return matches
def assess_work_issue_duplicate_gate(
issue_number: int,
*,
open_prs: list[dict] | None = None,
branch_names: list[str] | None = None,
claim_entry: dict | None = None,
locked_branch: str | None = None,
phase: str = PHASE_LOCK,
) -> dict[str, Any]:
"""Fail closed when duplicate work is already in flight for an issue."""
reasons: list[str] = []
outcome = OUTCOME_DUPLICATE_WORK_NOT_PREVENTED
prs = list(open_prs or [])
branches = list(branch_names or [])
pattern = _issue_pattern(issue_number)
linked = _linked_open_pr(issue_number, prs)
if linked:
reasons.append(
f"open PR #{linked.get('number')} already covers issue "
f"#{issue_number} (fail closed)"
)
outcome = OUTCOME_DUPLICATE_PR_PREVENTED
conflicting_branches = _matching_branches(
issue_number, branches, locked_branch=locked_branch
)
if conflicting_branches:
names = ", ".join(conflicting_branches[:5])
reasons.append(
f"remote branch(es) already match issue pattern '{pattern}': "
f"{names} (fail closed)"
)
if outcome == OUTCOME_DUPLICATE_WORK_NOT_PREVENTED:
outcome = OUTCOME_DUPLICATE_BRANCH_PREVENTED
entry = claim_entry or {}
if entry.get("linked_open_pr") and not linked:
reasons.append(
f"claim inventory reports open PR #{entry['linked_open_pr']} "
f"for issue #{issue_number} (fail closed)"
)
outcome = OUTCOME_DUPLICATE_PR_PREVENTED
status = (entry.get("status") or "").strip().lower()
if status in _ACTIVE_CLAIM_STATUSES and not linked:
heartbeat = entry.get("latest_heartbeat") or {}
claim_branch = (heartbeat.get("branch") or "").strip()
if locked_branch and claim_branch and claim_branch != locked_branch:
reasons.append(
f"active claim lease on branch '{claim_branch}' blocks "
f"work on '{locked_branch}' for issue #{issue_number} "
"(fail closed)"
)
if outcome == OUTCOME_DUPLICATE_WORK_NOT_PREVENTED:
outcome = OUTCOME_DUPLICATE_BRANCH_PREVENTED
elif not locked_branch and status == "active":
reasons.append(
f"issue #{issue_number} has an active claim lease "
"(fail closed)"
)
if outcome == OUTCOME_DUPLICATE_WORK_NOT_PREVENTED:
outcome = OUTCOME_DUPLICATE_BRANCH_PREVENTED
if phase in {PHASE_COMMIT, PHASE_PUSH} and reasons:
if outcome == OUTCOME_DUPLICATE_PR_PREVENTED:
outcome = OUTCOME_DUPLICATE_COMMIT_PREVENTED
elif outcome == OUTCOME_DUPLICATE_BRANCH_PREVENTED:
outcome = OUTCOME_DUPLICATE_COMMIT_PREVENTED
block = bool(reasons)
return {
"block": block,
"performed": not block,
"issue_number": issue_number,
"phase": phase,
"outcome": outcome,
"linked_open_pr": linked.get("number") if linked else entry.get("linked_open_pr"),
"conflicting_branches": conflicting_branches,
"claim_status": status or None,
"reasons": reasons,
"safe_next_action": (
"stop before mutating; preserve local work and produce a "
"reconciliation handoff if a concurrent PR appeared after push"
if block and phase == PHASE_CREATE_PR
else "stop before mutating; do not commit or push duplicate work"
if block
else "proceed"
),
}
def assess_work_issue_duplicate_report(report_text: str) -> dict[str, Any]:
"""Require explicit duplicate-work outcome wording in work-issue reports."""
text = (report_text or "").lower()
markers = {
OUTCOME_DUPLICATE_PR_PREVENTED: (
"duplicate pr prevented",
"duplicate_pr_prevented",
),
OUTCOME_DUPLICATE_BRANCH_PREVENTED: (
"duplicate branch prevented",
"duplicate_branch_prevented",
),
OUTCOME_DUPLICATE_COMMIT_PREVENTED: (
"duplicate commit prevented",
"duplicate_commit_prevented",
),
OUTCOME_DUPLICATE_WORK_NOT_PREVENTED: (
"duplicate work not prevented",
"duplicate_work_not_prevented",
"no duplicate work",
),
}
matched = [
key for key, phrases in markers.items()
if any(phrase in text for phrase in phrases)
]
if len(matched) != 1:
return {
"complete": False,
"downgraded": True,
"reasons": [
"work-issue report must state exactly one duplicate-work "
"outcome (duplicate PR/branch/commit prevented, or "
"duplicate work not prevented)"
],
}
return {
"complete": True,
"downgraded": False,
"outcome": matched[0],
"reasons": [],
}
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)
),
}
+61
View File
@@ -0,0 +1,61 @@
"""Merge approval must pin the current PR head SHA (#471).
Formal APPROVED reviews that predate the live PR head must not satisfy
``gitea_merge_pr`` eligibility. Pure assessment helpers are isolated here
for hermetic unit tests apart from MCP HTTP calls.
"""
from __future__ import annotations
def assess_merge_approval_head(
*,
current_head_sha: str | None,
latest_by_reviewer: dict,
) -> dict:
"""Return whether a visible approval applies to the live PR head.
Args:
current_head_sha: Current PR head commit SHA.
latest_by_reviewer: Map of reviewer login → review entry dicts with
``verdict``, ``dismissed``, and ``reviewed_head_sha`` keys.
Returns:
dict with ``approval_at_current_head``, ``latest_approved_head_sha``,
and ``stale_approval_block_reason`` (set when merge must fail closed).
"""
current = (current_head_sha or "").strip()
approved_entries = [
entry
for entry in (latest_by_reviewer or {}).values()
if (entry.get("verdict") or "").upper() == "APPROVED"
and not entry.get("dismissed")
]
at_current = any(
(entry.get("reviewed_head_sha") or "").strip() == current
for entry in approved_entries
if current
)
latest_approved = None
if approved_entries:
latest_entry = sorted(
approved_entries,
key=lambda entry: (
entry.get("submitted_at") or "",
entry.get("reviewed_head_sha") or "",
),
)[-1]
latest_approved = (latest_entry.get("reviewed_head_sha") or "").strip() or None
reason = None
if approved_entries and not at_current:
reason = (
f"stale approval: approved SHA '{latest_approved}' does not match "
f"current live PR head SHA '{current or '(unknown)'}' (fail closed); "
"required next action: re-review PR at current head before merge"
)
return {
"approval_at_current_head": at_current,
"latest_approved_head_sha": latest_approved,
"stale_approval_block_reason": reason,
}
+10 -14
View File
@@ -13,9 +13,9 @@ import subprocess
from typing import Any
from reviewer_worktree import parse_dirty_tracked_files
import issue_lock_store
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
ISSUE_LOCK_FILE = os.environ.get("GITEA_ISSUE_LOCK_FILE", "/tmp/gitea_issue_lock.json")
CLOSES_FIXES_RE = re.compile(r"\b(?:closes|fixes)\s+#(\d+)\b", re.IGNORECASE)
@@ -37,22 +37,18 @@ def resolve_worktree_path(project_root: str, branch: str) -> str:
def read_issue_lock(path: str | None = None) -> dict[str, Any] | None:
lock_path = (path or ISSUE_LOCK_FILE).strip()
if not lock_path or not os.path.exists(lock_path):
return None
try:
with open(lock_path, encoding="utf-8") as handle:
data = json.load(handle)
except (OSError, json.JSONDecodeError):
return None
return data if isinstance(data, dict) else None
if path:
return issue_lock_store.read_lock_file(path.strip())
return issue_lock_store.read_session_issue_lock()
def has_active_issue_lock(branch: str, lock_path: str | None = None) -> bool:
lock = read_issue_lock(lock_path)
if not lock:
return False
return (lock.get("branch_name") or "").strip() == (branch or "").strip()
if lock_path:
lock = issue_lock_store.read_lock_file(lock_path.strip())
if not lock:
return False
return (lock.get("branch_name") or "").strip() == (branch or "").strip()
return issue_lock_store.has_active_issue_lock(branch)
def collect_open_pr_heads(open_prs: list[dict[str, Any]]) -> set[str]:
+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,
}
+2
View File
@@ -31,6 +31,8 @@ REVIEWER_DEFAULT_FORBIDDEN = ["branch", "commit", "push", "open_pr"]
def infer_role(name, execution_profile):
"""Return the unambiguous role for a legacy profile name, or None."""
haystack = f"{name} {execution_profile or ''}".lower()
if "reconciler" in haystack:
return "reconciler"
has_author = "author" in haystack
has_reviewer = "reviewer" in haystack
if has_author == has_reviewer:
+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 [],
}
+369
View File
@@ -0,0 +1,369 @@
"""Native MCP preference gate and shell health circuit breaker (#270).
Gitea mutations must use native MCP tools first. Shell scripts, direct API
calls, browser helpers, and improvised encoders are blocked when MCP is
available unless explicit recovery-mode proof is supplied.
"""
from __future__ import annotations
import re
from typing import Any
from reviewer_fallback import LOCAL_GITEA_SCRIPT_NAMES
# Tasks that must prefer native MCP over shell/API/helper fallbacks.
GITEA_MUTATION_TASKS = frozenset({
"comment_issue",
"mark_issue",
"lock_issue",
"set_issue_labels",
"create_issue",
"close_issue",
"create_branch",
"push_branch",
"create_pr",
"close_pr",
"comment_pr",
"review_pr",
"merge_pr",
"approve_pr",
"request_changes_pr",
"commit_files",
"gitea_commit_files",
"delete_branch",
"address_pr_change_requests",
})
ALLOWED_PATH_KINDS = frozenset({
"mcp_native",
"recovery_fallback",
})
BLOCKED_PATH_KINDS = frozenset({
"shell_script",
"direct_api",
"webfetch",
"playwright",
"helper_script",
"unsafe_helper",
"mcp_server_touch",
})
SHELL_SPAWN_FAILURE_THRESHOLD = 2
TERMINAL_REPORT_HEADING = (
"MCP transport unavailable or shell circuit breaker tripped. "
"No unsafe Gitea fallback performed."
)
_RECOVERY_MODE_RE = re.compile(
r"\b(?:recovery mode|explicit recovery|mcp unavailable|mcp not available|"
r"mcp tools unavailable|no mcp path)\b",
re.IGNORECASE,
)
_LOCAL_SCRIPT_RE = re.compile(
r"(?:^|[\s\"'`/])(?:python3?|bash|sh)?\s*(?:"
+ "|".join(re.escape(name) for name in LOCAL_GITEA_SCRIPT_NAMES)
+ r")",
re.IGNORECASE,
)
_WEBFETCH_RE = re.compile(r"\b(?:webfetch|mcp_web_fetch|fetch\s+url)\b", re.IGNORECASE)
_PLAYWRIGHT_RE = re.compile(r"\b(?:playwright|browser_navigate|browser_click)\b", re.IGNORECASE)
_HELPER_SCRIPT_RE = re.compile(
r"(?:^|[\s\"'`/])(?:_encode_|_emit_|_inline_)[\w-]+\.py",
re.IGNORECASE,
)
_MANUAL_BASE64_RE = re.compile(
r"\b(?:manual\s+base64|llm-generated\s+base64|base64-encode\s+in\s+chat)\b",
re.IGNORECASE,
)
_DIRECT_API_RE = re.compile(
r"\b(?:api_request|urllib\.request|requests\.(?:post|patch|put)|curl\s+-X\s+POST)\b",
re.IGNORECASE,
)
_MCP_SERVER_TOUCH_RE = re.compile(
r"\b(?:kill|pkill|restart|reload|touch|edit|modify|write)\b.{0,40}\b(?:mcp_server|mcp-server|gitea_mcp_server)\b",
re.IGNORECASE,
)
_CLI_AUTH_DIVERGENCE_RE = re.compile(
r"GITEA_MCP_PROFILE\s*=\s*['\"]?([^\s'\";]+)",
re.IGNORECASE,
)
_session_shell: dict[str, Any] = {
"consecutive_spawn_failures": 0,
"shell_unavailable": False,
"hard_stopped": False,
"last_exit_code": None,
}
def _clean(value: str | None) -> str:
return (value or "").strip()
def is_spawn_failure(
*,
exit_code: int | None = None,
stdout: str | None = None,
stderr: str | None = None,
spawn_failure: bool | None = None,
) -> bool:
"""True for executor spawn failures (exit_code -1, empty output)."""
if spawn_failure is True:
return True
if spawn_failure is False:
return False
if exit_code != -1:
return False
return not (_clean(stdout) or _clean(stderr))
def record_shell_spawn_outcome(
*,
exit_code: int | None = None,
stdout: str | None = None,
stderr: str | None = None,
spawn_failure: bool | None = None,
probe_attempted: bool = False,
probe_succeeded: bool | None = None,
) -> dict[str, Any]:
"""Track shell spawn outcomes and trip the session circuit breaker (#270 AC4)."""
global _session_shell
failed = is_spawn_failure(
exit_code=exit_code,
stdout=stdout,
stderr=stderr,
spawn_failure=spawn_failure,
)
if failed:
_session_shell["consecutive_spawn_failures"] = (
int(_session_shell.get("consecutive_spawn_failures") or 0) + 1
)
_session_shell["last_exit_code"] = exit_code
if probe_attempted and probe_succeeded is False:
_session_shell["shell_unavailable"] = True
else:
_session_shell["consecutive_spawn_failures"] = 0
_session_shell["shell_unavailable"] = False
_session_shell["hard_stopped"] = False
_session_shell["last_exit_code"] = exit_code
failures = int(_session_shell["consecutive_spawn_failures"])
if failures >= SHELL_SPAWN_FAILURE_THRESHOLD:
_session_shell["shell_unavailable"] = True
_session_shell["hard_stopped"] = True
return shell_health_status()
def shell_health_status() -> dict[str, Any]:
"""Return the current shell health / circuit-breaker state."""
failures = int(_session_shell.get("consecutive_spawn_failures") or 0)
hard_stopped = bool(_session_shell.get("hard_stopped"))
shell_unavailable = bool(_session_shell.get("shell_unavailable"))
return {
"consecutive_spawn_failures": failures,
"shell_unavailable": shell_unavailable,
"hard_stopped": hard_stopped,
"threshold": SHELL_SPAWN_FAILURE_THRESHOLD,
"shell_use_allowed": not hard_stopped,
"last_exit_code": _session_shell.get("last_exit_code"),
"safe_next_action": (
"emit terminal recovery report; prefer native MCP for remaining Gitea mutations"
if hard_stopped
else (
"probe shell once with echo/pwd; if probe fails mark shell unavailable"
if failures == 1
else "shell healthy"
)
),
}
def clear_shell_health_for_tests() -> None:
"""Reset session shell state (tests only)."""
global _session_shell
_session_shell = {
"consecutive_spawn_failures": 0,
"shell_unavailable": False,
"hard_stopped": False,
"last_exit_code": None,
}
def classify_command_path(command_or_detail: str | None) -> str:
"""Classify a proposed command/detail into a path kind."""
text = command_or_detail or ""
if _MCP_SERVER_TOUCH_RE.search(text):
return "mcp_server_touch"
if _WEBFETCH_RE.search(text):
return "webfetch"
if _PLAYWRIGHT_RE.search(text):
return "playwright"
if _HELPER_SCRIPT_RE.search(text) or _MANUAL_BASE64_RE.search(text):
return "unsafe_helper"
if _LOCAL_SCRIPT_RE.search(text):
return "shell_script"
if _DIRECT_API_RE.search(text):
return "direct_api"
return "mcp_native"
def detect_cli_auth_divergence(
command_or_detail: str | None,
*,
active_profile: str | None,
) -> list[str]:
"""Flag shell commands that override GITEA_MCP_PROFILE away from the session."""
reasons: list[str] = []
text = command_or_detail or ""
active = _clean(active_profile)
match = _CLI_AUTH_DIVERGENCE_RE.search(text)
if not match or not active:
return reasons
requested = _clean(match.group(1))
if requested and requested != active:
reasons.append(
f"CLI auth divergence: command sets GITEA_MCP_PROFILE='{requested}' "
f"but active session profile is '{active}' (fail closed)"
)
return reasons
def format_mcp_unavailable_terminal_report(
*,
task: str | None = None,
reasons: list[str] | None = None,
) -> str:
"""Terminal report when MCP is broken and unsafe recovery is forbidden (#270 AC3)."""
lines = [TERMINAL_REPORT_HEADING, ""]
if task:
lines.append(f"Blocked task: {task}")
if reasons:
lines.extend(["", "Reasons:"])
lines.extend(f"- {reason}" for reason in reasons)
lines.extend([
"",
"Required recovery (no improvised fallback):",
"- Restart the MCP session",
"- Kill hung background terminals holding the shell executor",
"- Retry the native MCP tool once after reconnect",
"- If MCP remains unavailable, stop and hand off to the operator",
"- Do not run local Gitea scripts, WebFetch, Playwright, or manual base64 encoders",
])
return "\n".join(lines)
def assess_gitea_operation_path(
*,
task: str,
path_kind: str | None = None,
command_or_detail: str | None = None,
mcp_available: bool = True,
mcp_tool_visible: bool = True,
recovery_mode: bool = False,
recovery_proof_complete: bool = False,
role: str | None = None,
active_profile: str | None = None,
) -> dict[str, Any]:
"""Fail closed when a Gitea mutation would bypass native MCP (#270)."""
task_name = _clean(task)
resolved_kind = _clean(path_kind) or classify_command_path(command_or_detail)
reasons: list[str] = []
if task_name and task_name not in GITEA_MUTATION_TASKS:
reasons.append(
f"unknown or non-mutation Gitea task '{task_name}'; "
"native MCP preference gate applies only to Gitea mutations"
)
shell = shell_health_status()
if shell["hard_stopped"] and resolved_kind != "mcp_native":
reasons.append(
"shell circuit breaker is hard-stopped after consecutive spawn failures; "
"use native MCP or emit terminal recovery report"
)
recovery_declared = recovery_mode or bool(
_RECOVERY_MODE_RE.search(command_or_detail or "")
)
recovery_allowed = (
recovery_declared
and recovery_proof_complete
and not mcp_available
and resolved_kind in BLOCKED_PATH_KINDS - {"mcp_server_touch"}
)
if resolved_kind in BLOCKED_PATH_KINDS and not recovery_allowed:
reasons.append(f"path kind '{resolved_kind}' is not an approved Gitea mutation path")
if resolved_kind == "mcp_server_touch":
if _clean(role) == "reviewer" or (
active_profile and "reviewer" in active_profile.lower()
):
reasons.append(
"reviewer workflows must not touch, restart, or kill MCP server files/processes"
)
else:
reasons.append(
"MCP server files/processes must not be modified during normal Gitea workflows"
)
reasons.extend(
detect_cli_auth_divergence(command_or_detail, active_profile=active_profile)
)
if (
mcp_available
and mcp_tool_visible
and resolved_kind != "mcp_native"
and not recovery_allowed
):
if not recovery_declared:
reasons.append(
"native MCP tools are available; shell/API/helper fallback is forbidden"
)
elif not recovery_proof_complete:
reasons.append(
"recovery-mode fallback requires complete recovery proof before proceeding"
)
if not mcp_available and resolved_kind != "mcp_native" and not recovery_allowed:
if not recovery_declared:
reasons.append(
"MCP transport unavailable; produce terminal recovery report instead of "
"improvising unsafe fallback"
)
block = bool(reasons)
terminal_report = None
if block and (not mcp_available or shell["hard_stopped"]):
terminal_report = format_mcp_unavailable_terminal_report(
task=task_name or None,
reasons=reasons,
)
return {
"task": task_name or None,
"path_kind": resolved_kind,
"mcp_available": mcp_available,
"mcp_tool_visible": mcp_tool_visible,
"recovery_mode": recovery_declared,
"shell_health": shell,
"block": block,
"allowed": not block,
"reasons": reasons,
"terminal_report": terminal_report,
"safe_next_action": (
"use the native MCP tool for this task"
if mcp_available and mcp_tool_visible and block
else (
terminal_report or "proceed with native MCP"
if block
else "proceed with native MCP"
)
),
}
+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"
),
}
+225
View File
@@ -0,0 +1,225 @@
"""PR-only queue cleanup mode gates and report verifier (#390).
Cleanup mode dispatches exactly one canonical review run per PR. It forbids
author-side mutations (issue claiming, branch creation, implementation edits,
issue filing) and enforces the terminal-mutation chain: stop after
``REQUEST_CHANGES``; after ``APPROVED`` continue only to same-PR merge when
merge is explicitly authorized for that PR and merge gates pass; stop after
merge or a merge blocker. The next PR always requires a fresh run.
"""
from __future__ import annotations
import re
CLEANUP_WORKFLOW_PATH = "workflows/pr-queue-cleanup.md"
# Author-side resolver tasks that must never run inside cleanup mode.
CLEANUP_FORBIDDEN_TASKS = frozenset({
"claim_issue",
"mark_issue",
"lock_issue",
"create_issue",
"create_branch",
"push_branch",
"create_pr",
"commit_files",
"gitea_commit_files",
"address_pr_change_requests",
})
# Run-state outcomes for one cleanup dispatch.
STOP_AFTER_REQUEST_CHANGES = "STOP_AFTER_REQUEST_CHANGES"
STOP_AFTER_DECISION = "STOP_AFTER_DECISION"
CONTINUE_TO_SAME_PR_MERGE = "CONTINUE_TO_SAME_PR_MERGE"
STOP_APPROVED_NO_MERGE_AUTH = "STOP_APPROVED_NO_MERGE_AUTH"
STOP_APPROVED_MERGE_GATES_FAILED = "STOP_APPROVED_MERGE_GATES_FAILED"
STOP_AFTER_MERGE = "STOP_AFTER_MERGE"
STOP_AFTER_MERGE_BLOCKER = "STOP_AFTER_MERGE_BLOCKER"
STOP_GATE_NOT_PROVEN = "STOP_GATE_NOT_PROVEN"
_TERMINAL_DECISIONS = frozenset({"approved", "request_changes", "comment", "skip"})
def resolve_cleanup_run_state(
decision: str | None,
*,
merge_authorized_for_pr: bool = False,
merge_gates_passed: bool | None = None,
merge_completed: bool = False,
merge_blocker: bool = False,
) -> dict:
"""Resolve what one cleanup run may do after its terminal review decision.
Fail closed: unknown decisions stop the run with no further mutation.
"""
normalized = (decision or "").strip().lower()
if normalized not in _TERMINAL_DECISIONS:
return {
"outcome": STOP_GATE_NOT_PROVEN,
"further_mutation_allowed": False,
"reasons": [
f"unknown terminal decision {decision!r}; cleanup run stops "
"(fail closed)"
],
}
if normalized == "request_changes":
return {
"outcome": STOP_AFTER_REQUEST_CHANGES,
"further_mutation_allowed": False,
"reasons": ["REQUEST_CHANGES is terminal in cleanup mode"],
}
if normalized in {"comment", "skip"}:
return {
"outcome": STOP_AFTER_DECISION,
"further_mutation_allowed": False,
"reasons": [f"{normalized} decision ends the cleanup run"],
}
# normalized == "approved"
if merge_completed:
return {
"outcome": STOP_AFTER_MERGE,
"further_mutation_allowed": False,
"reasons": ["merge completed; cleanup run stops"],
}
if merge_blocker:
return {
"outcome": STOP_AFTER_MERGE_BLOCKER,
"further_mutation_allowed": False,
"reasons": ["merge blocker recorded; cleanup run stops"],
}
if not merge_authorized_for_pr:
return {
"outcome": STOP_APPROVED_NO_MERGE_AUTH,
"further_mutation_allowed": False,
"reasons": [
"APPROVED without explicit per-PR merge authorization; "
"cleanup run stops"
],
}
if merge_gates_passed is not True:
return {
"outcome": STOP_APPROVED_MERGE_GATES_FAILED,
"further_mutation_allowed": False,
"reasons": [
"merge gates not proven passed; cleanup run stops before merge"
],
}
return {
"outcome": CONTINUE_TO_SAME_PR_MERGE,
"further_mutation_allowed": True,
"reasons": [],
"allowed_mutation": "merge same PR only",
}
def check_cleanup_task_allowed(task: str) -> tuple[bool, list[str]]:
"""Fail closed on any author-side mutation task inside cleanup mode."""
normalized = (task or "").strip().lower()
if normalized in CLEANUP_FORBIDDEN_TASKS:
return False, [
f"task '{normalized}' is forbidden in PR-only cleanup mode: "
"no issue claiming, branch creation, implementation edits, or "
"issue filing"
]
return True, []
_SELECTED_PR_RE = re.compile(
r"^\s*[-*]?\s*selected pr\s*:\s*#?(\d+)", re.IGNORECASE | re.MULTILINE
)
_NEXT_SUGGESTED_RE = re.compile(
r"^\s*[-*]?\s*next suggested pr\s*:", re.IGNORECASE | re.MULTILINE
)
_TERMINAL_MUTATION_RE = re.compile(
r"^\s*[-*]?\s*(?:review (?:decision|verdict)|terminal (?:review )?"
r"(?:decision|mutation))\s*:\s*"
r"(approved|request_changes|request changes|merged|comment)",
re.IGNORECASE | re.MULTILINE,
)
_PAGINATION_HINT_RE = re.compile(
r"inventory_complete|final page|has_more\s*[=:]\s*false|total[_ ]count",
re.IGNORECASE,
)
_MERGE_RESULT_RE = re.compile(
r"^\s*[-*]?\s*merge result\s*:\s*(?!none\b|not attempted\b)\S",
re.IGNORECASE | re.MULTILINE,
)
_MERGE_AUTHORIZED_RE = re.compile(
r"^\s*[-*]?\s*merge authorized(?: for pr)?\s*:\s*true",
re.IGNORECASE | re.MULTILINE,
)
_ISSUE_MUTATION_CLAIM_RE = re.compile(
r"^\s*[-*]?\s*issue mutations\s*:\s*(?!none\b)\S",
re.IGNORECASE | re.MULTILINE,
)
_BRANCH_MUTATION_CLAIM_RE = re.compile(
r"^\s*[-*]?\s*branch mutations\s*:\s*(?!none\b)\S",
re.IGNORECASE | re.MULTILINE,
)
def assess_pr_queue_cleanup_report(report_text: str) -> dict:
"""Validate a PR-only cleanup run report (single dispatch, fail closed)."""
reasons: list[str] = []
text = report_text or ""
if CLEANUP_WORKFLOW_PATH not in text:
reasons.append(
f"report must cite the canonical cleanup workflow "
f"({CLEANUP_WORKFLOW_PATH})"
)
selected = _SELECTED_PR_RE.findall(text)
if len(selected) == 0:
reasons.append("report must name exactly one Selected PR")
elif len(set(selected)) > 1:
reasons.append(
"cleanup run selected multiple PRs "
f"({', '.join(sorted(set(selected)))}); one canonical review per "
"PR per run"
)
terminal = _TERMINAL_MUTATION_RE.findall(text)
if len(terminal) > 1:
reasons.append(
"multiple terminal review mutations reported in one cleanup run"
)
if not _PAGINATION_HINT_RE.search(text):
reasons.append(
"report missing PR inventory pagination proof "
"(inventory_complete / final page / total_count)"
)
if not _NEXT_SUGGESTED_RE.search(text):
reasons.append(
"report must include 'Next suggested PR' (without continuing to it)"
)
if _MERGE_RESULT_RE.search(text) and not _MERGE_AUTHORIZED_RE.search(text):
reasons.append(
"merge reported without explicit per-PR merge authorization "
"('Merge authorized: true')"
)
if _ISSUE_MUTATION_CLAIM_RE.search(text):
reasons.append("issue mutations are forbidden in PR-only cleanup mode")
if _BRANCH_MUTATION_CLAIM_RE.search(text):
reasons.append("branch mutations are forbidden in PR-only cleanup mode")
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"safe_next_action": (
"proceed" if proven else
"fix the cleanup report: one PR, one terminal mutation, pagination "
"proof, next-suggested-PR field, and no issue/branch mutations"
),
}
+482
View File
@@ -0,0 +1,482 @@
"""Conflict-fix and reviewer PR work leases (#399, #407 reader).
Structured PR/issue comments prove exclusive phases so author conflict-fix
pushes cannot race reviewer validation/approval/merge on the same head.
"""
from __future__ import annotations
import re
from datetime import datetime, timedelta, timezone
from typing import Any
REVIEWER_LEASE_MARKER = "<!-- mcp-review-lease:v1 -->"
CONFLICT_FIX_LEASE_MARKER = "<!-- mcp-conflict-fix-lease:v1 -->"
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
_FIELD_RE = re.compile(
r"^\s*([a-z_]+)\s*:\s*(.+?)\s*$",
re.IGNORECASE | re.MULTILINE,
)
_TERMINAL_REVIEWER_PHASES = frozenset({"done", "released", "blocked"})
_ACTIVE_REVIEWER_PHASES = frozenset({
"claimed",
"validating",
"approved",
"request-changes",
"merging",
})
_TERMINAL_CONFLICT_FIX_PHASES = frozenset({"released", "blocked", "done"})
_ACTIVE_CONFLICT_FIX_PHASES = frozenset({"claimed", "pushing", "pushed"})
DEFAULT_CONFLICT_FIX_TTL_MINUTES = 120
DEFAULT_REVIEWER_LEASE_TTL_MINUTES = 120
def _parse_timestamp(value: str | None) -> datetime | None:
if not value:
return None
text = value.strip()
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _normalize_sha(value: str | None) -> str | None:
text = (value or "").strip().lower()
if not text:
return None
return text if _FULL_SHA.match(text) else None
def _parse_pr_ref(value: str | None) -> int | None:
digits = re.sub(r"[^\d]", "", value or "")
return int(digits) if digits.isdigit() else None
def _parse_marker_comment(body: str, marker: str) -> dict[str, str] | None:
text = body or ""
if marker not in text:
return None
fields: dict[str, str] = {}
for match in _FIELD_RE.finditer(text):
fields[match.group(1).strip().lower()] = match.group(2).strip()
return fields or None
def parse_reviewer_lease_comment(body: str) -> dict[str, Any] | None:
fields = _parse_marker_comment(body, REVIEWER_LEASE_MARKER)
if not fields:
return None
return {
"lease_kind": "reviewer",
"pr_number": _parse_pr_ref(fields.get("pr")),
"issue_number": _parse_pr_ref(fields.get("issue")),
"reviewer_identity": fields.get("reviewer_identity"),
"profile": fields.get("profile"),
"session_id": fields.get("session_id"),
"worktree": fields.get("worktree"),
"phase": (fields.get("phase") or "").strip().lower() or None,
"candidate_head": _normalize_sha(fields.get("candidate_head")),
"target_branch": fields.get("target_branch"),
"target_branch_sha": _normalize_sha(fields.get("target_branch_sha")),
"last_activity": fields.get("last_activity"),
"expires_at": fields.get("expires_at"),
"blocker": fields.get("blocker"),
"raw_fields": fields,
}
def parse_conflict_fix_lease_comment(body: str) -> dict[str, Any] | None:
fields = _parse_marker_comment(body, CONFLICT_FIX_LEASE_MARKER)
if not fields:
return None
ff = (fields.get("fast_forward") or "").strip().lower()
reviewer_active = (fields.get("reviewer_active") or "").strip().lower()
return {
"lease_kind": "conflict_fix",
"pr_number": _parse_pr_ref(fields.get("pr")),
"branch": fields.get("branch"),
"worktree": fields.get("worktree"),
"profile": fields.get("profile"),
"session_id": fields.get("session_id"),
"phase": (fields.get("phase") or "").strip().lower() or None,
"head_before": _normalize_sha(fields.get("head_before")),
"head_after": _normalize_sha(fields.get("head_after")),
"expires_at": fields.get("expires_at"),
"reviewer_active": reviewer_active in {"yes", "true", "1"},
"fast_forward": ff in {"yes", "true", "1"},
"raw_fields": fields,
}
def _comment_entries(comments: list[dict], *, pr_number: int | None) -> list[dict]:
entries: list[dict] = []
for comment in comments or []:
body = comment.get("body") or ""
for parser in (parse_reviewer_lease_comment, parse_conflict_fix_lease_comment):
parsed = parser(body)
if not parsed:
continue
if pr_number is not None and parsed.get("pr_number") not in (None, pr_number):
continue
entries.append({
**parsed,
"comment_id": comment.get("id"),
"author": (comment.get("user") or {}).get("login") or comment.get("author"),
"created_at": comment.get("created_at"),
"updated_at": comment.get("updated_at"),
})
break
return entries
def _lease_expired(lease: dict, *, now: datetime) -> bool:
expires_at = _parse_timestamp(lease.get("expires_at"))
return bool(expires_at and expires_at <= now)
def _lease_phase_active(lease: dict, *, active_phases: frozenset[str]) -> bool:
phase = (lease.get("phase") or "").strip().lower()
if phase in _TERMINAL_REVIEWER_PHASES or phase in _TERMINAL_CONFLICT_FIX_PHASES:
return False
return phase in active_phases or bool(phase and phase not in (
_TERMINAL_REVIEWER_PHASES | _TERMINAL_CONFLICT_FIX_PHASES
))
def find_active_reviewer_lease(
comments: list[dict],
*,
pr_number: int,
now: datetime | None = None,
) -> dict[str, Any] | None:
"""Return the newest unexpired reviewer lease for *pr_number*, if any."""
now = now or datetime.now(timezone.utc)
candidates = [
entry for entry in _comment_entries(comments, pr_number=pr_number)
if entry.get("lease_kind") == "reviewer"
]
for lease in reversed(candidates):
if _lease_expired(lease, now=now):
continue
phase = (lease.get("phase") or "").strip().lower()
if phase in _TERMINAL_REVIEWER_PHASES:
continue
if phase in _ACTIVE_REVIEWER_PHASES or phase:
return lease
return None
def find_active_conflict_fix_lease(
comments: list[dict],
*,
pr_number: int,
now: datetime | None = None,
) -> dict[str, Any] | None:
"""Return the newest unexpired conflict-fix lease for *pr_number*, if any."""
now = now or datetime.now(timezone.utc)
candidates = [
entry for entry in _comment_entries(comments, pr_number=pr_number)
if entry.get("lease_kind") == "conflict_fix"
]
for lease in reversed(candidates):
if _lease_expired(lease, now=now):
continue
phase = (lease.get("phase") or "").strip().lower()
if phase in _TERMINAL_CONFLICT_FIX_PHASES:
continue
if phase in _ACTIVE_CONFLICT_FIX_PHASES or phase:
return lease
return None
def format_conflict_fix_lease_body(
*,
pr_number: int,
branch: str,
worktree: str,
profile: str,
head_before: str,
phase: str = "claimed",
session_id: str = "unknown",
expires_at: datetime | None = None,
reviewer_active: bool = False,
) -> str:
expires = expires_at or (
datetime.now(timezone.utc) + timedelta(minutes=DEFAULT_CONFLICT_FIX_TTL_MINUTES)
)
expires_text = expires.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
"+00:00", "Z"
)
lines = [
CONFLICT_FIX_LEASE_MARKER,
f"pr: #{pr_number}",
f"branch: {branch}",
f"worktree: {worktree}",
f"profile: {profile}",
f"session_id: {session_id}",
f"phase: {phase}",
f"head_before: {head_before}",
f"expires_at: {expires_text}",
f"reviewer_active: {'yes' if reviewer_active else 'no'}",
]
return "\n".join(lines)
def assess_head_sha_equality(
reviewed_head_sha: str | None,
live_head_sha: str | None,
) -> dict[str, Any]:
"""Fail closed when reviewed and live PR heads differ."""
reviewed = _normalize_sha(reviewed_head_sha)
live = _normalize_sha(live_head_sha)
reasons: list[str] = []
if not reviewed or not live:
reasons.append(
"reviewed/live head SHA missing or not full 40-hex; fail closed"
)
elif reviewed != live:
reasons.append(
"PR head changed after validation; re-pin and re-validate before "
"approval or merge"
)
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"reviewed_head_sha": reviewed,
"live_head_sha": live,
"head_changed": bool(reviewed and live and reviewed != live),
}
def assess_conflict_fix_push(
*,
pr_number: int,
comments: list[dict],
branch_head_before: str | None,
branch_head_after: str | None,
worktree_path: str | None,
push_cwd: str | None,
is_fast_forward: bool | None,
now: datetime | None = None,
) -> dict[str, Any]:
"""Author pre-push gate: block when a reviewer holds an active lease."""
now = now or datetime.now(timezone.utc)
reasons: list[str] = []
reviewer_lease = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
conflict_lease = find_active_conflict_fix_lease(comments, pr_number=pr_number, now=now)
if reviewer_lease:
reasons.append(
f"active reviewer lease on PR #{pr_number} "
f"(phase={reviewer_lease.get('phase')}); author push blocked"
)
head_before = _normalize_sha(branch_head_before)
head_after = _normalize_sha(branch_head_after)
if not head_before:
reasons.append("branch head before push missing or invalid SHA")
if head_after and head_before and head_before == head_after:
reasons.append("branch head unchanged; no push to perform")
worktree = (worktree_path or "").strip()
cwd = (push_cwd or "").strip()
if not worktree:
reasons.append("worktree path required for conflict-fix push proof")
elif cwd and worktree and not cwd.rstrip("/").endswith(worktree.rstrip("/").split("/")[-1]):
if worktree not in cwd:
reasons.append(
f"push cwd '{cwd}' does not match session worktree '{worktree}'"
)
if is_fast_forward is False:
reasons.append("non-fast-forward push rejected for conflict-fix (fail closed)")
if conflict_lease and conflict_lease.get("phase") == "pushing":
owner = conflict_lease.get("worktree")
if owner and worktree and owner != worktree:
reasons.append(
f"sibling conflict-fix lease active from worktree '{owner}'"
)
push_allowed = not reasons
return {
"push_allowed": push_allowed,
"block": not push_allowed,
"reasons": reasons,
"active_reviewer_lease": reviewer_lease,
"active_conflict_fix_lease": conflict_lease,
"branch_head_before": head_before,
"branch_head_after": head_after,
"reviewer_was_active": bool(reviewer_lease),
"fast_forward": is_fast_forward,
}
def assess_reviewer_mutation_blocked(
*,
pr_number: int,
comments: list[dict],
reviewed_head_sha: str | None,
live_head_sha: str | None,
mutation: str,
now: datetime | None = None,
) -> dict[str, Any]:
"""Reviewer gate: block when conflict-fix lease active or head moved."""
now = now or datetime.now(timezone.utc)
reasons: list[str] = []
conflict_lease = find_active_conflict_fix_lease(comments, pr_number=pr_number, now=now)
if conflict_lease and (conflict_lease.get("phase") or "") in _ACTIVE_CONFLICT_FIX_PHASES:
reasons.append(
f"active conflict-fix lease on PR #{pr_number} "
f"(phase={conflict_lease.get('phase')}); reviewer {mutation} blocked"
)
head_check = assess_head_sha_equality(reviewed_head_sha, live_head_sha)
if head_check["block"]:
reasons.extend(head_check["reasons"])
if not _normalize_sha(reviewed_head_sha):
reasons.append(
f"reviewed head SHA required before reviewer {mutation} (fail closed)"
)
allowed = not reasons
return {
"mutation_allowed": allowed,
"block": not allowed,
"reasons": reasons,
"active_conflict_fix_lease": conflict_lease,
"head_check": head_check,
"reviewed_head_sha": head_check.get("reviewed_head_sha"),
"live_head_sha": head_check.get("live_head_sha"),
"push_during_validation": bool(
conflict_lease and conflict_lease.get("phase") in {"pushing", "pushed"}
),
}
_REVIEWED_HEAD_RE = re.compile(
r"reviewed head sha\s*:\s*([0-9a-f]{40})",
re.IGNORECASE,
)
_LIVE_HEAD_BEFORE_APPROVAL_RE = re.compile(
r"(?:live head sha before approval|final live head sha before approval)\s*:\s*([0-9a-f]{40})",
re.IGNORECASE,
)
_LIVE_HEAD_BEFORE_MERGE_RE = re.compile(
r"(?:live head sha before merge|final live head sha before merge)\s*:\s*([0-9a-f]{40})",
re.IGNORECASE,
)
_PUSH_DURING_VALIDATION_RE = re.compile(
r"push(?:es)? occurred during validation\s*:\s*(yes|no|true|false)",
re.IGNORECASE,
)
_CONFLICT_HEAD_BEFORE_RE = re.compile(
r"branch head before push\s*:\s*([0-9a-f]{40})",
re.IGNORECASE,
)
_CONFLICT_HEAD_AFTER_RE = re.compile(
r"branch head after push\s*:\s*([0-9a-f]{40})",
re.IGNORECASE,
)
_REVIEWER_LEASE_STATUS_RE = re.compile(
r"active reviewer lease status\s*:\s*(.+)$",
re.IGNORECASE | re.MULTILINE,
)
_FAST_FORWARD_RE = re.compile(
r"whether push was fast-forward\s*:\s*(yes|no|true|false)",
re.IGNORECASE,
)
_REVIEWER_ACTIVE_RE = re.compile(
r"whether any reviewer was active\s*:\s*(yes|no|true|false)",
re.IGNORECASE,
)
def assess_reviewer_stale_head_final_report(report_text: str) -> dict[str, Any]:
"""Final-report proof for reviewed vs live head SHAs (#399 AC 6)."""
text = report_text or ""
reasons: list[str] = []
reviewed = _normalize_sha(_REVIEWED_HEAD_RE.search(text).group(1) if _REVIEWED_HEAD_RE.search(text) else None)
live_approval = _normalize_sha(
_LIVE_HEAD_BEFORE_APPROVAL_RE.search(text).group(1)
if _LIVE_HEAD_BEFORE_APPROVAL_RE.search(text)
else None
)
live_merge = _normalize_sha(
_LIVE_HEAD_BEFORE_MERGE_RE.search(text).group(1)
if _LIVE_HEAD_BEFORE_MERGE_RE.search(text)
else None
)
push_during = _PUSH_DURING_VALIDATION_RE.search(text)
if not reviewed:
reasons.append("reviewed head SHA not stated in final report")
if not live_approval:
reasons.append("final live head SHA before approval not stated")
if not live_merge:
reasons.append("final live head SHA before merge not stated")
if not push_during:
reasons.append("whether push occurred during validation not stated")
elif reviewed and live_approval and reviewed != live_approval:
reasons.append("live head before approval differs from reviewed head SHA")
elif reviewed and live_merge and reviewed != live_merge:
reasons.append("live head before merge differs from reviewed head SHA")
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"reviewed_head_sha": reviewed,
"live_head_sha_before_approval": live_approval,
"live_head_sha_before_merge": live_merge,
"push_during_validation": (push_during.group(1).lower() if push_during else None),
}
def assess_conflict_fix_final_report(report_text: str) -> dict[str, Any]:
"""Final-report proof for conflict-fix push sessions (#399 AC 7)."""
text = report_text or ""
reasons: list[str] = []
head_before = _normalize_sha(
_CONFLICT_HEAD_BEFORE_RE.search(text).group(1)
if _CONFLICT_HEAD_BEFORE_RE.search(text)
else None
)
head_after = _normalize_sha(
_CONFLICT_HEAD_AFTER_RE.search(text).group(1)
if _CONFLICT_HEAD_AFTER_RE.search(text)
else None
)
if not head_before:
reasons.append("branch head before push not stated")
if not head_after:
reasons.append("branch head after push not stated")
if not _REVIEWER_LEASE_STATUS_RE.search(text):
reasons.append("active reviewer lease status not stated")
if not _FAST_FORWARD_RE.search(text):
reasons.append("whether push was fast-forward not stated")
if not _REVIEWER_ACTIVE_RE.search(text):
reasons.append("whether any reviewer was active not stated")
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"branch_head_before": head_before,
"branch_head_after": head_after,
}
+94
View File
@@ -0,0 +1,94 @@
"""Reconciler profile model for already-landed PR closure (#304).
Defines the narrowly scoped operation set for a dedicated reconciler profile
such as ``prgs-reconciler``. Close MCP tooling and ancestry gates ship in the
#310 stack; this module validates profile shape only.
"""
from __future__ import annotations
import gitea_config
RECONCILER_REQUIRED_OPERATIONS = (
"gitea.read",
"gitea.pr.close",
)
RECONCILER_RECOMMENDED_OPERATIONS = (
"gitea.pr.comment",
"gitea.issue.comment",
"gitea.issue.close",
)
RECONCILER_FORBIDDEN_OPERATIONS = (
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.review",
"gitea.pr.create",
"gitea.branch.push",
"gitea.repo.commit",
)
def _normalized_allowed(allowed: list[str]) -> set[str]:
normalized: set[str] = set()
for entry in allowed or []:
try:
normalized.add(gitea_config.normalize_operation(entry))
except gitea_config.ConfigError:
continue
return normalized
def _forbidden_in_allowed(allowed: list[str], ops: tuple[str, ...]) -> list[str]:
"""Return forbidden ops that are explicitly listed in *allowed*."""
allowed_n = _normalized_allowed(allowed)
present: list[str] = []
for op in ops:
try:
if gitea_config.normalize_operation(op) in allowed_n:
present.append(op)
except gitea_config.ConfigError:
continue
return present
def is_reconciler_profile(allowed: list[str], forbidden: list[str]) -> bool:
"""Return True when *allowed*/*forbidden* describe a reconciler profile."""
def can(op: str) -> bool:
return gitea_config.check_operation(op, allowed, forbidden)[0]
if not can("gitea.pr.close"):
return False
if _forbidden_in_allowed(allowed, RECONCILER_FORBIDDEN_OPERATIONS):
return False
return True
def assess_reconciler_profile(allowed: list[str], forbidden: list[str]) -> dict:
"""Validate reconciler profile operations (read-only, fail closed)."""
allowed = list(allowed or [])
forbidden = list(forbidden or [])
reasons: list[str] = []
for op in RECONCILER_REQUIRED_OPERATIONS:
ok, _ = gitea_config.check_operation(op, allowed, forbidden)
if not ok:
reasons.append(f"missing required operation {op}")
for op in _forbidden_in_allowed(allowed, RECONCILER_FORBIDDEN_OPERATIONS):
reasons.append(f"forbidden operation must not be allowed: {op}")
missing_recommended = [
op for op in RECONCILER_RECOMMENDED_OPERATIONS
if not gitea_config.check_operation(op, allowed, forbidden)[0]
]
return {
"is_reconciler_profile": is_reconciler_profile(allowed, forbidden),
"valid": not reasons and is_reconciler_profile(allowed, forbidden),
"reasons": reasons,
"missing_recommended_operations": missing_recommended,
"required_operations": list(RECONCILER_REQUIRED_OPERATIONS),
"forbidden_operations": list(RECONCILER_FORBIDDEN_OPERATIONS),
}
+292
View File
@@ -0,0 +1,292 @@
"""Already-landed PR reconciliation workflow helpers (#301).
Read-only assessment and capability planning for reconciling open PRs whose
heads are already ancestors of the target branch. Does not invoke review or
merge paths.
"""
from __future__ import annotations
import subprocess
from typing import Any
import gitea_config
from merged_cleanup_reconcile import extract_linked_issue, is_head_ancestor_of_ref
ELIGIBILITY_ALREADY_LANDED = "ALREADY_LANDED_RECONCILE_REQUIRED"
ELIGIBILITY_NOT_LANDED = "NOT_ALREADY_LANDED"
ELIGIBILITY_STALE_TARGET = "TARGET_BRANCH_UNVERIFIED"
ELIGIBILITY_PR_NOT_OPEN = "PR_NOT_OPEN"
OUTCOME_FULL_RECONCILE = "FULL_RECONCILE_CLOSE_ALLOWED"
OUTCOME_PARTIAL_COMMENT = "PARTIAL_RECONCILE_COMMENT_THEN_STOP"
OUTCOME_RECOVERY_HANDOFF = "RECOVERY_HANDOFF_ONLY"
OUTCOME_NOT_LANDED = "NOT_LANDED_NO_ACTION"
OUTCOME_GATE_NOT_PROVEN = "GATE_NOT_PROVEN"
RECONCILE_WORKFLOW_MARKERS = (
"workflows/reconcile-landed-pr.md",
"reconcile-landed-pr.md",
)
RECONCILE_TASK_MARKERS = (
"reconcile-landed-pr",
"reconcile already-landed",
"reconcile_already_landed",
)
def fetch_target_branch(project_root: str, remote: str, branch: str) -> dict[str, Any]:
"""Fetch *branch* from *remote* and return the resolved SHA."""
ref = f"{remote}/{branch}"
fetch = subprocess.run(
["git", "-C", project_root, "fetch", remote, branch],
capture_output=True,
text=True,
check=False,
)
if fetch.returncode != 0:
return {
"success": False,
"target_branch": branch,
"target_ref": ref,
"target_branch_sha": None,
"reasons": [
f"git fetch {remote} {branch} failed: "
f"{(fetch.stderr or fetch.stdout or '').strip()}"
],
}
rev = subprocess.run(
["git", "-C", project_root, "rev-parse", ref],
capture_output=True,
text=True,
check=False,
)
if rev.returncode != 0:
return {
"success": False,
"target_branch": branch,
"target_ref": ref,
"target_branch_sha": None,
"reasons": [
f"git rev-parse {ref} failed: "
f"{(rev.stderr or rev.stdout or '').strip()}"
],
}
return {
"success": True,
"target_branch": branch,
"target_ref": ref,
"target_branch_sha": (rev.stdout or "").strip(),
"reasons": [],
"git_fetch_command": f"git fetch {remote} {branch}",
}
def profile_reconciliation_capabilities(
allowed: list[str] | None,
forbidden: list[str] | None,
) -> dict[str, bool]:
"""Map active profile operations to reconciliation capabilities."""
allowed = allowed or []
forbidden = forbidden or []
def can(op: str) -> bool:
return gitea_config.check_operation(op, allowed, forbidden)[0]
return {
"read": can("gitea.read"),
"comment_pr": can("gitea.pr.comment"),
"comment_issue": can("gitea.issue.comment"),
"close_pr": can("gitea.pr.close"),
"close_issue": can("gitea.issue.close"),
"review_pr": can("gitea.pr.review") or can("gitea.pr.approve"),
"merge_pr": can("gitea.pr.merge"),
}
def assess_open_pr_reconciliation(
*,
pr: dict[str, Any],
project_root: str,
remote: str,
target_branch: str,
target_fetch: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Assess whether an open PR is eligible for already-landed reconciliation."""
pr_number = int(pr.get("number") or 0)
pr_state = (pr.get("state") or "").strip().lower()
head = pr.get("head") or {}
head_sha = head.get("sha") if isinstance(head, dict) else None
head_ref = head.get("ref") if isinstance(head, dict) else None
base = pr.get("base") or {}
base_ref = base.get("ref") if isinstance(base, dict) else None
title = pr.get("title") or ""
body = pr.get("body") or ""
fetch_result = target_fetch or fetch_target_branch(
project_root, remote, target_branch
)
linked_issue = extract_linked_issue(title, body)
result: dict[str, Any] = {
"pr_number": pr_number,
"pr_state": pr_state,
"candidate_head_sha": head_sha,
"head_ref": head_ref,
"base_ref": base_ref or target_branch,
"target_branch": target_branch,
"target_branch_sha": fetch_result.get("target_branch_sha"),
"linked_issue": linked_issue,
"git_ref_mutations": [],
"reasons": [],
"review_merge_allowed": False,
}
if fetch_result.get("git_fetch_command"):
result["git_ref_mutations"].append(fetch_result["git_fetch_command"])
if pr_state != "open":
result["eligibility_class"] = ELIGIBILITY_PR_NOT_OPEN
result["ancestor_proof"] = None
result["reconciliation_allowed"] = False
result["reasons"].append(f"PR #{pr_number} state is {pr_state!r}, not open")
return result
if not fetch_result.get("success"):
result["eligibility_class"] = ELIGIBILITY_STALE_TARGET
result["ancestor_proof"] = None
result["reconciliation_allowed"] = False
result["reasons"].extend(fetch_result.get("reasons") or [])
return result
target_ref = fetch_result.get("target_ref") or f"{remote}/{target_branch}"
ancestor = is_head_ancestor_of_ref(project_root, head_sha, target_ref)
result["ancestor_proof"] = ancestor
if ancestor is None:
result["eligibility_class"] = ELIGIBILITY_STALE_TARGET
result["reconciliation_allowed"] = False
result["reasons"].append(
f"ancestor check failed for head {head_sha!r} against {target_ref}"
)
return result
if ancestor:
result["eligibility_class"] = ELIGIBILITY_ALREADY_LANDED
result["reconciliation_allowed"] = True
return result
result["eligibility_class"] = ELIGIBILITY_NOT_LANDED
result["reconciliation_allowed"] = False
result["reasons"].append(
f"PR head {head_sha} is not an ancestor of {target_ref}"
)
return result
def resolve_reconciliation_plan(
*,
assessment: dict[str, Any],
capabilities: dict[str, bool] | None,
) -> dict[str, Any]:
"""Map eligibility + profile capabilities to a reconciliation outcome."""
caps = capabilities or {}
reasons: list[str] = []
if not assessment.get("reconciliation_allowed"):
outcome = OUTCOME_NOT_LANDED
if assessment.get("eligibility_class") in {
ELIGIBILITY_STALE_TARGET,
ELIGIBILITY_PR_NOT_OPEN,
}:
outcome = OUTCOME_GATE_NOT_PROVEN
reasons.extend(assessment.get("reasons") or [])
return {
"outcome": outcome,
"close_pr_allowed": False,
"comment_pr_allowed": False,
"close_issue_allowed": False,
"comment_issue_allowed": False,
"review_merge_allowed": False,
"missing_capabilities": _missing_reconciliation_capabilities(caps),
"safe_next_action": (
"Do not reconcile via review/merge; repair missing proof first."
),
"reasons": reasons,
}
close_pr = bool(caps.get("close_pr"))
comment_pr = bool(caps.get("comment_pr"))
close_issue = bool(caps.get("close_issue"))
comment_issue = bool(caps.get("comment_issue"))
if caps.get("review_pr") or caps.get("merge_pr"):
reasons.append(
"reconciliation path must not use review/merge capabilities"
)
if close_pr:
outcome = OUTCOME_FULL_RECONCILE
safe_next_action = (
"Run reconciliation close via exact gitea.pr.close after proof; "
"do not approve or merge."
)
elif comment_pr:
outcome = OUTCOME_PARTIAL_COMMENT
safe_next_action = (
"Post one reconciliation comment with ancestor proof, then stop "
"for an authorized close profile."
)
else:
outcome = OUTCOME_RECOVERY_HANDOFF
safe_next_action = (
"Produce a recovery handoff naming missing gitea.pr.close and/or "
"gitea.pr.comment; do not loop through review/merge."
)
reasons.append("gitea.pr.close is not available in the active profile")
return {
"outcome": outcome,
"close_pr_allowed": close_pr,
"comment_pr_allowed": comment_pr,
"close_issue_allowed": close_issue,
"comment_issue_allowed": comment_issue,
"review_merge_allowed": False,
"missing_capabilities": _missing_reconciliation_capabilities(caps),
"safe_next_action": safe_next_action,
"reasons": reasons,
}
def _missing_reconciliation_capabilities(caps: dict[str, bool]) -> list[str]:
missing = []
if not caps.get("read"):
missing.append("gitea.read")
if not caps.get("close_pr"):
missing.append("gitea.pr.close")
if not caps.get("comment_pr"):
missing.append("gitea.pr.comment")
return missing
def assess_reconcile_workflow_source(report_text: str) -> dict[str, Any]:
"""Reconciliation reports must cite the canonical workflow file."""
lower = (report_text or "").lower()
reasons = []
if not any(marker in lower for marker in RECONCILE_WORKFLOW_MARKERS):
reasons.append(
"reconciliation report missing workflow source "
"(workflows/reconcile-landed-pr.md)"
)
if not any(marker in lower for marker in RECONCILE_TASK_MARKERS):
reasons.append(
"reconciliation report missing task mode declaration "
"(reconcile-landed-pr)"
)
return {
"complete": not reasons,
"downgraded": bool(reasons),
"reasons": reasons,
}
+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,
}
+321
View File
@@ -0,0 +1,321 @@
"""Reviewer final-report schema verification before session output (#391).
Composes the composable ``assess_final_report_validator`` (#327) with
review-specific schema gates required before a reviewer session completes.
"""
from __future__ import annotations
import re
from typing import Any
from final_report_validator import (
assess_final_report_validator,
validator_finding,
_handoff_fields,
)
_LEGACY_STALE_FIELDS = (
"pinned reviewed head",
"scratch worktree used",
"workspace mutations",
)
_REVIEWED_HEAD_RE = re.compile(
r"(?:pinned reviewed head|reviewed head sha)\s*:\s*([0-9a-f]{7,40})",
re.IGNORECASE,
)
_VALIDATION_PASS_RE = re.compile(
r"validation\s*:\s*(?:pass|passed|strong|ok|green)",
re.IGNORECASE,
)
_MERGED_CLAIM_RE = re.compile(
r"\b(?:merged|merge result\s*:\s*merged|pr\s+#\d+\s+merged)\b",
re.IGNORECASE,
)
_MERGE_RESULT_RE = re.compile(r"merge result\s*:", re.IGNORECASE)
_ANCESTRY_PROOF_RE = re.compile(
r"(?:ancestor proof|target branch sha|merge commit)",
re.IGNORECASE,
)
_ISSUE_CLOSED_RE = re.compile(
r"(?:linked issue(?:\s+live)?\s+status\s*:\s*closed|issue\s+#\d+\s+closed)",
re.IGNORECASE,
)
_LIVE_ISSUE_PROOF_RE = re.compile(
r"gitea_view_issue|(?:issue|linked issue).{0,40}fetched live|live fetch proof",
re.IGNORECASE,
)
_FULL_SUITE_PASS_RE = re.compile(
r"(?:full suite passed|full test suite passed|\d+\s+passed,\s*0\s+failed)",
re.IGNORECASE,
)
_TESTS_IGNORED_RE = re.compile(
r"(?:ignored tests|tests?\s+ignored|skipped tests|not run|tests?\s+skipped)",
re.IGNORECASE,
)
_BLOCKED_HANDOFF_RE = re.compile(
r"(?:blocker\s*:|recovery handoff|infra_stop|capability stop|mutation blocked)",
re.IGNORECASE,
)
_REPLAY_COMMAND_RE = re.compile(
r"(?:gitea_submit_pr_review|gitea_merge_pr|gitea_review_pr|"
r"submitted\s+['\"]approve['\"]|replay approve|replay merge)",
re.IGNORECASE,
)
_PENDING_RE = re.compile(r"\bPENDING\b", re.IGNORECASE)
_APPROVED_RE = re.compile(
r"(?:review decision\s*:\s*approve|submitted\s+approve|official review submitted)",
re.IGNORECASE,
)
_DRY_RUN_RE = re.compile(r"dry[- ]run", re.IGNORECASE)
_FINDING_READY_RE = re.compile(
r"(?:finding ready|ready to submit|not yet submitted)",
re.IGNORECASE,
)
_NARRATIVE_APPROVE_RE = re.compile(
r"(?:^|\n)\s*(?:##\s+)?(?:summary|verdict|recommendation)\b[^\n]*\bapprove\b",
re.IGNORECASE | re.MULTILINE,
)
_HANDOFF_DECISION_RE = re.compile(
r"review decision\s*:\s*(\w+)",
re.IGNORECASE,
)
def _rule_legacy_stale_fields(report_text: str) -> list[dict[str, str]]:
fields = _handoff_fields(report_text)
findings: list[dict[str, str]] = []
for stale in _LEGACY_STALE_FIELDS:
if stale in fields and fields[stale].lower() not in {"", "none", "n/a"}:
findings.append(
validator_finding(
"reviewer.legacy_stale_field",
"block",
stale.title(),
f"legacy or stale handoff field '{stale}' must not appear in "
"canonical reviewer final reports",
"remove stale fields; use Candidate head SHA and precise "
"mutation categories instead",
)
)
return findings
def _rule_reviewed_head_without_validation(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if not _REVIEWED_HEAD_RE.search(text):
return []
if _VALIDATION_PASS_RE.search(text):
return []
return [
validator_finding(
"reviewer.reviewed_head_without_validation",
"block",
"Pinned reviewed head",
"reviewed/pinned head SHA reported without validation pass proof",
"document validation command, cwd, and pass result before pinning head SHA",
)
]
def _rule_merged_without_proof(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if not _MERGED_CLAIM_RE.search(text):
return []
if _MERGE_RESULT_RE.search(text) and _ANCESTRY_PROOF_RE.search(text):
return []
return [
validator_finding(
"reviewer.merged_without_proof",
"block",
"Merge result",
"merged outcome claimed without merge result and target ancestry proof",
"include Merge result, target branch SHA, and ancestor proof before claiming merged",
)
]
def _rule_issue_closed_without_live_proof(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if not _ISSUE_CLOSED_RE.search(text):
return []
fields = _handoff_fields(text)
status_value = fields.get("linked issue live status", "")
readonly_value = fields.get("read-only diagnostics", "")
proof_blob = f"{status_value} {readonly_value}".lower()
if _LIVE_ISSUE_PROOF_RE.search(proof_blob):
return []
return [
validator_finding(
"reviewer.issue_closed_without_live_proof",
"block",
"Linked issue",
"issue closed claimed without live linked-issue verification proof",
"fetch linked issue live (gitea_view_issue) and record Linked issue live status",
)
]
def _rule_full_suite_pass_ignored_tests(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if not (_FULL_SUITE_PASS_RE.search(text) and _TESTS_IGNORED_RE.search(text)):
return []
if re.search(r"explicitly\s+(?:ignored|skipped)", text, re.IGNORECASE):
return []
return [
validator_finding(
"reviewer.full_suite_pass_ignored_tests",
"block",
"Validation",
"full suite pass claimed while tests were ignored or skipped without "
"explicit disclosure",
"state which tests were ignored/skipped or downgrade validation verdict",
)
]
def _rule_blocked_handoff_replay(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if not _BLOCKED_HANDOFF_RE.search(text):
return []
if not _REPLAY_COMMAND_RE.search(text):
return []
return [
validator_finding(
"reviewer.blocked_handoff_replay",
"block",
"Safe next action",
"blocked recovery handoff includes direct approve or merge replay commands",
"restart the full workflow after the blocker clears; do not replay mutations",
)
]
def _rule_narrative_handoff_drift(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
narrative_approve = bool(_NARRATIVE_APPROVE_RE.search(text))
decision_match = _HANDOFF_DECISION_RE.search(text)
if not narrative_approve or not decision_match:
return []
handoff_decision = decision_match.group(1).strip().lower()
if handoff_decision in {"approve", "approved"}:
return []
return [
validator_finding(
"reviewer.narrative_handoff_drift",
"block",
"Review decision",
f"narrative approves PR but controller handoff says '{handoff_decision}'",
"align narrative summary with controller handoff Review decision field",
)
]
def _rule_review_state_ambiguous(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
findings: list[dict[str, str]] = []
if _PENDING_RE.search(text) and _APPROVED_RE.search(text):
findings.append(
validator_finding(
"reviewer.pending_vs_approved",
"block",
"Review decision",
"report conflates PENDING review state with APPROVED outcome",
"distinguish official submitted review from pending or ready-to-submit state",
)
)
if _DRY_RUN_RE.search(text) and _APPROVED_RE.search(text):
if "dry-run only" not in text.lower():
findings.append(
validator_finding(
"reviewer.dry_run_vs_submitted",
"block",
"Review mutations",
"dry-run language mixed with official approve/submitted claims",
"mark dry-run explicitly or document the live review mutation proof",
)
)
if _FINDING_READY_RE.search(text) and _APPROVED_RE.search(text):
findings.append(
validator_finding(
"reviewer.finding_ready_vs_submitted",
"downgrade",
"Review decision",
"finding-ready wording combined with submitted-approve claims",
"state whether review was submitted live or only prepared for submission",
)
)
return findings
_SCHEMA_RULES = (
_rule_legacy_stale_fields,
_rule_reviewed_head_without_validation,
_rule_merged_without_proof,
_rule_issue_closed_without_live_proof,
_rule_full_suite_pass_ignored_tests,
_rule_blocked_handoff_replay,
_rule_narrative_handoff_drift,
_rule_review_state_ambiguous,
)
def _merge_validator_results(
base: dict[str, Any],
extra_findings: list[dict[str, str]],
) -> dict[str, Any]:
findings = list(base.get("findings") or []) + extra_findings
blocked = base.get("blocked") or any(f["severity"] == "block" for f in extra_findings)
downgraded = base.get("downgraded") or any(
f["severity"] == "downgrade" for f in extra_findings
)
grade = base.get("grade", "A")
if blocked:
grade = "blocked"
elif downgraded and grade == "A":
grade = "downgraded"
reasons = [f"{f['rule_id']}: {f['reason']}" for f in findings]
safe_next = base.get("safe_next_action") or "none"
if extra_findings:
safe_next = extra_findings[0].get("safe_next_action", safe_next)
return {
**base,
"grade": grade,
"blocked": blocked,
"downgraded": downgraded,
"findings": findings,
"reasons": reasons,
"safe_next_action": safe_next,
"complete": grade == "A",
"schema_rules_applied": len(_SCHEMA_RULES),
}
def assess_review_final_report_schema(
report_text: str,
*,
review_decision_lock: dict | None = None,
linked_issue_lock: dict | None = None,
validation_report: dict | None = None,
action_log: list[dict] | None = None,
mutations_observed: bool = False,
local_edits: bool = False,
validation_session: dict | None = None,
) -> dict[str, Any]:
"""Validate reviewer final report text before session completion (#391)."""
base = assess_final_report_validator(
report_text,
"review_pr",
review_decision_lock=review_decision_lock,
linked_issue_lock=linked_issue_lock,
validation_report=validation_report,
action_log=action_log,
mutations_observed=mutations_observed,
local_edits=local_edits,
validation_session=validation_session,
)
extra: list[dict[str, str]] = []
for rule in _SCHEMA_RULES:
extra.extend(rule(report_text))
return _merge_validator_results(base, extra)
+332
View File
@@ -0,0 +1,332 @@
"""Enforced PR review/merge workflow state machine (#290).
Review and merge must advance through explicit states. Any failed upstream
gate forbids downstream approve/merge mutations until the full workflow is
restarted after blockers clear.
"""
from __future__ import annotations
import re
from typing import Any
REVIEW_MERGE_STATES: tuple[str, ...] = (
"PRECHECK",
"INVENTORY",
"SELECT_PR",
"PIN_HEAD_SHA",
"CREATE_BRANCHES_WORKTREE",
"VALIDATE",
"REVIEW_DECISION",
"APPROVE_OR_REQUEST_CHANGES",
"PRE_MERGE_RECHECK",
"MERGE",
"POST_MERGE_REPORT",
)
TERMINAL_BLOCKED_HEADING = (
"PR review/merge workflow blocked. Restart the full workflow after blockers clear."
)
_FORBIDDEN_RECOVERY_REPLAY_RE = re.compile(
r"\b(?:approve(?:\s+pr)?\s*#?\d+|merge(?:\s+pr)?\s*#?\d+|gitea_merge_pr|"
r"gitea_submit_pr_review|submit\s+approve|run\s+merge)\b",
re.IGNORECASE,
)
_RESTART_WORKFLOW_RE = re.compile(
r"restart(?:\s+the)?\s+full\s+workflow|rerun\s+the\s+full\s+workflow",
re.IGNORECASE,
)
_READY_TO_MERGE_RE = re.compile(
r"\bready\s+to\s+merge\b",
re.IGNORECASE,
)
_REVIEWED_VALIDATED_RE = re.compile(
r"\b(?:reviewed|validated|approval\s+submitted)\b",
re.IGNORECASE,
)
_PRE_MERGE_REQUIRED_GATES = (
"whoami_verified",
"profile_runtime_verified",
"merge_capability_verified",
"pr_refetched",
"reviewed_head_sha_unchanged",
"pr_mergeable",
"checks_passed",
"reviewer_not_author",
"worktree_clean",
)
def _clean(value: str | None) -> str:
return (value or "").strip()
def state_index(state: str) -> int:
name = _clean(state).upper()
try:
return REVIEW_MERGE_STATES.index(name)
except ValueError as exc:
raise ValueError(f"unknown review/merge state '{state}' (fail closed)") from exc
def downstream_states(from_state: str) -> list[str]:
idx = state_index(from_state)
return list(REVIEW_MERGE_STATES[idx + 1 :])
def _completed_through(state_completion: dict[str, bool], state: str) -> bool:
return bool(state_completion.get(state))
def _first_incomplete_state(state_completion: dict[str, bool]) -> str | None:
for state in REVIEW_MERGE_STATES:
if not _completed_through(state_completion, state):
return state
return None
def assess_workflow_blockers(
*,
infra_stop: bool = False,
capability_blocked: bool = False,
mcp_reconnect_failed: bool = False,
stale_capability_state: bool = False,
) -> dict[str, Any]:
"""Return hard blockers that forbid all PR queue work (#290 AC3)."""
reasons: list[str] = []
if infra_stop:
reasons.append("infra_stop is active; PR selection/review/merge is forbidden")
if capability_blocked:
reasons.append("gitea_resolve_task_capability returned blocked/stop_required")
if mcp_reconnect_failed:
reasons.append("MCP reconnect failed; stale session state cannot be reused")
if stale_capability_state:
reasons.append("stale MCP capability state detected after reconnect failure")
return {
"block": bool(reasons),
"reasons": reasons,
"forbidden_states": list(REVIEW_MERGE_STATES) if reasons else [],
"safe_next_action": (
TERMINAL_BLOCKED_HEADING if reasons else "proceed with PRECHECK"
),
}
def assess_state_advancement(
state_completion: dict[str, bool] | None,
*,
target_state: str,
infra_stop: bool = False,
capability_blocked: bool = False,
) -> dict[str, Any]:
"""Fail closed when *target_state* is requested before upstream gates pass."""
completion = dict(state_completion or {})
target = _clean(target_state).upper()
blockers = assess_workflow_blockers(
infra_stop=infra_stop,
capability_blocked=capability_blocked,
)
reasons = list(blockers["reasons"])
try:
target_idx = state_index(target)
except ValueError as exc:
return {
"target_state": target,
"allowed": False,
"block": True,
"reasons": [str(exc)],
"next_allowed_state": None,
"safe_next_action": TERMINAL_BLOCKED_HEADING,
}
if blockers["block"]:
return {
"target_state": target,
"allowed": False,
"block": True,
"reasons": reasons,
"next_allowed_state": None,
"safe_next_action": blockers["safe_next_action"],
}
next_allowed = _first_incomplete_state(completion)
if next_allowed is None:
allowed = target_idx == len(REVIEW_MERGE_STATES) - 1
if not allowed:
reasons.append("workflow already completed through POST_MERGE_REPORT")
else:
allowed = state_index(next_allowed) >= target_idx
if not allowed:
reasons.append(
f"state '{target}' is forbidden until '{next_allowed}' completes"
)
return {
"target_state": target,
"allowed": allowed and not reasons,
"block": bool(reasons),
"reasons": reasons,
"next_allowed_state": next_allowed,
"completed_states": [s for s in REVIEW_MERGE_STATES if completion.get(s)],
"safe_next_action": (
f"complete state '{next_allowed}' before advancing"
if next_allowed and not allowed
else (
TERMINAL_BLOCKED_HEADING
if reasons
else f"advance to {target}"
)
),
}
def can_approve(state_completion: dict[str, bool] | None, **blocker_kwargs) -> dict[str, Any]:
"""Approval requires all states through REVIEW_DECISION (#290 AC)."""
required = REVIEW_MERGE_STATES[: REVIEW_MERGE_STATES.index("REVIEW_DECISION") + 1]
completion = dict(state_completion or {})
advance = assess_state_advancement(
completion,
target_state="APPROVE_OR_REQUEST_CHANGES",
**blocker_kwargs,
)
missing = [s for s in required if not completion.get(s)]
reasons = list(advance["reasons"])
if missing:
reasons.append(
"approval blocked: incomplete states: " + ", ".join(missing)
)
block = bool(reasons) or advance["block"]
return {
"allowed": not block,
"block": block,
"reasons": reasons,
"missing_states": missing,
"safe_next_action": advance["safe_next_action"],
}
def can_merge(
state_completion: dict[str, bool] | None,
*,
pre_merge_gates: dict[str, bool] | None = None,
**blocker_kwargs,
) -> dict[str, Any]:
"""Merge requires approve path plus fresh PRE_MERGE_RECHECK gates (#290 AC6)."""
completion = dict(state_completion or {})
approve = can_approve(completion, **blocker_kwargs)
reasons = list(approve["reasons"])
if not completion.get("APPROVE_OR_REQUEST_CHANGES"):
reasons.append("merge blocked: APPROVE_OR_REQUEST_CHANGES not completed")
gate_map = dict(pre_merge_gates or {})
missing_gates = [g for g in _PRE_MERGE_REQUIRED_GATES if not gate_map.get(g)]
if missing_gates:
reasons.append(
"merge blocked: pre-merge gates incomplete: " + ", ".join(missing_gates)
)
advance = assess_state_advancement(
completion,
target_state="MERGE",
**blocker_kwargs,
)
reasons.extend(advance["reasons"])
block = bool(reasons)
return {
"allowed": not block,
"block": block,
"reasons": reasons,
"missing_pre_merge_gates": missing_gates,
"safe_next_action": (
"complete PRE_MERGE_RECHECK with fresh whoami/capability/PR re-fetch "
"before merge"
if block
else "merge allowed"
),
}
def assess_blocked_recovery_handoff(report_text: str) -> dict[str, Any]:
"""Blocked handoffs must not replay approve/merge commands (#290 AC4)."""
text = report_text or ""
reasons: list[str] = []
if _FORBIDDEN_RECOVERY_REPLAY_RE.search(text):
reasons.append(
"blocked recovery handoff contains direct approve/merge replay command"
)
if reasons and not _RESTART_WORKFLOW_RE.search(text):
reasons.append(
"blocked recovery handoff must direct operator to restart full workflow"
)
return {
"block": bool(reasons),
"allowed": not reasons,
"reasons": reasons,
"safe_next_action": (
"remove approve/merge replay commands and say to restart full workflow "
"after blockers clear"
if reasons
else "recovery handoff wording acceptable"
),
}
def assess_final_report_state_claims(
report_text: str,
*,
state_completion: dict[str, bool] | None = None,
approve_completed: bool = False,
merge_completed: bool = False,
) -> dict[str, Any]:
"""Reports must not claim reviewed/ready-to-merge without gate proof (#290 AC10)."""
text = report_text or ""
completion = dict(state_completion or {})
reasons: list[str] = []
if _READY_TO_MERGE_RE.search(text) and not (
merge_completed or completion.get("PRE_MERGE_RECHECK")
):
reasons.append(
"report claims ready-to-merge without PRE_MERGE_RECHECK completion"
)
if _REVIEWED_VALIDATED_RE.search(text):
if not (approve_completed or completion.get("VALIDATE")):
reasons.append(
"report claims reviewed/validated without VALIDATE/APPROVE proof"
)
return {
"block": bool(reasons),
"allowed": not reasons,
"reasons": reasons,
"safe_next_action": (
"remove stale reviewed/ready-to-merge claims unless gates passed"
if reasons
else "final report state claims consistent with workflow"
),
}
def workflow_status(
state_completion: dict[str, bool] | None,
**blocker_kwargs,
) -> dict[str, Any]:
"""Summarize current state-machine position for MCP/runtime reporting."""
completion = dict(state_completion or {})
blockers = assess_workflow_blockers(**blocker_kwargs)
next_state = _first_incomplete_state(completion)
return {
"states": list(REVIEW_MERGE_STATES),
"completed_states": [s for s in REVIEW_MERGE_STATES if completion.get(s)],
"next_required_state": next_state,
"workflow_complete": next_state is None,
"approve_allowed": can_approve(completion, **blocker_kwargs)["allowed"],
"merge_allowed": can_merge(completion, **blocker_kwargs)["allowed"],
"blockers": blockers,
}
+1475 -107
View File
File diff suppressed because it is too large Load Diff
+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": [],
}
+217
View File
@@ -0,0 +1,217 @@
"""Canonical review-merge workflow load proof for reviewer mutations (#389, #403)."""
from __future__ import annotations
import hashlib
import os
import re
from pathlib import Path
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 record_review_workflow_load(
project_root: str,
*,
prompt_text: str | None = None,
) -> dict:
"""Record in-process workflow load proof for the current MCP session."""
global _REVIEW_WORKFLOW_LOAD
meta = build_canonical_workflow_metadata(
project_root, prompt_text=prompt_text)
boundary_state = boundary.assess_boundary_status(project_root)
_REVIEW_WORKFLOW_LOAD = {
**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 []),
}
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
boundary.clear_pre_review_commands()
def workflow_load_status(project_root: str | None = None) -> dict:
"""Non-throwing status for capability/runtime reports."""
load = _REVIEW_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"),
"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]:
reasons: list[str] = []
if load.get("session_pid") != os.getpid():
reasons.append(
"workflow load proof was recorded in a different process "
"(fail closed)"
)
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)
if boundary_reasons and _REVIEW_WORKFLOW_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.",
]
+143
View File
@@ -0,0 +1,143 @@
"""Already-landed PR classification verifier for reviewer reports (#295)."""
from __future__ import annotations
import re
from typing import Any
ALREADY_LANDED_ELIGIBILITY_CLASS = "ALREADY_LANDED_RECONCILE_REQUIRED"
_LANDED_CONTEXT_RE = re.compile(
r"already[- ]landed|ancestor proof\s*:\s*passed|"
r"eligibility class\s*:\s*already_landed",
re.IGNORECASE,
)
_ELIGIBILITY_CLASS_LINE_RE = re.compile(
r"eligibility class\s*:\s*(.+)$",
re.IGNORECASE | re.MULTILINE,
)
_INELIGIBLE_SELECTION_RE = re.compile(
r"\b(?:next|oldest)\s+eligible\s+pr\b",
re.IGNORECASE,
)
_REVIEW_ELIGIBLE_RE = re.compile(
r"\beligible for (?:review|merge)\b|\bready for (?:review|merge)\b",
re.IGNORECASE,
)
_PINNED_REVIEWED_HEAD_RE = re.compile(
r"\bpinned reviewed head\s*:",
re.IGNORECASE,
)
_CANDIDATE_HEAD_RE = re.compile(
r"\bcandidate head sha\s*:",
re.IGNORECASE,
)
_REVIEWED_HEAD_NONE_RE = re.compile(
r"\breviewed head sha\s*:\s*none\b",
re.IGNORECASE,
)
_VALIDATION_PROOF_RE = re.compile(
r"(?:validation passed|pytest.*passed|diff review passed|"
r"validated on pinned head)",
re.IGNORECASE,
)
_QUEUE_BLOCKED_RE = re.compile(
r"queue blocked by already[- ]landed|"
r"reconciliation(?:-only)?\s+(?:next step|required)",
re.IGNORECASE,
)
def _landed_context(report_text: str, eligibility_class: str | None) -> bool:
if (eligibility_class or "").strip().upper() == ALREADY_LANDED_ELIGIBILITY_CLASS:
return True
return bool(_LANDED_CONTEXT_RE.search(report_text or ""))
def assess_already_landed_classification_report(
report_text: str,
*,
eligibility_class: str | None = None,
selected_pr_already_landed: bool | None = None,
) -> dict[str, Any]:
"""#295: already-landed PRs are reconciliation-only, not review eligible."""
text = report_text or ""
reasons: list[str] = []
landed = _landed_context(text, eligibility_class)
if selected_pr_already_landed is True:
landed = True
if not landed:
return {
"proven": True,
"block": False,
"landed_context": False,
"reasons": [],
"safe_next_action": "proceed",
}
if _INELIGIBLE_SELECTION_RE.search(text):
reasons.append(
"already-landed PR must not be described as oldest/next eligible PR; "
"use 'Oldest open PR requiring action' and "
f"'Eligibility class: {ALREADY_LANDED_ELIGIBILITY_CLASS}'"
)
if _REVIEW_ELIGIBLE_RE.search(text):
reasons.append(
"already-landed PR must not be described as eligible for review or merge"
)
class_match = _ELIGIBILITY_CLASS_LINE_RE.search(text)
if class_match:
declared = class_match.group(1).strip().upper()
if declared != ALREADY_LANDED_ELIGIBILITY_CLASS:
reasons.append(
"already-landed PR eligibility class must be "
f"{ALREADY_LANDED_ELIGIBILITY_CLASS}"
)
elif selected_pr_already_landed is True:
reasons.append(
f"already-landed selected PR must declare Eligibility class: "
f"{ALREADY_LANDED_ELIGIBILITY_CLASS}"
)
if _PINNED_REVIEWED_HEAD_RE.search(text):
if not _VALIDATION_PROOF_RE.search(text):
reasons.append(
"already-landed pre-review report must use Candidate head SHA, "
"not Pinned reviewed head, unless validation and diff review passed"
)
if selected_pr_already_landed is True and not _CANDIDATE_HEAD_RE.search(text):
if _PINNED_REVIEWED_HEAD_RE.search(text) or "head sha" in text.lower():
reasons.append(
"already-landed ancestry check must report Candidate head SHA"
)
if selected_pr_already_landed is True and not _REVIEWED_HEAD_NONE_RE.search(text):
if _PINNED_REVIEWED_HEAD_RE.search(text) and not _VALIDATION_PROOF_RE.search(text):
reasons.append(
"already-landed report must state 'Reviewed head SHA: none' "
"when validation did not run"
)
if selected_pr_already_landed is True and not _QUEUE_BLOCKED_RE.search(text):
reasons.append(
"already-landed queue blocker must state reconciliation requirement "
"or queue blocked wording"
)
proven = not reasons
return {
"proven": proven,
"block": not proven,
"landed_context": True,
"reasons": reasons,
"safe_next_action": (
"classify as ALREADY_LANDED_RECONCILE_REQUIRED, use Candidate head SHA, "
"and stop normal review"
if not proven
else "proceed"
),
}
+216
View File
@@ -0,0 +1,216 @@
"""Already-landed controller handoff consistency verifier (#299)."""
from __future__ import annotations
import re
from typing import Any
from review_proofs import git_ref_mutating_commands
ALREADY_LANDED_STATE = "ALREADY_LANDED_RECONCILE_REQUIRED"
_HANDOFF_SECTION_RE = re.compile(r"^##\s*Controller Handoff\s*$", re.I | re.M)
_STALE_HANDOFF_FIELDS = (
"pinned reviewed head",
"scratch worktree used",
)
_LEGACY_MUTATIONS_NONE_RE = re.compile(
r"^\s*[-*]?\s*mutations\s*:\s*none\s*$",
re.I | re.M,
)
_LEGACY_WORKSPACE_NONE_RE = re.compile(
r"^\s*[-*]?\s*workspace\s+mutations\s*:\s*none\s*$",
re.I | re.M,
)
_FORBIDDEN_REVIEW_STATES_RE = re.compile(
r"review decision\s*:\s*approved?\b|"
r"merge result\s*:\s*merged\b|"
r"\bready[_ ]to[_ ]merge\b",
re.I,
)
_NARRATIVE_ELIGIBILITY_RE = re.compile(
r"eligibility class\s*:\s*([^\n]+)",
re.I,
)
_NARRATIVE_REVIEWED_SHA_RE = re.compile(
r"reviewed head sha\s*:\s*([^\n]+)",
re.I,
)
_NARRATIVE_WORKTREE_RE = re.compile(
r"review worktree used\s*:\s*([^\n]+)",
re.I,
)
def _handoff_field_map(report_text: str) -> dict[str, str]:
text = report_text or ""
match = _HANDOFF_SECTION_RE.search(text)
if not match:
return {}
fields: dict[str, str] = {}
for line in text[match.end() :].splitlines():
stripped = line.strip().lstrip("-*").strip()
if ":" not in stripped:
continue
key, value = stripped.split(":", 1)
fields[key.strip().lower()] = value.strip()
return fields
def _narrative_before_handoff(report_text: str) -> str:
text = report_text or ""
match = _HANDOFF_SECTION_RE.search(text)
if not match:
return text
return text[: match.start()]
def _is_truthy(value: str) -> bool:
lowered = (value or "").strip().lower()
return lowered not in {"", "none", "n/a", "not applicable", "false", "no", "", "-"}
def _gate_active(text: str, fields: dict[str, str], session: dict) -> bool:
if session.get("gate_fired") or session.get("already_landed_gate_fired"):
return True
eligibility = (fields.get("eligibility class") or "").upper()
if ALREADY_LANDED_STATE in eligibility:
return True
return ALREADY_LANDED_STATE.lower() in text.lower()
def assess_already_landed_handoff_report(
report_text: str,
*,
handoff_session: dict | None = None,
command_log: list | None = None,
) -> dict[str, Any]:
"""Reject stale handoff fields and narrative/handoff drift after the gate (#299)."""
text = report_text or ""
session = dict(handoff_session or {})
fields = _handoff_field_map(text)
reasons: list[str] = []
if not _gate_active(text, fields, session):
return {
"proven": True,
"block": False,
"reasons": [],
"gate_active": False,
"safe_next_action": "proceed",
}
for stale_field in _STALE_HANDOFF_FIELDS:
if stale_field in fields:
reasons.append(
f"already-landed handoff must not include stale field "
f"'{stale_field.title()}' (#299)"
)
if _LEGACY_WORKSPACE_NONE_RE.search(text):
reasons.append(
"already-landed handoff must not include legacy "
"'Workspace mutations: None' (#299)"
)
ref_commands = git_ref_mutating_commands(command_log or session.get("command_log"))
mutations_observed = bool(
ref_commands
or session.get("mutations_observed")
or session.get("mcp_mutations")
or session.get("review_mutations")
)
if mutations_observed and _LEGACY_MUTATIONS_NONE_RE.search(text):
reasons.append(
"already-landed handoff must not claim 'Mutations: None' when "
"git ref, MCP, review, merge, or cleanup mutations occurred (#299)"
)
git_ref_value = fields.get("git ref mutations", "").strip().lower()
if ref_commands and (not git_ref_value or git_ref_value == "none"):
reasons.append(
"git fetch/ref updates must be reported under Git ref mutations (#299)"
)
reviewed_sha = fields.get("reviewed head sha", "")
if reviewed_sha and _is_truthy(reviewed_sha):
reasons.append(
"already-landed handoff must use 'Reviewed head SHA: none' (#299)"
)
worktree_used = fields.get("review worktree used", "")
if worktree_used and _is_truthy(worktree_used):
reasons.append(
"already-landed handoff must set Review worktree used: false (#299)"
)
candidate_sha = fields.get("candidate head sha", "")
if not candidate_sha or candidate_sha.lower() in {"none", "n/a", "unknown"}:
reasons.append(
"already-landed handoff must include Candidate head SHA (#299)"
)
if _FORBIDDEN_REVIEW_STATES_RE.search(text):
reasons.append(
"already-landed handoff must not claim approved/merged/ready-to-merge (#299)"
)
narrative = _narrative_before_handoff(text)
handoff_eligibility = (fields.get("eligibility class") or "").strip()
narrative_eligibility = (
_NARRATIVE_ELIGIBILITY_RE.search(narrative).group(1).strip()
if _NARRATIVE_ELIGIBILITY_RE.search(narrative)
else ""
)
if handoff_eligibility and narrative_eligibility:
if handoff_eligibility.upper() != narrative_eligibility.upper():
reasons.append(
"narrative Eligibility class disagrees with controller handoff (#299)"
)
narrative_reviewed = (
_NARRATIVE_REVIEWED_SHA_RE.search(narrative).group(1).strip()
if _NARRATIVE_REVIEWED_SHA_RE.search(narrative)
else ""
)
if narrative_reviewed and reviewed_sha:
if narrative_reviewed.lower() != reviewed_sha.lower():
reasons.append(
"narrative Reviewed head SHA disagrees with controller handoff (#299)"
)
narrative_worktree = (
_NARRATIVE_WORKTREE_RE.search(narrative).group(1).strip()
if _NARRATIVE_WORKTREE_RE.search(narrative)
else ""
)
if narrative_worktree and worktree_used:
if narrative_worktree.lower() != worktree_used.lower():
reasons.append(
"narrative Review worktree used disagrees with controller handoff (#299)"
)
git_ref_narrative = "git ref mutations" in narrative.lower()
if git_ref_narrative and git_ref_value:
if "none" in git_ref_value and ref_commands:
reasons.append(
"narrative and handoff disagree on git ref mutation reporting (#299)"
)
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": list(dict.fromkeys(reasons)),
"gate_active": True,
"safe_next_action": (
"emit canonical ALREADY_LANDED_RECONCILE_REQUIRED handoff without "
"stale review/merge fields; align narrative and controller handoff"
if reasons
else "proceed"
),
}
+162
View File
@@ -0,0 +1,162 @@
"""Infra-stop repair handoff verifier for blocked reviewer workflows (#289)."""
from __future__ import annotations
import re
from typing import Any
from capability_stop_terminal import (
TERMINAL_REPORT_HEADING,
assess_capability_stop_report,
)
_REPAIR_HANDOFF_RE = re.compile(
r"repair handoff|control-checkout repair mode",
re.IGNORECASE,
)
_CONTROL_REPAIR_RE = re.compile(r"control-checkout repair mode", re.IGNORECASE)
_PR_QUEUE_ADVANCE_RE = re.compile(
r"(?:selected pr|pr #\d+ (?:to review|selected)|eligible pr|"
r"next (?:eligible )?pr(?: to review)?|oldest eligible pr|pinned (?:review )?head)",
re.IGNORECASE,
)
_REVIEW_MUTATION_RE = re.compile(
r"(?:gitea_review_pr|gitea_merge_pr|request_changes|submitted\s+approve|"
r"merge result|review decision\s*:\s*approve)",
re.IGNORECASE,
)
_BACKGROUND_TOOL_RE = re.compile(r"\b(?:schedule|manage_task)\b", re.IGNORECASE)
_PR_REVIEW_REPORT_RE = re.compile(
r"(?:review summary|merge recommendation|validation result\s*:\s*pass|"
r"queue status report with selected pr)",
re.IGNORECASE,
)
_DIAGNOSTIC_FIELDS = {
"mcp process root": re.compile(r"mcp process root\s*:", re.IGNORECASE),
"inspected git root": re.compile(r"inspected git root\s*:", re.IGNORECASE),
"conflict marker path": re.compile(
r"(?:conflict marker path|exact conflict marker path)\s*:",
re.IGNORECASE,
),
"merge/rebase control path": re.compile(
r"(?:merge/rebase control path|exact merge/rebase control path)\s*:",
re.IGNORECASE,
),
"safe next repair action": re.compile(
r"safe next (?:repair )?action\s*:",
re.IGNORECASE,
),
}
def assess_infra_stop_handoff_report(
report_text: str,
*,
stop_session: dict | None = None,
) -> dict[str, Any]:
"""Validate repair handoff purity when infra_stop blocks reviewer capability (#289)."""
text = report_text or ""
session = dict(stop_session or {})
reasons: list[str] = []
infra_stop = bool(
session.get("infra_stop")
or session.get("infra_stop_blocked")
or session.get("route_result") == "infra_stop"
)
if not infra_stop:
return {
"proven": True,
"block": False,
"reasons": [],
"infra_stop": False,
"safe_next_action": "proceed",
}
lower = text.lower()
repair_handoff = bool(
_REPAIR_HANDOFF_RE.search(text)
or TERMINAL_REPORT_HEADING.lower() in lower
)
if not repair_handoff:
reasons.append(
"infra_stop requires a repair handoff, not a PR review report"
)
if _PR_REVIEW_REPORT_RE.search(text) and not _REPAIR_HANDOFF_RE.search(text):
reasons.append(
"final output must be a repair handoff instead of stale PR review state"
)
if _PR_QUEUE_ADVANCE_RE.search(text):
reasons.append(
"infra_stop blocks PR queue advancement; do not select or advance next PR"
)
if _REVIEW_MUTATION_RE.search(text):
reasons.append(
"review, approval, request-changes, merge, or comment mutations "
"forbidden while infra_stop is active"
)
if session.get("pinned_head_sha") or re.search(
r"pinned (?:review )?head sha\s*:", text, re.IGNORECASE
):
if session.get("capability_cleared") is not True:
reasons.append(
"cannot pin PR head SHA while review capability never cleared"
)
if session.get("main_checkout_diagnostics") and not _CONTROL_REPAIR_RE.search(text):
reasons.append(
"main-checkout diagnostics must be labeled CONTROL-CHECKOUT REPAIR MODE"
)
if _BACKGROUND_TOOL_RE.search(text):
reasons.append(
"background schedule/manage_task tools must not be used during "
"blocked infra_stop recovery"
)
assessment = session.get("infra_stop_assessment") or {}
if assessment.get("infra_stop") or infra_stop:
missing = [
label
for label, pattern in _DIAGNOSTIC_FIELDS.items()
if not pattern.search(text)
]
if missing and session.get("require_infra_diagnostics", True):
reasons.append(
"infra_stop repair handoff missing diagnostic fields: "
+ ", ".join(missing)
)
conflict_file = assessment.get("conflict_file")
if conflict_file and conflict_file.lower() not in lower:
reasons.append(
f"infra_stop assessment conflict file {conflict_file!r} "
"not reflected in repair handoff"
)
capability = assess_capability_stop_report(
text,
trust_gate_status=session.get("trust_gate_status"),
capability_denied=True,
)
if not capability.get("pure"):
reasons.extend(capability.get("reasons") or [])
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": list(dict.fromkeys(reasons)),
"infra_stop": True,
"repair_handoff": repair_handoff,
"safe_next_action": (
"stop PR queue work; emit CONTROL-CHECKOUT REPAIR MODE handoff "
"with infra diagnostics and safe next repair action"
if reasons
else "proceed"
),
}
+308
View File
@@ -0,0 +1,308 @@
"""Reviewer inventory completeness and worktree state verifier (#293)."""
from __future__ import annotations
import re
from typing import Any
_PAGINATION_FINALITY_EVIDENCE = re.compile(
r"pagination_complete\s*:\s*true|inventory_complete\s*:\s*true|"
r"is_final_page\s*:\s*true|pages_fetched|has_more\s*:\s*false|"
r"no next page|final[- ]page|pr_inventory_trust_gate\.status|"
r"total_count\s*:|pagination.*(?:final|complete)",
re.I,
)
_INCOMPLETE_PAGINATION_PROOF = re.compile(
r"first page only|partial page|page 1 only|truncated|"
r"returned \d+ open prs?(?:\s*$|\s*[,;])",
re.I,
)
_DEFAULT_PAGE_SIZE_ASSUMPTION = re.compile(
r"(?:less|fewer) than (?:the )?(?:default )?(?:gitea )?page[- ]?(?:size|limit)|"
r"default (?:gitea )?page[- ]?size|under (?:the )?50[- ]?(?:item )?limit|"
r"(?:complete|exhaustive).*(?:default )?page[- ]?size|"
r"page[- ]?size assumption|assumed complete",
re.I,
)
_ELIGIBILITY_CLAIM_RE = re.compile(
r"\b(?:oldest eligible pr|next eligible pr|next pr to review|"
r"inventory (?:is )?complete|inventory exhaustive|exhaustive inventory)\b",
re.I,
)
_EXACT_PAGE_LIMIT_RE = re.compile(
r"(?:returned|listed|fetched)\s+(\d+)\s+open prs?|"
r"open pr count\s*:\s*(\d+)|"
r"page[- ]?size\s*(?:=|:)\s*(\d+)",
re.I,
)
_LEGACY_SCRATCH_FALSE_RE = re.compile(
r"scratch worktree used\s*:\s*false",
re.I,
)
_BRANCHES_WORKTREE_RE = re.compile(r"\bbranches/", re.I)
_CONTRADICTORY_HEAD_RE = re.compile(
r"detached head\s*/\s*branch\s+master|"
r"branch\s+master\s*/\s*detached head|"
r"detached head.*branch\s+(?:master|main|dev)\b.*detached|"
r"checkout\s*:\s*detached head\s*/\s*branch",
re.I,
)
_MAIN_CHECKOUT_BRANCH_RE = re.compile(
r"main checkout branch\s*:\s*(\S+)",
re.I,
)
_REVIEW_HEAD_STATE_RE = re.compile(
r"review worktree head state\s*:\s*(\S+)",
re.I,
)
_HANDOFF_SECTION_RE = re.compile(
r"^##\s*Controller Handoff\s*$",
re.I | re.M,
)
def _handoff_field_map(report_text: str) -> dict[str, str]:
"""Parse ``- Field: value`` lines from the Controller Handoff section."""
text = report_text or ""
match = _HANDOFF_SECTION_RE.search(text)
if not match:
return {}
section = text[match.end() :]
fields: dict[str, str] = {}
for line in section.splitlines():
stripped = line.strip().lstrip("-*").strip()
if ":" not in stripped:
continue
key, value = stripped.split(":", 1)
fields[key.strip().lower()] = value.strip()
return fields
def _truthy_field(value: str) -> bool:
lowered = (value or "").strip().lower()
if not lowered:
return False
if lowered in {"false", "no", "none", "not applicable", "n/a", "", "-"}:
return False
return True
def _field_proves_pagination(value: str) -> bool:
proof = (value or "").strip()
if not proof or proof.lower() in {"none", "n/a", "not applicable", "unknown"}:
return False
if _DEFAULT_PAGE_SIZE_ASSUMPTION.search(proof):
return False
if _INCOMPLETE_PAGINATION_PROOF.search(proof):
return False
return bool(_PAGINATION_FINALITY_EVIDENCE.search(proof))
def _pagination_proven(text: str, session: dict, *, pagination_field: str = "") -> bool:
if session.get("pagination_complete") or session.get("inventory_complete"):
return True
session_proof = session.get("inventory_pagination_proof") or ""
if _field_proves_pagination(str(session_proof)):
return True
if _field_proves_pagination(pagination_field):
return True
if _PAGINATION_FINALITY_EVIDENCE.search(text):
return True
return False
def _exact_page_limit_unproven(
text: str, session: dict, *, pagination_proven: bool = False
) -> bool:
"""True when a full page was returned without final-page proof."""
if pagination_proven:
return False
requested = session.get("requested_page_size")
returned = session.get("returned_page_size")
if isinstance(requested, int) and isinstance(returned, int):
if returned >= requested > 0:
return True
for match in _EXACT_PAGE_LIMIT_RE.finditer(text):
count = next((g for g in match.groups() if g), None)
if not count:
continue
try:
n = int(count)
except ValueError:
continue
if n in {10, 20, 50}:
return True
return False
def assess_inventory_worktree_report(
report_text: str,
*,
inventory_session: dict | None = None,
) -> dict[str, Any]:
"""Validate PR inventory pagination proof and worktree field consistency (#293)."""
text = report_text or ""
session = dict(inventory_session or {})
reasons: list[str] = []
fields = _handoff_field_map(text)
pagination_proof_field = fields.get("inventory pagination proof", "")
pagination_proven = _pagination_proven(
text, session, pagination_field=pagination_proof_field
)
if _ELIGIBILITY_CLAIM_RE.search(text):
if not pagination_proven:
reasons.append(
"oldest/next eligible PR or inventory-complete claim requires "
"final-page/no-next-page/pagination_complete proof"
)
if _DEFAULT_PAGE_SIZE_ASSUMPTION.search(text):
if not pagination_proven:
reasons.append(
"inventory pagination assumed from default page size without "
"final-page/no-next-page/total-count/traversal proof"
)
if pagination_proof_field:
if _DEFAULT_PAGE_SIZE_ASSUMPTION.search(pagination_proof_field):
reasons.append(
"Inventory pagination proof field relies on page-size assumption"
)
elif _INCOMPLETE_PAGINATION_PROOF.search(pagination_proof_field):
reasons.append(
"Inventory pagination proof field does not prove final page"
)
elif not _field_proves_pagination(pagination_proof_field):
if _ELIGIBILITY_CLAIM_RE.search(text) or _EXACT_PAGE_LIMIT_RE.search(text):
reasons.append(
"Inventory pagination proof field missing final-page metadata"
)
if _exact_page_limit_unproven(text, session, pagination_proven=pagination_proven):
reasons.append(
"exactly-full first page returned without final-page or "
"no-next-page proof"
)
review_worktree_used = fields.get("review worktree used", "")
review_worktree_path = fields.get("review worktree path", "")
scratch_used = fields.get("scratch worktree used", "")
inside_branches = fields.get("review worktree inside branches:", "") or fields.get(
"review worktree inside branches", ""
)
branches_path_in_text = bool(
_BRANCHES_WORKTREE_RE.search(review_worktree_path)
or _BRANCHES_WORKTREE_RE.search(text)
)
if branches_path_in_text:
if review_worktree_used and not _truthy_field(review_worktree_used):
reasons.append(
"reports using a branches/ review worktree must set "
"Review worktree used: true"
)
if scratch_used and re.search(r"\bfalse\b", scratch_used, re.I):
reasons.append(
"Scratch worktree used: false rejected when a branches/ "
"review worktree was created or used"
)
if _LEGACY_SCRATCH_FALSE_RE.search(text) and not _truthy_field(
review_worktree_used
):
reasons.append(
"legacy Scratch worktree used: false contradicts branches/ "
"review worktree usage"
)
if review_worktree_path and _truthy_field(review_worktree_used):
if "branches/" not in review_worktree_path.replace("\\", "/").lower():
reasons.append(
"Review worktree path must be under branches/ when "
"Review worktree used is true"
)
if inside_branches and re.search(r"\bfalse\b", inside_branches, re.I):
reasons.append(
"Review worktree inside branches must be true when path is "
"under branches/"
)
main_branch = (
fields.get("main checkout branch", "")
or _MAIN_CHECKOUT_BRANCH_RE.search(text).group(1)
if _MAIN_CHECKOUT_BRANCH_RE.search(text)
else ""
)
head_state = (
fields.get("review worktree head state", "")
or (
_REVIEW_HEAD_STATE_RE.search(text).group(1)
if _REVIEW_HEAD_STATE_RE.search(text)
else ""
)
)
if main_branch and head_state:
main_norm = main_branch.strip().lower()
head_norm = head_state.strip().lower()
if head_norm in {"branch", "on branch"} and main_norm in {
"master",
"main",
"dev",
}:
if "detached" in text.lower() and "review worktree" in text.lower():
reasons.append(
"review worktree HEAD state must not reuse main checkout "
"branch wording"
)
if _CONTRADICTORY_HEAD_RE.search(text):
reasons.append(
"contradictory checkout wording such as 'Detached HEAD / Branch master'"
)
if review_worktree_used and _truthy_field(review_worktree_used):
if not review_worktree_path or review_worktree_path.lower() in {
"none",
"n/a",
"not applicable",
}:
reasons.append(
"Review worktree used: true requires Review worktree path"
)
if not head_state or head_state.lower() in {
"none",
"n/a",
"not applicable",
"unknown",
}:
reasons.append(
"Review worktree used: true requires Review worktree HEAD state"
)
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": list(dict.fromkeys(reasons)),
"pagination_proven": pagination_proven,
"branches_worktree_used": branches_path_in_text,
"safe_next_action": (
"prove inventory pagination with final-page metadata; align "
"Review worktree used/path/HEAD state with branches/ usage"
if reasons
else "proceed"
),
}
+148
View File
@@ -0,0 +1,148 @@
"""Merge-simulation worktree mutation verifier for reviewer reports (#317)."""
from __future__ import annotations
import re
from typing import Any
_WORKTREE_INDEX_FIELD_RE = re.compile(
r"^\s*worktree/index mutations\s*:\s*(.+?)\s*$",
re.IGNORECASE | re.MULTILINE,
)
_READONLY_DIAG_RE = re.compile(
r"read[- ]only diagnostics\s*:\s*(.+)$",
re.IGNORECASE | re.MULTILINE,
)
_WORKTREE_PATH_RE = re.compile(
r"(?:worktree path|diagnostic worktree|simulation worktree)\s*:",
re.IGNORECASE,
)
_PRE_CLEAN_RE = re.compile(
r"(?:pre[- ]simulation|before simulation|clean before).*(?:clean|status)",
re.IGNORECASE,
)
_MERGE_RESULT_RE = re.compile(
r"(?:merge result|simulation result|conflict status|merge conflict)",
re.IGNORECASE,
)
_ABORT_RE = re.compile(
r"(?:merge --abort|abort/reset command|abort command|git merge --abort)",
re.IGNORECASE,
)
_POST_CLEAN_RE = re.compile(
r"(?:post[- ]abort|after abort|clean after).*(?:clean|status)",
re.IGNORECASE,
)
def _command_text(entry: Any) -> str:
if isinstance(entry, dict):
return str(entry.get("command") or "").strip()
return str(entry or "").strip()
def _git_subcommand_line(command: str) -> str | None:
tokens = command.split()
if not tokens or tokens[0] != "git":
return None
return " ".join(tokens[1:])
def merge_simulation_commands(command_log: list | None) -> list[str]:
"""Return logged commands that perform local merge simulation (#317)."""
out: list[str] = []
for entry in command_log or []:
command = _command_text(entry)
rest = _git_subcommand_line(command)
if rest is None:
continue
lower = command.lower()
if rest.startswith("merge"):
if "--no-commit" in lower or (
"--no-ff" in lower and "merge" in lower and "--commit" not in lower
):
out.append(command)
elif "--abort" in lower:
out.append(command)
return out
def assess_merge_simulation_report(
report_text: str,
*,
command_log: list | None = None,
) -> dict[str, Any]:
"""Reject merge simulation classified as read-only; require worktree proof (#317)."""
text = report_text or ""
lower = text.lower()
reasons: list[str] = []
simulation_commands = merge_simulation_commands(command_log)
has_abort = any("--abort" in c.lower() for c in simulation_commands)
has_simulation = any(
"--no-commit" in c.lower() or (
"--no-ff" in c.lower() and "--abort" not in c.lower()
)
for c in simulation_commands
)
if not simulation_commands:
return {
"proven": True,
"block": False,
"reasons": [],
"simulation_commands": [],
"safe_next_action": "proceed",
}
field = _WORKTREE_INDEX_FIELD_RE.search(text)
value = (field.group(1).strip().lower() if field else "") or ""
if not value or value in {"none", "n/a", "no"}:
reasons.append(
"merge simulation commands ran but report lacks "
"'Worktree/index mutations' entry"
)
elif "merge" not in value and "simulation" not in value:
if not any(cmd.lower() in lower for cmd in simulation_commands):
reasons.append(
"Worktree/index mutations must document merge simulation commands"
)
readonly = _READONLY_DIAG_RE.search(text)
if readonly:
readonly_value = readonly.group(1)
for command in simulation_commands:
if command.lower() in readonly_value.lower():
reasons.append(
"merge simulation may not be listed under read-only diagnostics"
)
if has_simulation and "merge simulation" in readonly_value.lower():
reasons.append(
"merge simulation may not be classified as read-only diagnostics"
)
if has_simulation:
if not _WORKTREE_PATH_RE.search(text):
reasons.append("merge simulation report missing worktree path")
if not _PRE_CLEAN_RE.search(text):
reasons.append("merge simulation report missing pre-simulation clean status")
if not _MERGE_RESULT_RE.search(text):
reasons.append("merge simulation report missing merge result/conflict status")
if has_abort or has_simulation:
if has_abort and not _ABORT_RE.search(text):
reasons.append("merge simulation report missing abort/reset command proof")
if has_abort and not _POST_CLEAN_RE.search(text):
reasons.append("merge simulation report missing post-abort clean status")
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"simulation_commands": simulation_commands,
"safe_next_action": (
"classify merge simulation under Worktree/index mutations with full "
"pre/merge/abort/post-clean proof; never list as read-only diagnostics"
if reasons
else "proceed"
),
}
+130
View File
@@ -0,0 +1,130 @@
"""Precise mutation category verifier for reviewer controller handoffs (#319)."""
from __future__ import annotations
import re
from typing import Any
_CONTROLLER_HANDOFF_RE = re.compile(r"controller handoff", re.IGNORECASE)
_WORKSPACE_MUTATIONS_RE = re.compile(
r"^\s*[-*]?\s*workspace\s+mutations\s*:",
re.IGNORECASE | re.MULTILINE,
)
_VAGUE_MUTATIONS_NONE_RE = re.compile(
r"^\s*[-*]?\s*mutations\s*:\s*none\s*$",
re.IGNORECASE | re.MULTILINE,
)
_REQUIRED_CATEGORIES = (
"file edits by reviewer",
"worktree/index mutations",
"git ref mutations",
)
_WORKTREE_FIELD_ALIASES = (
"worktree/index mutations",
"worktree mutations",
)
def _has_category(text: str, category: str) -> bool:
pattern = re.compile(
rf"^\s*[-*]?\s*{re.escape(category)}\s*:",
re.IGNORECASE | re.MULTILINE,
)
return bool(pattern.search(text))
def _has_worktree_category(text: str) -> bool:
return any(_has_category(text, alias) for alias in _WORKTREE_FIELD_ALIASES)
def _normalize_for_consistency_check(text: str) -> str:
"""Map #319 precise labels to #313 field names for consistency delegation."""
normalized = text
if _has_category(text, "worktree/index mutations") and not _has_category(
text, "worktree mutations"
):
normalized = re.sub(
r"(worktree/index mutations\s*:)",
"Worktree mutations:",
normalized,
flags=re.IGNORECASE,
)
return normalized
def assess_mutation_categories_report(
report_text: str,
*,
handoff_session: dict | None = None,
observed_commands: list | None = None,
) -> dict[str, Any]:
"""Require precise mutation categories in reviewer controller handoffs (#319)."""
text = report_text or ""
session = dict(handoff_session or {})
reasons: list[str] = []
lower = text.lower()
is_controller = bool(
session.get("controller_handoff")
or _CONTROLLER_HANDOFF_RE.search(text)
)
if not is_controller and not session.get("require_categories"):
return {
"proven": True,
"block": False,
"reasons": [],
"controller_handoff": False,
"safe_next_action": "proceed",
}
if _WORKSPACE_MUTATIONS_RE.search(text):
reasons.append(
"controller handoffs must not use legacy 'Workspace mutations'; "
"use precise mutation category fields"
)
if _VAGUE_MUTATIONS_NONE_RE.search(text):
reasons.append(
"controller handoffs must not use vague 'Mutations: none'; "
"report each precise category separately"
)
missing = [
category
for category in _REQUIRED_CATEGORIES
if category != "worktree/index mutations" and not _has_category(text, category)
]
if not _has_worktree_category(text):
missing.append("worktree/index mutations")
if missing:
reasons.append(
"controller handoff missing precise mutation categories: "
+ ", ".join(missing)
)
commands = observed_commands or session.get("observed_commands")
if commands:
from review_proofs import assess_workspace_mutation_consistency
consistency = assess_workspace_mutation_consistency(
_normalize_for_consistency_check(text),
commands,
)
if not consistency.get("complete"):
reasons.extend(consistency.get("reasons") or [])
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"controller_handoff": True,
"safe_next_action": (
"replace Workspace mutations with file edits, worktree/index, git ref, "
"and other precise mutation category fields"
if reasons
else "proceed"
),
}
+515
View File
@@ -0,0 +1,515 @@
"""Per-PR reviewer leases for safe parallel review sessions (#407)."""
from __future__ import annotations
import os
import re
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
MARKER = "<!-- mcp-review-lease:v1 -->"
_FIELD_RE = re.compile(
r"^\s*([a-z_]+)\s*:\s*(.+?)\s*$",
re.IGNORECASE | re.MULTILINE,
)
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
_TERMINAL_PHASES = frozenset({"done", "released", "blocked"})
_ACTIVE_PHASES = frozenset({
"claimed",
"validating",
"approved",
"request-changes",
"merging",
"adopted",
})
DEFAULT_LEASE_TTL_MINUTES = 120
STALE_WARNING_MINUTES = 30
RECLAIMABLE_MINUTES = 60
_SESSION_LEASE: dict[str, Any] | None = None
def _parse_timestamp(value: str | None) -> datetime | None:
if not value:
return None
text = value.strip()
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _normalize_sha(value: str | None) -> str | None:
text = (value or "").strip().lower()
return text if text and _FULL_SHA.match(text) else None
def _parse_pr_ref(value: str | None) -> int | None:
digits = re.sub(r"[^\d]", "", value or "")
return int(digits) if digits.isdigit() else None
def new_session_id() -> str:
return f"{os.getpid()}-{uuid.uuid4().hex[:12]}"
def format_lease_body(
*,
repo: str,
pr_number: int,
issue_number: int | None,
reviewer_identity: str,
profile: str,
session_id: str,
worktree: str,
phase: str,
candidate_head: str | None,
target_branch: str,
target_branch_sha: str | None,
last_activity: datetime | None = None,
expires_at: datetime | None = None,
blocker: str = "none",
) -> str:
now = last_activity or datetime.now(timezone.utc)
expires = expires_at or (now + timedelta(minutes=DEFAULT_LEASE_TTL_MINUTES))
last_text = now.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
"+00:00", "Z"
)
expires_text = expires.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
"+00:00", "Z"
)
issue_text = f"#{issue_number}" if issue_number else "none"
lines = [
MARKER,
f"repo: {repo}",
f"pr: #{pr_number}",
f"issue: {issue_text}",
f"reviewer_identity: {reviewer_identity}",
f"profile: {profile}",
f"session_id: {session_id}",
f"worktree: {worktree}",
f"phase: {phase}",
f"candidate_head: {candidate_head or 'none'}",
f"target_branch: {target_branch}",
f"target_branch_sha: {target_branch_sha or 'none'}",
f"last_activity: {last_text}",
f"expires_at: {expires_text}",
f"blocker: {blocker}",
]
return "\n".join(lines)
def parse_lease_comment(body: str) -> dict[str, Any] | None:
text = body or ""
if MARKER not in text:
return None
fields: dict[str, str] = {}
for match in _FIELD_RE.finditer(text):
fields[match.group(1).strip().lower()] = match.group(2).strip()
if not fields:
return None
return {
"repo": fields.get("repo"),
"pr_number": _parse_pr_ref(fields.get("pr")),
"issue_number": _parse_pr_ref(fields.get("issue")),
"reviewer_identity": fields.get("reviewer_identity"),
"profile": fields.get("profile"),
"session_id": fields.get("session_id"),
"worktree": fields.get("worktree"),
"phase": (fields.get("phase") or "").strip().lower() or None,
"candidate_head": _normalize_sha(fields.get("candidate_head")),
"target_branch": fields.get("target_branch"),
"target_branch_sha": _normalize_sha(fields.get("target_branch_sha")),
"last_activity": fields.get("last_activity"),
"expires_at": fields.get("expires_at"),
"blocker": fields.get("blocker"),
"raw_fields": fields,
}
def _lease_entries(comments: list[dict], *, pr_number: int) -> list[dict]:
entries: list[dict] = []
for comment in comments or []:
parsed = parse_lease_comment(comment.get("body") or "")
if not parsed:
continue
if parsed.get("pr_number") not in (None, pr_number):
continue
entries.append({
**parsed,
"comment_id": comment.get("id"),
"author": (comment.get("user") or {}).get("login") or comment.get("author"),
"created_at": comment.get("created_at"),
"updated_at": comment.get("updated_at"),
})
return entries
def _lease_expired(lease: dict, *, now: datetime) -> bool:
expires_at = _parse_timestamp(lease.get("expires_at"))
return bool(expires_at and expires_at <= now)
def _minutes_since_activity(lease: dict, *, now: datetime) -> float | None:
last = _parse_timestamp(lease.get("last_activity"))
if not last:
return None
return (now - last).total_seconds() / 60.0
def classify_lease_freshness(lease: dict, *, now: datetime | None = None) -> str:
"""Return active, stale_warning, reclaimable, expired, or terminal."""
now = now or datetime.now(timezone.utc)
phase = (lease.get("phase") or "").strip().lower()
if phase in _TERMINAL_PHASES:
return "terminal"
if _lease_expired(lease, now=now):
return "expired"
minutes = _minutes_since_activity(lease, now=now)
if minutes is None:
return "active"
if minutes >= RECLAIMABLE_MINUTES:
return "reclaimable"
if minutes >= STALE_WARNING_MINUTES:
return "stale_warning"
return "active"
def find_active_reviewer_lease(
comments: list[dict],
*,
pr_number: int,
now: datetime | None = None,
) -> dict[str, Any] | None:
"""Newest non-terminal, unexpired lease for *pr_number*."""
now = now or datetime.now(timezone.utc)
for lease in reversed(_lease_entries(comments, pr_number=pr_number)):
phase = (lease.get("phase") or "").strip().lower()
if phase in _TERMINAL_PHASES:
continue
if _lease_expired(lease, now=now):
continue
if phase in _ACTIVE_PHASES or phase:
lease = dict(lease)
lease["freshness"] = classify_lease_freshness(lease, now=now)
return lease
return None
def assess_acquire_lease(
comments: list[dict],
*,
pr_number: int,
reviewer_identity: str,
profile: str,
session_id: str,
repo: str,
issue_number: int | None,
worktree: str,
candidate_head: str | None,
target_branch: str,
target_branch_sha: str | None,
pr_merged_or_closed: bool = False,
now: datetime | None = None,
) -> dict[str, Any]:
"""Fail closed when another session holds an active lease.
When *pr_merged_or_closed* is true the PR has already merged/closed, so any
reviewer-lease acquisition or adoption for merge work is moot: fail closed
with a ``post_merge_moot`` reason and never mint a lease body (#515).
"""
now = now or datetime.now(timezone.utc)
reasons: list[str] = []
existing = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
post_merge_moot = bool(pr_merged_or_closed)
if post_merge_moot:
reasons.append(
f"post_merge_moot: PR #{pr_number} is already merged/closed; reviewer "
"lease adoption for merge is moot (fail closed)"
)
if existing:
owner_session = (existing.get("session_id") or "").strip()
freshness = existing.get("freshness") or classify_lease_freshness(existing, now=now)
if owner_session and owner_session != session_id and freshness in {
"active", "stale_warning"
}:
reasons.append(
f"PR #{pr_number} already has active reviewer lease "
f"(session_id={owner_session}, phase={existing.get('phase')})"
)
elif owner_session and owner_session != session_id and freshness == "reclaimable":
reasons.append(
f"PR #{pr_number} lease is reclaimable but still held by "
f"session_id={owner_session}; explicit reclaim not implemented "
"(fail closed)"
)
if not (reviewer_identity or "").strip():
reasons.append("reviewer identity required for lease acquisition")
if not (session_id or "").strip():
reasons.append("session_id required for lease acquisition")
if not (worktree or "").strip():
reasons.append("worktree path required for lease acquisition")
allowed = not reasons
body = None
if allowed:
body = format_lease_body(
repo=repo,
pr_number=pr_number,
issue_number=issue_number,
reviewer_identity=reviewer_identity,
profile=profile,
session_id=session_id,
worktree=worktree,
phase="claimed",
candidate_head=candidate_head,
target_branch=target_branch,
target_branch_sha=target_branch_sha,
last_activity=now,
)
return {
"acquire_allowed": allowed,
"reasons": reasons,
"existing_lease": existing,
"lease_body": body,
"session_id": session_id,
"post_merge_moot": post_merge_moot,
}
def assess_post_merge_moot_lease(
comments: list[dict],
*,
pr_number: int,
pr_merged: bool = False,
pr_state: str | None = None,
merge_commit_sha: str | None = None,
now: datetime | None = None,
) -> dict[str, Any]:
"""Assess a reviewer lease left lingering on an already-merged/closed PR (#515).
Read-first and fail-safe:
- Only treats a lease as moot when the live PR state is merged/closed.
- Never proposes touching an *active* lease while the PR is still open
(``cleanup_allowed`` stays false and a refusal reason is returned).
- When the PR is merged/closed and a lease is still active, ``cleanup_allowed``
is true and a terminal ``phase: released`` lease body (``blocker:
post-merge-moot``) is provided so the moot lease can be neutralised by an
append-only comment never by deleting a foreign session's comment, and
never by adopting or merging.
Posting the released body makes that lease terminal, so a subsequent call
finds no active lease and reports nothing left to clean (idempotent).
"""
now = now or datetime.now(timezone.utc)
merged_or_closed = bool(pr_merged) or (
str(pr_state or "").strip().lower() == "closed"
)
active = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
# The newest lease comment is authoritative: once a terminal marker
# (released/done/blocked) is the latest entry, the lease is resolved even if
# an earlier non-terminal comment from the same session still lingers. This
# keeps post-merge cleanup idempotent.
entries = _lease_entries(comments, pr_number=pr_number)
newest = entries[-1] if entries else None
newest_terminal = bool(newest) and (
(newest.get("phase") or "").strip().lower() in _TERMINAL_PHASES
)
reasons: list[str] = []
cleanup_allowed = False
release_body: str | None = None
is_moot = bool(active) and merged_or_closed and not newest_terminal
if not merged_or_closed:
if active:
reasons.append(
f"PR #{pr_number} is still open; refusing to touch active reviewer "
"lease (fail closed)"
)
else:
reasons.append(
f"PR #{pr_number} is still open; no post-merge lease cleanup applicable"
)
elif newest_terminal:
reasons.append(
f"PR #{pr_number} reviewer lease already released/terminal; nothing to clean"
)
elif active:
cleanup_allowed = True
release_body = format_lease_body(
repo=active.get("repo") or "",
pr_number=pr_number,
issue_number=active.get("issue_number"),
reviewer_identity=active.get("reviewer_identity") or "",
profile=active.get("profile") or "unknown",
session_id=active.get("session_id") or "",
worktree=active.get("worktree") or "",
phase="released",
candidate_head=active.get("candidate_head"),
target_branch=active.get("target_branch") or "master",
target_branch_sha=active.get("target_branch_sha"),
last_activity=now,
blocker="post-merge-moot",
)
else:
reasons.append(
f"PR #{pr_number} is merged/closed but no active reviewer lease remains; "
"nothing to clean"
)
return {
"pr_number": pr_number,
"pr_state": pr_state,
"pr_merged_or_closed": merged_or_closed,
"merge_commit_sha": merge_commit_sha,
"active_lease": active,
"is_moot": is_moot,
"cleanup_allowed": cleanup_allowed,
"release_body": release_body,
"reasons": reasons,
}
def record_session_lease(
lease: dict[str, Any],
*,
lease_provenance: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""Record the in-session lease mirror for mutation gates.
*lease_provenance* must be supplied by sanctioned MCP tools (#536). Bare
manual seeding without provenance cannot satisfy merger/reviewer mutation
gates.
"""
global _SESSION_LEASE
stored = dict(lease)
if lease_provenance:
stored["lease_provenance"] = dict(lease_provenance)
_SESSION_LEASE = stored
return dict(_SESSION_LEASE)
def clear_session_lease() -> None:
global _SESSION_LEASE
_SESSION_LEASE = None
def get_session_lease() -> dict[str, Any] | None:
return dict(_SESSION_LEASE) if _SESSION_LEASE else None
def assess_mutation_lease_gate(
*,
pr_number: int,
comments: list[dict],
reviewer_identity: str,
session_id: str | None,
mutation: str,
live_head_sha: str | None,
pinned_head_sha: str | None,
now: datetime | None = None,
) -> dict[str, Any]:
"""Reviewer mutations require an owned, current PR lease."""
now = now or datetime.now(timezone.utc)
reasons: list[str] = []
session = get_session_lease()
active = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
if not session:
reasons.append(
f"no in-session reviewer lease recorded; acquire via "
f"gitea_acquire_reviewer_pr_lease or adopt via "
f"gitea_adopt_merger_pr_lease before {mutation}"
)
else:
import merger_lease_adoption as mla
if not mla.is_sanctioned_session_lease(session):
reasons.append(
"in-session lease lacks sanctioned provenance; manual "
"_SESSION_LEASE seeding is not canonical proof — use "
"gitea_acquire_reviewer_pr_lease or gitea_adopt_merger_pr_lease"
)
if session and session.get("pr_number") != pr_number:
reasons.append(
f"session lease is for PR #{session.get('pr_number')}, not #{pr_number}"
)
elif session and (session.get("session_id") or "") != (
session_id or session.get("session_id")
):
reasons.append("session lease session_id mismatch (fail closed)")
if active:
owner = (active.get("session_id") or "").strip()
if owner and session_id and owner != session_id:
reasons.append(
f"active PR lease owned by session_id={owner}; current session "
f"cannot {mutation}"
)
pinned = _normalize_sha(pinned_head_sha)
live = _normalize_sha(live_head_sha)
lease_head = active.get("candidate_head")
if pinned and live and pinned != live:
reasons.append(
"PR head changed during lease; stop and re-validate before "
f"reviewer {mutation}"
)
if lease_head and live and lease_head != live:
reasons.append(
"live PR head differs from lease candidate_head; refresh lease "
f"before {mutation}"
)
freshness = active.get("freshness") or classify_lease_freshness(active, now=now)
if freshness in {"expired", "reclaimable"}:
reasons.append(f"reviewer lease freshness is '{freshness}' (fail closed)")
else:
reasons.append(f"no active reviewer lease found on PR #{pr_number}")
allowed = not reasons
return {
"mutation_allowed": allowed,
"block": not allowed,
"reasons": reasons,
"active_lease": active,
"session_lease": session,
}
def assess_lease_inventory(
comments_by_pr: dict[int, list[dict]],
*,
now: datetime | None = None,
) -> dict[str, Any]:
"""Summarize lease states across PR comment threads."""
now = now or datetime.now(timezone.utc)
active: list[dict] = []
stale: list[dict] = []
reclaimable: list[dict] = []
for pr_number, comments in (comments_by_pr or {}).items():
lease = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
if not lease:
continue
freshness = lease.get("freshness") or classify_lease_freshness(lease, now=now)
entry = {"pr_number": pr_number, "session_id": lease.get("session_id"), "freshness": freshness}
if freshness == "stale_warning":
stale.append(entry)
elif freshness == "reclaimable":
reclaimable.append(entry)
else:
active.append(entry)
return {
"active_review_leases": active,
"stale_review_leases": stale,
"reclaimable_review_leases": reclaimable,
}
+217
View File
@@ -0,0 +1,217 @@
"""Proof-backed reviewer handoff claim verifier (#395)."""
from __future__ import annotations
import re
from typing import Any
_PAGINATION_FINALITY = re.compile(
r"has_more\s*:\s*false|is_final_page\s*:\s*true|"
r"inventory_complete\s*:\s*true|pages_fetched|total_count\s*:",
re.I,
)
_PAGE_SIZE_ONLY = re.compile(
r"(?:less|fewer) than (?:the )?(?:default )?page[- ]?size|"
r"under (?:the )?50[- ]?(?:item )?limit|"
r"returned \d+ (?:open )?prs?.*less than 50",
re.I,
)
_INVENTORY_COMPLETE_CLAIM = re.compile(
r"\b(?:inventory (?:is )?complete|inventory exhaustive|"
r"complete (?:pr )?inventory)\b",
re.I,
)
_SKIP_CLAIM = re.compile(
r"\b(?:earlier prs? skipped|skipped (?:earlier )?pr|"
r"skip(?:ped)? pr #?\d+|non-mergeable|prior request.changes)\b",
re.I,
)
_CONFLICT_PROOF = re.compile(
r"merge simulation|git merge --no-commit|conflicting files|"
r"conflict proof|merge_exit|non-mergeable",
re.I,
)
_BASELINE_CLAIM = re.compile(
r"\b(?:baseline (?:validation|worktree|comparison)|"
r"same as master|pre-existing (?:on )?master|"
r"failure signatures match)\b",
re.I,
)
_BASELINE_PATH = re.compile(r"baseline worktree path\s*:\s*(\S+)", re.I)
_BASELINE_SHA = re.compile(r"baseline (?:target )?sha\s*:\s*([0-9a-f]{7,40})", re.I)
_BASELINE_DIRTY_BEFORE = re.compile(
r"baseline.*dirty before|dirty before.*baseline", re.I
)
_BASELINE_DIRTY_AFTER = re.compile(
r"baseline.*dirty after|dirty after.*baseline", re.I
)
_BASELINE_COMMAND = re.compile(
r"baseline.*(?:validation )?command|pytest.*baseline", re.I
)
_BASELINE_RESULT = re.compile(
r"baseline.*(?:validation )?result|baseline_exit|baseline failures", re.I
)
_MASTER_INTEGRATION = re.compile(
r"\b(?:merged master into|merge(?:d)? (?:remote[- ]tracking )?branch.*master|"
r"master integration|integrated master|rebase.*master)\b",
re.I,
)
_CLEANUP_CLAIM = re.compile(
r"\b(?:worktree(?:s)? (?:were )?cleaned|cleanup (?:result|mutations)|"
r"removed (?:session[- ]owned )?worktree|git worktree remove)\b",
re.I,
)
_WORKTREE_LIST = re.compile(r"git worktree list|worktree list proof", re.I)
_PROOF_SOURCE = re.compile(
r"proof source\s*:\s*(command|mcp metadata|prior blocker|not checked)",
re.I,
)
_HANDOFF_SECTION = re.compile(r"^##\s*Controller Handoff\s*$", re.I | re.M)
def _handoff_fields(report_text: str) -> dict[str, str]:
text = report_text or ""
match = _HANDOFF_SECTION.search(text)
if not match:
return {}
fields: dict[str, str] = {}
for line in text[match.end() :].splitlines():
stripped = line.strip().lstrip("-*").strip()
if ":" not in stripped:
continue
key, value = stripped.split(":", 1)
fields[key.strip().lower()] = value.strip()
return fields
def _command_text(entry: Any) -> str:
if isinstance(entry, dict):
return str(entry.get("command") or entry.get("tool") or "").strip()
return str(entry or "").strip()
def _action_log_has(action_log: list | None, pattern: re.Pattern[str]) -> bool:
for entry in action_log or []:
blob = " ".join(
filter(
None,
[
_command_text(entry),
str(entry.get("result") or "") if isinstance(entry, dict) else "",
str(entry.get("reason") or "") if isinstance(entry, dict) else "",
],
)
)
if pattern.search(blob):
return True
return False
def assess_proof_backed_handoff_report(
report_text: str,
*,
action_log: list | None = None,
inventory_session: dict | None = None,
baseline_session: dict | None = None,
skip_session: list[dict] | None = None,
) -> dict[str, Any]:
"""Require explicit command/tool evidence for proof-sensitive review claims (#395)."""
text = report_text or ""
fields = _handoff_fields(text)
reasons: list[str] = []
inventory = dict(inventory_session or {})
baseline = dict(baseline_session or {})
skips = list(skip_session or [])
pagination_field = fields.get("inventory pagination proof", "")
if _INVENTORY_COMPLETE_CLAIM.search(text) or _PAGE_SIZE_ONLY.search(text):
has_meta = bool(
inventory.get("inventory_complete")
or inventory.get("pagination_complete")
or _PAGINATION_FINALITY.search(pagination_field)
or _PAGINATION_FINALITY.search(text)
or _action_log_has(action_log, re.compile(r"gitea_list_prs", re.I))
)
if _PAGE_SIZE_ONLY.search(text) and not has_meta:
reasons.append(
"inventory completeness claimed from page-size assumption only; "
"require has_more=false, is_final_page=true, or inventory_complete=true"
)
elif _INVENTORY_COMPLETE_CLAIM.search(text) and not has_meta:
reasons.append(
"inventory complete claim lacks explicit pagination metadata proof"
)
if _SKIP_CLAIM.search(text) or fields.get("earlier prs skipped", "").lower() not in {
"",
"none",
"n/a",
}:
skip_proof = bool(
skips
or _CONFLICT_PROOF.search(text)
or _action_log_has(
action_log, re.compile(r"git merge --no-commit|gitea_get_pr_review_feedback", re.I)
)
)
if not skip_proof:
reasons.append(
"earlier PR skip claim lacks command/tool conflict or blocker proof"
)
if _BASELINE_CLAIM.search(text) or fields.get("baseline worktree used", "").lower() == "true":
baseline_ok = bool(
baseline.get("complete")
or (
_BASELINE_PATH.search(text)
and _BASELINE_SHA.search(text)
and (_BASELINE_DIRTY_BEFORE.search(text) or baseline.get("clean_before"))
and (_BASELINE_COMMAND.search(text) or baseline.get("command"))
and (_BASELINE_RESULT.search(text) or baseline.get("result"))
and (_BASELINE_DIRTY_AFTER.search(text) or baseline.get("clean_after"))
)
)
if not baseline_ok:
reasons.append(
"baseline validation claim missing worktree path, target SHA, "
"dirty-before/after status, command, or result proof"
)
if _MASTER_INTEGRATION.search(text):
if not _action_log_has(
action_log,
re.compile(r"git merge.*master|git rebase.*master", re.I),
) and not re.search(r"merge_exit\s*=\s*\d+|merge simulation", text, re.I):
reasons.append(
"master integration claim lacks exact merge/rebase command and result"
)
if _CLEANUP_CLAIM.search(text) or fields.get("cleanup mutations", "").lower() not in {
"",
"none",
"n/a",
}:
if not (_WORKTREE_LIST.search(text) or _action_log_has(action_log, _WORKTREE_LIST)):
reasons.append(
"cleanup claim lacks final git worktree list or equivalent proof"
)
if re.search(r"\blive proof\b", text, re.I) and not _PROOF_SOURCE.search(text):
if not action_log:
reasons.append(
"live proof wording requires proof source classification "
"(command, MCP metadata, prior blocker, or not checked)"
)
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"safe_next_action": (
"cite explicit command/tool evidence or structured MCP pagination metadata "
"for each proof-sensitive claim"
if not proven
else "proceed"
),
}
+162
View File
@@ -0,0 +1,162 @@
"""Reconciliation PR inventory pagination proof verifier (#308)."""
from __future__ import annotations
import re
from typing import Any
_COMPLETE_SCAN_CLAIM_RE = re.compile(
r"\b(?:all already[- ]landed(?:\s+prs?)?(?:\s+were)?\s+found|"
r"complete queue scan|all open prs? (?:were )?(?:found|checked|inventoried|listed)|"
r"inventory (?:is )?complete|exhaustive (?:pr )?inventory|"
r"no additional already[- ]landed|scanned (?:the )?(?:full )?open pr queue)\b",
re.I,
)
_FINALITY_RE = re.compile(
r"is_final_page\s*:\s*true|has_more\s*:\s*false|no next page|final[- ]page|"
r"pagination_complete\s*:\s*true|inventory pagination proof\s*:\s*(?!assumed|none\b).+",
re.I,
)
_REQUESTED_PAGE_SIZE_RE = re.compile(
r"requested page size\s*:\s*(\d+)|page[- ]?size\s*(?:=|:)\s*(\d+)",
re.I,
)
_PAGES_FETCHED_RE = re.compile(
r"pages? fetched\s*:\s*(\d+)|page count\s*:\s*(\d+)",
re.I,
)
_RETURNED_PER_PAGE_RE = re.compile(
r"returned pr count(?: per page)?\s*:|open pr count per page|"
r"returned \d+ open prs? per page|per[- ]page counts?\s*:",
re.I,
)
_EXACT_LIMIT_RETURN_RE = re.compile(
r"returned\s+(\d+)\s+open prs?|listed\s+(\d+)\s+open prs?",
re.I,
)
_DEFAULT_PAGE_SIZE_ASSUMPTION = re.compile(
r"(?:less|fewer) than (?:the )?(?:default )?(?:gitea )?page[- ]?(?:size|limit)|"
r"default (?:gitea )?page[- ]?size|assumed complete",
re.I,
)
def _int_from_groups(match: re.Match[str] | None) -> int | None:
if not match:
return None
for group in match.groups():
if group:
return int(group)
return None
def _pagination_proven(text: str, session: dict) -> bool:
if session.get("pagination_complete") or session.get("inventory_complete"):
return True
if _FINALITY_RE.search(text):
return True
pages = session.get("pages_fetched")
if isinstance(pages, int) and pages >= 1 and session.get("is_final_page"):
return True
return False
def _metadata_present(text: str, session: dict) -> list[str]:
missing: list[str] = []
has_page_size = bool(
_REQUESTED_PAGE_SIZE_RE.search(text)
or session.get("requested_page_size")
)
has_pages_fetched = bool(
_PAGES_FETCHED_RE.search(text) or session.get("pages_fetched")
)
has_returned_counts = bool(
_RETURNED_PER_PAGE_RE.search(text) or session.get("returned_per_page")
)
if not has_page_size:
missing.append("requested page size")
if not has_pages_fetched:
missing.append("page count fetched")
if not has_returned_counts:
missing.append("returned PR count per page")
if not _pagination_proven(text, session):
missing.append("final-page/no-next-page proof")
return missing
def assess_reconcile_inventory_report(
report_text: str,
*,
inventory_session: dict | None = None,
) -> dict[str, Any]:
"""Validate PR inventory pagination proof in reconciliation reports (#308)."""
text = report_text or ""
session = dict(inventory_session or {})
reasons: list[str] = []
claims_scan = bool(_COMPLETE_SCAN_CLAIM_RE.search(text))
lists_open_prs = bool(
re.search(r"open prs? listed|listed open prs?|pr inventory", text, re.I)
)
if not claims_scan and not lists_open_prs and not session.get("inventory_required"):
return {
"proven": True,
"block": False,
"reasons": [],
"inventory_claimed": False,
"safe_next_action": "proceed",
}
if _DEFAULT_PAGE_SIZE_ASSUMPTION.search(text) and not _pagination_proven(text, session):
reasons.append(
"reconciliation inventory assumed complete from page-size guess "
"without final-page proof (#308)"
)
if claims_scan and not _pagination_proven(text, session):
reasons.append(
"complete queue scan or all already-landed claim requires "
"pagination finality proof (#308)"
)
missing = _metadata_present(text, session)
if (claims_scan or lists_open_prs) and missing:
reasons.append(
"reconciliation inventory missing pagination metadata: "
+ ", ".join(missing)
)
requested = session.get("requested_page_size")
returned = session.get("returned_page_size")
if isinstance(requested, int) and isinstance(returned, int):
if returned >= requested > 0 and not _pagination_proven(text, session):
reasons.append(
"exactly-full first reconciliation page without final-page proof (#308)"
)
else:
for match in _EXACT_LIMIT_RETURN_RE.finditer(text):
count = _int_from_groups(match)
if count in {10, 20, 50} and not _pagination_proven(text, session):
reasons.append(
"exactly-full first reconciliation page without final-page proof (#308)"
)
break
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": list(dict.fromkeys(reasons)),
"inventory_claimed": claims_scan or lists_open_prs,
"pagination_proven": _pagination_proven(text, session),
"safe_next_action": (
"include requested page size, pages fetched, per-page counts, and "
"final-page/no-next-page proof before claiming complete scan"
if reasons
else "proceed"
),
}
+191
View File
@@ -0,0 +1,191 @@
"""Reconciliation linked-issue live proof verifier (#300)."""
from __future__ import annotations
import re
from typing import Any
_HANDOFF_SECTION_RE = re.compile(r"^##\s*Controller Handoff\s*$", re.I | re.M)
_LINKED_ISSUE_FIELD_RE = re.compile(
r"linked issue\s*:\s*(.+)$",
re.I | re.M,
)
_LINKED_ISSUE_STATUS_RE = re.compile(
r"linked issue(?:\s+live)?\s+status\s*:\s*(.+)$",
re.I | re.M,
)
_STATUS_CLAIM_RE = re.compile(
r"\b(?:issue\s+#?\d+\s*\()?(open|closed|resolved)\b|"
r"issue\s+(?:is\s+)?(open|closed|resolved)\b|"
r"linked issue.*\b(open|closed|resolved)\b",
re.I,
)
_NOT_VERIFIED_RE = re.compile(r"not verified in this session", re.I)
_LIVE_FETCH_PROOF_RE = re.compile(
r"gitea_view_issue|issue fetched live|live issue fetch|"
r"fetched linked issue.*(?:current|this) session|"
r"linked issue (?:fetch|proof).*(?:current|this) session|"
r"live linked issue proof",
re.I,
)
_ISSUE_ACTION_RECOMMEND_RE = re.compile(
r"(?:recommend(?:ed)?|should|must)\s+(?:close|reopen|comment on)\s+"
r"(?:the\s+)?(?:linked\s+)?issue|"
r"(?:close|reopen|comment on)\s+linked issue\s+#?\d+",
re.I,
)
_CAPABILITY_PROOF_RE = re.compile(
r"capability proof|exact capability|gitea_close_issue|"
r"gitea_mark_issue|gitea_create_issue_comment|gitea_edit_issue",
re.I,
)
_NO_LINKED_ISSUE_VALUES = frozenset({
"",
"none",
"n/a",
"not applicable",
"no linked issue",
"unknown",
})
def _handoff_field_map(report_text: str) -> dict[str, str]:
text = report_text or ""
match = _HANDOFF_SECTION_RE.search(text)
if not match:
return {}
fields: dict[str, str] = {}
for line in text[match.end() :].splitlines():
stripped = line.strip().lstrip("-*").strip()
if ":" not in stripped:
continue
key, value = stripped.split(":", 1)
fields[key.strip().lower()] = value.strip()
return fields
def _extract_linked_issue_number(text: str, fields: dict[str, str]) -> int | None:
linked = fields.get("linked issue", "")
if linked.lower() in _NO_LINKED_ISSUE_VALUES:
return None
match = re.search(r"#?(\d+)", linked)
if match:
return int(match.group(1))
field_match = _LINKED_ISSUE_FIELD_RE.search(text)
if field_match:
num = re.search(r"#?(\d+)", field_match.group(1))
if num:
return int(num.group(1))
return None
def _status_value(text: str, fields: dict[str, str]) -> str:
for key in ("linked issue live status", "linked issue status"):
if fields.get(key):
return fields[key]
match = _LINKED_ISSUE_STATUS_RE.search(text)
return match.group(1).strip() if match else ""
def _live_proof_present(text: str, session: dict, issue_number: int | None) -> bool:
if session.get("live_fetched") or session.get("verified_live"):
return True
if session.get("issue_fetch_proof"):
return True
if _LIVE_FETCH_PROOF_RE.search(text):
return True
if issue_number is not None:
pattern = re.compile(
rf"gitea_view_issue.*#?{issue_number}|"
rf"issue\s+#?{issue_number}.*(?:fetched|viewed).*(?:current|this) session",
re.I,
)
if pattern.search(text):
return True
return False
def assess_reconcile_linked_issue_report(
report_text: str,
*,
reconcile_session: dict | None = None,
) -> dict[str, Any]:
"""Validate linked issue status claims in reconciliation handoffs (#300)."""
text = report_text or ""
session = dict(reconcile_session or {})
fields = _handoff_field_map(text)
reasons: list[str] = []
issue_number = session.get("linked_issue_number")
if issue_number is None:
issue_number = _extract_linked_issue_number(text, fields)
if issue_number is None:
status = _status_value(text, fields)
if status and status.lower() not in _NO_LINKED_ISSUE_VALUES:
if _STATUS_CLAIM_RE.search(status):
reasons.append(
"linked issue status reported without identifying the linked issue (#300)"
)
if not reasons:
return {
"proven": True,
"block": False,
"reasons": [],
"linked_issue_number": None,
"safe_next_action": "proceed",
}
status = _status_value(text, fields)
status_lower = status.lower()
if session.get("issue_missing"):
if status_lower and "not verified" not in status_lower:
reasons.append(
"missing linked issue must be reported as "
"'not verified in this session' (#300)"
)
elif status:
if _NOT_VERIFIED_RE.search(status):
pass
elif _STATUS_CLAIM_RE.search(status):
if not _live_proof_present(text, session, issue_number):
reasons.append(
"linked issue open/closed/resolved status requires live "
"gitea_view_issue proof in the current session (#300)"
)
elif status_lower not in _NO_LINKED_ISSUE_VALUES:
if not _live_proof_present(text, session, issue_number):
reasons.append(
"linked issue status claim requires live fetch proof or "
"'not verified in this session' (#300)"
)
if _ISSUE_ACTION_RECOMMEND_RE.search(text):
if not _CAPABILITY_PROOF_RE.search(text) and not session.get(
"issue_action_capability_proven"
):
reasons.append(
"recommended linked issue close/reopen/comment requires "
"exact capability proof (#300)"
)
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": list(dict.fromkeys(reasons)),
"linked_issue_number": issue_number,
"live_proof_present": _live_proof_present(text, session, issue_number),
"safe_next_action": (
"fetch linked issue with gitea_view_issue in this session or "
"report 'Linked issue status: not verified in this session'"
if reasons
else "proceed"
),
}
+233
View File
@@ -0,0 +1,233 @@
"""Explicit worktree and cwd proof for PR review validation (#398)."""
from __future__ import annotations
import re
from typing import Any
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
_PWD_RE = re.compile(
r"(?:^|\n)\s*(?:pwd|working\s+directory|cwd)\s*:\s*(\S+)",
re.IGNORECASE,
)
_HEAD_RE = re.compile(
r"(?:git\s+rev-parse\s+head|observed\s+head\s+sha)\s*:\s*([0-9a-f]{7,40})",
re.IGNORECASE | re.MULTILINE,
)
_EXPECTED_HEAD_RE = re.compile(
r"(?:expected\s+(?:pr\s+)?head\s+sha|candidate\s+head\s+sha|pinned\s+head)\s*:\s*([0-9a-f]{7,40})",
re.IGNORECASE,
)
_STATUS_RE = re.compile(
r"git\s+status\s+(?:--short\s+--branch|--short|-sb)\s*:\s*(.+)",
re.IGNORECASE,
)
_VALIDATION_CMD_RE = re.compile(
r"validation\s+command\s*:\s*(.+)",
re.IGNORECASE,
)
_GIT_C_CMD_RE = re.compile(r"git\s+-C\s+\S+", re.IGNORECASE)
_CD_CMD_RE = re.compile(r"(?:^|&&\s*)cd\s+\S+", re.IGNORECASE)
_BASELINE_CWD_RE = re.compile(
r"baseline\s+(?:worktree|working\s+directory|cwd)\s*:\s*(\S+)",
re.IGNORECASE,
)
_BASELINE_SHA_RE = re.compile(
r"baseline\s+(?:target\s+)?sha\s*:\s*([0-9a-f]{7,40})",
re.IGNORECASE,
)
_BASELINE_CMD_RE = re.compile(
r"baseline\s+validation\s+command\s*:\s*(.+)",
re.IGNORECASE,
)
def _normalize_path(path: str) -> str:
return (path or "").replace("\\", "/").rstrip("/")
def _path_under_branches(path: str, project_root: str | None = None) -> bool:
normalized = _normalize_path(path)
if not normalized:
return False
if "/branches/" in f"{normalized}/":
return True
if normalized.endswith("/branches"):
return True
if project_root:
root = _normalize_path(project_root)
if normalized.startswith(f"{root}/"):
rel = normalized[len(root) + 1 :]
return rel == "branches" or rel.startswith("branches/")
return False
def _expand_sha(sha: str) -> str:
return (sha or "").strip().lower()
def _sha_matches(expected: str, observed: str) -> bool:
exp = _expand_sha(expected)
obs = _expand_sha(observed)
if not exp or not obs:
return False
if len(exp) == 40 and len(obs) == 40:
return exp == obs
return obs.startswith(exp) or exp.startswith(obs)
def _command_has_explicit_cwd(command: str, cwd: str) -> bool:
text = (command or "").strip()
if not text:
return False
if _GIT_C_CMD_RE.search(text):
return True
if _CD_CMD_RE.search(text):
return True
if cwd and cwd in text:
return True
return False
def assess_validation_cwd_proof_report(
report_text: str,
*,
validation_session: dict | None = None,
project_root: str | None = None,
) -> dict[str, Any]:
"""Require cwd/HEAD proof before reviewer validation claims (#398)."""
text = report_text or ""
session = dict(validation_session or {})
reasons: list[str] = []
violations: list[str] = []
claims_validation = bool(
session.get("validation_ran")
or _VALIDATION_CMD_RE.search(text)
or session.get("command")
)
if not claims_validation:
return {
"proven": True,
"block": False,
"claims_validation": False,
"reasons": [],
"violations": [],
"safe_next_action": "proceed",
}
expected_head = (
session.get("expected_head_sha")
or session.get("candidate_head_sha")
or ""
).strip()
if not expected_head:
match = _EXPECTED_HEAD_RE.search(text)
expected_head = (match.group(1) if match else "").strip()
observed_head = (session.get("observed_head_sha") or "").strip()
if not observed_head:
match = _HEAD_RE.search(text)
observed_head = (match.group(1) if match else "").strip()
cwd = (
session.get("working_directory")
or session.get("cwd")
or session.get("pwd")
or ""
).strip()
if not cwd:
match = _PWD_RE.search(text)
cwd = (match.group(1) if match else "").strip().rstrip(",.;")
command = (session.get("command") or "").strip()
if not command:
match = _VALIDATION_CMD_RE.search(text)
command = (match.group(1) if match else "").strip().rstrip(".;")
if not cwd:
reasons.append(
"validation claimed without pwd/working-directory proof (#398)"
)
elif not _path_under_branches(cwd, project_root):
violations.append(
f"validation cwd {cwd!r} is not under branches/ (#398)"
)
reasons.append(
"reviewer validation must run from a branches/ worktree, "
"not the main checkout (#398)"
)
if not observed_head:
reasons.append(
"validation claimed without git rev-parse HEAD / observed HEAD SHA "
"proof (#398)"
)
elif expected_head and not _sha_matches(expected_head, observed_head):
violations.append(
f"observed HEAD {observed_head} does not match expected "
f"PR head {expected_head} (#398)"
)
reasons.append("validation HEAD SHA must match pinned PR head (#398)")
if not _STATUS_RE.search(text) and session.get("git_status") is None:
reasons.append(
"validation claimed without git status --short --branch proof (#398)"
)
if command and cwd and not _command_has_explicit_cwd(command, cwd):
if session.get("tool_working_directory") is not True:
reasons.append(
"validation command must use git -C <worktree>, "
"cd <worktree> && ..., or tool-provided cwd metadata (#398)"
)
baseline_ran = bool(
session.get("baseline_validation_ran")
or _BASELINE_CMD_RE.search(text)
)
if baseline_ran:
baseline_cwd = (session.get("baseline_worktree_path") or "").strip()
if not baseline_cwd:
match = _BASELINE_CWD_RE.search(text)
baseline_cwd = (match.group(1) if match else "").strip().rstrip(",.;")
if not baseline_cwd or not _path_under_branches(baseline_cwd, project_root):
reasons.append(
"baseline validation claimed without baseline worktree cwd "
"under branches/ (#398)"
)
baseline_sha = (session.get("baseline_target_sha") or "").strip()
if not baseline_sha:
match = _BASELINE_SHA_RE.search(text)
baseline_sha = (match.group(1) if match else "").strip()
if not baseline_sha:
reasons.append(
"baseline validation claimed without baseline target SHA (#398)"
)
baseline_cmd = (session.get("baseline_command") or "").strip()
if not baseline_cmd:
match = _BASELINE_CMD_RE.search(text)
baseline_cmd = (match.group(1) if match else "").strip()
if not baseline_cmd:
reasons.append(
"baseline validation claimed without exact baseline command (#398)"
)
proven = not reasons and not violations
return {
"proven": proven,
"block": bool(violations) or not proven,
"claims_validation": True,
"expected_head_sha": expected_head or None,
"observed_head_sha": observed_head or None,
"working_directory": cwd or None,
"reasons": reasons,
"violations": violations,
"safe_next_action": (
"before validation record pwd, git rev-parse HEAD, git status, "
"expected PR head SHA; run commands with git -C or cd in the same line"
if not proven
else "proceed"
),
}
+198
View File
@@ -0,0 +1,198 @@
"""Transient validation failure history verifier (#396).
Reviewer sessions may observe validation failures that later pass on rerun.
Final reports must document every failure observed during the session, not
only the last passing result.
"""
from __future__ import annotations
import re
from typing import Any
_SECTION_RE = re.compile(
r"validation failure history",
re.IGNORECASE,
)
_FAILURE_ENTRY_RE = re.compile(
r"(?:failure\s*(?:#|entry)?\s*\d+|validation failure)\s*:",
re.IGNORECASE,
)
_COMMAND_RE = re.compile(
r"(?:command|validation command)\s*:\s*(.+)",
re.IGNORECASE,
)
_FAILING_TEST_RE = re.compile(
r"(?:failing test|failure|error)\s*:\s*(.+)",
re.IGNORECASE,
)
_CAUSE_RE = re.compile(
r"(?:suspected cause|cause)\s*:\s*(.+)",
re.IGNORECASE,
)
_REPRODUCED_RE = re.compile(
r"(?:reproduced|reproduces)\s*:\s*(yes|no|unknown)",
re.IGNORECASE,
)
_BASELINE_RE = re.compile(
r"(?:on baseline master|baseline master|exists on baseline)\s*:\s*(yes|no|unknown|not checked)",
re.IGNORECASE,
)
_PR_CAUSED_RE = re.compile(
r"(?:pr[- ]caused|pr caused)\s*:\s*(yes|no|unknown|not proven)",
re.IGNORECASE,
)
_TRANSIENT_STATUS_RE = re.compile(
r"(?:passed after transient failure investigation|transient failure investigation)",
re.IGNORECASE,
)
_PLAIN_PASS_RE = re.compile(
r"validation\s*:\s*(?:pass|passed|strong|ok|green)\b",
re.IGNORECASE,
)
_ENV_CLEANUP_RE = re.compile(
r"(?:environmental cleanup|state cleaned|what changed between runs)\s*:",
re.IGNORECASE,
)
_UNKNOWN_CAUSE_RE = re.compile(
r"(?:suspected cause|cause)\s*:\s*(?:unknown|unexplained|not determined)",
re.IGNORECASE,
)
def _failure_documented_in_text(text: str, failure: dict[str, Any]) -> bool:
"""Return True when *failure* appears documented in free-form report text."""
command = (failure.get("command") or "").strip()
failing = (failure.get("failing_test") or failure.get("error") or "").strip()
if command and command not in text:
return False
if failing and failing not in text:
return False
return bool(command or failing)
def _section_has_structured_fields(text: str) -> bool:
if not _SECTION_RE.search(text):
return False
section_start = _SECTION_RE.search(text).start()
section = text[section_start:]
has_command = bool(_COMMAND_RE.search(section))
has_failure = bool(_FAILING_TEST_RE.search(section))
return has_command and has_failure
def assess_validation_failure_history_report(
report_text: str,
*,
validation_session: dict | None = None,
) -> dict[str, Any]:
"""Require final reports to account for transient validation failures (#396)."""
text = report_text or ""
session = dict(validation_session or {})
reasons: list[str] = []
violations: list[str] = []
observed = list(session.get("observed_failures") or [])
final_status = (session.get("final_validation_status") or "").strip().lower()
cause_unknown = any(
(f.get("suspected_cause") or "").strip().lower() in {
"unknown", "unexplained", "not determined", ""
}
and not (f.get("pr_caused") or "").strip().lower() in {"yes", "no"}
for f in observed
) or bool(session.get("cause_unknown"))
if not observed:
return {
"proven": True,
"block": False,
"reasons": [],
"violations": [],
"observed_failure_count": 0,
"safe_next_action": "proceed",
}
documented = _section_has_structured_fields(text)
if not documented:
for failure in observed:
if _failure_documented_in_text(text, failure):
documented = True
break
if not documented:
violations.append(
"session observed validation failure(s) but final report omits "
"Validation failure history"
)
reasons.append(
"every validation failure observed during the session must appear "
"in a Validation failure history section"
)
if documented and not _SECTION_RE.search(text):
reasons.append(
"failure details must appear under an explicit "
"'Validation failure history' heading"
)
for idx, failure in enumerate(observed, start=1):
prefix = f"failure #{idx}"
if not _failure_documented_in_text(text, failure) and documented:
reasons.append(
f"{prefix}: command and failing test/error not documented in report"
)
command = (failure.get("command") or "").strip()
failing = (failure.get("failing_test") or failure.get("error") or "").strip()
if not command:
reasons.append(f"{prefix}: missing command in session failure record")
if not failing:
reasons.append(
f"{prefix}: missing failing test or error in session failure record"
)
plain_pass = bool(_PLAIN_PASS_RE.search(text))
transient_wording = bool(_TRANSIENT_STATUS_RE.search(text))
final_passed = final_status in {
"passed",
"pass",
"passed_after_transient_failure_investigation",
}
if (final_passed or plain_pass) and observed:
if cause_unknown and not transient_wording:
violations.append(
"unknown transient failure cause cannot be erased as plain 'passed'"
)
reasons.append(
"when cause is unknown, use status "
"'passed after transient failure investigation'"
)
elif not transient_wording and final_status != "passed_after_transient_failure_investigation":
if not _ENV_CLEANUP_RE.search(text):
reasons.append(
"later passing rerun must document what changed between runs "
"or environmental cleanup performed"
)
if session.get("environmental_contamination") and not _ENV_CLEANUP_RE.search(text):
reasons.append(
"environmental /tmp state contamination must document cleanup or "
"what changed before the passing rerun"
)
proven = not reasons and not violations
return {
"proven": proven,
"block": bool(violations) or not proven,
"reasons": reasons,
"violations": violations,
"observed_failure_count": len(observed),
"documented": documented,
"safe_next_action": (
"add Validation failure history with command, failing test, cause, "
"reproduction, baseline comparison, and PR-caused evidence; use "
"'passed after transient failure investigation' when appropriate"
if not proven
else "proceed"
),
}
+148
View File
@@ -0,0 +1,148 @@
"""PR-head vs diagnostic validation integrity verifier (#316)."""
from __future__ import annotations
import re
from typing import Any
_DIAGNOSTIC_LABEL_RE = re.compile(
r"diagnostic local experiment|not pr-head validation|diagnostic-only validation",
re.IGNORECASE,
)
_OFFICIAL_VALIDATION_RE = re.compile(
r"(?:official validation|pr-head validation|validation integrity)",
re.IGNORECASE,
)
_OFFICIAL_COMMAND_RE = re.compile(
r"(?:official validation command|pr-head validation command|validation command)",
re.IGNORECASE,
)
_OFFICIAL_RESULT_RE = re.compile(
r"(?:official validation result|pr-head validation result|validation result|validation integrity status)",
re.IGNORECASE,
)
_DIRTY_AFTER_RE = re.compile(
r"(?:worktree dirty after (?:official )?validation|dirty (?:state )?after (?:official )?validation|"
r"validation worktree dirty after)",
re.IGNORECASE,
)
_DIAGNOSTIC_EDIT_RE = re.compile(
r"(?:diagnostic edit(?:ed)? path|file edits by reviewer|diagnostic local edit)",
re.IGNORECASE,
)
_DIAGNOSTIC_COMMAND_RE = re.compile(
r"(?:diagnostic (?:validation )?command|diagnostic test run|diagnostic result)",
re.IGNORECASE,
)
_SUGGESTED_FIX_RE = re.compile(
r"(?:diagnostic results? (?:were )?used only for suggested fix|suggested fix only|"
r"not used as official validation)",
re.IGNORECASE,
)
_INTEGRITY_STATUS_RE = re.compile(
r"validation integrity status\s*:\s*(passed|failed|not run|contaminated)",
re.IGNORECASE,
)
_POST_EDIT_AS_OFFICIAL_RE = re.compile(
r"(?:official validation(?:\s+result)?\s*:\s*pass|validation passed after (?:local )?edit|"
r"full suite passed after diagnostic edit)",
re.IGNORECASE,
)
def _performed_edits(action_log: list[dict] | None) -> list[str]:
paths: list[str] = []
for entry in action_log or []:
if entry.get("gated_rejected") or entry.get("performed") is False:
continue
path = entry.get("path")
if path and entry.get("kind", "file_edit") in {"file_edit", "edit", "write"}:
paths.append(str(path))
return paths
def assess_validation_integrity_report(
report_text: str,
*,
validation_session: dict | None = None,
action_log: list[dict] | None = None,
) -> dict[str, Any]:
"""Separate official PR-head validation from diagnostic edited-worktree tests (#316)."""
text = report_text or ""
session = dict(validation_session or {})
reasons: list[str] = []
diagnostic_edits = list(session.get("diagnostic_edits") or [])
if not diagnostic_edits:
diagnostic_edits = _performed_edits(action_log)
diagnostic_ran = bool(
session.get("diagnostic_validation_ran")
or session.get("diagnostic_runs")
)
official_ran = bool(session.get("official_validation_ran", True))
official_result = (session.get("official_result") or "").strip().lower()
diagnostic_result = (session.get("diagnostic_result") or "").strip().lower()
dirty_after = session.get("worktree_dirty_after_official")
contaminated = bool(session.get("contaminated"))
if official_ran:
if not _OFFICIAL_VALIDATION_RE.search(text) and not _OFFICIAL_COMMAND_RE.search(text):
reasons.append("report missing official PR-head validation section")
if not _OFFICIAL_COMMAND_RE.search(text) and "validation:" not in text.lower():
reasons.append("report missing official validation command")
if not _OFFICIAL_RESULT_RE.search(text):
reasons.append("report missing official validation result or integrity status")
if dirty_after is not None and not _DIRTY_AFTER_RE.search(text):
reasons.append(
"report missing worktree dirty state after official validation"
)
elif dirty_after is None and official_ran and not _DIRTY_AFTER_RE.search(text):
reasons.append(
"report missing worktree dirty state after official validation"
)
if diagnostic_edits or diagnostic_ran:
if not _DIAGNOSTIC_LABEL_RE.search(text):
reasons.append(
"post-edit diagnostic runs must be labeled "
"'Diagnostic local experiment — not PR-head validation'"
)
if diagnostic_edits and not _DIAGNOSTIC_EDIT_RE.search(text):
reasons.append("report missing diagnostic edit path")
if diagnostic_ran and not _DIAGNOSTIC_COMMAND_RE.search(text):
reasons.append("report missing diagnostic command/result")
if not _SUGGESTED_FIX_RE.search(text):
reasons.append(
"report must state diagnostic results were used only for suggested fix"
)
if _POST_EDIT_AS_OFFICIAL_RE.search(text):
reasons.append(
"post-edit diagnostic results cannot be reported as official PR-head validation"
)
if diagnostic_result == "pass" and official_result == "fail":
if "request_changes" not in text.lower() and "failed" not in text.lower():
reasons.append(
"request-changes must cite unmodified PR-head failure, not diagnostic pass"
)
if contaminated:
status = _INTEGRITY_STATUS_RE.search(text)
if not status or "contaminated" not in status.group(1).lower():
reasons.append(
"contaminated validation must report integrity status "
"'contaminated — recovery required'"
)
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"diagnostic_edits": diagnostic_edits,
"safe_next_action": (
"separate official PR-head validation from diagnostic experiments; "
"report dirty-after-validation state"
if reasons
else "proceed"
),
}
+184
View File
@@ -0,0 +1,184 @@
"""PR validation worktree read-only edit verifier (#315)."""
from __future__ import annotations
import re
from typing import Any
_VALIDATION_WORKTREE_RE = re.compile(
r"branches/(?:review-pr\d+[\w/-]*|review-[\w-]+)",
re.IGNORECASE,
)
_DIAGNOSTIC_WORKTREE_RE = re.compile(
r"branches/(?:diagnostic[\w/-]*|scratch[\w/-]*)",
re.IGNORECASE,
)
_FILE_EDITS_NONE_RE = re.compile(
r"file edits by reviewer\s*:\s*none\b",
re.IGNORECASE,
)
_FILE_EDITS_FIELD_RE = re.compile(
r"file edits by reviewer\s*:\s*(.+)",
re.IGNORECASE,
)
_VALIDATION_WORKTREE_PATH_RE = re.compile(
r"(?:validation worktree path|review worktree path)\s*:\s*(\S+)",
re.IGNORECASE,
)
_DIAGNOSTIC_WORKTREE_PATH_RE = re.compile(
r"(?:diagnostic (?:scratch )?worktree path|diagnostic worktree)\s*:\s*(\S+)",
re.IGNORECASE,
)
_DIAGNOSTIC_LABEL_RE = re.compile(
r"diagnostic local experiment|not pr-head validation|diagnostic-only",
re.IGNORECASE,
)
_OFFICIAL_VALIDATION_RE = re.compile(
r"(?:official (?:pr-head )?validation|pr-head validation result)",
re.IGNORECASE,
)
_POST_EDIT_OFFICIAL_RE = re.compile(
r"(?:official validation(?:\s+result)?\s*:\s*pass|validation passed after (?:local )?edit)",
re.IGNORECASE,
)
_PERFORMED_EDIT_KINDS = frozenset({"file_edit", "edit", "write", "edited", "created", "wrote"})
def _normalize_path(path: str) -> str:
return (path or "").replace("\\", "/").strip()
def _is_validation_worktree(path: str) -> bool:
return bool(_VALIDATION_WORKTREE_RE.search(_normalize_path(path)))
def _is_diagnostic_worktree(path: str) -> bool:
normalized = _normalize_path(path)
return bool(
_DIAGNOSTIC_WORKTREE_RE.search(normalized)
or "diagnostic" in normalized.lower()
)
def _performed_edits(action_log: list[dict] | None) -> list[dict[str, str]]:
edits: list[dict[str, str]] = []
for entry in action_log or []:
if entry.get("gated_rejected") or entry.get("performed") is False:
continue
kind = (entry.get("kind") or entry.get("action") or "file_edit").strip().lower()
if kind not in _PERFORMED_EDIT_KINDS:
continue
path = (entry.get("path") or "").strip()
if not path:
continue
worktree = _normalize_path(str(entry.get("worktree_path") or entry.get("cwd") or ""))
edits.append({"path": path, "worktree_path": worktree})
return edits
def _extract_validation_path(text: str, session: dict) -> str:
path = _normalize_path(str(session.get("validation_worktree_path") or ""))
if path:
return path
match = _VALIDATION_WORKTREE_PATH_RE.search(text or "")
return _normalize_path(match.group(1)) if match else ""
def _extract_diagnostic_path(text: str, session: dict) -> str:
path = _normalize_path(str(session.get("diagnostic_worktree_path") or ""))
if path:
return path
match = _DIAGNOSTIC_WORKTREE_PATH_RE.search(text or "")
return _normalize_path(match.group(1)) if match else ""
def assess_validation_worktree_edit_report(
report_text: str,
*,
validation_session: dict | None = None,
action_log: list[dict] | None = None,
) -> dict[str, Any]:
"""Block edits in PR validation worktrees; require diagnostic separation (#315)."""
text = report_text or ""
session = dict(validation_session or {})
reasons: list[str] = []
validation_path = _extract_validation_path(text, session)
diagnostic_path = _extract_diagnostic_path(text, session)
validation_edits = list(session.get("validation_worktree_edits") or [])
diagnostic_edits = list(session.get("diagnostic_edits") or [])
edits = _performed_edits(action_log)
if not validation_edits:
for entry in edits:
wt = entry.get("worktree_path") or validation_path
if wt and _is_validation_worktree(wt) and not _is_diagnostic_worktree(wt):
validation_edits.append(entry["path"])
if not diagnostic_edits:
for entry in edits:
wt = entry.get("worktree_path") or ""
if wt and _is_diagnostic_worktree(wt):
diagnostic_edits.append(entry["path"])
elif entry["path"] and diagnostic_path and not validation_edits:
if _is_diagnostic_worktree(diagnostic_path):
diagnostic_edits.append(entry["path"])
claimed_none = bool(_FILE_EDITS_NONE_RE.search(text))
any_edits = bool(validation_edits or diagnostic_edits or edits)
if validation_edits:
reasons.append(
"reviewer must not edit files in the PR validation worktree; "
f"observed edits: {', '.join(validation_edits)}"
)
if diagnostic_edits or session.get("diagnostic_experiment"):
if not diagnostic_path and not _DIAGNOSTIC_WORKTREE_PATH_RE.search(text):
reasons.append(
"diagnostic experiments require a separate diagnostic scratch worktree path"
)
if not _DIAGNOSTIC_LABEL_RE.search(text):
reasons.append(
"diagnostic experiments must be labeled "
"'Diagnostic local experiment — not PR-head validation'"
)
if not _FILE_EDITS_FIELD_RE.search(text) or claimed_none:
reasons.append(
"diagnostic edits must be reported under 'File edits by reviewer'"
)
if any_edits and claimed_none:
reasons.append(
"report claims 'File edits by reviewer: none' but reviewer file edits occurred"
)
if session.get("official_validation_after_edit") or _POST_EDIT_OFFICIAL_RE.search(text):
if validation_edits or (validation_path and diagnostic_edits):
reasons.append(
"post-edit test results cannot be reported as official PR-head validation"
)
if validation_edits and _OFFICIAL_VALIDATION_RE.search(text):
if not _DIAGNOSTIC_LABEL_RE.search(text):
reasons.append(
"validation worktree was edited; official PR-head validation is no longer valid"
)
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"validation_worktree_path": validation_path or None,
"diagnostic_worktree_path": diagnostic_path or None,
"validation_worktree_edits": validation_edits,
"diagnostic_edits": diagnostic_edits,
"safe_next_action": (
"keep PR validation worktrees read-only; use a separate diagnostic "
"scratch worktree and report all reviewer file edits"
if reasons
else "proceed"
),
}
+207
View File
@@ -0,0 +1,207 @@
"""Reviewer worktree ownership and safe-reuse proof verifier (#312)."""
from __future__ import annotations
import re
from typing import Any
_SESSION_OWNED_RE = re.compile(
r"branches/(?:review-pr\d+[\w/-]*|review-[\w-]+)",
re.IGNORECASE,
)
_WORKTREE_PATH_RE = re.compile(
r"(?:review worktree path|worktree path|session-owned worktree)\s*:\s*(\S+)",
re.IGNORECASE,
)
_BRANCHES_PATH_RE = re.compile(r"/branches/|\bbranches/", re.IGNORECASE)
_MAIN_CHECKOUT_RE = re.compile(
r"main checkout|not (?:the )?main checkout|outside branches/",
re.IGNORECASE,
)
_SAFE_REUSE_RE = re.compile(r"safe[- ]reuse proof", re.IGNORECASE)
_REUSE_POLICY_RE = re.compile(
r"(?:project policy|policy) (?:allows|allowing) (?:reuse|reset)",
re.IGNORECASE,
)
_CLEAN_TRACKED_RE = re.compile(
r"(?:clean tracked state|tracked state\s*:\s*clean|no uncommitted tracked)",
re.IGNORECASE,
)
_CLEAN_UNTRACKED_RE = re.compile(
r"(?:clean untracked state|untracked state\s*:\s*clean|no untracked files)",
re.IGNORECASE,
)
_NOT_OTHER_SESSION_RE = re.compile(
r"not owned by (?:another|other) (?:active )?(?:task|session)",
re.IGNORECASE,
)
_BRANCH_BEFORE_RESET_RE = re.compile(
r"(?:branch/head before reset|head before reset|branch before reset)\s*:\s*(\S+)",
re.IGNORECASE,
)
_RESET_TARGET_RE = re.compile(
r"(?:reset target sha|reset target)\s*:\s*([0-9a-f]{7,40})",
re.IGNORECASE,
)
_DESTRUCTIVE_CMD_RE = re.compile(
r"\bgit\b(?:\s+(?:-C\s+\S+\s+)?)?"
r"(?:reset\s+--hard|clean(?:\s+-[A-Za-z]+)*|checkout\s+(?!HEAD\s+--)|switch\s+)",
re.IGNORECASE,
)
_WORKSPACE_NONE_RE = re.compile(r"workspace mutations\s*:\s*none", re.IGNORECASE)
_WORKTREE_MUTATION_RE = re.compile(
r"(?:worktree(?:/index)? mutations|destructive reset)\s*:\s*(?!none\b).+",
re.IGNORECASE,
)
_RESET_IN_REPORT_RE = re.compile(r"reset\s+--hard", re.IGNORECASE)
def _command_text(entry) -> str:
if isinstance(entry, dict):
return str(entry.get("command") or "").strip()
return str(entry or "").strip()
def _destructive_commands(command_log: list | None) -> list[str]:
return [
cmd
for cmd in (_command_text(entry) for entry in (command_log or []))
if cmd and _DESTRUCTIVE_CMD_RE.search(cmd)
]
def _extract_worktree_path(text: str, session: dict) -> str:
path = (session.get("worktree_path") or "").strip()
if path:
return path
match = _WORKTREE_PATH_RE.search(text or "")
return match.group(1).strip() if match else ""
def _is_session_owned_path(path: str) -> bool:
normalized = (path or "").replace("\\", "/")
return bool(_SESSION_OWNED_RE.search(normalized))
def _safe_reuse_fields_present(text: str) -> list[str]:
missing: list[str] = []
if not _WORKTREE_PATH_RE.search(text):
missing.append("exact worktree path")
if not _BRANCHES_PATH_RE.search(text):
missing.append("worktree inside branches/")
if not _MAIN_CHECKOUT_RE.search(text):
missing.append("worktree is not the main checkout")
if not _NOT_OTHER_SESSION_RE.search(text):
missing.append("worktree not owned by another active session")
if not _CLEAN_TRACKED_RE.search(text):
missing.append("clean tracked state")
if not _CLEAN_UNTRACKED_RE.search(text):
missing.append("clean untracked state")
if not _BRANCH_BEFORE_RESET_RE.search(text):
missing.append("branch/head before reset")
if not _RESET_TARGET_RE.search(text):
missing.append("reset target SHA")
if not _REUSE_POLICY_RE.search(text):
missing.append("explicit project policy allowing reuse/reset")
return missing
def assess_worktree_ownership_report(
report_text: str,
*,
ownership_session: dict | None = None,
command_log: list | None = None,
) -> dict[str, Any]:
"""Prove reviewer worktree ownership before reset or validation (#312)."""
text = report_text or ""
session = dict(ownership_session or {})
reasons: list[str] = []
worktree_path = _extract_worktree_path(text, session)
destructive = _destructive_commands(command_log or session.get("command_log"))
if not destructive and session.get("destructive_reset"):
destructive = ["git reset --hard (session)"]
session_owned = bool(
session.get("session_owned")
or (worktree_path and _is_session_owned_path(worktree_path))
)
safe_reuse = bool(session.get("safe_reuse") or _SAFE_REUSE_RE.search(text))
main_checkout = bool(session.get("main_checkout"))
dirty_tracked = session.get("dirty_tracked")
dirty_untracked = session.get("dirty_untracked")
other_session_owned = bool(session.get("other_session_owned"))
if worktree_path or destructive or session.get("validation_ran"):
if not worktree_path:
reasons.append("report missing exact review worktree path")
elif main_checkout or "branches/" not in worktree_path.replace("\\", "/"):
reasons.append("review worktree must be inside branches/, not the main checkout")
elif not _BRANCHES_PATH_RE.search(worktree_path.replace("\\", "/")):
reasons.append("review worktree path must be under branches/")
if other_session_owned and not safe_reuse:
reasons.append(
"reused worktree appears owned by another active task/session; "
"safe-reuse proof required"
)
if dirty_tracked:
reasons.append(
"worktree has uncommitted tracked changes before reset or validation"
)
if dirty_untracked and safe_reuse:
reasons.append("safe-reuse proof requires clean untracked state")
if safe_reuse and not session_owned:
reasons.extend(
f"safe-reuse proof missing {field}"
for field in _safe_reuse_fields_present(text)
)
if destructive:
if not session_owned and not safe_reuse:
reasons.append(
"destructive worktree commands forbidden without session-owned "
"worktree or safe-reuse proof"
)
if _WORKSPACE_NONE_RE.search(text) and (
_RESET_IN_REPORT_RE.search(text) or any("reset" in c.lower() for c in destructive)
):
reasons.append(
"final report must not claim 'Workspace mutations: none' when "
"git reset --hard occurred"
)
if not _WORKTREE_MUTATION_RE.search(text) and not _RESET_IN_REPORT_RE.search(text):
reasons.append(
"destructive reset must be reported under Worktree/index mutations "
"or destructive reset operations"
)
if (
worktree_path
and not session_owned
and not safe_reuse
and (destructive or session.get("validation_ran"))
and not _is_session_owned_path(worktree_path)
):
reasons.append(
"reused branch-named worktree requires safe-reuse proof before reset or validation"
)
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"worktree_path": worktree_path or None,
"session_owned": session_owned,
"safe_reuse": safe_reuse,
"destructive_commands": destructive,
"safe_next_action": (
"use a fresh session-owned review worktree under branches/review-pr<N>-* "
"or document full safe-reuse proof before destructive reset"
if reasons
else "proceed"
),
}
+47
View File
@@ -57,6 +57,8 @@ REVIEWER_TASKS = frozenset({
"review_pr",
"merge_pr",
"blind_pr_queue_review",
"pr_queue_cleanup",
"pr-queue-cleanup",
"request_changes_pr",
"approve_pr",
})
@@ -72,6 +74,15 @@ AUTHOR_TASKS = frozenset({
"comment_pr",
"address_pr_change_requests",
"delete_branch",
"work_issue",
"work-issue",
"reconcile_landed_pr",
})
RECONCILER_TASKS = frozenset({
"reconcile_already_landed_pr",
"reconcile_already_landed",
"reconcile-landed-pr",
})
TASK_REQUIRED_ROLE = {
@@ -88,14 +99,30 @@ TASK_REQUIRED_ROLE = {
"review_pr": "reviewer",
"merge_pr": "reviewer",
"blind_pr_queue_review": "reviewer",
"pr_queue_cleanup": "reviewer",
"pr-queue-cleanup": "reviewer",
"request_changes_pr": "reviewer",
"approve_pr": "reviewer",
"work_issue": "author",
"work-issue": "author",
"reconcile_landed_pr": "author",
"reconcile_already_landed_pr": "reconciler",
"reconcile_already_landed": "reconciler",
"reconcile-landed-pr": "reconciler",
# #309: reconciler tasks close already-landed PRs/issues only.
"reconcile_close_landed_pr": "reconciler",
"reconcile_close_landed_issue": "reconciler",
}
WRONG_ROLE_REVIEWER_MSG = (
"Wrong role/session for reviewer task. Launch reviewer MCP namespace."
)
WRONG_ROLE_RECONCILER_MSG = (
"Wrong role/session for reconciler task. Launch a reconciler-capable "
"MCP namespace/profile with exact close capability."
)
_session_last_route: dict | None = None
@@ -191,6 +218,26 @@ def route_task_session(
_record_route(result)
return result
if required_role == "reconciler":
result = {
"task_type": task_type,
"required_role": required_role,
"active_role": active_role_kind,
"active_profile": active_profile,
"route_result": ROUTE_WRONG_ROLE,
"downstream_allowed": False,
"reasons": [
WRONG_ROLE_RECONCILER_MSG,
"Reconciler tasks cannot run in author or reviewer "
"sessions without exact close capability.",
],
"message": WRONG_ROLE_RECONCILER_MSG,
"runtime_switching_supported": runtime_switching_supported,
"profile_switch_blocked": not runtime_switching_supported,
}
_record_route(result)
return result
if required_role == "author":
route = ROUTE_TO_AUTHOR
message = (
+133
View File
@@ -0,0 +1,133 @@
"""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,
) -> dict:
"""Fail closed when the control checkout is not clean master/prgs/master."""
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":
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
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/.." && pwd)"
cd "$repo_root"
exec python3 -m webui "$@"
+11 -5
View File
@@ -38,13 +38,21 @@ fi
branch="$1"
start_ref="${2:-prgs/master}"
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/.." && pwd)"
# Enforce issue-linked, traceable branch names (issue → branch → worktree → PR).
if [[ "$allow_unlinked" -eq 0 ]]; then
if [[ ! -f "/tmp/gitea_issue_lock.json" ]]; then
echo "Error: Issue lock file '/tmp/gitea_issue_lock.json' is missing. You must lock exactly one issue before branch creation (fail closed)." >&2
locked_branch=$(python3 -c "
import sys
sys.path.insert(0, '$repo_root')
import issue_lock_store
print(issue_lock_store.resolve_locked_branch_for_session('$branch'))
")
if [[ -z "$locked_branch" ]]; then
echo "Error: No session issue lock is bound. Call gitea_lock_issue before branch creation (fail closed)." >&2
exit 2
fi
locked_branch=$(python3 -c "import json; print(json.load(open('/tmp/gitea_issue_lock.json')).get('branch_name', ''))")
if [[ "$branch" != "$locked_branch" ]]; then
echo "Error: Requested branch '$branch' does not match locked branch '$locked_branch' (fail closed)." >&2
exit 2
@@ -68,8 +76,6 @@ EOF
fi
fi
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/.." && pwd)"
worktree_name="${branch//\//-}"
worktree_path="$repo_root/branches/$worktree_name"
+6
View File
@@ -22,6 +22,7 @@ workflow file.
| Reconcile already-landed open PRs | [`workflows/reconcile-landed-pr.md`](workflows/reconcile-landed-pr.md) | [`schemas/reconcile-landed-final-report.md`](schemas/reconcile-landed-final-report.md) |
| Create or update Gitea issues | [`workflows/create-issue.md`](workflows/create-issue.md) | [`schemas/create-issue-final-report.md`](schemas/create-issue-final-report.md) |
| Work on an assigned issue / author code | [`workflows/work-issue.md`](workflows/work-issue.md) | [`schemas/work-issue-final-report.md`](schemas/work-issue-final-report.md) |
| PR-only queue cleanup (one canonical review per PR) | [`workflows/pr-queue-cleanup.md`](workflows/pr-queue-cleanup.md) | [`schemas/pr-queue-cleanup-final-report.md`](schemas/pr-queue-cleanup-final-report.md) |
## Universal rules
@@ -50,6 +51,10 @@ changes, merge, implement fixes, create branches, commit, push, or create PRs.
A run that starts in `work-issue` mode may not review, approve, request changes,
merge, close PRs, or act as reviewer.
A run that starts in `pr-queue-cleanup` mode may not claim issues, create
branches, edit implementation files, file new issues, or review a second PR
after any terminal review mutation.
If the task requires a different mode, stop and produce a handoff for the
correct workflow.
@@ -156,6 +161,7 @@ Ready-to-copy task prompts live in [`templates/`](templates/):
- [`start-issue.md`](templates/start-issue.md) — author work (loads `work-issue.md`)
- [`review-pr.md`](templates/review-pr.md) — review (loads `review-merge-pr.md`)
- [`pr-queue-cleanup.md`](templates/pr-queue-cleanup.md) — one PR per cleanup run
- [`merge-pr.md`](templates/merge-pr.md) — merge (loads `review-merge-pr.md`)
- [`recover-bad-state.md`](templates/recover-bad-state.md)
- [`reconcile-closed-not-merged-pr.md`](templates/reconcile-closed-not-merged-pr.md)
@@ -0,0 +1,31 @@
# PR-queue-cleanup final report schema
One report per cleanup run (one run = one PR). Fields must all be present;
use `none` where nothing occurred. Validated by
`pr_queue_cleanup.assess_pr_queue_cleanup_report` (fail closed).
* Task: pr-queue-cleanup
* Workflow source: workflows/pr-queue-cleanup.md (+ version/commit/hash)
* Repo:
* Role/profile:
* Identity:
* PR inventory pagination proof: (inventory_complete / final page / total_count)
* Queue ordering proof:
* Earlier PRs skipped: (with live per-PR proof)
* Selected PR: (exactly one)
* Pinned head SHA:
* Review decision: (single terminal decision)
* Merge authorized for PR: true/false (explicit per-PR operator authorization)
* Merge gates result:
* Merge result: (none / not attempted / merged SHA / blocker)
* Run stop point: (which §4 chain rule ended the run)
* Next suggested PR: (named, not continued to)
* File edits by reviewer:
* Worktree/index mutations:
* Git ref mutations:
* MCP/Gitea mutations:
* Issue mutations: none (required — forbidden in cleanup mode)
* Branch mutations: none (required — forbidden in cleanup mode)
* Read-only diagnostics:
* Blockers:
* Safe next action: (fresh run for the next PR)
@@ -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`:
@@ -91,4 +97,23 @@ Verifier: `review_proofs.assess_queue_status_report()`.
Narrative final report and controller handoff must agree on eligibility class,
candidate/reviewed head SHA, mutation state, worktree usage, review decision,
terminal review mutation, merge result, and linked issue status.
terminal review mutation, merge result, and linked issue status.
### Proof-backed claims (#395)
Proof-sensitive claims must cite explicit command/tool evidence in the report
or structured MCP metadata — not narrative alone:
- **Inventory complete:** `has_more=false`, `is_final_page=true`,
`inventory_complete=true`, and/or `total_count` from `gitea_list_prs`.
- **Skipped earlier PRs:** merge-simulation command output, conflict file list,
or live `gitea_get_pr_review_feedback` / mergeability fields.
- **Baseline validation:** baseline worktree path, baseline target SHA,
dirty-before/after, exact command, exact result.
- **Master integration:** exact `git merge` / `git rebase` command and exit status.
- **Cleanup:** final `git worktree list` (or remove commands) proving session
worktrees were removed.
When a claim relies on prior-session blocker state or MCP metadata only, label
the proof source explicitly (`command`, `MCP metadata`, `prior blocker`,
`not checked`). Do not use `live proof` without that classification.
@@ -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
```
@@ -0,0 +1,25 @@
# Template: PR-only queue cleanup (one PR per run)
Copy, fill the `<...>` fields, and paste as the task prompt.
```text
Task: PR-only queue cleanup for <repo>.
Load workflows/pr-queue-cleanup.md and workflows/review-merge-pr.md before any
mutation. Route pr_queue_cleanup through gitea_route_task_session (reviewer
profile required).
Rules:
- One run = exactly one selected PR = one terminal review decision.
- Build full open-PR inventory with pagination proof before selection.
- Forbidden: issue claiming, branch creation, implementation edits, issue filing,
reviewing a second PR after a terminal mutation in this run.
- After REQUEST_CHANGES: stop. After APPROVED: merge only this PR and only if
operator explicitly authorized merge for this PR in this run.
- Report Next suggested PR without continuing to it.
Operator PR list (optional): <pr numbers or "oldest eligible from inventory">
Merge authorized for selected PR in this run: <true|false>
End with the pr-queue-cleanup final report schema.
```
@@ -35,6 +35,10 @@ Load the canonical workflow first:
Final report schema: `schemas/review-merge-final-report.md`.
Rules (llm-project-workflow):
- Repository targeting (#530): pass explicit `remote=`, `org=`, and `repo=` on
every gitea-tools call (e.g. `remote=prgs org=Scaled-Tech-Consulting
repo=Gitea-Tools`). A bare `remote=prgs` can resolve to the wrong default repo
and is blocked when it disagrees with the local git remote URL.
- Review in a SEPARATE detached review worktree, never the author's folder.
- Worktree safety (#233): before checkout, diff, validation, review, or merge,
report the starting worktree path and whether it was dirty. If unrelated
@@ -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.
@@ -0,0 +1,86 @@
---
task_mode: pr-queue-cleanup
canonical: true
final_report_schema: ../schemas/pr-queue-cleanup-final-report.md
---
# PR-only queue cleanup workflow (canonical)
**Task mode:** `pr-queue-cleanup`
Reviewer-role mode for cleanup periods when the queue holds many open PRs.
Each run dispatches **exactly one** canonical review for **exactly one** PR,
then stops after any terminal review mutation. The next PR always requires a
new run with fresh identity, capability, and inventory proof.
This mode composes with the canonical review workflow: for the selected PR,
load and follow [`workflows/review-merge-pr.md`](review-merge-pr.md) in full.
This file adds the cleanup-mode boundaries around that per-PR run; it does
not replace the review workflow.
## 0. Load the canonical workflow first
Load this file and `workflows/review-merge-pr.md` before any mutation and
report source, version/commit/hash, and any conflict with the operator
prompt. If either cannot be loaded, stop and produce a recovery handoff.
## 1. Mode isolation — forbidden actions
PR-only cleanup mode forbids, with no exceptions:
* issue claiming (`claim_issue`, `mark_issue`, `lock_issue`)
* branch creation or push (`create_branch`, `push_branch`)
* implementation edits of any kind
* new issue filing (`create_issue`)
* PR creation (`create_pr`) and commit tools (`commit_files`)
* reviewing a second PR after any terminal review mutation
`pr_queue_cleanup.check_cleanup_task_allowed` fails closed on these tasks.
If author-side work is needed, stop and hand off to `work-issue` mode in a
separate session.
## 2. Identity, capability, and routing
Route `pr_queue_cleanup` through `gitea_route_task_session` — reviewer role
required; author sessions receive `wrong_role_stop`. Prove `gitea.pr.review`
capability before selection. Merge additionally requires `gitea.pr.merge`
plus the explicit per-PR authorization in §4.
## 3. Inventory and selection
Build the complete open-PR inventory with pagination proof
(`inventory_complete`, final page, `total_count`) before any selection
claim. Select exactly one PR according to project queue ordering rules
(oldest-first unless the operator queue says otherwise), skipping earlier
PRs only with live per-PR proof. No multi-PR validation and no batch report
may substitute for per-PR proof.
## 4. Terminal mutation chain
`pr_queue_cleanup.resolve_cleanup_run_state` is the authority:
* After `REQUEST_CHANGES` → the run stops.
* After `COMMENT` or a proof-backed skip → the run stops.
* After `APPROVED` → the run may continue **only** to same-PR merge, and only
when the operator explicitly authorized merge for that specific PR in this
run (`Merge authorized: true`) and every merge gate passes.
* After merge, or on any merge blocker → the run stops.
* Any terminal mutation targeting a PR other than the selected PR is a hard
stop and must be reported as a violation.
## 5. Fresh run per PR
The next PR requires a new run with fresh identity, capability, inventory,
and ordering proof. Do not carry pinned SHAs, eligibility classes, or
validation results across runs.
## 6. Final report
Use the schema in
[`schemas/pr-queue-cleanup-final-report.md`](../schemas/pr-queue-cleanup-final-report.md).
The report must include the **Next suggested PR** (from the proven ordering)
without continuing to it, exactly one **Selected PR**, the single terminal
decision, pagination proof, and `Issue mutations: none` / `Branch mutations:
none`. `pr_queue_cleanup.assess_pr_queue_cleanup_report` validates these
fields and fails closed on batch reviews, missing pagination proof, missing
next-suggested-PR, unauthorized merges, or any issue/branch mutation.
@@ -248,6 +248,28 @@ Post a reconciliation comment only if:
If comment capability is missing, record the intended comment in the final
handoff only.
## 12A. Partial reconciliation policy (#302)
The durable policy when `close_pr` capability is missing is
**comment-then-stop** (`resolve_partial_reconciliation_plan` in
`review_proofs.py` enforces it):
* `close_pr` proven → full reconciliation: close the PR and report the
close result (`FULL_RECONCILE_CLOSE_ALLOWED`).
* `close_pr` missing, `comment_pr` proven → post exactly one
reconciliation comment carrying PR head SHA, target branch SHA,
ancestor proof, linked issue status, and the required missing
capability, then stop for a human or authorized close
(`PARTIAL_RECONCILE_COMMENT_THEN_STOP`).
* `comment_pr` also missing → no Gitea mutation; produce a recovery
handoff recording the intended comment and required capabilities
(`RECOVERY_HANDOFF_ONLY`).
* Ancestry not proven → no mutation regardless of capability
(`GATE_NOT_PROVEN`).
Final reports must explain why the comment was or was not posted and
name the missing capability (`assess_partial_reconciliation_report`).
## 13. PR close rules
Close a PR only if:
@@ -282,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:
@@ -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:
@@ -538,6 +576,58 @@ Official validation integrity status must be one of:
Do not invent a softer status.
## 21A. Validation failure history rule
Every validation failure observed during the review session must appear in the
final report, even if a later rerun passes.
Before claiming `Validation: passed`, list every failed validation command from
the session under `Validation failure history`.
For each failure, report:
* command
* failing test or error
* suspected cause
* whether it reproduced
* whether it exists on baseline master
* evidence for whether it is or is not PR-caused
If a later rerun passes, also report:
* what changed between runs
* whether environmental state was cleaned (for example `/tmp` lock files)
* why the pass is trustworthy
If the cause is unknown, do not erase the earlier failure with plain
`Validation: passed`. Use a status such as
`passed after transient failure investigation`.
`gitea_validate_review_final_report` rejects reports that omit known earlier
validation failures when `validation_session.observed_failures` is supplied.
## 21B. Validation status taxonomy (#406)
When the final report summarizes how validation concluded, use one of these
**validation status** labels (distinct from per-command pass/fail entries):
* `passed` — raw PR-head validation passed on the unmodified head.
* `failed` — raw PR-head validation failed and no allowed resolution path
was proven.
* `baseline-equivalent failure accepted` — only when a clean baseline
worktree under `branches/` proves matching failure signatures on the target
branch (baseline path, target SHA, exact commands, failure lists, and
`failure signatures match: true`).
* `raw-head failure resolved by merge simulation` — raw PR-head validation
failed, but merge simulation into the current target passed cleanly; report
merge simulation under `Worktree/index mutations` with full #317 proof.
* `passed after transient failure investigation` — a later run passed after an
earlier failure in the same session; document the failure history (#396).
Do not use `baseline-equivalent failure accepted` when only merge simulation
resolved the failure. Do not use bare `passed` when raw PR-head validation
failed unless one of the resolution statuses above applies.
## 22. Baseline validation rule
Do not run tests in the main checkout.
@@ -574,6 +664,28 @@ Do not claim “full-suite failures are pre-existing” unless baseline proof is
## 23. Validation command proof rule
Before any diff, test, or compile validation, record in the same command
transcript or final report:
* `pwd` or explicit working directory
* `git rev-parse HEAD`
* `git status --short --branch`
* expected PR head SHA (candidate head SHA)
Validation commands must use one of:
* `git -C <review_worktree> ...`
* `cd <review_worktree> && ...` in the same command
* tool-provided explicit working-directory metadata
Do not rely on inferred shell cwd from a prior command in a different block.
`gitea_validate_review_final_report` rejects validation claims without
cwd/HEAD proof when `validation_session` is supplied.
Baseline validation must document baseline worktree path, baseline target SHA,
cwd proof, exact baseline command, and baseline result using the same rules.
Report the exact validation command as executed.
Report the working directory where validation ran.
@@ -702,6 +814,59 @@ The final report must identify:
* whether same-PR merge continuation was allowed
* whether the run stopped as required
## 26B. Per-PR reviewer lease (#407)
Parallel reviewer sessions are allowed only when each session holds a distinct,
live PR lease.
Before validation or review mutation on a selected PR:
1. Call `gitea_acquire_reviewer_pr_lease` with worktree path, candidate head SHA,
and target branch SHA.
2. Post heartbeats via `gitea_heartbeat_reviewer_pr_lease` before validation,
after validation, before review mutation, and before merge.
3. Do not approve, request changes, or merge unless the in-session lease
matches the selected PR.
If PR head or target branch advances during the lease, stop and refresh
inventory before continuing.
Final reports must include lease session id, acquisition proof, heartbeat
status, and release/blocked status.
## 26B-1. Merger lease adoption (#536)
When review and merge are **different sessions**, the merger must adopt the
reviewer's lease — never manually seed `reviewer_pr_lease._SESSION_LEASE`.
Before merge in a merger-only session:
1. Confirm `approval_at_current_head` via `gitea_get_pr_review_feedback`.
2. Call `gitea_adopt_merger_pr_lease` from a clean merger worktree under
`branches/`, passing `expected_head_sha` pinned to the approved head.
3. Quote the adoption comment id and `adopted_from_session_id` in the handoff.
4. Proceed to `gitea_merge_pr` only after adoption succeeds.
Manual in-process lease seeding is rejected by mutation gates (fail closed).
## 26C. Conflict-fix lease and stale-head protection (#399)
Before validating, approving, or merging a PR:
1. Check for an active conflict-fix lease on the PR; stop if one is active.
2. Pin `expected_head_sha` before validation and pass it to
`gitea_mark_final_review_decision`, `gitea_submit_pr_review`, and
`gitea_merge_pr`.
3. Re-fetch live PR head immediately before approval and merge; refuse when
live head differs from the reviewed SHA.
Final reports must state:
* reviewed head SHA
* final live head SHA before approval
* final live head SHA before merge
* whether any push occurred during validation
## 27. Merge rules
Before merge, rerun fresh live checks:
@@ -712,6 +877,7 @@ Before merge, rerun fresh live checks:
* author safety
* PR re-fetch
* reviewed head SHA unchanged
* visible APPROVED review applies to the **current live PR head SHA** (`approval_at_current_head`); if the head moved after approval, re-review at the new head before merge (#471)
* target branch freshly fetched
* PR still open
* PR still mergeable
@@ -728,6 +894,7 @@ Do not merge if:
* capability state is stale
* worktree is dirty
* PR head changed
* approval is stale (approved SHA ≠ current live head SHA)
* validation failed
* inventory was incomplete
* PR is already landed
@@ -757,6 +924,16 @@ Confirm:
Clean only the session-owned `branches/` review worktree if the project workflow explicitly allows cleanup.
Review, baseline, and merge-simulation worktrees created during this run are
transient and are removed automatically at successful completion once they are
clean, carry no open PR, and hold no active lease (#401). Use
`gitea_audit_worktree_cleanup` (read-only) to classify `branches/` entries; only
`clean_stale_removable` and `detached_review_leftover` may be removed, one-by-one,
after `git worktree list` proof plus per-worktree proof of path, branch/HEAD,
clean/dirty status, no active PR/lease, and the removal result. Dirty,
active-PR, active-issue, and leased worktrees are never deleted automatically; a
failed removal must be reported with the leftover path and reason.
Do not delete or mutate unrelated branches/worktrees.
Do not touch the main checkout except to update the stable branch after merge if explicitly allowed by the workflow.
@@ -767,6 +944,61 @@ Do not update the main checkout if merge failed, was blocked, or produced reconc
If any local artifact is created after final cleanup, run and report a new final status check.
## 28A. Post-merge cleanup proof checklist (#402)
Successful tool execution is not proof that cleanup was authorized. Before claiming remote branch deletion or local worktree removal, the final report must carry the full safety checklist below. If any gate is missing, report `CLEANUP_SKIPPED` with the exact blocker — never perform cleanup and never claim it was performed.
### Remote branch deletion checklist
When `gitea_delete_branch` (or equivalent) deletes the merged PR head branch, report:
* Delete-branch capability resolved: name the task (`delete_branch` / `cleanup_branch` / `reconcile_merged_cleanups`) and permission (`gitea.branch.delete`) with resolver proof before the delete call
* Merge result: merged
* Merge commit SHA: full 40-character SHA
* Merged PR head branch / deleted branch: exact branch name (must match)
* Branch protection: none / branch is not protected
* Open PR inventory proof: no other open PR references the branch
* Active heartbeat/claim/lease: none
### Local worktree removal checklist
When removing session-owned review/simulation worktrees under `branches/`, report:
* Session-owned worktree path: exact path under `branches/`
* Pre-removal tracked state: clean
* Pre-removal untracked state: clean
* Git worktree list after removal: command output or equivalent proof
### Skipped cleanup
If any gate fails, report:
* Cleanup outcome: `CLEANUP_SKIPPED`
* Cleanup blocker: exact missing gate (for example `gitea.branch.delete capability not resolved`)
Skipped cleanup with an exact blocker passes validation. Performed-cleanup claims without the checklist fail validation.
## 28B. MCP-native cleanup only (#517)
Post-merge cleanup of leases, comments, branches, and worktrees must go through
explicit MCP tools — never raw git, curl/API scripts, or ad hoc helper scripts.
Merger and reviewer sessions must **not** perform cleanup inline. Record:
* **Merge mutations** — only `gitea_merge_pr` (or review mutations for review-only runs)
* **Cleanup mutations** — only authorized reconciler MCP tools, cited by exact tool name
Authorized cleanup tools include `gitea_reconcile_merged_cleanups`,
`gitea_cleanup_post_merge_moot_lease`, `gitea_delete_branch`, and
`gitea_cleanup_merged_pr_branch` (when available). Lease cleanup uses
append-only release comments — never delete another session's lease comment.
Hand cleanup to a `prgs-reconciler` session with `gitea.branch.delete` capability
proof. Raw `git branch -d`, `git push --delete`, and comment-deletion API calls
are blocked in final-report validation.
Template: `templates/post-merge-cleanup-handoff.md`.
## 29. Recovery handoff rules
If blocked, produce a recovery handoff with:
@@ -1096,6 +1328,8 @@ Controller Handoff:
* Baseline worktree path:
* Files reviewed:
* Validation:
* Validation failure history:
* Validation cwd/HEAD proof:
* Official validation integrity status:
* Terminal review mutation:
* Review decision:
@@ -83,6 +83,20 @@ Examples:
If capability cannot be proven, stop and produce a recovery handoff only.
### 2A. Pre-task role routing (#139)
Before issue selection or any mutation, resolve the composite author task:
* `gitea_route_task_session(task_type="work-issue", remote=…)` — must return
`route_result: allowed_current_session` and `downstream_allowed: true`
* `gitea_resolve_task_capability(task="work_issue", remote=…)` — must show
`required_role_kind: author` and `allowed_in_current_session: true`
Hyphen (`work-issue`) and underscore (`work_issue`) aliases are equivalent.
If routing returns `ambiguous_task_stop`, `wrong_role_stop`, or
`route_to_author_session`, stop and produce a recovery handoff only — do not
fall back to reviewer tools or guess a different profile.
## 3. Stop immediately on blocked infrastructure
If any of the following appears, stop immediately:
@@ -130,6 +144,14 @@ If the main checkout is dirty before selection, stop and produce a recovery hand
If the main checkout becomes dirty during the run, stop and produce a recovery handoff unless the change is explicitly allowed by the canonical workflow.
### Stacked PRs (explicit exception, #484)
Normal author work stays base-equivalent to `master`/`main`/`dev`. A **stacked PR** — deliberately based on another unmerged PR's branch — is the only sanctioned non-master base, and only when the operator/controller explicitly chooses it:
- Branch the `branches/` worktree from the dependency's branch, then lock with `gitea_lock_issue(..., stacked_base_branch=<dep-branch>, stacked_base_pr=<open-PR#>)`. The lock fails closed unless that open PR owns the branch; arbitrary or stale branches are rejected.
- Open the PR with `gitea_create_pr(base=<dep-branch>)`. The body must state: `Stacked on PR #<X> / issue #<Y>`, `Base branch: <dep-branch>`, `Head branch: <this-branch>`, `Do not merge before PR #<X>`, and note retarget/rebase to `master` after the dependency lands if required.
- This does not relax the main-checkout rule or bypass the issue lock — work still happens under `branches/`, and the approved base is recorded on the lock.
## 5. No raw MCP repair during normal issue work
Do not run `pkill`, kill MCP processes, edit MCP config, restart servers, or perform control-checkout repair during normal issue work.
@@ -283,6 +305,36 @@ Report:
Do not create another branch/PR for the same issue unless the project explicitly allows taking over or updating existing work and exact capability is proven.
### 10A. Duplicate-work gate phases (#400)
Before any file edits, prove duplicate-work clearance with
`gitea_assess_work_issue_duplicate` or `gitea_lock_issue` (which runs the same
gate). The gate checks live:
* open PRs linked to the issue (head branch or Closes/Fixes reference),
* remote branches matching `issue-<number>`,
* active claim leases from structured heartbeats.
Re-check immediately before:
* `gitea_commit_files` (commit),
* branch push,
* `gitea_create_pr` (PR creation).
If a concurrent open PR appears after work begins:
* before commit/push → stop and preserve local work without pushing,
* after commit but before push → stop without pushing,
* after push but before PR creation → stop and produce a reconciliation
handoff instead of opening a PR.
Final reports must state exactly one duplicate-work outcome:
* `duplicate PR prevented`
* `duplicate branch prevented`
* `duplicate commit prevented`
* `duplicate work not prevented`
## 11. Claim or lock the issue before implementation
Claim/lock the issue before implementation if the project provides a claim/lock mechanism.
@@ -563,6 +615,34 @@ After push, report:
If push fails, stop and produce a recovery handoff.
## 20A. Conflict-fix lease and push gate (#399)
When pushing to an existing PR branch to resolve merge conflicts:
1. Call `gitea_acquire_conflict_fix_lease` before any push.
2. Call `gitea_assess_conflict_fix_push` immediately before `git push` with:
* branch head before push
* branch head after push (local)
* session worktree path
* push cwd
* whether the push is fast-forward
* explicit `remote`, `org`, and `repo` when not using defaults
3. If assessment returns `assessment_failed: true` or `pr_lookup: failed`, stop
and produce a recovery handoff with the structured `reasons` and
`resolved_repo` fields — do not treat an MCP HTTP 500 as proof the push was
unsafe or safe (#519).
4. Do not push when a reviewer holds an active lease on the same PR.
5. Do not force-push.
6. Do not push from the main checkout or wrong cwd.
Conflict-fix final reports must state:
* branch head before push
* branch head after push
* active reviewer lease status
* whether push was fast-forward
* whether any reviewer was active
## 21. PR creation rules
Create a PR only if implementation and validation pass, unless project policy explicitly allows draft PRs with documented validation failures.
@@ -624,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:
@@ -703,6 +816,12 @@ Use only precise categories:
* External-state mutations:
* Read-only diagnostics:
Issue-lock file (`/tmp/gitea_issue_lock.json`) read/write/delete is always an
external-state mutation. Never claim `External-state mutations: none` after
seeding, restoring, or removing that file. Manual lock seeding is not a normal
recovery path (#447); use `gitea_lock_issue` or the #442 adoption recovery path
instead. Link broader redesign: #438.
`git fetch`, `git remote update`, and any command that updates refs must be listed under `Git ref mutations`, not read-only diagnostics.
If `git reset --hard`, checkout, clean, worktree add/remove, merge simulation, merge abort, or similar commands occurred, report them under `Worktree/index mutations`.
+211
View File
@@ -0,0 +1,211 @@
"""Stacked-PR support for author issue locks and PR creation (#484).
Normal author work locks a worktree that is base-equivalent to ``master``/
``main``/``dev`` and opens a PR against one of those base branches. A *stacked*
PR is deliberately based on another unmerged PR's branch, so its worktree is not
master-equivalent and its PR base is not a normal base branch.
This module holds the pure decision logic that lets:
* ``gitea_lock_issue`` approve a non-master base **only** when it is explicitly
declared and proven to correspond to an open pull request, and
* ``gitea_create_pr`` accept that approved base while still rejecting arbitrary,
mismatched, or stale (merged/closed) branches.
The normal master-based path is unchanged: when no stacked base is declared, and
when the PR base is a normal base branch, these helpers are inert. Nothing here
bypasses the issue lock a stacked base is recorded *on* the lock and re-checked
at PR time.
"""
from __future__ import annotations
BASE_BRANCHES = frozenset({"master", "main", "dev"})
# Phrases that satisfy the required merge-ordering statement in a stacked PR body.
MERGE_ORDER_PHRASES = ("do not merge before", "do not merge until")
def is_base_branch(base: str | None, base_branches: frozenset[str] | None = None) -> bool:
"""True when ``base`` is a normal base branch (master/main/dev)."""
bases = base_branches or BASE_BRANCHES
return (base or "").strip() in bases
def _pr_head_ref(pr: dict) -> str:
head = pr.get("head") or {}
if isinstance(head, dict):
return (head.get("ref") or "").strip()
return (str(head) if head else "").strip()
def find_open_pr_for_branch(open_prs: list[dict] | None, branch: str | None) -> dict | None:
"""Return the first OPEN PR whose head ref equals ``branch`` (else ``None``)."""
branch = (branch or "").strip()
if not branch:
return None
for pr in open_prs or []:
if (pr.get("state") or "").strip().lower() != "open":
continue
if _pr_head_ref(pr) == branch:
return pr
return None
def assess_stacked_base_declaration(
*,
stacked_base_branch: str | None,
stacked_base_pr: int | None,
open_prs: list[dict] | None,
) -> dict:
"""Validate an explicit stacked-base declaration at lock time.
Returns a dict with ``block`` (fail closed), ``reasons``, ``declared``
(whether a stacked base was requested), and ``approved`` (the metadata to
persist on the lock when valid, else ``None``).
"""
branch = (stacked_base_branch or "").strip()
if not branch:
# No stacked base requested — normal master-based lock path.
return {"block": False, "reasons": [], "approved": None, "declared": False}
if branch in BASE_BRANCHES:
return {
"block": True,
"declared": True,
"approved": None,
"reasons": [
f"stacked base '{branch}' is already a normal base branch; do not "
"declare a base branch as a stacked base"
],
}
if stacked_base_pr is None:
return {
"block": True,
"declared": True,
"approved": None,
"reasons": [
"stacked base branch declared without stacked_base_pr; a stacked PR "
"must cite the open PR that owns the base branch"
],
}
pr = find_open_pr_for_branch(open_prs, branch)
if pr is None:
return {
"block": True,
"declared": True,
"approved": None,
"reasons": [
f"stacked base branch '{branch}' does not correspond to any OPEN pull "
"request; arbitrary or stale branches are not allowed as stacked bases"
],
}
if int(pr.get("number")) != int(stacked_base_pr):
return {
"block": True,
"declared": True,
"approved": None,
"reasons": [
f"declared stacked_base_pr #{stacked_base_pr} does not match the open "
f"PR #{pr.get('number')} that owns base branch '{branch}'"
],
}
return {
"block": False,
"declared": True,
"reasons": [],
"approved": {
"branch": branch,
"pr_number": int(pr.get("number")),
"verified_open": True,
},
}
def assess_stacked_pr_body(
body: str | None, *, base_branch: str | None, pr_number: int | None
) -> list[str]:
"""Return the list of missing stacked-PR documentation fields (empty = ok)."""
text = body or ""
low = text.lower()
missing: list[str] = []
if base_branch and base_branch not in text:
missing.append(f"base branch '{base_branch}'")
if pr_number is not None and f"#{pr_number}" not in text:
missing.append(f"stacked-on PR reference '#{pr_number}'")
if not any(phrase in low for phrase in MERGE_ORDER_PHRASES):
missing.append("merge-ordering statement (e.g. 'Do not merge before PR #<n>')")
return missing
def assess_create_pr_base(
*,
base: str | None,
approved_stacked_base: dict | None,
body: str | None,
open_prs: list[dict] | None,
base_branches: frozenset[str] | None = None,
) -> dict:
"""Validate the PR base at create time.
Normal base branches pass through unchanged (``stacked`` False). A non-base
branch is allowed only when it matches the lock's approved stacked base, that
base still has an open PR, and the body documents the stack.
"""
bases = base_branches or BASE_BRANCHES
base = (base or "").strip()
if base in bases:
return {"block": False, "reasons": [], "stacked": False}
approved = approved_stacked_base or {}
approved_branch = (approved.get("branch") or "").strip()
if not approved_branch:
return {
"block": True,
"stacked": True,
"reasons": [
f"PR base '{base}' is not one of {'/'.join(sorted(bases))} and the "
"issue lock has no approved stacked base; re-lock with an explicit, "
"proof-backed stacked base to open a stacked PR"
],
}
if base != approved_branch:
return {
"block": True,
"stacked": True,
"reasons": [
f"PR base '{base}' does not match the issue lock's approved stacked "
f"base '{approved_branch}'"
],
}
pr = find_open_pr_for_branch(open_prs, base)
if pr is None:
return {
"block": True,
"stacked": True,
"reasons": [
f"approved stacked base '{base}' no longer corresponds to an OPEN pull "
"request (dependency merged, closed, or stale); retarget/rebase onto "
"master or re-lock against a live base"
],
}
pr_number = approved.get("pr_number") or pr.get("number")
missing = assess_stacked_pr_body(body, base_branch=base, pr_number=pr_number)
if missing:
return {
"block": True,
"stacked": True,
"reasons": [
"stacked PR body must document the stack; missing: "
+ ", ".join(missing)
],
}
return {"block": False, "reasons": [], "stacked": True, "stacked_base_pr": pr_number}
+58
View File
@@ -68,10 +68,22 @@ 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",
},
"pr_queue_cleanup": {
"permission": "gitea.pr.review",
"role": "reviewer",
},
"pr-queue-cleanup": {
"permission": "gitea.pr.review",
"role": "reviewer",
},
"request_changes_pr": {
"permission": "gitea.pr.request_changes",
"role": "reviewer",
@@ -96,6 +108,52 @@ 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",
},
"work-issue": {
"permission": "gitea.pr.create",
"role": "author",
},
"reconcile_landed_pr": {
"permission": "gitea.read",
"role": "author",
},
"reconcile-landed-pr": {
"permission": "gitea.read",
"role": "author",
},
"reconcile_already_landed_pr": {
"permission": "gitea.pr.close",
"role": "reconciler",
},
# #309: dedicated reconciler path for already-landed open PRs. Exact
# close capabilities only — never review/approve/request_changes/merge.
"reconcile_close_landed_pr": {
"permission": "gitea.pr.close",
"role": "reconciler",
},
"reconcile_close_landed_issue": {
"permission": "gitea.issue.close",
"role": "reconciler",
},
"post_heartbeat": {
"permission": "gitea.issue.comment",
"role": "author",
},
"reconcile_issue_claims": {
"permission": "gitea.read",
"role": "author",
},
"cleanup_stale_claims": {
"permission": "gitea.issue.comment",
"role": "author",
},
}
# Issue-mutating MCP tools and their resolver task keys.
+28 -2
View File
@@ -62,16 +62,42 @@ class TestPreflightWarnings(unittest.TestCase):
self.assertIn("_encode_commit_payload.py", status["preflight_warnings"][0])
# Issue-write tools are profile-gated (#69); gitea_lock_issue requires
# gitea.issue.comment (see task_capability_map), so the gate must be
# seeded exactly like tests/test_mcp_server.py::TestIssueLocking (#359).
ISSUE_WRITE_ENV = {
"GITEA_ALLOWED_OPERATIONS": (
"gitea.issue.create,gitea.issue.close,gitea.issue.comment"
),
}
class TestIssueLockArtifactWarning(unittest.TestCase):
def setUp(self):
self._lock_dir = tempfile.TemporaryDirectory()
env = {**ISSUE_WRITE_ENV, "GITEA_ISSUE_LOCK_DIR": self._lock_dir.name}
self._env_patcher = patch.dict(os.environ, env, clear=True)
self._env_patcher.start()
def tearDown(self):
self._env_patcher.stop()
self._lock_dir.cleanup()
@patch(
"mcp_server.issue_duplicate_context_fetcher",
return_value=([], [], {"status": "not_claimed"}),
)
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server._auth", return_value="token x")
@patch("mcp_server._resolve", return_value=("h", "o", "r"))
@patch("mcp_server.ISSUE_LOCK_FILE", new_callable=lambda: tempfile.mktemp())
@patch("issue_lock_worktree.read_worktree_git_state")
def test_lock_success_includes_artifact_warning(self, mock_state, _lock_file, *_mocks):
def test_lock_success_includes_artifact_warning(self, mock_state, *_mocks):
mock_state.return_value = {
"current_branch": "master",
"porcelain_status": "?? _emit_payload.py\n",
"base_equivalent": True,
"inspected_git_root": "/scratch/wt",
"base_branch": "origin/master",
}
result = mcp_server.gitea_lock_issue(
issue_number=261,
+131
View File
@@ -0,0 +1,131 @@
"""Tests for already-landed PR reconciliation gates (#310)."""
import os
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import already_landed_reconcile
class TestAssessAlreadyLandedReconciliation(unittest.TestCase):
def test_open_pr_already_landed_allows_close(self):
with patch(
"already_landed_reconcile.is_head_ancestor_of_ref",
return_value=True,
):
assessment = already_landed_reconcile.assess_already_landed_reconciliation(
pr={
"number": 278,
"state": "open",
"title": "Fix thing (Closes #263)",
"body": "",
"head": {"ref": "feat/x", "sha": "abc123"},
"base": {"ref": "master"},
},
project_root="/tmp/repo",
remote="prgs",
target_branch="master",
target_fetch={
"success": True,
"target_branch": "master",
"target_ref": "prgs/master",
"target_branch_sha": "deadbeef",
"git_fetch_command": "git fetch prgs master",
},
)
self.assertTrue(assessment["close_allowed"])
self.assertEqual(
assessment["eligibility_class"],
already_landed_reconcile.ELIGIBILITY_ALREADY_LANDED,
)
self.assertEqual(assessment["linked_issue"], 263)
self.assertTrue(assessment["ancestor_proof"])
def test_not_landed_pr_denies_close(self):
with patch(
"already_landed_reconcile.is_head_ancestor_of_ref",
return_value=False,
):
assessment = already_landed_reconcile.assess_already_landed_reconciliation(
pr={
"number": 99,
"state": "open",
"title": "WIP",
"body": "",
"head": {"ref": "feat/y", "sha": "fff111"},
"base": {"ref": "master"},
},
project_root="/tmp/repo",
remote="prgs",
target_branch="master",
target_fetch={
"success": True,
"target_branch": "master",
"target_ref": "prgs/master",
"target_branch_sha": "deadbeef",
},
)
self.assertFalse(assessment["close_allowed"])
self.assertEqual(
assessment["eligibility_class"],
already_landed_reconcile.ELIGIBILITY_NOT_LANDED,
)
def test_stale_target_branch_denies_close(self):
assessment = already_landed_reconcile.assess_already_landed_reconciliation(
pr={
"number": 99,
"state": "open",
"title": "WIP",
"body": "",
"head": {"ref": "feat/y", "sha": "fff111"},
"base": {"ref": "master"},
},
project_root="/tmp/repo",
remote="prgs",
target_branch="master",
target_fetch={
"success": False,
"target_branch": "master",
"target_ref": "prgs/master",
"target_branch_sha": None,
"reasons": ["git fetch failed"],
},
)
self.assertFalse(assessment["close_allowed"])
self.assertEqual(
assessment["eligibility_class"],
already_landed_reconcile.ELIGIBILITY_STALE_TARGET,
)
def test_closed_pr_denies_close(self):
assessment = already_landed_reconcile.assess_already_landed_reconciliation(
pr={
"number": 50,
"state": "closed",
"title": "Done",
"body": "",
"head": {"ref": "feat/z", "sha": "aaa"},
"base": {"ref": "master"},
},
project_root="/tmp/repo",
remote="prgs",
target_branch="master",
target_fetch={
"success": True,
"target_branch": "master",
"target_ref": "prgs/master",
"target_branch_sha": "deadbeef",
},
)
self.assertFalse(assessment["close_allowed"])
self.assertEqual(
assessment["eligibility_class"],
already_landed_reconcile.ELIGIBILITY_PR_NOT_OPEN,
)
if __name__ == "__main__":
unittest.main()
+151
View File
@@ -0,0 +1,151 @@
"""Tests for already-landed final-report wording validator (#298).
An already-landed PR is reconciliation-only, never review/merge
eligible, and a head SHA may not be called reviewed unless validation
and diff review actually passed. Final reports and controller handoffs
must use the reconciliation wording, not the legacy eligible/reviewed
fields.
"""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from review_proofs import assess_already_landed_report_wording # noqa: E402
GOOD_ALREADY_LANDED = (
"Controller Handoff\n"
"- Oldest open PR requiring action: PR #278\n"
"- Eligibility class: ALREADY_LANDED_RECONCILE_REQUIRED\n"
"- Candidate head SHA: 2a544b7\n"
"- Reviewed head SHA: none\n"
"- Review worktree used: false\n"
)
class TestAlreadyLandedWording(unittest.TestCase):
def test_correct_reconciliation_wording_passes(self):
result = assess_already_landed_report_wording(
GOOD_ALREADY_LANDED, already_landed=True
)
self.assertTrue(result["complete"])
self.assertFalse(result["downgraded"])
self.assertEqual(result["reasons"], [])
def test_oldest_eligible_wording_fails(self):
report = GOOD_ALREADY_LANDED + "- oldest eligible PR: PR #278\n"
result = assess_already_landed_report_wording(
report, already_landed=True
)
self.assertFalse(result["complete"])
self.assertTrue(
any("oldest eligible pr" in r.lower() for r in result["reasons"])
)
def test_next_eligible_wording_fails(self):
report = GOOD_ALREADY_LANDED + "- next eligible PR: PR #278\n"
result = assess_already_landed_report_wording(
report, already_landed=True
)
self.assertFalse(result["complete"])
def test_pinned_reviewed_head_fails(self):
report = GOOD_ALREADY_LANDED + "- Pinned reviewed head: 2a544b7\n"
result = assess_already_landed_report_wording(
report, already_landed=True
)
self.assertFalse(result["complete"])
self.assertTrue(
any("pinned reviewed head" in r.lower() for r in result["reasons"])
)
def test_scratch_worktree_field_fails(self):
report = GOOD_ALREADY_LANDED + "- Scratch worktree used: False\n"
result = assess_already_landed_report_wording(
report, already_landed=True
)
self.assertFalse(result["complete"])
def test_missing_eligibility_class_fails(self):
report = (
"Controller Handoff\n"
"- Oldest open PR requiring action: PR #278\n"
"- Candidate head SHA: 2a544b7\n"
"- Reviewed head SHA: none\n"
"- Review worktree used: false\n"
)
result = assess_already_landed_report_wording(
report, already_landed=True
)
self.assertFalse(result["complete"])
self.assertTrue(
any("eligibility class" in r.lower() for r in result["reasons"])
)
def test_populated_reviewed_head_sha_fails(self):
report = GOOD_ALREADY_LANDED.replace(
"- Reviewed head SHA: none\n",
"- Reviewed head SHA: 2a544b7\n",
)
result = assess_already_landed_report_wording(
report, already_landed=True
)
self.assertFalse(result["complete"])
self.assertTrue(
any("reviewed head" in r.lower() for r in result["reasons"])
)
class TestNonAlreadyLandedReports(unittest.TestCase):
def test_normal_reviewed_report_passes(self):
report = (
"- Selected PR: 281\n"
"- Pinned reviewed head: abc1234\n"
"- Review decision: approve\n"
)
result = assess_already_landed_report_wording(
report, already_landed=False, review_validated=True
)
self.assertTrue(result["complete"])
def test_blocked_infra_report_with_pinned_head_fails(self):
report = (
"- Selected PR: 281\n"
"- Pinned reviewed head: abc1234\n"
"- Current status: blocked on infra_stop\n"
)
result = assess_already_landed_report_wording(
report, already_landed=False, review_validated=False
)
self.assertFalse(result["complete"])
self.assertTrue(
any("before validation" in r.lower() for r in result["reasons"])
)
def test_validation_failed_report_with_reviewed_sha_fails(self):
report = (
"- Selected PR: 281\n"
"- Reviewed head SHA: abc1234\n"
"- Validation: full suite failed\n"
)
result = assess_already_landed_report_wording(
report, already_landed=False, review_validated=False
)
self.assertFalse(result["complete"])
def test_validation_failed_report_with_reviewed_none_passes(self):
report = (
"- Selected PR: 281\n"
"- Reviewed head SHA: none\n"
"- Validation: full suite failed\n"
)
result = assess_already_landed_report_wording(
report, already_landed=False, review_validated=False
)
self.assertTrue(result["complete"])
if __name__ == "__main__":
unittest.main()
+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()
+52 -7
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):
@@ -284,8 +285,43 @@ class TestSimpleToolAudit(_AuditWiringBase):
self.assertEqual(result["number"], 9)
_NO_PR_WORK_LEASE_BLOCK = {"block": False, "reasons": [], "mutation_allowed": True}
class TestGatedToolAudit(_AuditWiringBase):
def setUp(self):
super().setUp()
from tests.test_mcp_server import _init_reviewer_session, _install_owned_reviewer_lease
import reviewer_pr_lease
import review_workflow_boundary
import review_workflow_load
# Session init clears any prior session lease (#407) and loads workflow (#389).
_init_reviewer_session("prgs")
self.addCleanup(review_workflow_load.clear_review_workflow_load)
self.addCleanup(review_workflow_boundary.clear_pre_review_commands)
self._lease_patch = _install_owned_reviewer_lease(8)
self._lease_patch.start()
self._auth_identity_patch = patch(
"mcp_server._authenticated_username", return_value="reviewer-bot"
)
self._auth_identity_patch.start()
self._pr_lease_comments_patch = patch(
"mcp_server._list_pr_lease_comments", return_value=[]
)
self._pr_lease_comments_patch.start()
self._pr_work_lease_patch = patch(
"mcp_server._pr_work_lease_reviewer_block",
return_value=dict(_NO_PR_WORK_LEASE_BLOCK),
)
self._pr_work_lease_patch.start()
self.addCleanup(self._auth_identity_patch.stop)
self.addCleanup(self._lease_patch.stop)
self.addCleanup(self._pr_lease_comments_patch.stop)
self.addCleanup(self._pr_work_lease_patch.stop)
self.addCleanup(reviewer_pr_lease.clear_session_lease)
def _pr(self, author, state="open", sha="abc123", mergeable=True):
return {"user": {"login": author}, "state": state,
"head": {"sha": sha}, "mergeable": mergeable}
@@ -298,12 +334,14 @@ class TestGatedToolAudit(_AuditWiringBase):
{"login": "merger-bot"}, self._pr("author-bot"),
self._pr("author-bot"),
[{"id": 1, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
"submitted_at": "2026-07-06T10:00:00Z", "dismissed": False}],
"commit_id": "abc123", "submitted_at": "2026-07-06T10:00:00Z",
"dismissed": False}],
{}, {"merged_commit_sha": "c1"},
]
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
GITEA_ALLOWED_OPERATIONS="read,merge")
with patch.dict(os.environ, env, clear=True):
mcp_server.gitea_load_review_workflow()
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8",
expected_head_sha="abc123", remote="prgs")
self.assertTrue(r["performed"])
@@ -323,6 +361,7 @@ class TestGatedToolAudit(_AuditWiringBase):
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
GITEA_ALLOWED_OPERATIONS="read,merge")
with patch.dict(os.environ, env, clear=True):
mcp_server.gitea_load_review_workflow()
r = gitea_merge_pr(pr_number=8, confirmation="MERGE PR 8", remote="prgs")
self.assertFalse(r["performed"])
recs = self._records()
@@ -334,7 +373,9 @@ class TestGatedToolAudit(_AuditWiringBase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_submit_review_success_audited(self, _auth, mock_api):
# mark_final_review_decision and submit each run eligibility (user + PR).
mock_api.side_effect = [
{"login": "reviewer-bot"}, self._pr("author-bot"),
{"login": "reviewer-bot"}, self._pr("author-bot"),
{"id": 7, "state": "APPROVED"},
[{"id": 7, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
@@ -343,12 +384,16 @@ class TestGatedToolAudit(_AuditWiringBase):
env = self._env(GITEA_PROFILE_NAME="gitea-reviewer",
GITEA_ALLOWED_OPERATIONS="read,review,approve")
with patch.dict(os.environ, env, clear=True):
from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
init_review_decision_lock("prgs", "review_pr")
gitea_mark_final_review_decision(8, "approve", remote="prgs")
r = gitea_submit_pr_review(pr_number=8, action="approve",
body="LGTM", remote="prgs",
final_review_decision_ready=True)
from mcp_server import gitea_mark_final_review_decision
gitea_mark_final_review_decision(
8, "approve", expected_head_sha="abc123", remote="prgs",
)
r = gitea_submit_pr_review(
pr_number=8, action="approve",
body="LGTM", remote="prgs",
final_review_decision_ready=True,
)
self.assertTrue(r["performed"])
recs = self._records()
self.assertEqual(len(recs), 1)
+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()
+109
View File
@@ -0,0 +1,109 @@
"""Tests for branches-only author mutation worktree guard (#274)."""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import author_mutation_worktree as amw # noqa: E402
class TestPathUnderBranches(unittest.TestCase):
ROOT = "/repo/Gitea-Tools"
def test_branches_subdirectory_passes(self):
self.assertTrue(
amw.is_path_under_branches(f"{self.ROOT}/branches/issue-274", self.ROOT)
)
def test_control_checkout_fails(self):
self.assertFalse(amw.is_path_under_branches(self.ROOT, self.ROOT))
def test_sibling_directory_fails(self):
self.assertFalse(
amw.is_path_under_branches("/repo/other-checkout", self.ROOT)
)
class TestAssessAuthorMutationWorktree(unittest.TestCase):
ROOT = "/repo/Gitea-Tools"
def test_branches_worktree_passes(self):
result = amw.assess_author_mutation_worktree(
workspace_path=f"{self.ROOT}/branches/issue-274",
project_root=self.ROOT,
current_branch="feat/issue-274-branches-only-worktrees",
)
self.assertTrue(result["proven"])
self.assertFalse(result["block"])
def test_control_checkout_blocks(self):
result = amw.assess_author_mutation_worktree(
workspace_path=self.ROOT,
project_root=self.ROOT,
current_branch="master",
)
self.assertFalse(result["proven"])
self.assertTrue(result["block"])
self.assertIn("control checkout", result["reasons"][0])
def test_drifted_control_branch_blocks(self):
result = amw.assess_author_mutation_worktree(
workspace_path=self.ROOT,
project_root=self.ROOT,
current_branch="feat/some-task",
)
self.assertTrue(result["block"])
self.assertTrue(any("drift" in r for r in result["reasons"]))
def test_branches_worktree_as_project_root_passes(self):
"""MCP launched from a branches/ worktree uses that path as PROJECT_ROOT."""
worktree_root = f"{self.ROOT}/branches/issue-274"
result = amw.assess_author_mutation_worktree(
workspace_path=worktree_root,
project_root=worktree_root,
current_branch="feat/issue-274-branches-only-worktrees",
)
self.assertTrue(result["proven"])
self.assertFalse(result["block"])
class TestPreflightIntegration(unittest.TestCase):
def test_verify_preflight_blocks_control_checkout_with_test_porcelain(self):
import mcp_server
mcp_server._preflight_whoami_called = True
mcp_server._preflight_capability_called = True
mcp_server._preflight_resolved_role = "author"
control_root = "/repo/Gitea-Tools"
with mock.patch.object(mcp_server, "PROJECT_ROOT", control_root):
with mock.patch.dict(
"os.environ",
{"GITEA_TEST_PORCELAIN": ""},
clear=False,
):
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity()
self.assertIn("Branches-only mutation guard", str(ctx.exception))
def test_verify_preflight_allows_branches_worktree(self):
import mcp_server
mcp_server._preflight_whoami_called = True
mcp_server._preflight_capability_called = True
mcp_server._preflight_resolved_role = "author"
worktree = "/repo/Gitea-Tools/branches/issue-274"
with mock.patch.dict(
"os.environ",
{"GITEA_TEST_PORCELAIN": ""},
clear=False,
):
mcp_server.verify_preflight_purity(worktree_path=worktree)
if __name__ == "__main__":
unittest.main()
+7
View File
@@ -76,7 +76,14 @@ class CommitFilesCapabilityBase(unittest.TestCase):
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(CONFIG))
self._dup_fetcher_patcher = patch(
"mcp_server.issue_duplicate_context_fetcher",
return_value=([], [], {"status": "not_claimed"}),
)
self._dup_fetcher_patcher.start()
def tearDown(self):
self._dup_fetcher_patcher.stop()
self._remotes.stop()
mcp_server._IDENTITY_CACHE.clear()
mcp_server._preflight_whoami_called, mcp_server._preflight_capability_called = (
+38 -6
View File
@@ -55,10 +55,30 @@ class TestCommitPayloads(unittest.TestCase):
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(CONFIG))
self.locked_worktree_dir = tempfile.TemporaryDirectory()
repo_root = os.path.realpath(
os.path.join(os.path.dirname(__file__), os.pardir)
)
branches_root = os.path.join(repo_root, "branches")
os.makedirs(branches_root, exist_ok=True)
self.locked_worktree_dir = tempfile.TemporaryDirectory(
dir=branches_root,
prefix="test-commit-payloads-",
)
self.locked_worktree_path = os.path.realpath(self.locked_worktree_dir.name)
self.lock_file_path = "/tmp/gitea_issue_lock.json"
import issue_lock_store
import issue_lock_provenance
self._lock_dir = tempfile.TemporaryDirectory()
os.environ["GITEA_ISSUE_LOCK_DIR"] = self._lock_dir.name
work_lease = {
"operation_type": "author_issue_work",
"issue_number": 263,
"branch": "feat/issue-263-native-commit-payloads",
"claimant": {"username": "test-user", "profile": "test-author"},
"expires_at": "2999-01-01T00:00:00Z",
}
self.lock_data = {
"issue_number": 263,
"branch_name": "feat/issue-263-native-commit-payloads",
@@ -66,9 +86,13 @@ class TestCommitPayloads(unittest.TestCase):
"org": "Example-Org",
"repo": "Example-Repo",
"worktree_path": self.locked_worktree_path,
"work_lease": work_lease,
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
tool="gitea_lock_issue",
claimant=work_lease.get("claimant"),
),
}
with open(self.lock_file_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(self.lock_data))
self.lock_file_path = issue_lock_store.bind_session_lock(self.lock_data)
# Reset preflight status to bypass/pass verification in tests
self.orig_whoami_called = mcp_server._preflight_whoami_called
@@ -76,7 +100,14 @@ class TestCommitPayloads(unittest.TestCase):
mcp_server._preflight_whoami_called = True
mcp_server._preflight_capability_called = True
self._dup_fetcher_patcher = patch(
"mcp_server.issue_duplicate_context_fetcher",
return_value=([], [], {"status": "not_claimed"}),
)
self._dup_fetcher_patcher.start()
def tearDown(self):
self._dup_fetcher_patcher.stop()
self._remotes.stop()
mcp_server._IDENTITY_CACHE.clear()
@@ -85,8 +116,7 @@ class TestCommitPayloads(unittest.TestCase):
self._dir.cleanup()
self.locked_worktree_dir.cleanup()
if os.path.exists(self.lock_file_path):
os.remove(self.lock_file_path)
self._lock_dir.cleanup()
def _env(self, profile: str) -> dict:
return {
@@ -94,6 +124,8 @@ class TestCommitPayloads(unittest.TestCase):
"GITEA_MCP_PROFILE": profile,
"GITEA_TOKEN_AUTHOR": "author-pass",
"GITEA_TEST_PORCELAIN": "",
"GITEA_AUTHOR_WORKTREE": self.locked_worktree_path,
"GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
}
@patch("mcp_server.api_request")
+167
View File
@@ -0,0 +1,167 @@
import os
import sys
import unittest
from pathlib import Path
from unittest.mock import patch, MagicMock
# Ensure we import from the repo root
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import gitea_mcp_server as srv
FAKE_AUTH = {"Authorization": "token test-token"}
# Stable control checkout (parent of branches/), not the MCP server worktree root.
CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3])
PROJECT_ROOT = srv.PROJECT_ROOT
class TestCreateIssueWorkspaceGuard(unittest.TestCase):
def setUp(self):
# Reset preflight flags
srv._preflight_whoami_called = True
srv._preflight_capability_called = True
srv._preflight_resolved_role = "author"
srv._preflight_whoami_violation = False
srv._preflight_capability_violation = False
# Disable early return in verify_preflight_purity for testing
self._orig_in_test = srv._preflight_in_test_mode
srv._preflight_in_test_mode = lambda: False
def tearDown(self):
srv._preflight_in_test_mode = self._orig_in_test
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
@patch("gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, []))
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server.api_get_all", return_value=[])
@patch("gitea_mcp_server.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):
with self.assertRaises(RuntimeError) as ctx:
srv.gitea_create_issue(title="Test issue", body="body text")
self.assertIn("stable control checkout", str(ctx.exception))
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
@patch("gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, []))
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server.api_get_all", return_value=[])
@patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", return_value={"current_branch": "feat/issue-1"})
@patch("os.path.exists", return_value=True)
@patch("os.path.isdir", return_value=True)
@patch("subprocess.run")
def test_create_issue_valid_worktree_succeeds(self, mock_run, mock_isdir, mock_exists, _git, _get_all, mock_api, _role, _ns, _prof, _auth):
# Mock subprocess.run for git --git-common-dir to return PROJECT_ROOT/.git
mock_res = MagicMock()
mock_res.stdout = f"{CONTROL_CHECKOUT_ROOT}/.git\n"
mock_run.return_value = mock_res
mock_api.return_value = {"number": 42, "html_url": "https://gitea.example.com/issues/42"}
# Provide a valid branches path under the control checkout root
valid_path = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "feat-issue-1")
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""):
res = srv.gitea_create_issue(
title="Test issue", body="body", worktree_path=valid_path
)
self.assertEqual(res["number"], 42)
mock_api.assert_called_once()
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
@patch("gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, []))
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server.api_get_all", return_value=[])
@patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", return_value={"current_branch": "feat/issue-1"})
def test_create_issue_missing_worktree_fails_closed(self, _git, _get_all, mock_api, _role, _ns, _prof, _auth):
# Path under branches/ but doesn't exist
missing_path = os.path.join(
CONTROL_CHECKOUT_ROOT, "branches", "nonexistent-worktree-path-999"
)
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
with self.assertRaises(RuntimeError) as ctx:
srv.gitea_create_issue(
title="Test issue", body="body", worktree_path=missing_path
)
self.assertIn("does not exist (fail closed)", str(ctx.exception))
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
@patch("gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, []))
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server.api_get_all", return_value=[])
@patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", return_value={"current_branch": "feat/issue-1"})
@patch("os.path.exists", return_value=True)
@patch("os.path.isdir", return_value=True)
@patch("author_mutation_worktree.subprocess.run")
@patch("subprocess.run")
def test_create_issue_wrong_repo_fails_closed(self, mock_run, mock_amw_run, mock_isdir, mock_exists, _git, _get_all, mock_api, _role, _ns, _prof, _auth):
wrong_repo_path = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "feat-issue-1")
def _subprocess_side_effect(cmd, *args, **kwargs):
mock_res = MagicMock(returncode=0)
if "--git-common-dir" in cmd:
cwd = cmd[cmd.index("-C") + 1] if "-C" in cmd else ""
if cwd == wrong_repo_path:
mock_res.stdout = "/Users/jasonwalker/Development/some-other-repo/.git\n"
else:
mock_res.stdout = f"{CONTROL_CHECKOUT_ROOT}/.git\n"
else:
mock_res.stdout = ""
return mock_res
mock_run.side_effect = _subprocess_side_effect
mock_amw_run.side_effect = _subprocess_side_effect
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""):
with self.assertRaises(RuntimeError) as ctx:
srv.gitea_create_issue(
title="Test issue", body="body", worktree_path=wrong_repo_path
)
self.assertIn("does not belong to the target repository", str(ctx.exception))
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
def test_create_issue_fails_without_whoami_preflight(self, _ns, _prof, _auth):
srv._preflight_whoami_called = False
with self.assertRaises(RuntimeError) as ctx:
srv.gitea_create_issue(title="Test issue", body="body")
self.assertIn("Identity (gitea_whoami) has not been verified", str(ctx.exception))
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
def test_create_issue_fails_without_capability_preflight(self, _ns, _prof, _auth):
srv._preflight_capability_called = False
with self.assertRaises(RuntimeError) as ctx:
srv.gitea_create_issue(title="Test issue", body="body")
self.assertIn("Task capability (gitea_resolve_task_capability) has not been resolved", str(ctx.exception))
if __name__ == "__main__":
unittest.main()
+217
View File
@@ -0,0 +1,217 @@
"""Tests for gitea_delete_branch capability gate (Issue #408).
``gitea_delete_branch`` requires the exact ``gitea.branch.delete`` operation:
without it the delete fails closed (no preflight, no auth lookup, no API call,
structured permission report). With it, deletion proceeds through existing
preflight and audit unchanged.
"""
import json
import os
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import mcp_server
from mcp_server import gitea_delete_branch
FAKE_AUTH = "token fake"
AUTHOR_NO_DELETE = {
"profile_name": "prgs-author",
"allowed_operations": [
"gitea.read", "gitea.pr.create", "gitea.pr.comment",
"gitea.branch.push", "gitea.issue.comment",
],
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
"audit_label": "prgs-author",
}
AUTHOR_WITH_DELETE = {
"profile_name": "prgs-author-deleter",
"allowed_operations": AUTHOR_NO_DELETE["allowed_operations"] + [
"gitea.branch.delete",
],
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
"audit_label": "prgs-author-deleter",
}
CONFIG = {
"version": 2,
"contexts": {
"ctx": {
"enabled": True,
"gitea": {"enabled": True, "base_url": "https://gitea.example.com"},
}
},
"profiles": {
"author-no-delete": {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "author-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": AUTHOR_NO_DELETE["allowed_operations"],
"forbidden_operations": [],
"execution_profile": "author-no-delete",
},
"author-with-delete": {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "deleter-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": AUTHOR_WITH_DELETE["allowed_operations"],
"forbidden_operations": [],
"execution_profile": "author-with-delete",
},
"reviewer-profile": {
"enabled": True,
"context": "ctx",
"role": "reviewer",
"username": "reviewer-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
"allowed_operations": [
"gitea.read", "gitea.pr.review", "gitea.pr.merge",
],
"forbidden_operations": [
"gitea.branch.delete", "gitea.branch.push", "gitea.pr.create",
],
"execution_profile": "reviewer-profile",
},
},
"rules": {"allow_runtime_switching": False},
}
class TestDeleteBranchToolGate(unittest.TestCase):
def setUp(self):
self._preflight_snapshot = (
mcp_server._preflight_whoami_called,
mcp_server._preflight_capability_called,
)
mcp_server._preflight_whoami_called = False
mcp_server._preflight_capability_called = False
self._remotes = patch.dict(mcp_server.REMOTES, {
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
"repo": "Example-Repo"},
})
self._remotes.start()
mcp_server._IDENTITY_CACHE.clear()
patch("gitea_config.load_config", return_value={}).start()
patch(
"gitea_config.is_runtime_switching_enabled", return_value=False
).start()
patch("gitea_audit.audit_enabled", return_value=False).start()
self.mock_api = patch("mcp_server.api_request").start()
self.mock_auth = patch(
"mcp_server.get_auth_header", return_value=FAKE_AUTH
).start()
def tearDown(self):
patch.stopall()
mcp_server._IDENTITY_CACHE.clear()
mcp_server._preflight_whoami_called, mcp_server._preflight_capability_called = (
self._preflight_snapshot
)
def _set_profile(self, profile):
patch("mcp_server.get_profile", return_value=profile).start()
def test_blocked_without_delete_capability(self):
self._set_profile(AUTHOR_NO_DELETE)
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
self.assertFalse(res["success"])
self.assertFalse(res["performed"])
self.assertEqual(res["required_permission"], "gitea.branch.delete")
self.assertTrue(res["reasons"])
self.assertEqual(
res["permission_report"]["missing_permission"],
"gitea.branch.delete",
)
self.mock_api.assert_not_called()
self.mock_auth.assert_not_called()
def test_allowed_delete_proceeds(self):
self._set_profile(AUTHOR_WITH_DELETE)
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="author")
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
self.assertTrue(res["success"])
self.assertIn("deleted", res["message"])
delete_calls = [
c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"
]
self.assertTrue(delete_calls)
def test_allowed_delete_audited_with_capability_proof(self):
self._set_profile(AUTHOR_WITH_DELETE)
patch("gitea_audit.audit_enabled", return_value=True).start()
mock_write = patch("gitea_audit.write_event").start()
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="author")
self.mock_api.return_value = {}
gitea_delete_branch(branch="feat/branch", remote="prgs")
mock_write.assert_called()
event = mock_write.call_args[0][0]
self.assertEqual(event["action"], "delete_branch")
self.assertEqual(
event["request_metadata"]["required_permission"],
"gitea.branch.delete",
)
class TestDeleteBranchResolverParity(unittest.TestCase):
def setUp(self):
self._remotes = patch.dict(mcp_server.REMOTES, {
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
"repo": "Example-Repo"},
})
self._remotes.start()
mcp_server._IDENTITY_CACHE.clear()
self._dir = tempfile.TemporaryDirectory()
self.config_path = os.path.join(self._dir.name, "profiles.json")
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(CONFIG))
patch(
"gitea_config.is_runtime_switching_enabled", return_value=False
).start()
patch("gitea_audit.audit_enabled", return_value=False).start()
self.mock_api = patch("mcp_server.api_request").start()
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
def tearDown(self):
patch.stopall()
mcp_server._IDENTITY_CACHE.clear()
self._dir.cleanup()
def _env(self, profile: str) -> dict:
return {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": profile,
"GITEA_TOKEN_AUTHOR": "author-pass",
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
}
def test_reviewer_resolver_denial_blocks_raw_tool(self):
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
resolve = mcp_server.gitea_resolve_task_capability(
task="delete_branch", remote="prgs")
self.assertFalse(resolve["allowed_in_current_session"])
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
self.assertFalse(res["success"])
self.assertEqual(
res["permission_report"]["missing_permission"],
resolve["required_operation_permission"],
)
delete_calls = [
c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"
]
self.assertFalse(delete_calls)
if __name__ == "__main__":
unittest.main()
+256 -9
View File
@@ -25,11 +25,15 @@ def _review_handoff(**overrides):
"- Files changed: review_proofs.py",
"- Validation: pytest tests/test_review_proofs.py -q in branches/review-203",
"- Mutations: review only",
"- Workspace mutations: none",
"- File edits by reviewer: none",
"- Worktree/index mutations: none",
"- Git ref mutations: git fetch prgs master",
"- MCP/Gitea mutations: review submitted",
"- Review mutations: submitted request_changes review",
"- Merge mutations: none",
"- Cleanup mutations: none",
"- External-state mutations: none",
"- Read-only diagnostics: issue and PR metadata inspected",
"- Current status: PR open",
"- Blockers: none",
"- Next: await author",
@@ -37,6 +41,10 @@ def _review_handoff(**overrides):
"- Selected PR: #203",
"- Reviewer eligibility: eligible",
"- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Reviewed head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Final live head SHA before approval: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Final live head SHA before merge: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Push occurred during validation: no",
"- Worktree path: branches/review-203",
"- Worktree dirty: clean",
"- Scratch worktree used: yes (branches/review-203)",
@@ -53,26 +61,47 @@ def _review_handoff(**overrides):
return text
def _reconcile_handoff(**overrides):
def _reconcile_handoff(drop=(), **overrides):
lines = [
"## Controller Handoff",
"",
"- Task: reconcile already-landed PR #99",
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
"- Role: reconciler",
"- Identity: sysadmin / prgs-author",
"- Role/profile: reconciler / prgs-reconciler",
"- Identity: sysadmin",
"- Selected PR: #99",
"- Eligibility class: ALREADY_LANDED_RECONCILE_REQUIRED",
"- PR live state: open",
"- Candidate head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Target branch: master",
"- Target branch SHA: 5e023dc71b0e2b813a0b1eee6b0f42630c47a95c",
"- Ancestor proof: candidate head is ancestor of target branch SHA",
"- Linked issue: #98",
"- Linked issue live status: not verified in this session",
"- Eligibility class: ALREADY_LANDED_RECONCILE_REQUIRED",
"- Capabilities proven: gitea.read, gitea.pr.comment",
"- Missing capabilities: gitea.pr.close",
"- PR comments posted: 1 reconciliation comment on PR #99",
"- Issue comments posted: none",
"- PRs closed: none",
"- Issues closed: none",
"- File edits by reconciler: none",
"- Git ref mutations: git fetch prgs master",
"- Worktree mutations: none",
"- MCP/Gitea mutations: PR comment posted",
"- External-state mutations: none",
"- Read-only diagnostics: gitea_view_pr, gitea_view_issue",
"- Reconciliation mutations: PR comment posted",
"- Current status: reconciliation complete",
"- Safe next action: none",
"- Blocker: missing gitea.pr.close capability",
"- Safe next action: hand off to a profile with gitea.pr.close",
"- No review/merge confirmation: confirmed",
]
text = "\n".join(lines)
kept = [
line
for line in lines
if not any(line.startswith(f"- {name}:") for name in drop)
]
text = "\n".join(kept)
for key, value in overrides.items():
if f"- {key}:" in text:
text = text.replace(f"- {key}:", f"- {key}: {value}")
@@ -190,11 +219,76 @@ class TestReviewPrRules(unittest.TestCase):
)
)
def test_already_landed_oldest_eligible_pr_blocks(self):
report = (
_review_handoff()
+ "\nAlready landed.\nOldest eligible PR: PR #278."
)
result = assess_final_report_validator(report, "review_pr")
self.assertTrue(result["blocked"])
self.assertTrue(
any(
f["rule_id"] == "reviewer.already_landed_eligible_wording"
for f in result["findings"]
)
)
def test_already_landed_gate_blocks_review_terminal_states(self):
cases = {
"APPROVED": ("- Review decision: request_changes", "- Review decision: APPROVED"),
"MERGED": ("- Merge result: not attempted", "- Merge result: MERGED"),
"READY_TO_MERGE": ("- Current status: PR open", "- Current status: READY_TO_MERGE"),
}
for state, (old, new) in cases.items():
with self.subTest(state=state):
report = (
_review_handoff()
.replace("- Reviewer eligibility: eligible", "- Reviewer eligibility: blocked")
.replace(old, new)
+ "\n- Already-landed gate: fired"
+ "\n- Ancestor proof: passed"
+ "\n- Eligibility class: ALREADY_LANDED_RECONCILE_REQUIRED"
)
result = assess_final_report_validator(report, "review_pr")
self.assertTrue(result["blocked"])
self.assertTrue(
any(
f["rule_id"] == "reviewer.already_landed_review_state"
for f in result["findings"]
)
)
def test_target_branch_up_to_date_requires_fetch_and_sha(self):
report = (
_review_handoff()
.replace("- Git ref mutations: git fetch prgs master", "- Git ref mutations: none")
+ "\nTarget branch up to date."
)
result = assess_final_report_validator(report, "review_pr")
self.assertTrue(result["blocked"])
self.assertTrue(
any(
f["rule_id"] == "reviewer.target_branch_freshness_proof"
for f in result["findings"]
)
)
def test_target_branch_up_to_date_with_fetch_and_sha_passes(self):
report = (
_review_handoff()
+ "\n- Target branch SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9"
+ "\nTarget branch up to date."
)
result = assess_final_report_validator(report, "review_pr")
self.assertEqual(result["grade"], "A")
def test_git_fetch_must_not_be_readonly_only(self):
report = (
_review_handoff()
.replace("- Git ref mutations: git fetch prgs master", "- Git ref mutations: none")
+ "\n- Read-only diagnostics: git fetch prgs master"
.replace(
"- Read-only diagnostics: issue and PR metadata inspected",
"- Read-only diagnostics: git fetch prgs master",
)
)
result = assess_final_report_validator(
report,
@@ -251,6 +345,159 @@ class TestReconciliationRules(unittest.TestCase):
)
class TestCanonicalReconcileSchema(unittest.TestCase):
"""Issue #307: reconciliation workflows emit only the canonical schema."""
def test_comment_only_reconciliation_passes(self):
result = assess_final_report_validator(
_reconcile_handoff(),
"reconcile_already_landed",
)
self.assertEqual(result["grade"], "A")
self.assertEqual(result["findings"], [])
def test_successful_close_reconciliation_passes(self):
report = _reconcile_handoff().replace(
"- Capabilities proven: gitea.read, gitea.pr.comment",
"- Capabilities proven: gitea.read, gitea.pr.comment, gitea.pr.close",
).replace(
"- Missing capabilities: gitea.pr.close",
"- Missing capabilities: none",
).replace(
"- PRs closed: none",
"- PRs closed: #99",
).replace(
"- Blocker: missing gitea.pr.close capability",
"- Blocker: none",
)
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertEqual(result["grade"], "A")
def test_blocked_reconciliation_passes(self):
report = _reconcile_handoff().replace(
"- Capabilities proven: gitea.read, gitea.pr.comment",
"- Capabilities proven: gitea.read",
).replace(
"- Missing capabilities: gitea.pr.close",
"- Missing capabilities: gitea.pr.close, gitea.pr.comment",
).replace(
"- PR comments posted: 1 reconciliation comment on PR #99",
"- PR comments posted: none",
).replace(
"- MCP/Gitea mutations: PR comment posted",
"- MCP/Gitea mutations: none",
).replace(
"- Reconciliation mutations: PR comment posted",
"- Reconciliation mutations: none",
)
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertEqual(result["grade"], "A")
def test_missing_capabilities_field_required(self):
report = _reconcile_handoff(drop=("Missing capabilities",))
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertEqual(result["grade"], "downgraded")
self.assertTrue(
any(
f["rule_id"] == "reconcile.controller_handoff"
and "Missing capabilities" in f["reason"]
for f in result["findings"]
)
)
def test_missing_ancestor_proof_and_candidate_sha_downgrade(self):
report = _reconcile_handoff(drop=("Ancestor proof", "Candidate head SHA"))
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertEqual(result["grade"], "downgraded")
reasons = " ".join(f["reason"] for f in result["findings"])
self.assertIn("Ancestor proof", reasons)
self.assertIn("Candidate head SHA", reasons)
def test_stale_issue_lock_proof_blocks(self):
report = _reconcile_handoff() + "\n- Issue lock proof: locked before diff"
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertTrue(result["blocked"])
self.assertTrue(
any(
f["rule_id"] == "reconcile.stale_author_reviewer_fields"
and f["field"] == "issue lock proof"
for f in result["findings"]
)
)
def test_stale_claim_comment_status_blocks(self):
report = _reconcile_handoff() + "\n- Claim/comment status: claimed"
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertTrue(result["blocked"])
self.assertTrue(
any(
f["rule_id"] == "reconcile.stale_author_reviewer_fields"
and f["field"] == "claim/comment status"
for f in result["findings"]
)
)
def test_stale_workspace_mutations_blocks_without_observed_mutations(self):
report = _reconcile_handoff() + "\n- Workspace mutations: None"
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertTrue(result["blocked"])
self.assertTrue(
any(
f["rule_id"] == "reconcile.stale_author_reviewer_fields"
and f["field"] == "workspace mutations"
for f in result["findings"]
)
)
def test_pr_number_opened_allowed_when_session_opened_pr(self):
report = _reconcile_handoff() + "\n- PR number opened: #12"
result = assess_final_report_validator(
report,
"reconcile_already_landed",
session_pr_opened=True,
)
self.assertFalse(
any(
f["rule_id"] == "reconcile.stale_author_reviewer_fields"
and f["field"] == "pr number opened"
for f in result["findings"]
)
)
def test_pr_number_opened_still_blocked_without_session_proof(self):
report = _reconcile_handoff() + "\n- PR number opened: #12"
result = assess_final_report_validator(
report,
"reconcile_already_landed",
session_pr_opened=False,
)
self.assertTrue(result["blocked"])
self.assertTrue(
any(
f["rule_id"] == "reconcile.stale_author_reviewer_fields"
and f["field"] == "pr number opened"
for f in result["findings"]
)
)
def test_action_log_fetch_requires_git_ref_mutation_entry(self):
report = _reconcile_handoff().replace(
"- Git ref mutations: git fetch prgs master",
"- Git ref mutations: none",
)
result = assess_final_report_validator(
report,
"reconcile_already_landed",
action_log=[{"command": "git fetch prgs master", "performed": True}],
)
self.assertTrue(
any(
f["rule_id"] == "reviewer.git_fetch_readonly"
for f in result["findings"]
)
)
class TestEntryPoint(unittest.TestCase):
def test_unknown_task_kind_blocks(self):
result = assess_final_report_validator("report", "unknown_mode")
@@ -270,4 +517,4 @@ class TestEntryPoint(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
unittest.main()
+116
View File
@@ -0,0 +1,116 @@
"""Tests for issue claim heartbeat leases (#268)."""
from __future__ import annotations
import sys
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import issue_claim_heartbeat as ich # noqa: E402
class TestHeartbeatFormatting(unittest.TestCase):
def test_format_and_parse_roundtrip(self):
body = ich.format_heartbeat_body(
kind="claim",
issue_number=268,
branch="feat/issue-268-heartbeat-leases",
phase="claimed",
profile="prgs-author",
next_action="implement module",
)
parsed = ich.parse_heartbeat_comment(body)
self.assertIsNotNone(parsed)
assert parsed is not None
self.assertEqual(parsed["issue_number"], 268)
self.assertEqual(parsed["branch"], "feat/issue-268-heartbeat-leases")
self.assertEqual(parsed["phase"], "claimed")
class TestClaimClassification(unittest.TestCase):
NOW = datetime(2026, 7, 7, 12, 0, tzinfo=timezone.utc)
def _comment(self, *, minutes_ago: int, kind: str = "progress") -> dict:
ts = (self.NOW - timedelta(minutes=minutes_ago)).isoformat()
body = ich.format_heartbeat_body(
kind=kind,
issue_number=268,
branch="feat/issue-268-heartbeat-leases",
phase="implementation",
profile="prgs-author",
)
return {"id": 1, "body": body, "created_at": ts, "updated_at": ts}
def test_active_claim_within_lease(self):
issue = {"number": 268, "title": "t", "labels": [{"name": "status:in-progress"}]}
result = ich.classify_issue_claim(
issue=issue,
comments=[self._comment(minutes_ago=5)],
now=self.NOW,
)
self.assertEqual(result["status"], "active")
def test_stale_claim_without_branch(self):
issue = {"number": 268, "title": "t", "labels": [{"name": "status:in-progress"}]}
result = ich.classify_issue_claim(
issue=issue,
comments=[self._comment(minutes_ago=45)],
now=self.NOW,
)
self.assertEqual(result["status"], "stale")
def test_reclaimable_after_sixty_minutes_without_branch(self):
issue = {"number": 268, "title": "t", "labels": [{"name": "status:in-progress"}]}
result = ich.classify_issue_claim(
issue=issue,
comments=[self._comment(minutes_ago=90)],
now=self.NOW,
)
self.assertEqual(result["status"], "reclaimable")
def test_awaiting_review_when_open_pr_exists(self):
issue = {"number": 268, "title": "t", "labels": [{"name": "status:in-progress"}]}
open_prs = [
{
"number": 400,
"title": "feat",
"body": "Closes #268",
"head": {"ref": "feat/issue-268-heartbeat-leases"},
}
]
result = ich.classify_issue_claim(
issue=issue,
comments=[self._comment(minutes_ago=120)],
open_prs=open_prs,
now=self.NOW,
)
self.assertEqual(result["status"], "awaiting_review")
def test_phantom_claim_without_heartbeat(self):
issue = {"number": 268, "title": "t", "labels": [{"name": "status:in-progress"}]}
result = ich.classify_issue_claim(
issue=issue,
comments=[{"id": 1, "body": "working on it"}],
now=self.NOW,
)
self.assertEqual(result["status"], "phantom")
class TestInventory(unittest.TestCase):
def test_build_cleanup_plan_flags_phantom(self):
inventory = {
"entries": [
{"issue_number": 1, "status": "phantom", "reasons": ["no heartbeat"]},
{"issue_number": 2, "status": "active", "reasons": []},
]
}
plan = ich.build_cleanup_plan(inventory)
self.assertEqual(len(plan), 1)
self.assertEqual(plan[0]["issue_number"], 1)
if __name__ == "__main__":
unittest.main()
+227
View File
@@ -0,0 +1,227 @@
"""Unit tests for own-branch lock adoption decision (#442 / #443)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from issue_lock_adoption import ( # noqa: E402
ADOPT,
BLOCK_COMPETING,
NO_MATCH,
assess_own_branch_adoption,
build_adoption_proof,
build_non_adoption_lock_proof,
)
REQ = "feat/issue-420-server-code-parity"
class TestAssessOwnBranchAdoption(unittest.TestCase):
def test_exact_own_branch_is_adopted(self):
result = assess_own_branch_adoption(
issue_number=420,
requested_branch=REQ,
existing_branches=[{"name": REQ, "commit_sha": "934688a"}],
)
self.assertEqual(result["outcome"], ADOPT)
self.assertTrue(result["adopt"])
self.assertFalse(result["block"])
self.assertEqual(result["matched_branch"], REQ)
self.assertEqual(result["matched_head_sha"], "934688a")
def test_exact_own_branch_adopted_when_sha_missing(self):
result = assess_own_branch_adoption(
issue_number=420, requested_branch=REQ, existing_branches=[REQ]
)
self.assertEqual(result["outcome"], ADOPT)
self.assertIsNone(result["matched_head_sha"])
def test_different_branch_same_issue_blocks(self):
result = assess_own_branch_adoption(
issue_number=420,
requested_branch=REQ,
existing_branches=[{"name": "feat/issue-420-other-work"}],
)
self.assertEqual(result["outcome"], BLOCK_COMPETING)
self.assertTrue(result["block"])
self.assertFalse(result["adopt"])
self.assertIn("feat/issue-420-other-work", result["competing_branches"])
self.assertIn("fail closed", result["reason"])
def test_own_branch_plus_competing_branch_blocks(self):
# Ambiguous ownership: fail closed even though the exact branch exists.
result = assess_own_branch_adoption(
issue_number=420,
requested_branch=REQ,
existing_branches=[{"name": REQ}, {"name": "feat/issue-420-rogue"}],
)
self.assertEqual(result["outcome"], BLOCK_COMPETING)
self.assertTrue(result["block"])
self.assertEqual(result["competing_branches"], ["feat/issue-420-rogue"])
def test_no_matching_branch_is_normal_path(self):
result = assess_own_branch_adoption(
issue_number=420,
requested_branch=REQ,
existing_branches=[{"name": "feat/issue-999-unrelated"}],
)
self.assertEqual(result["outcome"], NO_MATCH)
self.assertFalse(result["block"])
self.assertFalse(result["adopt"])
def test_empty_branch_list_is_normal_path(self):
result = assess_own_branch_adoption(
issue_number=420, requested_branch=REQ, existing_branches=[]
)
self.assertEqual(result["outcome"], NO_MATCH)
def test_higher_issue_number_branch_does_not_block_lower_issue_adoption(self):
# issue-420 must not be treated as competing work for issue #42.
own_branch = "feat/issue-42-widget"
result = assess_own_branch_adoption(
issue_number=42,
requested_branch=own_branch,
existing_branches=[
{"name": own_branch, "commit_sha": "abc1234"},
{"name": "feat/issue-420-server-code-parity"},
],
)
self.assertEqual(result["outcome"], ADOPT)
self.assertTrue(result["adopt"])
self.assertFalse(result["block"])
self.assertEqual(result["matched_branch"], own_branch)
def test_unrelated_higher_number_branch_is_ignored_without_own_branch(self):
result = assess_own_branch_adoption(
issue_number=42,
requested_branch="feat/issue-42-thing",
existing_branches=[{"name": "feat/issue-420-server-code-parity"}],
)
self.assertEqual(result["outcome"], NO_MATCH)
self.assertFalse(result["block"])
self.assertFalse(result["adopt"])
class TestBuildAdoptionProof(unittest.TestCase):
def test_proof_has_all_required_fields(self):
assessment = assess_own_branch_adoption(
issue_number=420,
requested_branch=REQ,
existing_branches=[{"name": REQ, "commit_sha": "934688a"}],
)
proof = build_adoption_proof(
issue_number=420,
branch_name=REQ,
assessment=assessment,
open_pr_checked=True,
competing_lock_checked=True,
lock_file_path="/tmp/example-lock.json",
lock_file_status="written",
)
for key in (
"issue_number",
"branch_name",
"branch_head_commit",
"adoption_reason",
"no_existing_pr_proof",
"no_competing_live_lock_proof",
"lock_file_path",
"lock_file_status",
):
self.assertIn(key, proof)
self.assertEqual(proof["branch_head_commit"], "934688a")
self.assertTrue(proof["no_existing_pr_proof"])
self.assertTrue(proof["no_competing_live_lock_proof"])
class TestExplicitAdoptionProofFields(unittest.TestCase):
"""#477: explicit, citable adoption-proof fields for all outcomes."""
def _proof(self, assessment, branch):
return build_adoption_proof(
issue_number=420,
branch_name=branch,
assessment=assessment,
open_pr_checked=True,
competing_lock_checked=True,
lock_file_path="/tmp/example-lock.json",
lock_file_status="written",
)
def test_adopt_proof_exposes_explicit_fields(self):
assessment = assess_own_branch_adoption(
issue_number=420,
requested_branch=REQ,
existing_branches=[{"name": REQ, "commit_sha": "934688a"}],
)
proof = self._proof(assessment, REQ)
self.assertEqual(proof["adoption_decision"], "ADOPT")
self.assertTrue(proof["adopted"])
self.assertEqual(proof["adopted_branch"], REQ)
self.assertEqual(proof["adopted_branch_head"], "934688a")
self.assertEqual(proof["competing_branch_check"]["result"], "clear")
self.assertEqual(proof["competing_branch_check"]["competing_branches"], [])
self.assertIn("gitea_create_pr", proof["safe_next_action"])
self.assertIn("exactly matches", proof["matcher_summary"])
def test_block_proof_reports_competing_and_does_not_claim_adoption(self):
assessment = assess_own_branch_adoption(
issue_number=420,
requested_branch=REQ,
existing_branches=[{"name": "feat/issue-420-rogue"}],
)
proof = self._proof(assessment, REQ)
self.assertEqual(proof["adoption_decision"], "BLOCK_COMPETING")
self.assertFalse(proof["adopted"])
self.assertIsNone(proof["adopted_branch"])
self.assertIsNone(proof["adopted_branch_head"])
self.assertEqual(proof["competing_branch_check"]["result"], "blocked")
self.assertIn(
"feat/issue-420-rogue",
proof["competing_branch_check"]["competing_branches"],
)
self.assertIn("fail closed", proof["safe_next_action"])
def test_no_match_proof_does_not_claim_adoption(self):
assessment = assess_own_branch_adoption(
issue_number=420,
requested_branch=REQ,
existing_branches=[{"name": "feat/issue-999-unrelated"}],
)
proof = self._proof(assessment, REQ)
self.assertEqual(proof["adoption_decision"], "NO_MATCH")
self.assertFalse(proof["adopted"])
self.assertIsNone(proof["adopted_branch"])
self.assertEqual(proof["competing_branch_check"]["result"], "clear")
def test_substring_collision_stays_boundary_safe(self):
# issue-42 must not adopt/claim against an issue-420 branch (#440/#477).
own = "feat/issue-42-widget"
assessment = assess_own_branch_adoption(
issue_number=42,
requested_branch=own,
existing_branches=[
{"name": own, "commit_sha": "abc1234"},
{"name": "feat/issue-420-server-code-parity"},
],
)
proof = self._proof(assessment, own)
self.assertEqual(proof["adoption_decision"], "ADOPT")
self.assertEqual(proof["adopted_branch"], own)
self.assertEqual(proof["competing_branch_check"]["competing_branches"], [])
def test_non_adoption_lock_proof_is_adoption_free(self):
proof = build_non_adoption_lock_proof(
issue_number=196, branch_name="feat/issue-196-mutations"
)
self.assertEqual(proof["adoption_decision"], "NO_MATCH")
self.assertFalse(proof["adopted"])
self.assertIsNone(proof["adopted_branch"])
self.assertIsNone(proof["adopted_branch_head"])
self.assertEqual(proof["competing_branch_check"]["result"], "clear")
self.assertIn("no adoption", proof["safe_next_action"].lower())
if __name__ == "__main__":
unittest.main()
+130
View File
@@ -0,0 +1,130 @@
"""Tests for issue-lock provenance and external-state disclosure (#447)."""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import issue_lock_provenance as ilp # noqa: E402
from final_report_validator import assess_final_report_validator # noqa: E402
def _sanctioned_lock(**overrides):
work_lease = {
"operation_type": "author_issue_work",
"issue_number": 447,
"branch": "feat/issue-447-lock-provenance",
"claimant": {"username": "jcwalker3", "profile": "prgs-author"},
"expires_at": "2999-01-01T00:00:00Z",
}
data = {
"issue_number": 447,
"branch_name": "feat/issue-447-lock-provenance",
"work_lease": work_lease,
"lock_provenance": ilp.build_sanctioned_lock_provenance(
tool="gitea_lock_issue",
claimant=work_lease["claimant"],
),
}
data.update(overrides)
return data
class TestLockProvenanceForCreatePr(unittest.TestCase):
def test_sanctioned_lock_passes(self):
result = ilp.assess_lock_file_for_create_pr(_sanctioned_lock())
self.assertTrue(result["proven"])
self.assertFalse(result["block"])
def test_manual_seed_without_provenance_blocked(self):
result = ilp.assess_lock_file_for_create_pr(
{"issue_number": 420, "branch_name": "feat/x", "work_lease": {}}
)
self.assertTrue(result["block"])
self.assertIn("lock_provenance", result["reasons"][0])
def test_operator_override_requires_reason(self):
result = ilp.assess_lock_file_for_create_pr(
_sanctioned_lock(
lock_provenance=ilp.build_sanctioned_lock_provenance(
tool="operator_override",
source=ilp.SOURCE_OPERATOR_OVERRIDE,
)
)
)
self.assertTrue(result["block"])
class TestExternalStateReportRules(unittest.TestCase):
def test_seed_with_external_none_blocked(self):
report = (
"Restored /tmp/gitea_issue_lock.json to unblock PR creation.\n"
"- External-state mutations: none\n"
)
result = ilp.assess_issue_lock_external_state_report(report)
self.assertTrue(result["block"])
def test_seed_with_disclosure_passes(self):
report = (
"Restored /tmp/gitea_issue_lock.json after MCP restart.\n"
"- External-state mutations: wrote /tmp/gitea_issue_lock.json\n"
)
result = ilp.assess_issue_lock_external_state_report(report)
self.assertTrue(result["proven"])
def test_remove_claimed_as_cleanup_only_blocked(self):
report = (
"rm /tmp/gitea_issue_lock.json after PR creation.\n"
"- Cleanup mutations: lock removed\n"
"- External-state mutations: none\n"
)
result = ilp.assess_issue_lock_external_state_report(report)
self.assertTrue(result["block"])
def test_manual_lock_pr_without_override_blocked(self):
report = (
"Programmatically seeded gitea_issue_lock.json then gitea_create_pr.\n"
"PR #444 created.\n"
)
result = ilp.assess_manual_lock_pr_without_override(report)
self.assertTrue(result["block"])
def test_author_reviewer_same_run_blocked(self):
report = (
"gitea_create_pr opened PR #444.\n"
"Submitted approve review on PR #444.\n"
)
result = ilp.assess_author_reviewer_same_run_report(report)
self.assertTrue(result["block"])
class TestFinalReportValidatorIntegration(unittest.TestCase):
def test_work_issue_blocks_hidden_lock_mutation(self):
report = (
"## Controller Handoff\n"
"- Task: work issue #420\n"
"- External-state mutations: none\n"
"Restored /tmp/gitea_issue_lock.json before PR creation.\n"
)
result = assess_final_report_validator(report, "work_issue")
rule_ids = {f["rule_id"] for f in result["findings"]}
self.assertIn("shared.issue_lock_external_state", rule_ids)
self.assertTrue(result["blocked"])
def test_review_pr_blocks_create_and_approve(self):
report = (
"## Controller Handoff\n"
"- Task: review PR #444\n"
"- Review decision: approve\n"
"Created PR #444 via gitea_create_pr earlier in this run.\n"
)
result = assess_final_report_validator(report, "review_pr")
rule_ids = {f["rule_id"] for f in result["findings"]}
self.assertIn("shared.author_reviewer_same_run", rule_ids)
if __name__ == "__main__":
unittest.main()
+280
View File
@@ -0,0 +1,280 @@
"""Unit tests for keyed issue-lock storage (#443) and flock hardening (#438)."""
import json
import os
import sys
import tempfile
import threading
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import issue_lock_store as ils # noqa: E402
def _lease(expires_at: str) -> dict:
return {
"operation_type": ils.AUTHOR_ISSUE_WORK_LEASE,
"expires_at": expires_at,
"created_at": "2026-01-01T00:00:00Z",
"last_heartbeat_at": "2026-01-01T00:00:00Z",
}
def _lock_record(**overrides) -> dict:
record = {
"issue_number": 420,
"branch_name": "feat/issue-420-server-code-parity",
"remote": "prgs",
"org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools",
"worktree_path": "/tmp/wt-420",
"work_lease": _lease("2999-01-01T00:00:00Z"),
}
record.update(overrides)
return record
class TestIssueLockStore(unittest.TestCase):
def setUp(self):
self._dir = tempfile.TemporaryDirectory()
self.lock_dir = self._dir.name
self._env = mock.patch.dict(os.environ, {"GITEA_ISSUE_LOCK_DIR": self.lock_dir})
self._env.start()
def tearDown(self):
self._env.stop()
self._dir.cleanup()
def test_concurrent_repo_locks_do_not_overwrite(self):
lock_a = _lock_record(
issue_number=108,
branch_name="feat/issue-108-root-menu",
repo="mcp-control-plane",
worktree_path="/tmp/wt-108",
)
lock_b = _lock_record(
issue_number=420,
branch_name="feat/issue-420-server-code-parity",
repo="Gitea-Tools",
worktree_path="/tmp/wt-420",
)
path_a = ils.bind_session_lock(lock_a)
with mock.patch("os.getpid", return_value=9999):
path_b = ils.bind_session_lock(lock_b)
self.assertNotEqual(path_a, path_b)
self.assertTrue(os.path.exists(path_a))
self.assertTrue(os.path.exists(path_b))
stored_a = ils.read_lock_file(path_a)
stored_b = ils.read_lock_file(path_b)
self.assertEqual(stored_a["issue_number"], 108)
self.assertEqual(stored_b["issue_number"], 420)
def test_concurrent_issue_locks_same_repo_do_not_overwrite(self):
lock_a = _lock_record(issue_number=427, branch_name="feat/issue-427-a")
lock_b = _lock_record(issue_number=428, branch_name="feat/issue-428-b")
path_a = ils.bind_session_lock(lock_a)
with mock.patch("os.getpid", return_value=4242):
path_b = ils.bind_session_lock(lock_b)
self.assertNotEqual(path_a, path_b)
self.assertEqual(ils.read_lock_file(path_a)["issue_number"], 427)
self.assertEqual(ils.read_lock_file(path_b)["issue_number"], 428)
def test_foreign_live_lease_blocks_overwrite(self):
existing = _lock_record(
branch_name="feat/issue-420-other",
worktree_path="/tmp/other",
work_lease=_lease("2999-01-01T00:00:00Z"),
)
path = ils.lock_file_path(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
issue_number=420,
)
ils.save_lock_file(path, existing)
incoming = _lock_record(worktree_path="/tmp/mine")
block = ils.assess_foreign_lock_overwrite(existing, incoming)
self.assertIn("live foreign issue lock", block or "")
def test_expired_lease_allows_takeover_with_conflict_check(self):
existing = _lock_record(
branch_name="feat/issue-420-other",
worktree_path="/tmp/other",
work_lease=_lease("2000-01-01T00:00:00Z"),
)
incoming = _lock_record(worktree_path="/tmp/mine")
self.assertIsNone(ils.assess_foreign_lock_overwrite(existing, incoming))
block = ils.assess_same_issue_lease_conflict(
existing,
issue_number=420,
branch_name="feat/issue-420-server-code-parity",
worktree_path="/tmp/mine",
)
self.assertIn("Recovery review is required", block or "")
def test_same_owner_lease_conflict_allows_refresh(self):
worktree = "/tmp/wt-420"
existing = _lock_record(worktree_path=worktree)
block = ils.assess_same_issue_lease_conflict(
existing,
issue_number=420,
branch_name="feat/issue-420-server-code-parity",
worktree_path=worktree,
)
self.assertIsNone(block)
def test_find_lock_for_branch_after_restart(self):
record = _lock_record()
path = ils.lock_file_path(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
issue_number=420,
)
ils.save_lock_file(path, record)
with mock.patch("os.getpid", return_value=5555):
self.assertIsNone(ils.read_session_issue_lock())
found = ils.find_lock_for_branch(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
branch_name="feat/issue-420-server-code-parity",
)
self.assertEqual(found["issue_number"], 420)
def test_has_active_issue_lock_scans_keyed_store(self):
ils.bind_session_lock(_lock_record())
self.assertTrue(
ils.has_active_issue_lock("feat/issue-420-server-code-parity")
)
self.assertFalse(ils.has_active_issue_lock("feat/issue-999-other"))
def test_approved_stacked_base_survives_round_trip(self):
# #484: the approved stacked base recorded on the lock must persist so
# gitea_create_pr can validate the non-master base at PR time.
record = _lock_record(
issue_number=482,
branch_name="feat/issue-482-skip-stale-request-changes-pr",
approved_stacked_base={
"branch": "feat/issue-478-mcp-menu-shell",
"pr_number": 479,
"verified_open": True,
},
)
path = ils.lock_file_path(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
issue_number=482,
)
ils.save_lock_file(path, record)
stored = ils.read_lock_file(path)
self.assertEqual(stored["approved_stacked_base"]["branch"], "feat/issue-478-mcp-menu-shell")
self.assertEqual(stored["approved_stacked_base"]["pr_number"], 479)
self.assertTrue(stored["approved_stacked_base"]["verified_open"])
def test_atomic_write_preserves_unrelated_lock(self):
path_a = ils.lock_file_path(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
issue_number=108,
)
ils.save_lock_file(path_a, _lock_record(issue_number=108, repo="mcp-control-plane"))
path_b = ils.lock_file_path(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
issue_number=420,
)
ils.save_lock_file(path_b, _lock_record())
self.assertTrue(os.path.exists(path_a))
self.assertTrue(os.path.exists(path_b))
self.assertEqual(ils.read_lock_file(path_a)["issue_number"], 108)
def test_concurrent_bind_same_issue_only_one_wins(self):
barrier = threading.Barrier(2)
results: list[str | Exception] = []
def worker():
barrier.wait()
try:
ils.bind_session_lock(
_lock_record(worktree_path=f"/tmp/wt-{threading.get_ident()}")
)
results.append("ok")
except Exception as exc: # noqa: BLE001
results.append(exc)
threads = [threading.Thread(target=worker) for _ in range(2)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
successes = [item for item in results if item == "ok"]
failures = [item for item in results if isinstance(item, Exception)]
self.assertEqual(len(successes), 1)
self.assertEqual(len(failures), 1)
failure_text = str(failures[0]).lower()
self.assertTrue(
"active" in failure_text or "lock contention" in failure_text,
failures[0],
)
def test_verify_lock_for_mutation_blocks_stale_lock(self):
record = _lock_record(
work_lease=_lease("2000-01-01T00:00:00Z"),
)
record["pid"] = 999999
record["session_pid"] = 999999
result = ils.verify_lock_for_mutation(
record,
issue_number=420,
branch_name="feat/issue-420-server-code-parity",
worktree_path="/tmp/wt-420",
)
self.assertTrue(result["block"])
self.assertIn("not live", result["reasons"][0])
def test_list_live_locks_excludes_stale_records(self):
live_path = ils.lock_file_path(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
issue_number=420,
)
ils.save_lock_file(
live_path,
_lock_record(worktree_path="/tmp/wt-420"),
)
stale_path = ils.lock_file_path(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
issue_number=440,
)
ils.save_lock_file(
stale_path,
_lock_record(
issue_number=440,
branch_name="feat/issue-440-recovery",
work_lease=_lease("2000-01-01T00:00:00Z"),
worktree_path="/tmp/wt-440",
),
)
live = ils.list_live_locks(lock_dir=self.lock_dir)
self.assertEqual([entry["issue_number"] for entry in live], [420])
if __name__ == "__main__":
unittest.main()
+53
View File
@@ -72,6 +72,59 @@ class TestIssueLockWorktreeAssessment(unittest.TestCase):
self.assertTrue(result["proven"])
class TestStackedBaseEquivalence(unittest.TestCase):
"""extra_bases (an approved stacked base) can anchor base-equivalence (#484)."""
def _git(self, *args):
import subprocess
subprocess.run(
["git", "-C", self.repo, *args],
check=True,
capture_output=True,
text=True,
)
def setUp(self):
import subprocess
import tempfile
self.tmp = tempfile.TemporaryDirectory()
self.repo = self.tmp.name
subprocess.run(["git", "init", "-q", self.repo], check=True, capture_output=True)
self._git("config", "user.email", "t@t")
self._git("config", "user.name", "t")
self._git("commit", "--allow-empty", "-q", "-m", "base")
# Rename the default branch away from master/main/dev so no *base* branch
# exists at HEAD — otherwise HEAD would be base-equivalent for free.
self._git("branch", "-m", "trunk")
# Create a non-master "dependency" branch at the same commit, then a
# feature branch off it — mirrors a stacked worktree.
self._git("branch", "feat/issue-100-dep")
self._git("checkout", "-q", "-b", "feat/issue-101-stacked")
def tearDown(self):
self.tmp.cleanup()
def test_stacked_base_not_equivalent_without_extra_bases(self):
state = issue_lock_worktree.read_worktree_git_state(self.repo)
# HEAD does not match master/main/dev, so base-equivalence is False.
self.assertFalse(state["base_equivalent"])
def test_stacked_base_equivalent_with_extra_bases(self):
state = issue_lock_worktree.read_worktree_git_state(
self.repo, extra_bases=("feat/issue-100-dep",)
)
self.assertTrue(state["base_equivalent"])
self.assertEqual(state["base_branch"], "feat/issue-100-dep")
def test_unrelated_extra_base_does_not_anchor(self):
state = issue_lock_worktree.read_worktree_git_state(
self.repo, extra_bases=("feat/does-not-exist",)
)
self.assertFalse(state["base_equivalent"])
class TestIssueLockWorktreeResolution(unittest.TestCase):
def test_explicit_path_wins(self):
resolved = issue_lock_worktree.resolve_author_worktree_path(
+272
View File
@@ -0,0 +1,272 @@
"""Tests for early duplicate-work detection (#400)."""
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import issue_lock_provenance
import issue_lock_store
import issue_work_duplicate_gate as dup_gate
import mcp_server
from issue_work_duplicate_gate import (
OUTCOME_DUPLICATE_BRANCH_PREVENTED,
OUTCOME_DUPLICATE_COMMIT_PREVENTED,
OUTCOME_DUPLICATE_PR_PREVENTED,
OUTCOME_DUPLICATE_WORK_NOT_PREVENTED,
PHASE_COMMIT,
PHASE_CREATE_PR,
PHASE_LOCK,
assess_work_issue_duplicate_gate,
assess_work_issue_duplicate_report,
)
class TestDuplicateGateAssessment(unittest.TestCase):
def test_clear_issue_passes(self):
result = assess_work_issue_duplicate_gate(
400,
open_prs=[],
branch_names=["feat/other-issue-99"],
claim_entry={"status": "not_claimed"},
locked_branch="feat/issue-400-duplicate-work-preflight",
phase=PHASE_LOCK,
)
self.assertFalse(result["block"])
self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_WORK_NOT_PREVENTED)
def test_open_pr_blocks(self):
prs = [{
"number": 397,
"title": "feat: handoff",
"body": "Closes #395",
"head": {"ref": "feat/issue-395-proof-backed-review-handoff"},
}]
result = assess_work_issue_duplicate_gate(
395,
open_prs=prs,
branch_names=[],
phase=PHASE_LOCK,
)
self.assertTrue(result["block"])
self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_PR_PREVENTED)
def test_conflicting_remote_branch_blocks(self):
result = assess_work_issue_duplicate_gate(
395,
open_prs=[],
branch_names=["feat/issue-395-proof-backed-handoff-claims"],
locked_branch="feat/issue-395-new-attempt",
phase=PHASE_LOCK,
)
self.assertTrue(result["block"])
self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_BRANCH_PREVENTED)
def test_active_claim_on_other_branch_blocks(self):
result = assess_work_issue_duplicate_gate(
398,
open_prs=[],
branch_names=[],
claim_entry={
"status": "active",
"latest_heartbeat": {
"branch": "feat/issue-398-validation-cwd-proof",
},
},
locked_branch="feat/issue-398-other-branch",
phase=PHASE_LOCK,
)
self.assertTrue(result["block"])
def test_commit_phase_maps_to_commit_outcome(self):
prs = [{
"number": 411,
"title": "x",
"body": "Closes #398",
"head": {"ref": "feat/issue-398-validation-cwd-proof"},
}]
result = assess_work_issue_duplicate_gate(
398,
open_prs=prs,
branch_names=[],
locked_branch="feat/issue-398-alt",
phase=PHASE_COMMIT,
)
self.assertTrue(result["block"])
self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_COMMIT_PREVENTED)
def test_stale_claim_does_not_block_by_status_alone(self):
result = assess_work_issue_duplicate_gate(
400,
open_prs=[],
branch_names=[],
claim_entry={"status": "reclaimable", "reasons": ["stale"]},
locked_branch="feat/issue-400-duplicate-work-preflight",
phase=PHASE_LOCK,
)
self.assertFalse(result["block"])
class TestDuplicateReportOutcome(unittest.TestCase):
def test_requires_exactly_one_outcome(self):
bad = assess_work_issue_duplicate_report("work finished")
self.assertFalse(bad["complete"])
good = assess_work_issue_duplicate_report(
"Duplicate work not prevented for issue #400."
)
self.assertTrue(good["complete"])
self.assertEqual(good["outcome"], OUTCOME_DUPLICATE_WORK_NOT_PREVENTED)
class TestInjectableDuplicateFetcher(unittest.TestCase):
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.get_auth_header", return_value="token x")
def test_lock_issue_uses_injected_fetcher(self, _auth, _api):
seen = {}
def fetcher(h, o, r, auth, issue_number):
seen["issue_number"] = issue_number
return [], [], {"status": "not_claimed"}
with patch(
"mcp_server.issue_duplicate_context_fetcher",
side_effect=fetcher,
), patch(
"mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value={
"current_branch": "master",
"porcelain_status": "",
"base_equivalent": True,
},
):
with tempfile.TemporaryDirectory() as lock_dir:
with patch.dict(os.environ, {
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.comment",
"GITEA_ISSUE_LOCK_DIR": lock_dir,
}, clear=True):
mcp_server.gitea_lock_issue(
issue_number=400,
branch_name="feat/issue-400-duplicate-work-preflight",
remote="prgs",
)
self.assertEqual(seen["issue_number"], 400)
class TestMcpDuplicateRecheck(unittest.TestCase):
def setUp(self):
self._dir = tempfile.TemporaryDirectory()
self._env_patch = patch.dict(
os.environ,
{"GITEA_ISSUE_LOCK_DIR": self._dir.name},
clear=False,
)
self._env_patch.start()
self._remotes = patch.dict(mcp_server.REMOTES, {
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
"repo": "Example-Repo"},
})
self._remotes.start()
mcp_server._IDENTITY_CACHE.clear()
def tearDown(self):
patch.stopall()
self._dir.cleanup()
def _write_lock(self, issue_number=400, branch="feat/issue-400-x"):
worktree_path = os.path.realpath(os.getcwd())
work_lease = {
"operation_type": "author_issue_work",
"issue_number": issue_number,
"branch": branch,
"claimant": {"username": "test-user", "profile": "test-author"},
"expires_at": "2999-01-01T00:00:00Z",
}
issue_lock_store.bind_session_lock({
"issue_number": issue_number,
"branch_name": branch,
"remote": "prgs",
"org": "Example-Org",
"repo": "Example-Repo",
"worktree_path": worktree_path,
"work_lease": work_lease,
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
tool="gitea_lock_issue",
claimant=work_lease.get("claimant"),
),
})
@patch("mcp_server._assess_issue_duplicate_gate")
@patch("mcp_server.get_profile", return_value={
"profile_name": "test-author",
"allowed_operations": ["gitea.read", "gitea.repo.commit"],
"forbidden_operations": [],
"audit_label": "test-author",
})
@patch("mcp_server.get_auth_header", return_value="token x")
def test_commit_files_blocked_on_recheck(self, _auth, _profile, mock_gate):
self._write_lock()
mock_gate.return_value = {
"block": True,
"reasons": ["open PR #412 already covers issue #400"],
"outcome": OUTCOME_DUPLICATE_COMMIT_PREVENTED,
"safe_next_action": "stop",
}
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="author")
with patch(
"mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []),
):
result = mcp_server.gitea_commit_files(
files=[{
"operation": "create",
"path": "a.txt",
"content_plain": "hi",
}],
message="test",
remote="prgs",
)
self.assertFalse(result["success"])
self.assertIn("duplicate_gate", result)
@patch("mcp_server._assess_issue_duplicate_gate")
@patch("mcp_server.get_profile", return_value={
"profile_name": "test-author",
"allowed_operations": ["gitea.read", "gitea.pr.create"],
"forbidden_operations": [],
"audit_label": "test-author",
})
@patch("mcp_server.get_auth_header", return_value="token x")
def test_create_pr_returns_handoff_on_duplicate(self, _auth, _profile, mock_gate):
self._write_lock()
mock_gate.return_value = {
"block": True,
"reasons": ["open PR #412 already covers issue #400"],
"outcome": OUTCOME_DUPLICATE_PR_PREVENTED,
"safe_next_action": "reconciliation handoff",
}
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="author")
with patch(
"mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []),
):
result = mcp_server.gitea_create_pr(
title="feat: x (Closes #400)",
head="feat/issue-400-x",
base="master",
body="Closes #400",
remote="prgs",
worktree_path=os.path.realpath(os.getcwd()),
)
self.assertFalse(result["success"])
self.assertIsNone(result.get("number"))
self.assertIn("duplicate_gate", result)
if __name__ == "__main__":
unittest.main()
+2
View File
@@ -172,6 +172,8 @@ class TestAllowedIssueWritesProceed(IssueWriteGateBase):
return [{"id": 10, "name": "status:in-progress"}]
if method == "POST" and "/issues/9/labels" in url:
return [{"name": "status:in-progress"}]
if method == "POST" and "/issues/9/comments" in url:
return {"id": 501}
if method == "GET" and url.endswith("/user"):
return {"login": "author-user"}
return {}
+9 -4
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,
@@ -122,15 +123,19 @@ class TestShaCannotBypassSelfReview(unittest.TestCase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_review_tool_refuses_self_approval_despite_sha(self, _auth, mock_api, mock_get_all):
mock_get_all.return_value = [{"number": 9, "title": "PR 9", "state": "open", "head": {"ref": "branch9", "sha": "abc1234"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "jcwalker3"}}]
from mcp_server import init_review_decision_lock
from tests.test_mcp_server import FULL_HEAD_SHA, _seed_ready_review_decision
head_sha = FULL_HEAD_SHA
mock_get_all.return_value = [{"number": 9, "title": "PR 9", "state": "open", "head": {"ref": "branch9", "sha": head_sha}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "jcwalker3"}}]
mock_api.side_effect = [
{"login": "jcwalker3"}, # /user (inventory)
{"login": "jcwalker3"}, # /user (submit eligibility)
{"user": {"login": "jcwalker3"}, "state": "open", "head": {"sha": "abc1234"}, "mergeable": True}, # /pulls/9
{"user": {"login": "jcwalker3"}, "state": "open", "head": {"sha": head_sha}, "mergeable": True}, # /pulls/9
]
from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
init_review_decision_lock("prgs", "review_pr")
gitea_mark_final_review_decision(9, "approve", remote="prgs")
with patch("mcp_server._list_pr_lease_comments", return_value=[]):
_seed_ready_review_decision(9, "approve", sha=head_sha, remote="prgs")
env = self._env(SHA_WOULD_BE_REVIEWER, "reviewer")
with patch.dict(os.environ, env, clear=True):
r = gitea_review_pr(
+170
View File
@@ -16,6 +16,7 @@ def test_all_workflow_files_exist():
"reconcile-landed-pr.md",
"create-issue.md",
"work-issue.md",
"pr-queue-cleanup.md",
):
assert (SKILL_DIR / "workflows" / name).is_file(), name
@@ -26,6 +27,7 @@ def test_skill_references_all_workflow_files():
"workflows/reconcile-landed-pr.md",
"workflows/create-issue.md",
"workflows/work-issue.md",
"workflows/pr-queue-cleanup.md",
):
assert name in SKILL
@@ -36,6 +38,7 @@ def test_skill_contains_mode_isolation_language():
assert "reconcile-landed-pr" in SKILL
assert "create-issue" in SKILL
assert "work-issue" in SKILL
assert "pr-queue-cleanup" in SKILL
assert "Do not mix modes" in SKILL or "do not mix modes" in SKILL.lower()
@@ -78,6 +81,20 @@ def test_reconcile_landed_workflow_contract():
assert "canonical: true" in text
assert "Do not review or merge normal PRs" in text
assert "Already-landed proof" in text
# Issue #302: partial reconciliation policy must stay documented.
assert "## 12A. Partial reconciliation policy (#302)" in text
assert "comment-then-stop" in text
assert "PARTIAL_RECONCILE_COMMENT_THEN_STOP" in text
assert "RECOVERY_HANDOFF_ONLY" in text
assert "resolve_partial_reconciliation_plan" in text
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():
@@ -91,6 +108,19 @@ def test_work_issue_workflow_contract():
assert "canonical: true" in text
assert "Do not merge your own PR" in text
assert "## 21. PR creation rules" in text
assert "gitea_route_task_session" in text
assert "work-issue" in text
def test_pr_queue_cleanup_workflow_contract():
text = (SKILL_DIR / "workflows" / "pr-queue-cleanup.md").read_text(
encoding="utf-8"
)
assert "canonical: true" in text
assert "exactly one" in text
assert "check_cleanup_task_allowed" in text
assert "resolve_cleanup_run_state" in text
assert "Next suggested PR" in text
def test_final_report_schemas_exist():
@@ -99,6 +129,7 @@ def test_final_report_schemas_exist():
"reconcile-landed-final-report.md",
"create-issue-final-report.md",
"work-issue-final-report.md",
"pr-queue-cleanup-final-report.md",
):
assert (SKILL_DIR / "schemas" / name).is_file(), name
@@ -118,6 +149,30 @@ def test_start_issue_template_references_work_issue_workflow():
assert "workflows/work-issue.md" in text
def test_reconcile_skill_registered_in_mcp_server():
from mcp_server import _PROJECT_SKILLS
assert "gitea-reconcile-landed-pr" in _PROJECT_SKILLS
steps = _PROJECT_SKILLS["gitea-reconcile-landed-pr"]["steps"]
joined = " ".join(steps).lower()
assert "gitea_scan_already_landed_open_prs" in joined
assert "gitea_assess_already_landed_reconciliation" in joined
def test_pr_queue_cleanup_template_references_workflow():
text = (SKILL_DIR / "templates" / "pr-queue-cleanup.md").read_text(
encoding="utf-8"
)
assert "workflows/pr-queue-cleanup.md" in text
assert "pr_queue_cleanup" in text
def test_pr_queue_cleanup_verifier_exported():
from review_proofs import assess_pr_queue_cleanup_report
assert callable(assess_pr_queue_cleanup_report)
def test_reviewer_fallback_verifier_exported():
from review_proofs import assess_reviewer_fallback_report
@@ -130,12 +185,48 @@ def test_final_report_validator_exported():
assert callable(assess_final_report_validator)
def test_review_final_report_schema_verifier_exported():
from review_proofs import assess_review_final_report_schema
assert callable(assess_review_final_report_schema)
def test_create_issue_final_report_verifier_exported():
from review_proofs import assess_create_issue_final_report
assert callable(assess_create_issue_final_report)
def test_work_issue_final_report_verifier_exported():
from review_proofs import assess_work_issue_final_report
assert callable(assess_work_issue_final_report)
def test_merge_simulation_verifier_exported():
from review_proofs import assess_merge_simulation_report
assert callable(assess_merge_simulation_report)
def test_validation_integrity_verifier_exported():
from review_proofs import assess_validation_integrity_report
assert callable(assess_validation_integrity_report)
def test_validation_failure_history_verifier_exported():
from review_proofs import assess_validation_failure_history_report
assert callable(assess_validation_failure_history_report)
def test_validation_cwd_proof_verifier_exported():
from review_proofs import assess_validation_cwd_proof_report
assert callable(assess_validation_cwd_proof_report)
def test_prior_blocker_skip_verifier_exported():
from review_proofs import assess_prior_blocker_skip_proof
@@ -146,3 +237,82 @@ def test_non_mergeable_skip_verifier_exported():
from review_proofs import assess_non_mergeable_skip_proof
assert callable(assess_non_mergeable_skip_proof)
def test_validation_worktree_edit_verifier_exported():
from review_proofs import assess_validation_worktree_edit_report
assert callable(assess_validation_worktree_edit_report)
def test_reconcile_inventory_verifier_exported():
from review_proofs import assess_reconcile_inventory_report
assert callable(assess_reconcile_inventory_report)
def test_reconcile_linked_issue_verifier_exported():
from review_proofs import assess_reconcile_linked_issue_report
assert callable(assess_reconcile_linked_issue_report)
def test_already_landed_handoff_verifier_exported():
from review_proofs import assess_already_landed_handoff_report
assert callable(assess_already_landed_handoff_report)
def test_inventory_worktree_verifier_exported():
from review_proofs import assess_inventory_worktree_report
assert callable(assess_inventory_worktree_report)
def test_proof_backed_handoff_verifier_exported():
from review_proofs import assess_proof_backed_handoff_report
assert callable(assess_proof_backed_handoff_report)
def test_infra_stop_handoff_verifier_exported():
from review_proofs import assess_infra_stop_handoff_report
assert callable(assess_infra_stop_handoff_report)
def test_mutation_categories_verifier_exported():
from review_proofs import assess_mutation_categories_report
assert callable(assess_mutation_categories_report)
def test_worktree_ownership_verifier_exported():
from review_proofs import assess_worktree_ownership_report
assert callable(assess_worktree_ownership_report)
def test_already_landed_classification_verifier_exported():
from review_proofs import assess_already_landed_classification_report
assert callable(assess_already_landed_classification_report)
def test_pr_queue_cleanup_verifier_exported():
from review_proofs import assess_pr_queue_cleanup_report
assert callable(assess_pr_queue_cleanup_report)
def test_pr_queue_cleanup_workflow_contract():
text = (SKILL_DIR / "workflows" / "pr-queue-cleanup.md").read_text(encoding="utf-8")
assert "canonical: true" in text
assert "task_mode: pr-queue-cleanup" in text
assert "## 1. Mode isolation" in text
assert "## 4. Terminal mutation chain" in text
assert "## 5. Fresh run per PR" in text
assert "exactly one" in text.lower()
assert "review-merge-pr.md" in text
assert "Next suggested PR" in text
assert "schemas/pr-queue-cleanup-final-report.md" in text
+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)
+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()
+863 -152
View File
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
"""Hermetic tests for merge approval head pinning (#471)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from merge_approval_gate import assess_merge_approval_head # noqa: E402
HEAD_OLD = "8b61c4b41f1b49b271ed3b99657431cf06eeda3e"
HEAD_NEW = "3e4b721d60e97147ba0704773cf57cd0d42cbe31"
class TestMergeApprovalGate(unittest.TestCase):
def test_fresh_approval_at_current_head(self):
result = assess_merge_approval_head(
current_head_sha=HEAD_NEW,
latest_by_reviewer={
"reviewer1": {
"verdict": "APPROVED",
"dismissed": False,
"reviewed_head_sha": HEAD_NEW,
"submitted_at": "2026-07-06T12:00:00Z",
}
},
)
self.assertTrue(result["approval_at_current_head"])
self.assertIsNone(result["stale_approval_block_reason"])
def test_stale_approval_after_rebase(self):
result = assess_merge_approval_head(
current_head_sha=HEAD_NEW,
latest_by_reviewer={
"reviewer1": {
"verdict": "APPROVED",
"dismissed": False,
"reviewed_head_sha": HEAD_OLD,
"submitted_at": "2026-07-06T10:00:00Z",
}
},
)
self.assertFalse(result["approval_at_current_head"])
self.assertEqual(result["latest_approved_head_sha"], HEAD_OLD)
self.assertIn("stale approval", result["stale_approval_block_reason"])
self.assertIn(HEAD_OLD, result["stale_approval_block_reason"])
self.assertIn(HEAD_NEW, result["stale_approval_block_reason"])
self.assertIn("re-review PR at current head", result["stale_approval_block_reason"])
def test_no_approval_entries(self):
result = assess_merge_approval_head(
current_head_sha=HEAD_NEW,
latest_by_reviewer={},
)
self.assertFalse(result["approval_at_current_head"])
self.assertIsNone(result["latest_approved_head_sha"])
if __name__ == "__main__":
unittest.main()
+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()

Some files were not shown because too many files have changed in this diff Show More