Compare commits

...
Author SHA1 Message Date
sysadminandClaude Opus 4.8 6ca879d298 feat: gate author work on early duplicate-work detection (Closes #400)
Add author_duplicate_work_gate and enforce it at claim, lock, and PR
creation. Expose gitea_assess_author_duplicate_work for pre-commit/push
checks and extend work-issue final-report verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 11:35:52 -04: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
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
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 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 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
sysadmin 5e023dcc97 Merge pull request 'feat: verify linked-issue consistency for the selected PR (Closes #314)' (#360) from feat/issue-314-linked-issue-consistency into master 2026-07-07 04:45:10 -05: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
sysadmin f87101ccaa Merge pull request 'feat: require live blocker proof before skipping earlier PRs (Closes #318)' (#355) from feat/issue-318-prior-blocker-skip-proof into master 2026-07-07 04:41:56 -05: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 fa0cb12464 Merge pull request 'feat: block reviewer local Gitea fallbacks during normal review (Closes #324)' (#350) from feat/issue-324-reviewer-no-fallback into master 2026-07-07 04:37:12 -05: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 f1c7054871 fix: resolve conflicts for PR #360 2026-07-07 05:35:13 -04:00
sysadmin 4204503b41 fix: resolve conflicts for PR #355 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 f2d82a93ed fix: resolve conflicts for PR #350 2026-07-07 05:33:47 -04:00
sysadmin e1dabd1b0f Merge pull request 'feat: reject legacy Workspace mutations field in reviewer handoffs (Closes #320)' (#357) from feat/issue-320-remove-workspace-mutations into master 2026-07-07 04:33:31 -05: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
sysadmin 4d3e90e7c5 Merge pull request 'feat: enforce queue ordering proof in reviewer reports (Closes #321)' (#351) from feat/issue-321-queue-ordering-proof into master 2026-07-07 04:31:14 -05:00
sysadmin 634d8a17aa Merge pull request 'feat: composable final-report validator for reviewer and reconciliation handoffs (Closes #327)' (#348) from feat/issue-327-final-report-validator-rules into master 2026-07-07 04:29:04 -05: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
sysadmin bc227a9a6d feat: block reviewer local Gitea fallbacks during normal review (Closes #324) 2026-07-07 05:27:24 -04:00
sysadmin 2cf0ad5908 feat: add composable final-report validator for reviewer and reconciliation handoffs (Closes #327) 2026-07-07 05:27:24 -04:00
sysadmin 077cedc0c9 feat: enforce queue ordering proof in reviewer reports (Closes #321) 2026-07-07 05:27:18 -04:00
sysadmin c83c18e52a Merge pull request 'feat: enforce proof wording for pagination and live claims (Closes #330)' (#346) from feat/issue-330-proof-wording-enforcement into master 2026-07-07 04:26:44 -05: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
sysadmin 1666e55e60 Merge pull request 'feat: reject workspace-none claims after worktree mutations (Closes #313)' (#354) from feat/issue-313-worktree-mutation-consistency into master 2026-07-07 04:23:54 -05:00
sysadminandClaude Opus 4.8 1e6bd34d6f feat: verify linked-issue consistency for the selected PR (Closes #314)
Add assess_linked_issue_consistency to review_proofs: reviewer final
reports must report a "Linked issue status" that matches the issue(s)
the selected PR was live-verified to link this session. Without live
proof the field may only say "not verified in this session"; with
proof, every linked issue must be named, and any other "Issue #N"
mention anywhere in the report fails as stale leakage from a previous
PR (the PR #280 / Issue #260-vs-#275 incident). Missing field fails
closed. Tests cover correct linked issue, stale previous issue in both
the status field and diagnostics, missing proof, unverified wording,
multiple linked issues, and PR-number mentions not being mistaken for
issue mentions.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:22:08 -04:00
sysadmin e9fdb356dd fix: resolve conflicts for issue 330 (master refresh) 2026-07-07 05:21:43 -04:00
sysadmin 897d256f98 Merge pull request 'feat: add queue-status report verifier for reviewer handoffs (Closes #339)' (#347) from feat/issue-339-queue-status-report-verifier into master 2026-07-07 04:20:09 -05: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
sysadmin 240e7adf46 fix: resolve conflicts for issue 339 2026-07-07 05:18:29 -04:00
sysadmin deb228d629 Merge pull request 'feat: require conflict proof for skipped non-mergeable PRs (Closes #322)' (#353) from feat/issue-322-mergeability-skip-proof into master 2026-07-07 04:17:09 -05:00
sysadminandClaude Opus 4.8 94b022b24d fix: resolve conflicts for issue 330
Merge prgs/master into feat/issue-330-proof-wording-enforcement and keep
both assess_proof_wording (#330) and git ref mutation reporting (#297).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:16:57 -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
sysadmin 6bf0210128 Merge pull request 'feat: enforce branches-only baseline validation in reviewer reports (Closes #325)' (#349) from feat/issue-325-baseline-worktree-validation into master 2026-07-07 04:13:22 -05:00
sysadminandClaude Opus 4.8 56661cd0a7 feat: require live blocker proof before skipping earlier PRs (Closes #318)
Add assess_prior_blocker_skip_proof to validate reviewer reports document
live REQUEST_CHANGES blocker proof for skipped earlier open PRs, reject
stale-memory skips when head changed after blocker, and require
BLOCKER_STATUS_UNVERIFIED when review feedback cannot be fetched.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:12:01 -04:00
sysadminandClaude Opus 4.8 c2bba27b86 feat: reject legacy Workspace mutations field in reviewer handoffs (Closes #320)
Reviewer controller handoffs must now report the precise mutation
categories (File edits by reviewer, Worktree/index mutations, Git ref
mutations, MCP/Gitea mutations, Review mutations, Merge mutations,
Cleanup mutations, External-state mutations, Read-only diagnostics)
instead of the ambiguous legacy "Workspace mutations" field.

- assess_controller_handoff(role="review") no longer requires
  "Workspace mutations" and fails closed when any legacy
  "Workspace mutations:" line is present, regardless of value.
- New HANDOFF_REVIEW_MUTATION_FIELDS makes the nine precise categories
  required fields for review-role handoffs, matching the canonical
  schemas/review-merge-final-report.md field set.
- Tests cover approval, request-changes, merge, worktree cleanup,
  fetch-only, and blocked handoffs, plus rejection of
  "Workspace mutations: none" and non-none values.
- Author/create-issue/inventory roles keep their existing contracts.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:11:05 -04:00
sysadminandClaude Opus 4.8 9fbe556466 feat: reject workspace-none claims after worktree mutations (Closes #313)
Add assess_workspace_mutation_consistency to review_proofs: final
reports fail validation when they claim "Workspace mutations: none"
while worktree mutations (reset --hard, checkout, clean, worktree
add/remove, stash, merge, rebase, ...) occurred — either observed in
the workflow command log or self-reported in the report's own
"Worktree mutations" field. Observed worktree and git ref mutations
must be reported under their precise category fields; reports pass
when they say "File edits by reviewer: none" alongside a non-none
"Worktree mutations" entry. Tests cover reset, checkout, worktree add,
clean, fetch-only, and no-op cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:09:44 -04:00
sysadmin 407483f6e0 Merge pull request 'feat: enforce session hard-stop after terminal review mutation (Closes #332)' (#345) from feat/issue-332-terminal-review-hard-stop into master 2026-07-07 04:09:19 -05:00
sysadminandClaude Opus 4.8 cf1cedf5e3 feat: require conflict proof for skipped non-mergeable PRs (Closes #322)
Add assess_non_mergeable_skip_proof to validate reviewer reports document
head SHA, mergeability result, conflict proof command, conflicting files,
and head-change status for each skipped non-mergeable PR, or classify
MERGEABILITY_UNVERIFIED when proof cannot be retrieved.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:08:53 -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
sysadmin d7d2f92286 Merge pull request 'feat(reports): classify git fetch as a git ref mutation (closes #297)' (#344) from feat/issue-297-fetch-ref-mutation into master 2026-07-07 04:07:11 -05:00
sysadmin cbae9a3ecd Merge pull request 'docs: pin GlitchTip filing orchestrator contract (Closes #153)' (#343) from feat/issue-153-glitchtip-filing-orchestrator into master 2026-07-07 04:05:22 -05:00
sysadmin f92d1a29c2 Merge pull request 'Add pagination metadata to gitea_list_prs' (#342) from feat/issue-340-list-prs-pagination-metadata into master 2026-07-07 04:03:30 -05:00
sysadminandClaude Opus 4.8 a47083b9b8 feat: enforce branches-only baseline validation in reviewer reports (Closes #325)
Add assess_reviewer_baseline_validation_proof to block test-suite runs in
the main checkout and require complete baseline worktree proof when reports
claim pre-existing master failures.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 05:02:07 -04:00
sysadmin 0ae39c231d Merge pull request 'feat: add create-issue final-report verifier (Closes #337)' (#341) from feat/issue-337-create-issue-verifier into master 2026-07-07 03:59:00 -05:00
sysadmin 6c653ce32a feat: add queue-status report verifier for reviewer handoffs (#339)
Add assess_queue_status_report and assess_proof_wording to reject
contradictory queue-status-only reports: passed gates without a selected
PR, prior diagnostic conflict proof, pagination assumptions, and
blocker contradictions. Wire into build_final_report and document the
queue-status-only schema path.

Closes #339
2026-07-07 04:57:50 -04:00
sysadmin 5e145957a5 Merge pull request 'feat: extract review-merge-pr workflow from monolithic LLM skill' (#335) from feat/issue-334-review-merge-workflow-extract into master 2026-07-07 03:57:18 -05:00
sysadmin 46703eac3f feat: enforce proof wording for pagination and live claims (#330)
Add assess_proof_wording to reject unsupported phrases such as
inventory complete, live proof, same as master, and file edits none
unless matching evidence is present. Wire into build_final_report and
add doc-contract tests.

Closes #330
2026-07-07 04:46:48 -04:00
sysadmin 6e024db9eb Merge remote-tracking branch 'prgs/master' into feat/issue-337-create-issue-verifier 2026-07-07 04:44:03 -04:00
sysadmin fd6f7ff22c Merge pull request 'docs: add subagent tool-budget guardrails for MCP tasks (Closes #259)' (#284) from docs/issue-259-subagent-tool-budget into master 2026-07-07 03:43:47 -05:00
sysadmin a06af6f0e3 Merge pull request 'feat: merged PR cleanup reconciliation report (Closes #269)' (#282) from feat/issue-269-merged-pr-cleanup-reconcile into master 2026-07-07 03:39:48 -05:00
sysadmin 72c20ee1e1 Merge remote-tracking branch 'prgs/master' into feat/issue-334-review-merge-workflow-extract 2026-07-07 04:38:09 -04:00
sysadmin eb6f006a31 Merge pull request 'fix: recompute infra_stop live on capability resolve (Closes #285)' (#286) from feat/issue-285-live-infra-stop-recompute into master 2026-07-07 03:37:22 -05:00
sysadminandClaude Opus 4.8 3a490521e4 Merge prgs/master into feat/issue-269-merged-pr-cleanup-reconcile
Resolve task_capability_map.py conflict by keeping all three capability
entries: commit_files and gitea_commit_files (gitea.repo.commit, #262,
from master) and reconcile_merged_cleanups (gitea.read, #269, this
branch). Capability boundaries unchanged — reconciliation reporting stays
read-only; no close/merge capability broadened.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 04:37:11 -04:00
sysadmin 3c50db61eb merge: integrate remote branch history 2026-07-07 04:35:59 -04:00
sysadmin edf26410a0 merge: resolve master conflicts for #259 subagent tool-budget docs
Integrate split-workflow router SKILL.md (#333) with subagent tool-budget
guardrails (#259). Keep shell spawn hard-stop (#258) and subagent
delegation (#266) operator-guide rules alongside subagent_tool_budget.
2026-07-07 04:35:46 -04:00
sysadmin e1fbf0112a test: mock assess_infra_stop in route_task_session merge blocker test
route_task_session now calls assess_infra_stop() live instead of
check_mid_merge(). Update TestMCPHealth.test_route_task_session_blocks_during_merge
to mock the infra-stop assessment path and assert infra_stop_assessment
is returned when blocked.

Part of #285 / PR #286 review fix.
2026-07-07 04:35:34 -04:00
sysadminandClaude Opus 4.8 456e46d3fc feat: enforce session hard-stop after terminal review mutation (#332)
Extends the single-terminal-decision machinery (#211) to session-level
hard-stop semantics:

- terminal_review_hard_stop_reasons: after a live terminal verdict, only
  the merge sequence for the same approved PR may continue; REQUEST_CHANGES
  stops the run; operator-approved corrections re-open the review path
  only, never a cross-PR merge.
- gitea_merge_pr: new Gate 2b consults the hard-stop before any API call,
  so a run can never verdict one PR and merge another.
- gitea_mark_final_review_decision: hard-stop check with explicit stop
  guidance naming the consumed terminal mutation; plus duplicate
  REQUEST_CHANGES suppression — an unresolved request-changes at the
  current head SHA refuses a second request-changes (fail closed when
  feedback cannot be verified).
- tests/test_terminal_review_hard_stop.py: 13 tests covering the
  acceptance criteria; existing TestSubmitPrReview setups stub the new
  feedback fetch.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 04:32:15 -04:00
sysadmin 25c9d20870 merge: resolve PR #335 conflicts with master (fd396df)
Keep master router-style SKILL.md (full #333 split) and merge #334
review-merge test coverage into test_llm_workflow_split.py.

Conflicts resolved in:
- skills/llm-project-workflow/SKILL.md
- tests/test_llm_workflow_split.py
2026-07-07 04:31:42 -04:00
sysadmin 873bcd06e5 docs: pin GlitchTip filing orchestrator contract (Closes #153) 2026-07-07 04:27:02 -04:00
sysadminandClaude Opus 4.8 c41a698a08 feat(reports): classify git fetch as a git ref mutation (closes #297)
Reviewer reports treated 'git fetch prgs master' as read-only
diagnostics while claiming 'Git/worktree mutations: None'. Fetch edits
no files but updates remote-tracking refs, so hiding it under
diagnostics understates what the run mutated.

Add git_ref_mutating_commands() classifying fetch, pull, push, merge,
update-ref, remote update, branch -d/-D, and reset --hard as git ref
mutations, and assess_git_ref_mutation_report() which fails a report
when ref-updating commands ran but the report lacks a non-none 'Git ref
mutations' entry, omits 'fetched <remote>/<branch>' for a targeted
fetch, still claims 'Git/worktree mutations: None', or lists the
command under read-only diagnostics.

Tests cover fetch-only review, missing ledger entry, missing fetch
target, contradictory none-claim, read-only misclassification, worktree
creation (not a ref mutation), merge, cleanup branch deletion,
no-mutation blocked handoff, and dict command-log entries.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 04:26:58 -04:00
sysadmin 5c4153ad57 Merge branch 'master' into feat/issue-285-live-infra-stop-recompute 2026-07-07 03:25:51 -05:00
sysadmin 8929286276 feat: add pagination metadata to gitea_list_prs (#340)
Return wrapped {prs, pagination} from gitea_list_prs with has_more,
inventory_complete, and page traversal via api_fetch_page. Route
gitea_review_pr inventory through gitea_list_prs so trust gates can
prove pagination finality. Add assess_list_prs_pagination_proof verifier.

Closes #340
2026-07-07 04:25:02 -04:00
sysadmin 14f5c865b3 feat: add create-issue final-report verifier (#337)
Extend review_proofs with assess_create_issue_final_report to enforce
workflow-source proof, create-issue handoff schema fields, and rejection
of work-issue/review-merge cross-mode handoff fields.

Closes #337
2026-07-07 04:23:45 -04:00
sysadmin fd396df334 Merge pull request 'Add final-report mutation ledger verifier (#331)' (#338) from feat/issue-331-mutation-ledger-verifier into master 2026-07-07 03:10:30 -05:00
sysadminandClaude Opus 4.8 ae4dd0ff91 Add final-report mutation ledger verifier (#331)
Introduce assess_mutation_ledger_report so reviewer final reports cannot
claim no file edits when the action log records performed mutations.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 04:08:27 -04:00
sysadmin c73e68bd75 Merge pull request 'feat(reports): add no-email identity summary and disclosure validator (closes #305)' (#329) from feat/issue-305-report-email-redaction into master 2026-07-07 03:03:12 -05:00
sysadmin a73d5b351e Merge pull request 'Add REQUEST_CHANGES override proof helper before approval (#326)' (#328) from feat/issue-326-request-changes-override-proof into master 2026-07-07 02:59:33 -05:00
sysadmin 7fdf803a50 Merge pull request 'feat: add fail-closed subagent delegation gates (Closes #266)' (#291) from feat/issue-266-subagent-gate-inheritance into master 2026-07-07 02:56:00 -05:00
sysadmin f7621b155e Merge pull request 'docs: split LLM project workflow into task-specific modes' (#336) from feat/issue-333-work-issue-workflow-extract into master 2026-07-07 02:47:03 -05:00
sysadmin 6200e6bbec docs: split LLM project workflow into task-specific modes (Closes #333)
Consolidate all four canonical workflow files and router SKILL.md:
- review-merge-pr.md (from #334 canonical prompt)
- reconcile-landed-pr.md (reconciliation gates + handoff schema)
- create-issue.md (canonical issue-creation prompt)
- work-issue.md (canonical author/coder prompt)

SKILL.md is now a short router with mode isolation and universal rules.
Adds final-report schemas for all modes and 13 doc-contract tests.

Supersedes partial split work in PR #335; full #333 acceptance in one PR.
2026-07-07 03:44:21 -04:00
sysadmin 8d7203d701 feat: extract canonical work-issue workflow from monolithic skill (Closes #333)
Add workflows/work-issue.md with full author/coder gates (§0–§30),
work-issue final-report schema, task-mode router entry, start-issue template
link, and doc-contract tests.

Part of #333 workflow split (#334 review-merge extraction remains in PR #335).
2026-07-07 03:40:13 -04:00
sysadminandClaude Opus 4.8 c05b2f4c05 docs(skill): load canonical review-merge prompt into workflow file
Replace summary workflow with full §0–§38 canonical PR review/merge rules.
Align review-merge-final-report schema with §37 controller handoff fields.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 03:37:42 -04:00
sysadminandClaude Opus 4.8 3976f886e4 feat(skill): extract review-merge-pr workflow from monolithic SKILL.md
Closes #334

- Add workflows/review-merge-pr.md with reviewer-only gates and steps
- Add schemas/review-merge-final-report.md with mutation ledger fields
- Turn SKILL.md into task-mode router; replace inlined §F/§G with links
- Point review/merge templates at extracted workflow
- Add doc-contract tests for split structure

Part of #333.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 03:32:59 -04:00
sysadmin e4d78a5013 Merge pull request 'test: make sibling-worktree skip test hermetic (closes #283)' (#288) from fix/issue-283-hermetic-walk-root-test into master 2026-07-07 02:08:38 -05:00
sysadmin e96f5bda7a Merge pull request 'feat: add validation ledger and final-report verifier (Closes #271)' (#287) from feat/issue-271-validation-ledger-verifier into master 2026-07-07 02:03:33 -05:00
sysadmin f5f364739b Merge branch 'master' into feat/issue-271-validation-ledger-verifier 2026-07-07 02:01:25 -05:00
sysadminandClaude Opus 4.8 a3b79736e0 feat(reports): add no-email identity summary and disclosure validator (closes #305)
Workflow reports included the authenticated user's personal email even
when username/profile identity was sufficient (observed: 'Identity:
jcwalker3 / [email protected]' in a reconciliation handoff).

Add format_identity_summary() producing the canonical no-email identity
line ('<username> / <profile>' with optional role/remote, reducing an
email passed in the username slot to its local part), and
assess_email_disclosure() flagging personal email addresses in report
text unless the report itself justifies the disclosure (tool proof,
explicit request, or identity disambiguation).

Tests cover no-email summaries for author and reviewer identities,
role/remote suffixes, email-in-username reduction, clean reports,
single and multiple unjustified disclosures, and justified
disambiguation.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 02:57:45 -04:00
sysadminandClaude Opus 4.8 096bb878d5 Merge prgs/master into feat/issue-266-subagent-gate-inheritance
Resolve _GUIDE_RULES conflict by keeping both operator guide keys:
shell_spawn_hard_stop (#258, landed on master via PR #281) and
subagent_delegation (#266, this branch).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 02:55:15 -04:00
sysadminandClaude Opus 4.8 efb0a2d076 Add REQUEST_CHANGES override proof helper before approval (#326)
Introduce assess_request_changes_approval_proof in review_proofs.py so
review workflows can fail closed when approving an unchanged head after a
prior REQUEST_CHANGES unless explicit override proof is provided.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 02:53:25 -04:00
sysadmin a5341684f6 Merge pull request 'feat: align claim/lock gates with branches-only worktrees (Closes #275)' (#280) from feat/issue-275-claim-lock-branches-worktree into master 2026-07-07 01:39:35 -05:00
sysadmin 2a28fee7f2 Merge pull request 'docs: add Shell Spawn Hard-Stop Rule for agent workflows (Closes #258)' (#281) from docs/issue-258-shell-spawn-hard-stop into master 2026-07-07 01:18:50 -05:00
sysadmin 95603b24a8 Merge pull request 'docs: forbid improvised commit fallbacks when MCP commit exists (Closes #260)' (#279) from docs/issue-260-forbid-commit-fallbacks into master 2026-07-07 01:01:16 -05:00
sysadmin ceb22e0d65 Merge branch 'master' into fix/issue-283-hermetic-walk-root-test 2026-07-07 01:01:11 -05:00
sysadmin aae189a801 Merge branch 'master' into feat/issue-271-validation-ledger-verifier 2026-07-07 01:01:01 -05:00
sysadmin 988a5cd975 Merge branch 'master' into feat/issue-285-live-infra-stop-recompute 2026-07-07 01:00:48 -05:00
sysadmin 52b729e86a Merge branch 'master' into docs/issue-259-subagent-tool-budget 2026-07-07 01:00:37 -05:00
sysadmin 3354da1973 Merge branch 'master' into docs/issue-258-shell-spawn-hard-stop 2026-07-07 01:00:06 -05:00
sysadmin bcf64928b1 Merge branch 'master' into feat/issue-275-claim-lock-branches-worktree 2026-07-07 00:59:50 -05:00
sysadmin d0e250e860 Merge branch 'master' into docs/issue-260-forbid-commit-fallbacks 2026-07-07 00:59:30 -05:00
sysadmin a4736a2346 Merge branch 'master' into feat/issue-266-subagent-gate-inheritance 2026-07-07 00:56:30 -05:00
sysadmin 042783076f Merge pull request 'docs: agent temp artifact cleanup guidance (Closes #261)' (#277) from docs/issue-261-agent-artifact-cleanup into master 2026-07-06 23:38:44 -05:00
sysadmin 8fc41ccf29 Merge pull request 'feat: map gitea_commit_files in task capability resolver (Closes #262)' (#276) from feat/issue-262-commit-files-capability into master 2026-07-06 23:35:31 -05:00
sysadminandClaude Opus 4.8 e09df4a348 feat: add fail-closed subagent delegation gates (#266)
Adds subagent_gate.py following the author_proofs/review_proofs pure-policy
pattern:

- assess_subagent_delegation: deterministic write tasks (issue claim,
  branch creation, code edits, commits, PR creation, review, merge,
  cleanup) are blocked for subagents unless explicitly allowed with a
  recorded justification and the full inherited gate context (issue lock,
  branch/worktree path, identity/profile, allowed tool class, command
  deny list, validation ledger requirement, final report schema);
  read-only delegation stays allowed; unknown task types fail closed.
- validate_subagent_report: subagent output is accepted only when it
  carries the same proof fields as a parent workflow final report.
- Operator guide: new subagent_delegation rule in _GUIDE_RULES so MCP
  clients discover the policy.
- tests/test_subagent_gate.py: 13 tests covering blocked write
  delegation, justification requirement, missing/blank inherited
  context, allowed read-only delegation, unknown tasks, and invalid
  subagent final reports.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 00:28:32 -04:00
sysadminandClaude Opus 4.8 b5a025102d feat: add validation ledger and final-report verifier (Closes #271)
Introduce validation_ledger with command ledger format, full-suite claim
checks, and structured final-report verification so sessions cannot
overclaim validation or misreport review/merge state.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 00:22:28 -04:00
sysadminandClaude Opus 4.8 5af1b796ef fix: recompute infra_stop live on every capability resolve (#285)
Add assess_infra_stop() to scan git state and conflict markers on each
reviewer capability check, return the exact conflict path in responses,
and clear stale capability-stop terminal state once the worktree is clean.

Closes #285

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 00:21:18 -04:00
sysadminandClaude Opus 4.8 2b04108f93 test: make sibling-worktree skip test hermetic (closes #283)
test_skip_scan_walk_root_skips_sibling_worktrees_only derived its
fixture paths from __file__, so the branches/ sibling-worktree skip
assertion only held when the suite ran from a branches/<slug> worktree
whose orchestration checkout has a real branches/ directory —
skip_python_scan_walk_root skips a branches/ walk root only when
<project_root>/branches exists on disk. From the main checkout or any
plain clone the test failed with 'AssertionError: False is not true'.

Build the <main>/branches/<worktree> layout in a TemporaryDirectory and
assert against those paths instead. All four behavioural assertions are
preserved and no production code changes.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 00:19:43 -04:00
sysadminandClaude Opus 4.8 82e6d3a3f4 docs: add subagent tool-budget guardrails for MCP tasks (Closes #259)
Document default tool-call and wall-time budgets for deterministic MCP
work, forbid subagent commit delegation when gitea_commit_files is
available, and pin the rules in runbooks, portable skill, operator guide,
and doc-contract tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 00:17:26 -04:00
sysadminandClaude Opus 4.8 4cc31a42f9 feat: add merged PR cleanup reconciliation report (#269)
Implement gitea_reconcile_merged_cleanups with dry-run reporting for merged
PR remote branches and local branches/ worktrees, plus confirmation-gated
execute mode. Safety gates cover protected branches, open PR references,
active issue locks, dirty worktrees, and delete_branch capability.

Closes #269

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 00:16:42 -04:00
sysadminandClaude Opus 4.8 cf292e4166 docs: add Shell Spawn Hard-Stop Rule for agent workflows (#258)
Documents fail-closed behavior when the shell executor fails to spawn
(exit_code: -1, empty stdout/stderr): probe once, mark shell unavailable,
hard-stop after two consecutive spawn failures, and emit a recovery report
(restart session, kill hung background terminals, prefer MCP-native paths)
instead of retry-spiralling.

- skills/llm-project-workflow/SKILL.md: portable rule section
- docs/llm-workflow-runbooks.md: Gitea runbook section
- gitea_mcp_server.py: shell_spawn_hard_stop operator guide rule
- tests/test_shell_spawn_hard_stop_docs.py: doc-contract tests (5)
- tests/test_operator_guide.py: require the new guide key

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 14:52:12 -04:00
sysadmin d73a070b5f Merge remote-tracking branch 'origin/feat/issue-275-claim-lock-branches-worktree' into feat/issue-275-claim-lock-branches-worktree 2026-07-06 14:51:02 -04:00
sysadminandClaude Opus 4.8 5e64c96851 feat: align claim/lock gates with branches-only worktrees (#275)
Allow issue lock from base-equivalent branches/ worktrees instead of
requiring the literal master/main branch name. Runtime preflight and
mark_issue/lock_issue now inspect the declared active task workspace so
dirty control-checkout state does not block clean task worktrees.

Closes #275

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 14:49:39 -04:00
sysadminandClaude Opus 4.8 67e34baece feat: align claim/lock gates with branches-only worktrees (#275)
Allow issue lock from base-equivalent branches/ worktrees instead of
requiring the literal master/main branch name. Runtime preflight and
mark_issue/lock_issue now inspect the declared active task workspace so
dirty control-checkout state does not block clean task worktrees.

Closes #275

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 14:47:56 -04:00
sysadminandClaude Opus 4.8 2f4672218c docs: forbid improvised commit fallbacks when MCP commit exists (#260)
Document MCP-native gitea_commit_files as the only approved commit path when
gitea.repo.commit is allowed; ban WebFetch, Playwright, and manual base64
workarounds in runbooks and safety model. Add doc tests.

Closes #260.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 14:44:41 -04:00
sysadmin b50d265247 Merge branch 'feat/issue-263-native-commit-payloads' into docs/issue-261-agent-artifact-cleanup 2026-07-06 14:44:33 -04:00
sysadmin 2a544b78d1 feat(mcp): support native commit payload preparation without shell (closes #263) 2026-07-06 14:42:59 -04:00
sysadminandClaude Opus 4.8 850b7c34b6 docs: agent temp artifact cleanup guidance and detection (#261)
Add runbook section, root-level .gitignore patterns, and detection helper
for _encode_/_emit_/_inline_ throwaway scripts. Surface non-blocking
warnings from assess_preflight_status and gitea_lock_issue.

Closes #261

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 14:42:33 -04:00
sysadminandClaude Opus 4.8 f464716e89 docs: document agent temp artifact cleanup and gitignore patterns (#261)
Add runbook guidance for deleting throwaway _encode/_emit/_inline commit
helpers after MCP commit attempts, ignore those patterns in .gitignore,
and cross-link the worktree-cleanup template.

Closes #261.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 14:42:22 -04:00
sysadmin 67634c399e Merge branch 'feat/issue-262-commit-files-capability' into feat/issue-263-native-commit-payloads 2026-07-06 14:41:07 -04:00
sysadmin 89c9b9a4ec feat(mcp): add commit_files gates and tests for capability resolver 2026-07-06 14:39:26 -04:00
sysadminandClaude Opus 4.8 23c6593b07 feat: map commit_files in task capability resolver (#262)
Add commit_files and gitea_commit_files resolver entries for
gitea.repo.commit, align gitea_commit_files tool gates with other
issue-write mutations, and add regression tests for author allow,
reviewer deny, preflight order, and resolver/tool alignment.

Refs #262

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-06 14:38:35 -04:00
123 changed files with 25106 additions and 920 deletions
+4
View File
@@ -10,3 +10,7 @@ gitea-mcp*.json
.vscode/
graphify-out/
branches/
# Throwaway agent commit-encoding helpers (#261) — never commit.
/_encode_*.py
/_emit_*.py
/_inline_*.py
+26
View File
@@ -0,0 +1,26 @@
"""Detect throwaway agent helper scripts left in the repo root (#261)."""
from __future__ import annotations
import fnmatch
AGENT_TEMP_BASENAME_PATTERNS = (
"_encode_*.py",
"_emit_*.py",
"_inline_*.py",
)
def find_agent_temp_artifacts_from_porcelain(porcelain: str) -> list[str]:
"""Return untracked repo-root helper paths matching agent temp patterns."""
found: list[str] = []
for line in (porcelain or "").splitlines():
if not line.startswith("??"):
continue
path = line[3:].strip()
if not path or "/" in path or "\\" in path:
continue
basename = path.split("/")[-1]
if any(fnmatch.fnmatch(basename, pat) for pat in AGENT_TEMP_BASENAME_PATTERNS):
found.append(path)
return sorted(found)
+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
+198
View File
@@ -0,0 +1,198 @@
"""Early duplicate-work detection for author work-issue flows (#400)."""
from __future__ import annotations
import re
from typing import Any
from issue_claim_heartbeat import (
_linked_open_pr,
_matching_branch_names,
classify_issue_claim,
)
STAGES = (
"claim",
"lock",
"worktree",
"edit",
"commit",
"push",
"create_pr",
)
ELIGIBILITY_OPEN_PR_EXISTS = "OPEN_PR_EXISTS"
ELIGIBILITY_DUPLICATE_BRANCH_EXISTS = "DUPLICATE_BRANCH_EXISTS"
ELIGIBILITY_ACTIVE_CLAIM = "ACTIVE_CLAIM_BY_OTHER"
ELIGIBILITY_CLEAR = "CLEAR"
def assess_author_duplicate_work(
issue_number: int,
*,
stage: str,
open_prs: list[dict] | None = None,
branch_names: list[str] | None = None,
claim_entry: dict | None = None,
matching_branches: list[str] | None = None,
allow_stale_takeover: bool = False,
) -> dict[str, Any]:
"""Fail closed when duplicate work is detected before author mutations (#400)."""
stage_norm = (stage or "").strip().lower()
if stage_norm not in STAGES:
return {
"allowed": False,
"block": True,
"eligibility_class": "INVALID_STAGE",
"stage": stage_norm or None,
"reasons": [f"unknown duplicate-work stage {stage!r}"],
"safe_next_action": f"use one of: {', '.join(STAGES)}",
}
prs = list(open_prs or [])
linked_pr = _linked_open_pr(int(issue_number), prs)
branches = list(
matching_branches
if matching_branches is not None
else _matching_branch_names(int(issue_number), list(branch_names or []))
)
reasons: list[str] = []
eligibility = ELIGIBILITY_CLEAR
if linked_pr:
eligibility = ELIGIBILITY_OPEN_PR_EXISTS
reasons.append(
f"open PR #{linked_pr.get('number')} already covers issue "
f"#{issue_number}"
)
if branches and stage_norm in {"claim", "lock", "worktree", "edit"}:
if eligibility == ELIGIBILITY_CLEAR:
eligibility = ELIGIBILITY_DUPLICATE_BRANCH_EXISTS
reasons.append(
f"remote branch(es) already exist for issue #{issue_number}: "
f"{', '.join(branches)}"
)
entry = claim_entry or {}
claim_status = (entry.get("status") or "").strip()
if claim_status in {"active", "awaiting_review"} and stage_norm == "claim":
if entry.get("reclaimable") and allow_stale_takeover:
pass
elif claim_status == "active" and not entry.get("reclaimable"):
if eligibility == ELIGIBILITY_CLEAR:
eligibility = ELIGIBILITY_ACTIVE_CLAIM
reasons.append(
f"issue #{issue_number} has active claim "
f"(status={claim_status})"
)
elif claim_status == "awaiting_review" and stage_norm == "claim":
if not linked_pr:
reasons.append(
f"issue #{issue_number} is awaiting_review but no linked open PR "
"was supplied for duplicate-work proof"
)
allowed = not reasons
outcome = "duplicate_work_prevented" if not allowed else "clear"
if stage_norm == "create_pr" and not allowed:
outcome = "duplicate_pr_prevented"
elif stage_norm in {"commit", "push"} and not allowed:
outcome = "duplicate_push_prevented" if stage_norm == "push" else "duplicate_commit_prevented"
return {
"allowed": allowed,
"block": not allowed,
"eligibility_class": eligibility if not allowed else ELIGIBILITY_CLEAR,
"stage": stage_norm,
"linked_open_pr": linked_pr.get("number") if linked_pr else None,
"matching_branches": branches,
"claim_status": claim_status or None,
"outcome": outcome,
"reasons": reasons,
"safe_next_action": (
"stop without edits/commit/push/PR; produce reconciliation handoff "
"preserving local work only"
if not allowed
else "proceed"
),
}
def build_claim_entry_from_classification(classification: dict) -> dict:
"""Map ``classify_issue_claim`` output to gate claim metadata."""
return {
"status": classification.get("status"),
"reclaimable": classification.get("reclaimable"),
"linked_open_pr": classification.get("linked_open_pr"),
}
_DUPLICATE_OUTCOME_RE = re.compile(
r"(duplicate\s+(?:pr|branch|commit|push)\s+prevented|"
r"duplicate\s+work\s+not\s+prevented|reconciliation\s+handoff)",
re.IGNORECASE,
)
def assess_work_issue_duplicate_prevention_report(report_text: str) -> dict:
"""#400: work-issue reports must state duplicate-work prevention outcome."""
text = report_text or ""
if "duplicate work" not in text.lower() and "duplicate pr" not in text.lower():
return {
"proven": True,
"block": False,
"reasons": [],
"safe_next_action": "proceed",
}
if _DUPLICATE_OUTCOME_RE.search(text):
return {
"proven": True,
"block": False,
"reasons": [],
"safe_next_action": "proceed",
}
return {
"proven": False,
"block": True,
"reasons": [
"duplicate-work discussion must name a prevention outcome "
"(duplicate PR/branch/commit/push prevented, or duplicate work "
"not prevented, or reconciliation handoff)"
],
"safe_next_action": "state exact duplicate-work prevention class in final report",
}
def classify_and_assess(
issue: dict,
*,
stage: str,
comments: list[dict] | None = None,
open_prs: list[dict] | None = None,
branch_names: list[str] | None = None,
allow_stale_takeover: bool = False,
) -> dict[str, Any]:
"""Combine claim classification with duplicate-work gate assessment."""
issue_number = int(issue.get("number") or 0)
claim = classify_issue_claim(
issue=issue,
comments=comments or [],
open_prs=open_prs or [],
branch_names=branch_names or [],
)
assessment = assess_author_duplicate_work(
issue_number,
stage=stage,
open_prs=open_prs,
branch_names=branch_names,
claim_entry=build_claim_entry_from_classification(claim),
matching_branches=claim.get("matching_branches"),
allow_stale_takeover=allow_stale_takeover,
)
return {
"issue_number": issue_number,
"claim": claim,
"duplicate_work": assessment,
}
+105
View File
@@ -0,0 +1,105 @@
"""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
BASE_BRANCHES = frozenset({"master", "main", "dev"})
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 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,21 +1,34 @@
# GlitchTip-to-Gitea Issue Filing Workflow Design
# GlitchTip-to-Gitea Issue Filing Workflow Contract
- **Status:** Design (no implementation in this repo)
- **Issue:** #74 (parent umbrella: #75)
- **Status:** Contract for the library-only implementation in
`Scaled-Tech-Consulting/mcp-control-plane` (#57)
- **Issue:** #153 (supersedes #74/#78 as the Gitea-Tools tracker)
- **Related:** #78 (deduplication design, child of #74)
- **Date:** 2026-07-02
- **Date:** 2026-07-07
## 1. Boundary and Orchestration
* **GlitchTip-to-Gitea filing is NOT a GlitchTip MCP capability.** The `glitchtip-mcp` boundary remains strictly read-only per ADR-0001.
* The filing capability lives in an **orchestrator / runbook / release workflow**. It **composes** separate GlitchTip **read** tools (from the `glitchtip-mcp` server) and Gitea **issue** tools (from the `gitea-mcp` server).
* The filing capability lives in a **library-only orchestrator** in
`mcp-control-plane` and is not exposed through `glitchtip-mcp`.
* The orchestrator composes the real GlitchTip read path with Gitea
issue-write tools. It must not use mocked GlitchTip issue data in production
filing paths.
* The orchestrator **must not centralize credentials** into a single server. The GlitchTip MCP holds only GlitchTip tokens, and the Gitea MCP holds only Gitea tokens.
* If a future MCP surface exposes filing, it must be a separate write-boundary
server/profile with explicit Gitea issue-write permission and the same audit
gates. It must not be added to the read-only `glitchtip-mcp` surface.
## 2. Invocation and Safety
* **Explicit invocation only:** There is no automatic, unsupervised filing in phase 1. A human or an explicitly-triggered automation must initiate the workflow.
* **Dry-run / Preview required:** The orchestrator must present a preview of the drafted Gitea issue (title, body, labels) and obtain explicit confirmation before calling the Gitea mutation tool to file the issue.
* **Gitea Profile Checks & Audit Logging:** The actual Gitea issue creation relies on `gitea-mcp`, and therefore inherently subjects the mutation to Gitea profile checks and audit logging as standard.
* **Gitea Profile Checks & Audit Logging:** The actual Gitea issue creation
relies on `gitea-mcp`, and therefore must pass Gitea profile checks and
fail-closed mutation audit before create/link/comment actions.
* **Deduplication before create:** The orchestrator must run the
GlitchTip-to-Gitea dedup/linking logic before any Gitea create action. Create,
link, and skip decisions must be represented in tests.
## 3. Gitea Issue Format
@@ -51,6 +64,23 @@ To prevent PII or secret leakage into Gitea, the orchestrator and the underlying
The principle is: **"Link, don't dump"**. The generated issue acts as an alert/pointer, while the raw context remains protected inside GlitchTip.
## 5. Deduplication and Linking (Deferred)
## 5. Deduplication and Linking
Deduplication logic (e.g. searching existing Gitea issues, managing GlitchTip issue IDs, and race condition handling) is specifically handled by **Issue #78** and will augment this design.
Deduplication logic (e.g. searching existing Gitea issues, managing GlitchTip
issue IDs, and race condition handling) is integrated into the library-only
filing orchestrator in `mcp-control-plane`. The orchestrator must reuse that
dedup/linking path instead of duplicating or bypassing it.
## 6. Gitea-Tools Acceptance Contract for #153
Gitea-Tools does not host the filing implementation. This repository owns the
operator-facing contract:
* `glitchtip-mcp` remains read-only and exposes only GlitchTip inspection tools.
* Filing is implemented in `mcp-control-plane`, not in this Gitea MCP runtime.
* Filing uses the real GlitchTip read path, not mocked issue data.
* Dedup runs before any Gitea create action.
* Create/link/skip decisions and audit failure are covered by orchestrator tests.
* Mutation audit fails closed before any Gitea create, link, or comment mutation.
* Any future MCP exposure requires a separate write-boundary server/profile and
must not add Gitea write credentials to `glitchtip-mcp`.
+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:
+166 -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,20 @@ 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 the issue-lock payload with issue
number, optional PR number, branch, worktree path, claimant identity/profile,
created timestamp, expiry timestamp, and last heartbeat timestamp. An active
same-issue/same-operation lease blocks duplicate work. An expired lease still
blocks takeover until a recovery review records why the prior work is abandoned,
completed, or unsafe to continue.
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 +317,66 @@ explicit control-checkout repair.
Portable wording: [`skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md).
## Shell Spawn Hard-Stop Rule
Symptom: a shell tool call returns `exit_code: -1` with empty stdout/stderr.
That is an executor spawn failure, not a command failure — the command never
ran, and retrying the identical call cannot succeed.
Required behavior (fail closed, issue #258):
1. **Probe once.** On the first spawn failure, run one trivial probe
(`echo ok` or `pwd`). If the probe also returns `exit_code: -1`, mark
shell unavailable for the session.
2. **Hard-stop at two.** After two consecutive spawn failures, stop all
further shell tool use for the session; never retry the same failing
spawn. A hundred retries produce a hundred identical failures (session
`019f382e`: 100+ tool calls stalled on a trivial encode-and-commit task).
3. **Emit a recovery report.** The report must direct the operator to:
- restart the session,
- kill hung background terminals (a hung test runner holding the
executor is a known contributor),
- prefer MCP-native paths for remaining mutations (for example
`gitea_commit_files` under `gitea.repo.commit`) instead of shell.
4. **No improvised fallbacks.** Shell unavailability never authorizes
WebFetch/browser/manual-encoding workarounds (see #260). No shell means
stop-and-report.
Doc-contract tests: `tests/test_shell_spawn_hard_stop_docs.py`.
## Subagent Tool-Budget Guardrails
General-purpose subagents tasked with **deterministic MCP work** (for example a
single `gitea_commit_files` call) have expanded to 100122 tool calls,
WebFetch/Playwright fallbacks, and throwaway helper-script generation instead of
calling the native MCP tool once (observed during #152 closure, issue #259).
**Default budgets** (fail closed when exceeded):
| Task class | Max tool calls | Max wall time |
|------------|----------------|---------------|
| Single-step MCP mutation (`commit_files`, `create_pr`, `lock_issue`) | 15 | 5 minutes |
| Review / merge queue inspection | 40 | 15 minutes |
| Exploration / codebase search (non-mutating) | 60 | 20 minutes |
**Required behavior:**
1. **Main session first.** When the active author profile allows
`gitea.repo.commit` and `gitea_commit_files` is visible, the main session
must call it directly — do not delegate commit authority to a subagent
(see #260).
2. **Native MCP before fallback.** After a shell spawn failure (#258), attempt
the native MCP tool once before any alternate path. Shell unavailability
never authorizes WebFetch, Playwright, or manual base64 encoding.
3. **No retry spirals.** Never resume a failed subagent into a larger retry
loop or spawn a second subagent for the same deterministic step. Stop and
emit a recovery report instead.
4. **Forbidden detours** when `gitea_commit_files` is available: WebFetch,
Playwright/browser automation, manual LLM-generated base64, and ad-hoc
`_encode_*` / `_emit_*` helper scripts left in the repo.
Doc-contract tests: `tests/test_subagent_tool_budget_docs.py`.
## Branch worktree isolation
All LLM implementation and review work happens in an isolated branch worktree
@@ -316,6 +408,30 @@ may edit another issue's branch folder unless explicitly assigned to that issue.
No LLM may clean another issue's branch folder unless the PR is merged or closed
and cleanup is explicitly part of the task.
## Agent temp artifact cleanup (#261)
Failed or aborted MCP commit attempts sometimes leave throwaway helper scripts in
the **repository root**. These are not part of any issue scope and pollute
`git status`, which can break `gitea_lock_issue` and preflight checks.
**Patterns (repo root only, untracked):**
- `_encode_*.py` — base64 payload encoders
- `_emit_*.py` — commit payload emitters
- `_inline_*.py` — inline encoding helpers
**Required cleanup (after MCP commit completes or aborts):**
1. Delete any matching files at the repo root (`rm ./_encode_*.py` etc.).
2. Confirm `git status` is clean on the orchestration checkout before
`gitea_lock_issue`.
3. Prefer native `gitea_commit_files` / gated commit paths — do not leave shell
encoding fallbacks behind.
Root-level matches are listed in `.gitignore` so they never get committed.
`gitea_get_runtime_context` and `gitea_lock_issue` surface **warnings** (not
hard blocks) when these artifacts are still present.
Implementation work and review work must use separate branch folders. For
example, an implementation branch might live under
`branches/fix-issue-123-example`, while a review branch for the resulting PR
@@ -370,6 +486,36 @@ git branch -d fix/issue-123-example
All three helpers accept `--dry-run` to print the exact commands/paths without
touching anything.
### MCP-native commit path (#260)
When the active author profile allows **`gitea.repo.commit`** and
**`gitea_commit_files`** is visible in the client, that is the **only** approved
path for committing files to the tracked repository. Do not improvise alternate
encoding or transport when MCP commit is available.
**Required before commit:**
1. Call `gitea_resolve_task_capability` for `commit_files` or
`gitea_commit_files` and confirm `allowed_in_current_session` is true.
2. Use `gitea_commit_files` with file payloads prepared in the author worktree.
3. Stage only issue-scoped paths; never commit throwaway `_encode_*` /
`_emit_*` / `_inline_*` helpers.
**Explicitly forbidden workarounds** when MCP commit is reachable:
- `WebFetch` / HTTP calls to external decode sites (for example httpbin base64
endpoints)
- Playwright or other browser automation to bypass MCP
- Manual LLM-generated base64 pasted into ad-hoc scripts
- Delegating commit authority to a subagent while the main session has
`gitea.repo.commit` on an author profile
**If shell encoding is unavailable** (spawn failure, hung terminal) **and** MCP
commit cannot run: **stop** with a recovery report. Mention restarting the
session, clearing hung background terminals, switching to MCP-native commit, and
the agent temp artifact cleanup checklist. Do **not** retry shell encoding in a
loop and do **not** substitute WebFetch/Playwright/manual base64.
### Create an issue / child issues
- **Profile:** issue-manager or author (any profile allowed to create issues).
@@ -635,3 +781,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.
+9 -2
View File
@@ -95,7 +95,14 @@ back to shell commands, raw service APIs, or unrelated MCP servers.
- Do not register `jenkins-write-mcp` until an operator approves a trigger
profile; no shipped profile carries trigger capability by default.
- `glitchtip-mcp` remains read-only. It must not file or mutate Gitea issues.
- GlitchTip-to-Gitea filing is a separate orchestrator that composes GlitchTip
read tools with Gitea issue-write tools.
- GlitchTip-to-Gitea filing is a separate library-only orchestrator in
`mcp-control-plane` that composes the real GlitchTip read path with Gitea
issue-write tools.
- The filing orchestrator must run GlitchTip/Gitea dedup before any Gitea
create action, and its create/link/skip decisions must be covered by tests.
- The filing orchestrator must fail closed on mutation-audit failure before
any Gitea create, link, or comment mutation.
- If filing is ever MCP-exposed, it must use a separate write-boundary
server/profile. It must not be exposed by `glitchtip-mcp`.
- Gitea credentials never enter Jenkins or GlitchTip runtimes.
- Jenkins and GlitchTip credentials never enter the Gitea MCP runtime.
+18
View File
@@ -28,3 +28,21 @@ Any mutating action (e.g., Gitea issue creation from GlitchTip, or Jenkins build
No default profile carries trigger capability (#152 / mcp-control-plane #56).
- **GlitchTip to Gitea issue filing** is a library-only orchestrator in
mcp-control-plane (not on `glitchtip-mcp`). See #153 / mcp-control-plane #57.
## 6. Agent Commit Path (no improvised fallbacks)
When an author execution profile allows **`gitea.repo.commit`** and the
**`gitea_commit_files`** tool is visible, agents must use that MCP path for
repository commits. Fail closed instead of improvising alternate transports.
Forbidden when MCP commit is available:
- WebFetch or other HTTP calls to external base64/decode services
- Playwright or browser automation used to work around MCP commit
- Manual LLM-generated base64 embedded in throwaway scripts as the primary
commit transport
If shell helpers are unavailable and MCP commit cannot run, stop with a recovery
report (restart session, clear hung terminals, use MCP-native commit). See
[`llm-workflow-runbooks.md`](llm-workflow-runbooks.md) § MCP-native commit path
(#260) and agent temp artifact cleanup (#261).
+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.
File diff suppressed because it is too large Load Diff
+37
View File
@@ -420,6 +420,43 @@ def api_get_all(url, auth_header, *, limit=None, page_size=50, max_pages=100,
return results
def api_fetch_page(url, auth_header, *, page=1, limit=50, **kwargs):
"""Fetch one page from a Gitea list endpoint with explicit pagination metadata.
Returns ``(items, pagination)`` where *pagination* includes ``has_more``,
``next_page``, and ``is_final_page`` derived from the returned page length.
"""
page = max(1, int(page))
limit = max(1, min(50, int(limit)))
page_url = _add_query(url, page=page, limit=limit)
data = api_request("GET", page_url, auth_header, **kwargs)
if data is None:
pagination = {
"page": page,
"per_page": limit,
"returned_count": 0,
"has_more": False,
"next_page": None,
"is_final_page": True,
}
return [], pagination
if not isinstance(data, list):
raise RuntimeError(
f"expected a list page from Gitea, got {type(data).__name__}"
)
returned = len(data)
has_more = returned >= limit
pagination = {
"page": page,
"per_page": limit,
"returned_count": returned,
"has_more": has_more,
"next_page": page + 1 if has_more else None,
"is_final_page": not has_more,
}
return data, pagination
def gitea_url(host, path):
"""Build a full URL for *host* and *path*, using http for loopback and https for others."""
if not path.startswith("/"):
+1870 -119
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
+81 -14
View File
@@ -14,7 +14,7 @@ import subprocess
from reviewer_worktree import parse_dirty_tracked_files
AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE"
BASE_BRANCHES = frozenset({"master", "main"})
BASE_BRANCHES = frozenset({"master", "main", "dev"})
def resolve_author_worktree_path(
@@ -50,9 +50,28 @@ def read_worktree_git_state(worktree_path: str) -> dict:
text=True,
check=False,
)
root_res = subprocess.run(
["git", "-C", path, "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=False,
)
head_res = subprocess.run(
["git", "-C", path, "rev-parse", "HEAD"],
capture_output=True,
text=True,
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)
return {
"current_branch": current_branch,
"porcelain_status": status_res.stdout or "",
"inspected_git_root": (root_res.stdout or "").strip() if root_res.returncode == 0 else None,
"head_sha": head_sha,
"base_branch": base_branch,
"base_sha": base_sha,
"base_equivalent": bool(head_sha and base_sha and head_sha == base_sha),
}
@@ -61,6 +80,9 @@ def assess_issue_lock_worktree(
worktree_path: str,
current_branch: str | None,
porcelain_status: str,
base_equivalent: bool | None = None,
inspected_git_root: str | None = None,
base_branch: str | None = None,
base_branches: frozenset[str] | None = None,
) -> dict:
"""Fail closed when lock preconditions are not met on the declared worktree."""
@@ -77,21 +99,39 @@ def assess_issue_lock_worktree(
if dirty_files:
reasons.append(
"tracked file edits exist before issue lock; "
"lock must precede implementation work"
)
if not branch:
reasons.append(
"current branch unknown (detached HEAD?); issue lock must be taken "
f"from base branch ({_base_list(bases)})"
)
elif branch not in bases:
reasons.append(
f"issue lock must be taken from base branch ({_base_list(bases)}), "
f"not '{branch}'"
f"lock must precede implementation work in '{path}' "
f"(dirty files: {', '.join(dirty_files)})"
)
if base_equivalent is False:
reasons.append(
"issue lock worktree must be base-equivalent to one of "
f"{_base_list(bases)} before implementation work; inspected "
f"branch '{branch or '(detached)'}' at '{path}'"
)
elif base_equivalent is None:
if not branch:
reasons.append(
"current branch unknown (detached HEAD?); issue lock base-equivalence "
f"to {_base_list(bases)} could not be proven"
)
elif branch not in bases:
reasons.append(
"issue lock worktree base-equivalence could not be proven; "
f"branch '{branch}' is not {_base_list(bases)}"
)
proven = not reasons
return _assessment(proven, reasons, path, branch or None, dirty_files)
return _assessment(
proven,
reasons,
path,
branch or None,
dirty_files,
inspected_git_root=inspected_git_root,
base_branch=base_branch,
base_equivalent=base_equivalent,
)
def format_issue_lock_worktree_error(assessment: dict) -> str:
@@ -145,12 +185,39 @@ def _assessment(
worktree_path: str,
current_branch: str | None,
dirty_files: list[str],
*,
inspected_git_root: str | None = None,
base_branch: str | None = None,
base_equivalent: bool | None = None,
) -> dict:
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"worktree_path": worktree_path or None,
"inspected_git_root": inspected_git_root,
"current_branch": current_branch,
"dirty_files": dirty_files,
}
"base_branch": base_branch,
"base_equivalent": base_equivalent,
}
def _find_matching_base_ref(path: str, head_sha: str | None) -> tuple[str | None, str | None]:
"""Return the stable branch ref whose commit matches HEAD, if any."""
if not head_sha:
return None, None
candidates: list[str] = []
for branch in sorted(BASE_BRANCHES):
candidates.extend((f"origin/{branch}", branch))
for ref in candidates:
res = subprocess.run(
["git", "-C", path, "rev-parse", "--verify", ref],
capture_output=True,
text=True,
check=False,
)
sha = (res.stdout or "").strip()
if res.returncode == 0 and sha == head_sha:
return ref, sha
return None, None
+333
View File
@@ -0,0 +1,333 @@
"""Merged PR branch and worktree cleanup reconciliation (#269).
Builds dry-run reports for merged pull requests and optionally executes
remote branch deletion and local worktree removal when every safety gate passes.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
from typing import Any
from reviewer_worktree import parse_dirty_tracked_files
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)
def extract_linked_issue(title: str, body: str) -> int | None:
"""Return the first Closes/Fixes issue number from PR metadata."""
for text in (title or "", body or ""):
match = CLOSES_FIXES_RE.search(text)
if match:
return int(match.group(1))
return None
def branch_worktree_folder(branch: str) -> str:
return (branch or "").replace("/", "-")
def resolve_worktree_path(project_root: str, branch: str) -> str:
return os.path.join(project_root, "branches", branch_worktree_folder(branch))
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
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()
def collect_open_pr_heads(open_prs: list[dict[str, Any]]) -> set[str]:
heads: set[str] = set()
for pr in open_prs or []:
head = pr.get("head")
if isinstance(head, dict):
ref = head.get("ref")
else:
ref = head
if ref:
heads.add(str(ref))
return heads
def read_local_worktree_state(worktree_path: str) -> dict[str, Any]:
path = (worktree_path or "").strip()
if not path or not os.path.isdir(path):
return {
"exists": False,
"clean": None,
"current_branch": None,
"head_sha": None,
"dirty_files": [],
}
branch_res = subprocess.run(
["git", "-C", path, "branch", "--show-current"],
capture_output=True,
text=True,
check=False,
)
current_branch = (branch_res.stdout or "").strip() or None
status_res = subprocess.run(
["git", "-C", path, "status", "--porcelain"],
capture_output=True,
text=True,
check=False,
)
dirty_files = parse_dirty_tracked_files(status_res.stdout or "")
head_res = subprocess.run(
["git", "-C", path, "rev-parse", "HEAD"],
capture_output=True,
text=True,
check=False,
)
head_sha = (head_res.stdout or "").strip() if head_res.returncode == 0 else None
return {
"exists": True,
"clean": not dirty_files,
"current_branch": current_branch,
"head_sha": head_sha,
"dirty_files": dirty_files,
}
def is_head_ancestor_of_ref(project_root: str, head_sha: str | None, base_ref: str) -> bool | None:
if not head_sha:
return None
res = subprocess.run(
["git", "-C", project_root, "merge-base", "--is-ancestor", head_sha, base_ref],
capture_output=True,
text=True,
check=False,
)
if res.returncode == 0:
return True
if res.returncode == 1:
return False
return None
def assess_remote_branch_cleanup(
*,
pr_number: int,
head_branch: str,
merged: bool,
remote_branch_exists: bool,
open_pr_heads: set[str],
protected_branches: frozenset[str] | None = None,
head_on_master: bool | None,
delete_capability_allowed: bool,
active_lock: bool,
) -> dict[str, Any]:
protected = protected_branches or PROTECTED_BRANCHES
reasons: list[str] = []
if not merged:
reasons.append("PR is not merged")
if not remote_branch_exists:
reasons.append("remote branch already absent")
if head_branch in protected:
reasons.append(f"branch '{head_branch}' is protected")
if head_branch in open_pr_heads:
reasons.append("an open PR still references this head branch")
if active_lock:
reasons.append("active issue lock references this branch")
if head_on_master is False:
reasons.append("PR head is not an ancestor of master")
if not delete_capability_allowed:
reasons.append("delete_branch capability is not allowed in the active profile")
safe = merged and remote_branch_exists and not reasons
return {
"pr_number": pr_number,
"head_branch": head_branch,
"remote_branch_exists": remote_branch_exists,
"safe_to_delete_remote": safe,
"block_reasons": reasons,
"recommended_action": "delete_remote_branch" if safe else "keep_remote_branch",
}
def assess_local_worktree_cleanup(
*,
pr_number: int,
head_branch: str,
merged: bool,
worktree_state: dict[str, Any],
active_lock: bool,
) -> dict[str, Any]:
reasons: list[str] = []
exists = bool(worktree_state.get("exists"))
if not merged:
reasons.append("PR is not merged")
if not exists:
reasons.append("local worktree not present")
if exists and worktree_state.get("clean") is False:
dirty = worktree_state.get("dirty_files") or []
reasons.append(
"local worktree has tracked edits"
+ (f" ({', '.join(dirty)})" if dirty else "")
)
if exists:
current_branch = worktree_state.get("current_branch")
if current_branch and current_branch != head_branch:
reasons.append(
f"worktree branch '{current_branch}' does not match PR head '{head_branch}'"
)
if active_lock:
reasons.append("active issue lock references this branch")
safe = merged and exists and not reasons
return {
"pr_number": pr_number,
"head_branch": head_branch,
"worktree_path": worktree_state.get("worktree_path"),
"worktree_exists": exists,
"worktree_clean": worktree_state.get("clean"),
"safe_to_remove_worktree": safe,
"block_reasons": reasons,
"recommended_action": "remove_local_worktree" if safe else "keep_local_worktree",
}
def build_pr_cleanup_entry(
*,
pr: dict[str, Any],
project_root: str,
open_pr_heads: set[str],
remote_branch_exists: bool,
head_on_master: bool | None,
delete_capability_allowed: bool,
issue_lock_path: str | None = None,
protected_branches: frozenset[str] | None = None,
) -> dict[str, Any]:
pr_number = int(pr["number"])
head_branch = pr.get("head") or ""
if isinstance(head_branch, dict):
head_branch = head_branch.get("ref") or ""
title = pr.get("title") or ""
body = pr.get("body") or ""
merged = bool(pr.get("merged_at"))
worktree_path = resolve_worktree_path(project_root, head_branch)
worktree_state = read_local_worktree_state(worktree_path)
worktree_state["worktree_path"] = worktree_path
active_lock = has_active_issue_lock(head_branch, issue_lock_path)
remote = assess_remote_branch_cleanup(
pr_number=pr_number,
head_branch=head_branch,
merged=merged,
remote_branch_exists=remote_branch_exists,
open_pr_heads=open_pr_heads,
protected_branches=protected_branches,
head_on_master=head_on_master,
delete_capability_allowed=delete_capability_allowed,
active_lock=active_lock,
)
local = assess_local_worktree_cleanup(
pr_number=pr_number,
head_branch=head_branch,
merged=merged,
worktree_state=worktree_state,
active_lock=active_lock,
)
return {
"pr_number": pr_number,
"issue_number": extract_linked_issue(title, body),
"title": title,
"head_branch": head_branch,
"merge_commit_sha": pr.get("merge_commit_sha"),
"merged_at": pr.get("merged_at"),
"merged": merged,
"remote_branch": remote,
"local_worktree": local,
}
def build_reconciliation_report(
*,
project_root: str,
closed_prs: list[dict[str, Any]],
open_prs: list[dict[str, Any]],
remote_branch_exists: dict[str, bool],
head_on_master: dict[int, bool | None],
delete_capability_allowed: bool,
issue_lock_path: str | None = None,
protected_branches: frozenset[str] | None = None,
) -> dict[str, Any]:
open_heads = collect_open_pr_heads(open_prs)
entries: list[dict[str, Any]] = []
for pr in closed_prs or []:
if not pr.get("merged_at"):
continue
pr_number = int(pr["number"])
head = pr.get("head") or ""
if isinstance(head, dict):
head = head.get("ref") or ""
entries.append(
build_pr_cleanup_entry(
pr=pr,
project_root=project_root,
open_pr_heads=open_heads,
remote_branch_exists=bool(remote_branch_exists.get(head)),
head_on_master=head_on_master.get(pr_number),
delete_capability_allowed=delete_capability_allowed,
issue_lock_path=issue_lock_path,
protected_branches=protected_branches,
)
)
return {
"project_root": os.path.realpath(project_root),
"merged_pr_count": len(entries),
"entries": entries,
"dry_run": True,
"executed": False,
}
def remove_local_worktree(project_root: str, branch: str) -> dict[str, Any]:
worktree_path = resolve_worktree_path(project_root, branch)
if not os.path.isdir(worktree_path):
return {
"success": False,
"performed": False,
"message": f"worktree not found: {worktree_path}",
}
res = subprocess.run(
["git", "-C", project_root, "worktree", "remove", worktree_path],
capture_output=True,
text=True,
check=False,
)
if res.returncode != 0:
return {
"success": False,
"performed": False,
"message": (res.stderr or res.stdout or "worktree remove failed").strip(),
}
return {
"success": True,
"performed": True,
"message": f"removed worktree {worktree_path}",
"worktree_path": worktree_path,
}
+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:
+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"
)
),
}
+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"
),
}
+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,
}
+319
View File
@@ -0,0 +1,319 @@
"""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,
) -> 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,
)
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,
}
+3118 -5
View File
File diff suppressed because it is too large Load Diff
+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"
),
}
+164
View File
@@ -0,0 +1,164 @@
"""Prior-blocker skip proof verifier for reviewer queue reports (#318)."""
from __future__ import annotations
import re
from typing import Any
_FULL_SHA = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE)
_SHORT_SHA = re.compile(r"\b[0-9a-f]{7,39}\b", re.IGNORECASE)
_PR_NUMBER_RE = re.compile(r"(?:\bPR\s*#?|#)(\d+)\b", re.IGNORECASE)
_BLOCKING_DECISION_RE = re.compile(
r"(?:blocking review decision|review decision|blocking decision)\s*:\s*request[_ ]changes|"
r"request[_ ]changes",
re.IGNORECASE,
)
_BLOCKING_HEAD_RE = re.compile(
r"(?:blocking review head(?:\s+sha)?|blocker head(?:\s+sha)?|review head at blocker)",
re.IGNORECASE,
)
_HEAD_CHANGED_RE = re.compile(
r"(?:head (?:changed|unchanged)|head sha (?:changed|unchanged)|"
r"head changed (?:after|since) (?:the )?blocker|head unchanged since blocker)",
re.IGNORECASE,
)
_BLOCKER_REASON_RE = re.compile(
r"(?:reason (?:it )?remains blocked|remains blocked because|blocking category)",
re.IGNORECASE,
)
_LIVE_BLOCKER_PROOF_RE = re.compile(
r"(?:blocker revalidated live|live proof|gitea_get_pr_review_feedback|"
r"gitea_view_pr|review feedback fetched|current review state)",
re.IGNORECASE,
)
_BLOCKER_UNVERIFIED_RE = re.compile(r"\bBLOCKER_STATUS_UNVERIFIED\b")
def _pr_section(text: str, pr_number: int) -> str:
lines = (text or "").splitlines()
chunks: list[str] = []
capture = False
token = f"#{pr_number}"
for line in lines:
lower = line.lower()
if token in lower or f"pr {pr_number}" in lower or f"pr#{pr_number}" in lower.replace(" ", ""):
capture = True
chunks.append(line)
continue
if capture:
if _PR_NUMBER_RE.search(line) and token not in line.lower():
break
if line.strip() == "" and len(chunks) > 3:
break
chunks.append(line)
if chunks:
return "\n".join(chunks)
return text or ""
def _has_head_sha(text: str, head_sha: str | None) -> bool:
if not head_sha:
return bool(_FULL_SHA.search(text) or _SHORT_SHA.search(text))
head = head_sha.strip().lower()
if head in text.lower():
return True
if len(head) >= 7 and head[:7] in text.lower():
return True
return bool(_FULL_SHA.search(text))
def assess_prior_blocker_skip_proof(
report_text: str,
*,
skipped_prs: list[dict] | None = None,
) -> dict[str, Any]:
"""Validate live blocker proof for skipped earlier open PRs (#318)."""
text = report_text or ""
reasons: list[str] = []
assessments: list[dict[str, Any]] = []
for entry in skipped_prs or []:
pr_number = entry.get("pr_number")
if pr_number is None:
reasons.append("skipped PR entry missing pr_number")
continue
pr_number = int(pr_number)
section = _pr_section(text, pr_number)
verified = entry.get("blocker_verified")
head_changed = entry.get("head_changed_since_blocker")
blocking_head = (entry.get("blocking_review_head_sha") or "").strip() or None
current_head = (entry.get("head_sha") or "").strip() or None
skip_reason = (entry.get("skip_reason") or entry.get("blocking_decision") or "").lower()
item_reasons: list[str] = []
if head_changed is True:
item_reasons.append(
f"PR #{pr_number} head changed after blocker; it cannot be skipped "
"based on stale REQUEST_CHANGES"
)
if f"#{pr_number}" not in text.lower() and f"pr {pr_number}" not in text.lower():
item_reasons.append(f"skipped PR #{pr_number} not documented in final report")
if "request_changes" in skip_reason or entry.get("blocking_decision") == "request_changes":
if verified is False:
if not _BLOCKER_UNVERIFIED_RE.search(section):
item_reasons.append(
f"PR #{pr_number} blocker proof unavailable; report must classify "
"BLOCKER_STATUS_UNVERIFIED"
)
else:
if not _BLOCKING_DECISION_RE.search(section):
item_reasons.append(
f"PR #{pr_number} skip missing blocking review decision proof"
)
if not _has_head_sha(section, current_head):
item_reasons.append(
f"PR #{pr_number} skip missing current head SHA"
)
if blocking_head and not _BLOCKING_HEAD_RE.search(section):
if blocking_head.lower() not in section.lower():
item_reasons.append(
f"PR #{pr_number} skip missing blocking review head SHA"
)
if head_changed is False and not _HEAD_CHANGED_RE.search(section):
item_reasons.append(
f"PR #{pr_number} skip missing head-changed-since-blocker proof"
)
if not _BLOCKER_REASON_RE.search(section):
item_reasons.append(
f"PR #{pr_number} skip missing reason-it-remains-blocked"
)
if not _LIVE_BLOCKER_PROOF_RE.search(section):
item_reasons.append(
f"PR #{pr_number} skip missing live blocker proof for this session"
)
proven = not item_reasons
assessments.append({
"pr_number": pr_number,
"proven": proven,
"classification": (
"BLOCKER_STATUS_UNVERIFIED"
if verified is False
else "BLOCKED_SKIPPED"
),
"reasons": item_reasons,
})
reasons.extend(item_reasons)
proven = not reasons
return {
"proven": proven,
"block": not proven,
"downgraded": False,
"assessments": assessments,
"reasons": reasons,
"safe_next_action": (
"fetch live review feedback and document blocker proof for each skipped PR, "
"or classify BLOCKER_STATUS_UNVERIFIED"
if reasons
else "proceed"
),
}
+213
View File
@@ -0,0 +1,213 @@
"""Reviewer local-fallback detection for normal PR review workflows (#324).
Normal reviewer runs must use MCP tools. Reading profile secret files or
running local Gitea helper scripts while MCP is available is a fail-closed
violation. Explicit recovery mode may use local fallback only with full proof.
"""
from __future__ import annotations
import re
from typing import Any
LOCAL_GITEA_SCRIPT_NAMES = (
"list_prs.py",
"view_pr.py",
"create_pr.py",
"merge_pr.py",
"edit_pr.py",
"list_issues.py",
"delete_branch.py",
"create_issue.py",
"close_issue.py",
"review_pr.py",
"mark_issue.py",
"mirror_refs.sh",
)
PROFILE_SECRET_MARKERS = (
"profiles.json",
".config/gitea-tools/profiles",
"gitea_auth.py",
"gitea_config.py",
"keychain",
"credential fill",
"token store",
)
_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,
)
_FALLBACK_CLASSIFICATION_RE = re.compile(
r"\b(?:local fallback|fallback mode|used local gitea|ran local script)\b",
re.IGNORECASE,
)
_MCP_TOOL_USE_RE = re.compile(
r"\b(?:gitea_list_prs|gitea_view_pr|gitea_review_pr|gitea_merge_pr|"
r"gitea_resolve_task_capability|gitea_whoami|mcp tool)\b",
re.IGNORECASE,
)
_LOCAL_SCRIPT_RE = re.compile(
r"(?:^|[\s\"'`/])(?:" + "|".join(re.escape(name) for name in LOCAL_GITEA_SCRIPT_NAMES) + r")",
re.IGNORECASE,
)
_PROFILE_ACCESS_RE = re.compile(
r"(?:read|open|inspect|cat|view|access(?:ed)?|loaded?)\s+(?:file\s+)?[`'\"]?"
r"[^`'\"]*profiles\.json|profiles\.json[`'\"]?\s+(?:read|opened|inspected|accessed)|"
r"~/?\.config/gitea-tools/profiles",
re.IGNORECASE,
)
_LOCAL_ENV_SCRIPT_RE = re.compile(
r"GITEA_MCP_(?:CONFIG|PROFILE)=.*\b(?:python\s+)?(?:list_prs|view_pr|create_pr|merge_pr)\.py",
re.IGNORECASE,
)
_RECOVERY_PROOF_FIELDS = (
("identity proof", ("identity proof", "exact identity proof")),
("profile proof", ("profile proof", "exact profile proof")),
("repo proof", ("repo proof", "exact repo proof")),
("capability proof", ("capability proof", "exact capability proof")),
("mcp unavailable reason", (
"why mcp was unavailable",
"mcp unavailable",
"mcp unavailability",
)),
)
def _collect_observed_text(
report_text: str,
action_log: list[dict] | None,
) -> str:
chunks = [report_text or ""]
for entry in action_log or []:
for key in ("command", "action", "detail", "path", "script"):
value = entry.get(key)
if value:
chunks.append(str(value))
return "\n".join(chunks)
def _detect_profile_secret_access(text: str) -> list[str]:
reasons = []
lower = text.lower()
if _PROFILE_ACCESS_RE.search(text):
reasons.append("report or action log shows profiles.json/profile secret access")
for marker in PROFILE_SECRET_MARKERS:
if marker == "profiles.json":
continue
if marker in lower and "do not" not in lower and "must not" not in lower:
if any(verb in lower for verb in ("read ", "open ", "inspect ", "cat ", "loaded ")):
reasons.append(f"profile secret surface '{marker}' accessed during review")
return reasons
def _detect_local_script_use(text: str) -> list[str]:
reasons = []
match = _LOCAL_SCRIPT_RE.search(text)
if match:
reasons.append(
f"local Gitea helper script invoked ({match.group(0).strip()})"
)
if _LOCAL_ENV_SCRIPT_RE.search(text):
reasons.append(
"local Gitea script run with GITEA_MCP_CONFIG/GITEA_MCP_PROFILE env overrides"
)
return reasons
def _recovery_proof_missing(text: str) -> list[str]:
lower = text.lower()
missing = []
for label, aliases in _RECOVERY_PROOF_FIELDS:
if not any(alias in lower for alias in aliases):
missing.append(label)
if not _FALLBACK_CLASSIFICATION_RE.search(text):
missing.append("fallback classification")
if not _RECOVERY_MODE_RE.search(text):
missing.append("recovery mode declaration")
return missing
def assess_reviewer_fallback_report(
report_text: str,
*,
action_log: list[dict] | None = None,
recovery_mode: bool = False,
mcp_available: bool = True,
mcp_tools_used: bool | None = None,
) -> dict[str, Any]:
"""Fail closed when normal reviewer workflows use local Gitea fallbacks (#324)."""
text = _collect_observed_text(report_text, action_log)
lower = (report_text or "").lower()
profile_violations = _detect_profile_secret_access(text)
script_violations = _detect_local_script_use(text)
violations = profile_violations + script_violations
if mcp_tools_used is None:
mcp_tools_used = bool(_MCP_TOOL_USE_RE.search(report_text or ""))
elif mcp_tools_used is False and _MCP_TOOL_USE_RE.search(report_text or ""):
mcp_tools_used = True
reasons: list[str] = []
recovery_declared = recovery_mode or bool(_RECOVERY_MODE_RE.search(report_text or ""))
if violations and mcp_available and not recovery_declared:
reasons.extend(violations)
if mcp_tools_used:
reasons.append(
"MCP tools were available and used; local fallback/profile access is forbidden"
)
else:
reasons.append(
"MCP path is available; normal review must not use local Gitea fallbacks"
)
if violations and recovery_declared:
missing = _recovery_proof_missing(report_text or "")
if missing:
reasons.append(
"recovery-mode fallback missing proof fields: "
+ ", ".join(missing)
)
if (
not violations
and recovery_declared
and _FALLBACK_CLASSIFICATION_RE.search(report_text or "")
and not recovery_mode
):
missing = _recovery_proof_missing(report_text or "")
if missing:
reasons.append(
"report claims local fallback but recovery proof is incomplete: "
+ ", ".join(missing)
)
proven = not reasons
blocked = bool(reasons) and not recovery_declared or any(
"recovery-mode fallback missing" in r or "recovery proof is incomplete" in r
for r in reasons
)
return {
"proven": proven,
"block": blocked or (bool(reasons) and mcp_available and not recovery_declared),
"downgraded": bool(reasons) and not blocked,
"recovery_mode": recovery_declared,
"mcp_available": mcp_available,
"mcp_tools_used": mcp_tools_used,
"profile_violations": profile_violations,
"script_violations": script_violations,
"reasons": reasons,
"safe_next_action": (
"use MCP reviewer tools only; do not read profiles.json or run local Gitea scripts"
if reasons and not recovery_declared
else (
"complete recovery-mode fallback proof before claiming local fallback"
if reasons
else "proceed with MCP tools"
)
),
}
+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"
),
}
+159
View File
@@ -0,0 +1,159 @@
"""Non-mergeable PR skip conflict-proof verifier for reviewer reports (#322)."""
from __future__ import annotations
import re
from typing import Any
_FULL_SHA = re.compile(r"\b[0-9a-f]{40}\b", re.IGNORECASE)
_SHORT_SHA = re.compile(r"\b[0-9a-f]{7,39}\b", re.IGNORECASE)
_PR_NUMBER_RE = re.compile(r"(?:\bPR\s*#?|#)(\d+)\b", re.IGNORECASE)
_MERGEABILITY_FALSE_RE = re.compile(
r"(?:mergeable\s*:\s*false|not mergeable|non[- ]mergeable|mergeability\s*:\s*false|"
r"mergeability result\s*:\s*false)",
re.IGNORECASE,
)
_CONFLICT_PROOF_RE = re.compile(
r"(?:merge-tree|git merge-tree|gitea_view_pr|gitea_check_pr_eligibility|"
r"conflict proof|mergeability tool|merge simulation|conflicting files?)",
re.IGNORECASE,
)
_CONFLICTING_FILES_RE = re.compile(
r"(?:conflicting files?|conflict files?)\s*:\s*(.+)$",
re.IGNORECASE | re.MULTILINE,
)
_HEAD_CHANGED_RE = re.compile(
r"(?:head (?:changed|unchanged)|head sha (?:changed|unchanged)|"
r"head changed since|head unchanged since)",
re.IGNORECASE,
)
_MERGEABILITY_UNVERIFIED_RE = re.compile(
r"\bMERGEABILITY_UNVERIFIED\b",
)
def _pr_section(text: str, pr_number: int) -> str:
"""Return report lines likely describing one skipped PR."""
lines = (text or "").splitlines()
chunks: list[str] = []
capture = False
token = f"#{pr_number}"
for line in lines:
lower = line.lower()
if token in lower or f"pr {pr_number}" in lower or f"pr#{pr_number}" in lower.replace(" ", ""):
capture = True
chunks.append(line)
continue
if capture:
if _PR_NUMBER_RE.search(line) and token not in line.lower():
break
if line.strip() == "" and len(chunks) > 3:
break
chunks.append(line)
if chunks:
return "\n".join(chunks)
return text or ""
def _has_head_sha(text: str, head_sha: str | None) -> bool:
if not head_sha:
return bool(_FULL_SHA.search(text) or _SHORT_SHA.search(text))
head = head_sha.strip().lower()
if head in text.lower():
return True
if len(head) >= 7 and head[:7] in text.lower():
return True
return bool(_FULL_SHA.search(text))
def assess_non_mergeable_skip_proof(
report_text: str,
*,
skipped_prs: list[dict] | None = None,
) -> dict[str, Any]:
"""Validate skipped non-mergeable PR documentation in reviewer reports (#322)."""
text = report_text or ""
reasons: list[str] = []
assessments: list[dict[str, Any]] = []
for entry in skipped_prs or []:
pr_number = entry.get("pr_number")
if pr_number is None:
reasons.append("skipped PR entry missing pr_number")
continue
pr_number = int(pr_number)
section = _pr_section(text, pr_number)
mergeable = entry.get("mergeable")
verified = entry.get("mergeability_verified")
classification = (entry.get("classification") or "").strip().upper()
head_sha = (entry.get("head_sha") or "").strip() or None
item_reasons: list[str] = []
if f"#{pr_number}" not in text.lower() and f"pr {pr_number}" not in text.lower():
item_reasons.append(f"skipped PR #{pr_number} not documented in final report")
if mergeable is False or verified is False:
if not _MERGEABILITY_UNVERIFIED_RE.search(section) and verified is False:
if "MERGEABILITY_UNVERIFIED" not in classification:
item_reasons.append(
f"PR #{pr_number} conflict proof unavailable; report must classify "
"MERGEABILITY_UNVERIFIED"
)
elif verified is not False:
if not _MERGEABILITY_FALSE_RE.search(section):
item_reasons.append(
f"PR #{pr_number} skip missing mergeability:false proof"
)
if not _has_head_sha(section, head_sha):
item_reasons.append(
f"PR #{pr_number} skip missing current head SHA"
)
if not _CONFLICT_PROOF_RE.search(section):
item_reasons.append(
f"PR #{pr_number} skip missing conflict proof command/tool"
)
if entry.get("head_changed_since_prior_blocker") is not None:
if not _HEAD_CHANGED_RE.search(section):
item_reasons.append(
f"PR #{pr_number} skip missing head-changed-since-blocker proof"
)
if (
entry.get("conflicting_files")
and not _CONFLICTING_FILES_RE.search(section)
and not any(
path.lower() in section.lower()
for path in (entry.get("conflicting_files") or [])
)
):
item_reasons.append(
f"PR #{pr_number} has conflicting files in session proof but "
"report omits file-level conflict proof"
)
proven = not item_reasons
assessments.append({
"pr_number": pr_number,
"proven": proven,
"classification": classification or (
"MERGEABILITY_UNVERIFIED" if verified is False else "NON_MERGEABLE_SKIPPED"
),
"reasons": item_reasons,
})
reasons.extend(item_reasons)
proven = not reasons
return {
"proven": proven,
"block": not proven,
"downgraded": False,
"assessments": assessments,
"reasons": reasons,
"safe_next_action": (
"document each skipped non-mergeable PR with head SHA, mergeability result, "
"conflict proof command, conflicting files, and head-change status"
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"
),
}
+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"
),
}
+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"
),
}
+105 -26
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
@@ -134,22 +161,28 @@ def route_task_session(
_record_route(result)
return result
if required_role == "reviewer" and check_mid_merge():
result = {
"task_type": task_type,
"required_role": required_role,
"active_role": active_role_kind,
"active_profile": active_profile,
"route_result": ROUTE_INFRA_STOP,
"downstream_allowed": False,
"reasons": [
"infra_stop: Unresolved merge conflict or mid-merge state detected in MCP runtime source.",
"Please resolve all conflicts manually, finish/abort the merge, restart the MCP server, and retry."
],
"message": "infra_stop: Unresolved merge conflict or mid-merge state detected in MCP runtime source.",
}
_record_route(result)
return result
if required_role == "reviewer":
infra = assess_infra_stop()
if infra["infra_stop"]:
detail = "; ".join(infra.get("infra_stop_reasons") or [])
message = (
"infra_stop: Unresolved merge conflict or mid-merge state detected "
f"in MCP runtime source ({detail}). Please resolve all conflicts "
"manually, finish/abort the merge, and retry."
)
result = {
"task_type": task_type,
"required_role": required_role,
"active_role": active_role_kind,
"active_profile": active_profile,
"route_result": ROUTE_INFRA_STOP,
"downstream_allowed": False,
"reasons": [message],
"message": message,
"infra_stop_assessment": infra,
}
_record_route(result)
return result
if allowed_in_current_session:
result = {
@@ -185,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 = (
@@ -317,14 +370,40 @@ def first_conflict_marker_path(project_root: str | None = None) -> str | None:
return None
def check_mid_merge() -> bool:
"""Return True if the repository is mid-merge, mid-rebase, or has conflict markers."""
project_root = os.path.dirname(os.path.abspath(__file__))
git_dir = os.path.join(project_root, ".git")
if os.path.exists(git_dir):
if (os.path.exists(os.path.join(git_dir, "MERGE_HEAD"))
or os.path.exists(os.path.join(git_dir, "rebase-merge"))
or os.path.exists(os.path.join(git_dir, "rebase-apply"))):
return True
def _default_project_root() -> str:
override = (os.environ.get("GITEA_MCP_PROJECT_ROOT") or "").strip()
if override:
return os.path.realpath(override)
return os.path.dirname(os.path.abspath(__file__))
return first_conflict_marker_path(project_root) is not None
def assess_infra_stop(project_root: str | None = None) -> dict:
"""Recompute infra_stop from live git and source scan state (#285)."""
root_dir = os.path.realpath(project_root or _default_project_root())
reasons: list[str] = []
mid_merge = False
git_dir = os.path.join(root_dir, ".git")
if os.path.isdir(git_dir):
for marker in ("MERGE_HEAD", "rebase-merge", "rebase-apply"):
marker_path = os.path.join(git_dir, marker)
if os.path.exists(marker_path):
mid_merge = True
reasons.append(f"git state: {marker} present under {git_dir}")
conflict_file = first_conflict_marker_path(root_dir)
if conflict_file:
reasons.append(
f"conflict markers detected in {conflict_file} (project_root={root_dir})"
)
infra_stop = mid_merge or bool(conflict_file)
return {
"infra_stop": infra_stop,
"project_root": root_dir,
"conflict_file": conflict_file,
"mid_merge": mid_merge,
"infra_stop_reasons": reasons,
}
def check_mid_merge(project_root: str | None = None) -> bool:
"""Return True if the repository is mid-merge, mid-rebase, or has conflict markers."""
return assess_infra_stop(project_root)["infra_stop"]
+123 -620
View File
@@ -1,65 +1,79 @@
---
name: llm-project-workflow
description: >-
Portable, safe operating workflow for LLMs working on any Git/forge project:
issue-first, isolated branch worktrees, no self-review/self-merge, distinct
author/reviewer profiles, cleanup after merge, and fail-closed behavior.
Use at the start of any implementation, review, or merge task on a repo.
Router skill for safe LLM project work: identify task mode, load the matching
canonical workflow file, enforce mode isolation, and emit the correct final
report schema. Use at the start of any implementation, review, merge,
reconciliation, or issue-filing task.
---
# LLM Project Workflow
# LLM Project Workflow Skill
A reusable workflow any LLM can follow to work on any repository safely. Copy
this `skills/llm-project-workflow/` directory into another project unchanged;
adapt only the forge-specific names in [Adapting to a project](#adapting-to-a-project).
This skill is a **router**. Do not perform project work from this file alone.
The core promise: **an LLM never does unsafe or untracked work.** Every change
is tracked by an issue, isolated in its own worktree, reviewed by a different
identity, and cleaned up only after a real merge.
Before any project mutation, identify the task mode and load the matching
workflow file.
## Workflow modes
| Task mode | Workflow | Final report schema |
|-----------|----------|---------------------|
| PR review / approval / merge | [`workflows/review-merge-pr.md`](workflows/review-merge-pr.md) | [`schemas/review-merge-final-report.md`](schemas/review-merge-final-report.md) |
| 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
- Prove identity, active profile, runtime context, and **exact** capability before
mutation.
- A nearby capability does not count.
- Do not self-review or self-merge.
- Do not mix modes in one run.
- If the required workflow cannot be loaded, stop and produce a recovery handoff
only.
- Final report must use the schema for the loaded workflow.
- If a task requires a different mode, stop and produce a handoff for the
correct workflow.
## Mode isolation
A run that starts in `review-merge-pr` mode may not create process issues,
implement fixes, or edit source files.
A run that starts in `reconcile-landed-pr` mode may not approve, request
changes, merge, implement fixes, or create normal issues.
A run that starts in `create-issue` mode may not review, approve, request
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.
---
## Definitions
- **Merged**: Gitea PR metadata says `merged=true`.
- **Landed**: Equivalent content is present on remote `master`, but PR metadata may not say merged.
- **Landed**: Equivalent content is present on remote `master`, but PR metadata
may not say merged.
- **Closed-not-merged**: PR state is closed and `merged=false`.
- **Reconciled**: A human/LLM verified whether closed-not-merged content landed, partially landed, or was lost, and repaired issue/label/tracker state.
## A. Issue-first rule
**No repository change without a tracking issue.** This includes creating,
editing, deleting, or `chmod`-ing files; docs; scripts; commits; pushes; and PRs.
1. Before any change, confirm a tracking issue exists.
2. If none exists, create one first (title + problem + scope + acceptance).
3. Claim it (assign yourself or apply the `status:in-progress` label) and comment
that work is starting, including the planned branch name.
4. **If the issue cannot be created or claimed, stop.** Do not touch files.
Reading the repo, running read-only status/`git log`, and creating/claiming the
issue itself are allowed from the orchestration checkout without a prior issue.
Additional issue-first rules:
- Do not implement code without an issue unless explicitly authorized.
- **Design-only work uses a discussion/RFC issue** — create one or comment on
the existing one. Design debates belong on the issue, where other LLMs
comment directly. Discussion-only tasks must **not** create branches or PRs;
their comments should include recommendations, risks, open questions, and a
Controller Handoff (§K; compact format unless high-risk).
- **If the repo/tracker home for the work is unclear, stop and ask for an
owner decision.** Do not create a new repository or a new tracker unless
explicitly approved by the owner.
- **Reconciled**: Verified whether closed-not-merged or already-landed content
is present on the target branch; issue/label/tracker state repaired.
## Work Selection Rule for LLMs
Before starting any issue or PR work, acquire or verify a work lease.
Do not begin coding, reviewing, fixing, branching, committing, pushing,
commenting, or creating a PR until you prove the target is not already being
worked.
Before starting any issue or PR work, acquire or verify a work lease. Do not
begin coding, reviewing, fixing, branching, committing, pushing, commenting, or
creating a PR until you prove the target is not already being worked.
Required checks:
@@ -71,612 +85,101 @@ Required checks:
6. Check active leases or recent handoffs.
7. Check whether the issue was already completed by a merged PR.
If another active LLM/session owns the lease, stop.
Allowed responses:
- continue as the lease owner,
- review the existing PR if reviewer capability allows,
- produce a handoff,
- request takeover after lease expiry,
- stop with "work already claimed."
Never create a parallel branch or PR for the same issue unless the old branch
is proven abandoned and the takeover is recorded.
If another active session owns the lease, stop with "work already claimed" or
produce a handoff.
For Gitea-Tools: `gitea_lock_issue` is the fail-closed lease gate before author
mutations; `status:in-progress` and claim comments are supporting lease signals.
Use `review_proofs.classify_issue_for_selection` when reporting fresh issue
selection (#188).
## Global LLM Worktree Rule
The main project checkout is a stable control checkout. It must stay on the
configured stable branch: `master`, `main`, or `dev`.
The main project checkout is a stable control checkout on `master`, `main`, or
`dev`. All LLM task work must happen inside the project's `branches/` directory.
All LLM task work must happen inside the project's `branches/` directory.
If `cwd` is not inside `branches/`, stop before any file edit, test write,
commit, merge, rebase, or cleanup. The main checkout is orchestration-only.
Before any mutation, prove:
## Shell Spawn Hard-Stop Rule
1. current project root
2. current working directory
3. current branch
4. stable branch for the main checkout
5. session-owned worktree path under `branches/`
`exit_code: -1` with empty stdout/stderr means the shell failed to spawn — not a
command failure. After two consecutive spawn failures, hard-stop shell use for
the session and emit a recovery report (#258).
If `cwd` is not inside `branches/`, stop. Do not edit, create, delete, format,
test-write, commit, merge, rebase, checkout task branches, resolve conflicts,
or run cleanup.
## Isolated worktree naming
There are no exceptions for small fixes, docs, tests, cleanup, PR review fixes,
conflict resolution, or emergencies.
Implementation: `(fix|feat|docs|chore)/issue-<number>-<short-description>`
The main checkout may only be used for read-only inspection, fetching,
stable-branch update after merged PRs, creating `branches/` worktrees, or
explicit control-checkout repair.
Review: `review/pr-<number>-<short-description>`
## B. Isolated worktree rule
## Subagent Tool-Budget Guardrails
**Never implement or review in the main checkout** (Global LLM Worktree Rule).
The main checkout is for orchestration and status only (issue creation,
`git status`, creating worktrees) and must remain on the stable branch.
General-purpose subagents on **single-step MCP tasks** (for example
`gitea_commit_files`) must not expand into 100+ tool-call retry spirals with
WebFetch/Playwright/manual-encoding fallbacks (issue #259).
- Each issue gets its own branch worktree under an ignored `branches/` directory.
- Review work uses a **separate** review worktree, never the author's folder.
- Dirty work in one branch folder must not block starting another issue.
- No LLM may edit another issue's worktree unless explicitly assigned to it.
- Branch folders are removed only after the PR is merged/closed **and** cleanup
is explicitly part of the task.
Default budgets (stop when exceeded):
Every implementation branch **must include its issue number** so it is
traceable end to end: **issue → branch → worktree folder → PR → cleanup.**
- **Single-step MCP mutation** (`commit_files`, `create_pr`, `lock_issue`):
15 tool calls, 5 minutes wall time.
- **Review / merge queue inspection**: 40 tool calls, 15 minutes.
- **Non-mutating exploration**: 60 tool calls, 20 minutes.
Allowed implementation patterns:
Rules:
- `fix/issue-123-short-description`
- `feat/issue-123-short-description`
- `docs/issue-123-short-description`
- `chore/issue-123-short-description`
1. When the main session has `gitea.repo.commit`, call `gitea_commit_files`
directly — do not delegate commit to a subagent (#260).
2. After shell spawn failure (#258), attempt the native MCP tool once before
any fallback; shell unavailability never authorizes WebFetch/Playwright/
manual base64.
3. Never resume a failed subagent into a larger retry loop or spawn a second
subagent for the same deterministic step — stop and report.
4. When `gitea_commit_files` is available, forbid WebFetch, Playwright,
manual encoding, and ad-hoc `_encode_*` / `_emit_*` helpers in the repo.
Review-only branches:
Worktree folder: branch with `/` replaced by `-` under `branches/`.
- `review/pr-456-short-description`
Helpers: `scripts/worktree-start`, `scripts/worktree-review`,
`scripts/worktree-clean`.
Use a filesystem-safe folder under `branches/` by replacing slashes with
hyphens, for example `branches/fix-issue-123-short-description`.
## Identity and profile safety
`scripts/worktree-start` **enforces** this: it rejects an implementation branch
that does not match `(fix|feat|docs|chore)/issue-<number>-…` (or a
`review/pr-<number>-…` branch), unless `--allow-unlinked` is passed. Traceability
is maintained by:
- Author and reviewer identities must be distinct.
- Never place raw tokens in LLM/MCP config.
- Use `gitea_whoami` and `gitea_resolve_task_capability` before mutating.
- the branch name (contains the issue number),
- a claim comment on the issue, e.g.
`Claimed. Branch: fix/issue-123-short-description. Worktree: branches/fix-issue-123-short-description.`,
- the PR body — `Closes #123` or `Fixes #123` when the PR should close the issue
(do NOT use `Implements #123` or `Refs #123` to close, as Gitea will not auto-close),
- cleanup after merge — remove the remote branch, local branch, and the issue
worktree folder, and drop `status:in-progress`.
For projects using `Gitea-Tools` helpers:
```bash
scripts/worktree-start fix/issue-123-example # → branches/fix-issue-123-example
scripts/worktree-review fix/issue-123-example # → branches/review-fix-issue-123-example (detached)
scripts/worktree-clean --delete-branch fix/issue-123-example
```
Manual equivalent:
```bash
git fetch <remote> --prune
git worktree add -b fix/issue-123-example branches/fix-issue-123-example <remote>/master
cd branches/fix-issue-123-example
```
`venv/` and similar are not copied into new worktrees — run checks with a known
interpreter path, or create a venv inside the branch folder.
## C. Identity and profile safety
- Use canonical execution profiles where available; the profile is the role, not the LLM. A task selects a profile; a profile is not permanently assigned.
- **Author and reviewer identities must be distinct.**
- Never place raw tokens/passwords in an LLM/MCP client config. Reference secrets by keychain id or environment variable name only. Prefer a single canonical config file selected by two env vars, e.g.:
- `GITEA_MCP_CONFIG` — path to the canonical profiles file
- `GITEA_MCP_PROFILE` — the profile to activate
- **Dual-Profile MCP Launcher Pattern (Recommended):** To avoid relaunch bottlenecks and PR-author deadlocks, register multiple instances of the same MCP server in the client's configuration simultaneously (e.g., `gitea-author` and `gitea-reviewer`), each pointing to its respective `GITEA_MCP_PROFILE`.
- Tool calls become namespace-scoped: `mcp__gitea-author__*` and `mcp__gitea-reviewer__*`.
- **Trust Model:** Separate tokens remain separate. Profile gates enforce allowed operations, `whoami` is still checked, and self-review/self-merge prevention remains mandatory. This pattern is for convenience and does not bypass security gates.
- **Deadlock Warning:** Reviewer/merge identities must not be used to create PRs, as this makes the reviewer the PR author in Gitea and blocks independent review. PRs should normally be created by the author/work identity, keeping the reviewer identity available for reviews.
- **Fallback:** If a dual-server launcher is not available in the client, relaunch or restart the client with the correct profile environment variable before claiming work.
- **If the authenticated user equals the PR author, stop** — no self-review, no self-merge.
## D. Branch naming
```text
fix/issue-123-short-description
feat/issue-123-short-description
docs/issue-123-short-description
review/pr-456-scope-check
```
Worktree folder = branch with `/` replaced by `-`
(`branches/fix-issue-123-short-description`).
## E. Start-work workflow
0. Acquire or verify a work lease (Work Selection Rule) — complete all seven
checks before any claim, branch, or PR work.
0b. Global LLM Worktree Rule — prove project root, `cwd`, branch, main-checkout
stable branch, and session-owned `branches/` worktree path. If `cwd` is not
under `branches/`, stop before any mutation (no exceptions).
1. Verify the orchestration checkout (right repo, clean tree, on stable branch).
2. Fetch/prune: `git fetch <remote> --prune`.
3. Confirm local `master` equals remote `master` (`git rev-list --left-right --count <remote>/master...master``0 0`).
4. Create/claim the issue (§A).
4b. **Issue lock from your scratch clone (#249):** when using
`gitea_lock_issue`, pass `worktree_path` pointing at your own clean
scratch clone (or set `GITEA_AUTHOR_WORKTREE`). The lock gate validates
*that* path — clean tree on `master`/`main`, no tracked edits yet —
not the shared MCP/orchestration checkout. Another session's dirty
feature branch in the shared dev worktree must not block your lock.
Never stash, reset, or checkout files in the shared worktree to satisfy
the gate. Pass the same `worktree_path` to `gitea_create_pr` so the PR
gate matches the lock record.
5. Create the isolated worktree (§B) from latest remote `master`.
6. Implement the narrow scope only — no unrelated refactors or formatting churn.
7. Add/update focused tests when behavior changes.
8. Run the checks (tests, compile/lint, `git diff --check`, secret scan).
Record the branch name and `HEAD` SHA at validation time — the drift
check in step 9 compares against exactly this state.
9. **Branch proof before commit (#177):** prove and state, immediately
before staging/committing (`author_proofs.verify_branch_for_commit`,
`author_proofs.detect_branch_drift`):
- current branch (`git branch --show-current`) equals the intended
feature branch from the issue claim
- current branch is not `master`, `main`, `develop`, `development`, or
`dev`
- branch and `HEAD` have not changed since validation (step 8) — in a
shared checkout another session may switch branches mid-session;
treat that as expected and **stop before committing** when detected
If any check fails, stop and reconcile; do not commit.
10. Commit with an issue-linked message.
11. **Branch proof before push (#177):** prove that the local branch, the
push target branch, and the intended issue branch all match, and that
none of them is a protected branch
(`author_proofs.verify_push_target`). If a commit accidentally landed
on a protected branch, do **not** push: report the accident and the
exact repair steps (`author_proofs.assess_protected_branch_commit`) —
never silently continue after a repair.
12. Push the branch.
13. Open a PR to `master`. The final report must include the branch proofs
from steps 9 and 11 (`author_proofs.build_commit_push_report`).
14. **If you are the author, stop before review/merge.**
15. **Normal issue work must not directly push to `master`.** PR content should be merged through the forge PR merge mechanism.
16. Direct push to `master` is allowed only as a documented recovery exception. If used, the final report must include:
- why the PR merge path could not be used
- exact commits pushed
- PR metadata state
- issue labels/state repaired
- whether the PR is closed-not-merged
## F. Review workflow
1. Use a separate review worktree (`scripts/worktree-review <branch>`), detached.
2. Verify your authenticated identity.
3. Verify the PR author — **you must not be the author.** Self-review
contamination must be *evidence-backed* (#173): state the authenticated
reviewer identity, the PR author identity, whether this session
authored/touched the PR branch, and the evidence source for any
"same-session author" claim. If the evidence is missing, report the
status as **unknown** — never declare contamination by assumption — and
choose another PR or stop (`review_proofs.assess_self_review_contamination`).
4. Verify the worktree is clean.
5. **Checkout proof (#173):** before reviewing or validating, prove and
state: the selected PR head SHA from Gitea (pinned), the local checkout
SHA (`git rev-parse HEAD`), that `HEAD ==` the pinned PR head SHA, and
that the diff base is the PR base branch. If `HEAD` does not match the
pinned head, **stop before review/merge**
(`review_proofs.verify_pinned_head_checkout`).
6. **Inventory proof (#173 + repo disambiguation hardening):** a blind queue
review must prove listing completeness before claiming "only PRs found".
Use repo-name disambiguation:
- "Gitea-Tools" / "gitea tool" / "MCP Gitea tool" / "gitea MCP tool" /
"gitea-tools repo" resolve **only** to `Scaled-Tech-Consulting/Gitea-Tools`.
- "mcp-control-plane" resolves only to `Scaled-Tech-Consulting/mcp-control-plane`.
- Ambiguous ("open PRs", no explicit repo, "MCP Gitea tooling") → inventory
**both** configured repos.
Report must state exactly which repo(s) were checked. If only one checked:
"Only <repo> was checked. Other configured repos were not checked. This is
not a complete queue inventory." Never let a single-repo zero hide PRs in
the other.
Both configured repos must be reported with state filter, pagination proof,
and open-PR count (`review_proofs.assess_inventory_completeness` and
`resolve_repos_from_user_reference`). Before inventory, reconcile the
operator-supplied PR backlog against the target repo
(`review_proofs.reconcile_queue_target`); never report `trusted_empty`
for one repo while ignoring contradictory supplied PR numbers in another.
7. **Role-boundary proof (#175):** a reviewer queue task must not silently
become author implementation. If no eligible PR exists, stop with the
queue report. Do not claim issues, create branches, commit, push, or open
PRs unless the operator explicitly retasks the run as author work. Mixed
reviewer+author namespace use must be reported with a justification, and
scratch-only notes are not durable evidence unless posted or committed
intentionally (`review_proofs.assess_role_boundary`).
8. Inspect the full diff; confirm scope matches the linked issue; flag unrelated files.
9. Run the tests. Validation reporting must include the exact command and
exact results: pass/fail, counts of tests passed/skipped/failed, any
ignored paths and why they are safe to ignore, and whether the command
differs from the repository's canonical validation command. Only claim a
validation result after the command has completed and its output has
been read (`review_proofs.assess_validation_report`).
During validation, review work is **read-only**: use
`gitea_dry_run_pr_review` to prove submission mechanics — never post live
APPROVE, REQUEST_CHANGES, or review comments to probe tool paths. After
validation completes, call `gitea_mark_final_review_decision`, then submit
exactly one live review via
`gitea_submit_pr_review(..., final_review_decision_ready=True)`.
After submitting, re-read `gitea_get_pr_review_feedback` and confirm the
verdict is visible (`approval_visible` true for APPROVE; PENDING drafts do
not count — #244). Do not merge until a visible APPROVED review exists.
Final reports must list exactly one review mutation
(`review_proofs.assess_review_mutation_final_report`) unless an
operator-approved correction flow was invoked and explained.
10. **Do not merge if checks fail. Do not merge if the reviewer is the author.**
11. **#179 A-bar proofs** (all fail closed when missing —
`review_proofs.assess_capability_evidence`, `assess_sweep_evidence`,
`assess_live_state_recheck`, `assess_role_boundary`):
- Capability claims must cite exact `gitea_resolve_task_capability`
output (or runtime context); a bare "capability checks passed" is
downgraded.
- The secret/provenance sweep must state the exact command/script/
pattern/named method and the scope scanned.
- Immediately before submitting a review verdict (and again before any
merge), re-read live PR state and prove: still open, live head ==
pinned head, base unchanged, no unresolved blocking review state.
- Reviewer runs stay in the reviewer namespace; any author-namespace
call requires an explicit justification in the report.
12. The final report must distinguish (`review_proofs.build_final_report`):
identity eligible; PR author different from reviewer; session
contamination absent (with evidence); validation performed on the pinned
head; capability evidence; sweep verdict; live-state recheck; role
boundary; merge performed; issue status verified. If any proof is
missing, stop or downgrade the result instead of merging confidently.
## G. Merge / cleanup workflow
Only an eligible (non-author) reviewer merges. Before merging: always verify
the authenticated identity **and** the PR author; cite exact capability
evidence for merge_pr (#179); respect runtime profile gates; run independent
validation (do not trust the author's reported results); perform the **final
live-state recheck** (#179 — PR still open, live head == pinned head, base
unchanged, no unresolved blocking review state) immediately before the merge
mutation; and merge with a **pinned head SHA** and, where supported, the
**expected changed-file set**, so a moved head or widened diff refuses the
merge. After a real merge:
1. Confirm remote `master` actually contains the merge commit or expected squashed changes via post-merge file-presence verification (A PR is not done just because `master` moved or is marked "closed". Verify that expected files added/modified in the PR are actually present on `master` using `git pull`, `git log --oneline -- <file>`, or `git merge-base --is-ancestor`; linked issues are closed; `status:in-progress` is removed).
2. Close/release the issue.
3. Whenever an issue is closed, check for `status:in-progress`: remove it, or report why it could not be removed.
4. Do not delete the remote source branch until: PR `merged=true`, or reconciliation confirms content is safely landed, or the issue owner explicitly abandons the work.
5. Remove the local branch.
6. Remove the branch worktree folder (`scripts/worktree-clean --delete-branch <branch>`). Branches/worktrees are cleaned only after the above is verified.
7. Fetch/prune.
8. Confirm the main checkout is clean and current (`0 0` vs remote).
9. Final merge/reconciliation reports must include: PR metadata (state, merged flag, merge commit/hash), Git content (remote master hash, expected content present or not), and the exact post-merge verification method used & results.
Never run cleanup before the merge is confirmed on remote `master`.
## H. Fail-closed cases
**Stop and report — take no mutating action — if:**
- No issue exists and one cannot be created.
- Worktree state is unclear or unexpected.
- Branch/PR state conflicts with the prompt (e.g. prompt says "merged" but it is not).
- A PR is closed but not merged (closed with `merged=false`). In this case:
- stop normal review/merge
- do not delete branches/worktrees
- do not start dependent work
- run reconciliation
- Local `master` is ahead of remote unexpectedly.
- The authenticated user is the PR author (for review/merge).
- Secrets/tokens appear in the diff.
- Tests fail.
- A cleanup step would delete unmerged work.
When in doubt, stop and surface the discrepancy; do not guess or work around a gate.
## I. Recovery patterns
- **Dirty worktree from another issue:** do not touch it. Start your issue in its
own new worktree; unrelated dirty work must not block you. For Gitea-Tools
author flows, lock the issue from your scratch clone (`worktree_path` on
`gitea_lock_issue`) — do not manipulate the shared dev checkout.
- **Local `master` ahead of remote unexpectedly:** do not push `master`. Confirm
the commits are preserved on a feature branch (local + remote) first, then
`git reset --hard <remote>/master` to realign. Never discard commits that are
not safely pushed elsewhere.
- **PR closed but not merged (`merged=false`):** do not merge. Run reconciliation: compare PR content to remote `master` and decide:
- **fully landed:** comment that content is present on `master`, remove `status:in-progress`, keep/close issue as appropriate, clean up only after content equivalence is confirmed.
- **partially landed:** do not clean up, reopen issue if needed, create corrective issue/PR for missing pieces.
- **not landed:** reopen issue if needed, reopen PR or create replacement PR, do not clean up source branch/worktree.
- **Branch deleted before merge:** if the commits still exist locally (a branch or
reflog), re-push them and reopen the PR; otherwise recover via
`git fsck --lost-found`. Preserve first, then proceed.
- **Unauthorized/untracked file created:** do not commit it. Leave pre-existing
untracked artifacts (e.g. editor/agent dirs, reports) alone; stage only the
files your issue names (`git add <files>`, never blind `git add -A`).
- **Preserve commits before a reset:** confirm the target commits are reachable
from a branch that is pushed to the remote, then reset. Verify with
`git branch --contains <sha>` and `git log <remote>/<branch>`.
## J. Prompt snippets
Ready-to-copy templates live in [`templates/`](templates/):
- [`start-issue.md`](templates/start-issue.md) — start a new issue.
- [`review-pr.md`](templates/review-pr.md) — review a PR.
- [`merge-pr.md`](templates/merge-pr.md) — merge a PR (eligible reviewer only).
- [`recover-bad-state.md`](templates/recover-bad-state.md) — recover from bad state.
- [`reconcile-closed-not-merged-pr.md`](templates/reconcile-closed-not-merged-pr.md) — reconcile a closed-not-merged PR.
- [`worktree-cleanup.md`](templates/worktree-cleanup.md) — clean up after merge.
- [`release-tag.md`](templates/release-tag.md) — create a release tag.
## K. Controller Handoff (required, every task)
Every LLM task **must end with a `Controller Handoff`** (exact title) — whether the
task was implementation, review, merge, issue triage, documentation,
discussion-only, or blocked planning. It lets a controller LLM understand the
current state immediately, without rereading the conversation.
The section title must be exactly "Controller Handoff" (or "Controller Handoff Summary" for long form). Reports without it are downgraded (see review_proofs.assess_controller_handoff).
**The compact format is the default.** It is written for controller-LLM
readability, not as a full human status report. PR bodies still carry the
full review detail — the handoff never replaces PR documentation.
Compact format (default, canonical field set per issue #182):
```md
## Controller Handoff
- Task:
- Repo:
- Role:
- Identity:
- Issue/PR:
- Branch/SHA:
- Files changed:
- Validation:
- Mutations:
- Current status:
- Blockers:
- Next:
- Safety:
```
Every task must end with a section titled exactly `Controller Handoff`. Compact
format canonical field set per issue #182; mode-specific schemas in
`schemas/*-final-report.md` define required fields. Use the final report schema
for the loaded workflow mode — not the legacy compact block alone.
`review_proofs.assess_controller_handoff()` validates presence.
Role-specific fields (append to the compact block):
## Prompt templates
- review/merge tasks: `Selected PR:`, `Reviewer eligibility:`,
`Pinned reviewed head:`, `Review decision:`, `Merge result:`,
`Linked issue status:`, `Cleanup status:`
- issue-filing tasks (#191): `Issue created or updated:`, `Related issues:`;
body must cite exact issue number/title, duplicate-search summary (issues
searched, closest matches, why update rejected / new issue justified), full
40-char SHAs when citing commits, exact mutation capability per change, and
`Only mutation(s):` when a single mutation was performed
(`review_proofs.assess_issue_filing_final_report`).
- author tasks: `Selected issue:`, `Claim/comment status:`,
`PR number opened:`, `No review/merge:` (explicit confirmation)
- continuation tasks (#188): `Continuation mode:`, `Existing PR:`,
`PR author:`, `Branch:`, `Old PR head:`, `New PR head:`,
`Session authored PR:`, `Why continuation allowed:` — issues with open
PRs are excluded from fresh selection unless operator explicitly requests
continuation (`review_proofs.classify_issue_for_selection`,
`assess_issue_selection_final_report`)
- queue/inventory tasks: `Repositories checked:`, `Open PR counts:`,
`Selected PR or reason none selected:`, `Inventory completeness:`
Ready-to-copy task prompts live in [`templates/`](templates/):
The section title must be exactly `Controller Handoff`.
`review_proofs.assess_controller_handoff()` validates this section; reports
missing it (or missing required fields) are downgraded. The handoff never
replaces the full report — it is the compact continuation summary at the end,
and the full report must still carry exact validation results and mutation
confirmation.
The `Safety:` line is never omitted; it is usually:
```text
no self-review; no self-merge; no tags; no secrets; no prod
```
Rules (both formats):
- Never omit the handoff, and never omit the safety confirmations.
- Never bury blockers in earlier text only — they must appear here.
- If you opened a PR, state clearly that review is needed.
- If you reviewed but could not merge, name the exact gate that blocked it.
- If you only commented on a discussion issue, say no code review is needed
but owner/design feedback may be needed.
- If release state was touched, state exactly which tag/commit changed and why.
- If blocked (permissions, missing repo, missing second reviewer identity,
stale dependency, unclear tracker home): stop and report clearly; **never
bypass classifiers, profile gates, missing permissions, or live-consent
requirements**; give the owner concrete options.
**Use the long format below instead of the compact one only when the task was
high-risk or complex** — i.e. when any of these happened:
- a merge, tag, or release
- failed validation
- permissions/profile gates blocked work
- secrets or production access were involved
- a complicated owner decision
- multiple repos or cross-issue state
- the owner explicitly asks for the full format
Long format (high-risk/complex tasks only):
```md
## Controller Handoff Summary
### Work performed
Briefly state what was done.
### Current state
Include:
- current repo
- current branch or master commit
- issue number(s)
- PR number(s), if any
- whether work is complete, blocked, ready for review, or discussion-only
### Files changed
List files changed, or say `None`.
### Validation
List commands run and results, or say `Not applicable — discussion only`.
### Issues encountered
List errors, confusing state, permission/profile problems, stale branches,
failing tests, missing labels, or blocked decisions.
### Review needed?
Say one of:
- `No review needed — discussion/comment only`
- `Review needed — PR is open`
- `Independent non-author review needed`
- `Owner decision needed`
- `Blocked`
### Next recommended action
State exactly what should happen next.
### Safety confirmations
Confirm:
- no self-review
- no self-merge
- no release/tag changes unless explicitly requested
- no secrets committed
- no production access used unless explicitly authorized
```
### Example blocked handoff
```md
## Example blocked handoff
### Work performed
Audited phase-2 MCP Control Plane planning. Found target repo
`mcp-control-plane` does not exist. Prepared issue pack but did not file it.
### Current state
- Repo: `Scaled-Tech-Consulting/Gitea-Tools`, unmodified
- Target repo: `mcp-control-plane`, missing
- Issues: none open in Gitea-Tools
- PRs: none open
- Status: blocked pending owner decision
### Files changed
None.
### Validation
Tracker/repo audit only. No code validation required.
### Issues encountered
Repo creation was denied by permission/classifier because it would be scope
escalation without live consent.
### Review needed?
Owner decision needed.
### Next recommended action
Owner must choose:
1. create `Scaled-Tech-Consulting/mcp-control-plane`
2. authorize repo creation while present
3. file phase-2 issues in Gitea-Tools instead
### Safety confirmations
- no self-review
- no self-merge
- no release/tag changes
- no secrets committed
- no production access used
```
- [`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)
- [`worktree-cleanup.md`](templates/worktree-cleanup.md)
- [`release-tag.md`](templates/release-tag.md)
## Adapting to a project
Replace these project-specific names when copying the skill elsewhere:
| Placeholder | Meaning | Example here |
|-------------|---------|--------------|
| `<remote>` | Git remote for the forge | `prgs` |
| default branch | Integration branch | `master` |
| profile env vars | Canonical config + profile selectors | `GITEA_MCP_CONFIG`, `GITEA_MCP_PROFILE` |
| `branches/` | Ignored worktree directory | `branches/` |
| helper scripts | Worktree helpers | `scripts/worktree-start` / `-review` / `-clean` |
The rules in §A–§K are project-agnostic and should not change.
| Placeholder | Example here |
|-------------|--------------|
| `<remote>` | `prgs` |
| default branch | `master` |
| profile env vars | `GITEA_MCP_CONFIG`, `GITEA_MCP_PROFILE` |
| `branches/` | `branches/` |
| helpers | `scripts/worktree-start` / `-review` / `-clean` |
## Versioning And Tagging
Releases follow SemVer: **`vMAJOR.MINOR.PATCH`** (use **`v0.x.y`** while
unstable). Choose the bump by the largest change since the last tag:
- **PATCH** — bug fixes, docs, tests, wrappers, non-breaking workflow polish.
- **MINOR** — new tools/helpers/config features; backward-compatible behavior.
- **MAJOR** — breaking config/schema/API behavior or a changed MCP contract.
Tags must:
- be created **only from `master`** (the exact commit on remote `master`),
- be created **only after the full test suite passes**,
- be **annotated** tags (`git tag -a`), never lightweight,
- include release notes / a changelog summary referencing the merged PRs/issues.
**Never tag** feature branches, dirty worktrees, unreviewed or self-authored
work, or commits not present on remote `master`.
Additional tag rules:
- Do **not** create, move, delete, or push tags unless explicitly instructed.
- Tag only **after** the intended PR is merged, and tag only the **verified
final master merge commit** (never the PR branch head unless the merge
commit is exactly that commit).
- Always **report the tag target commit** in the final report / handoff.
Release process (see [`templates/release-tag.md`](templates/release-tag.md)):
1. `git fetch <remote> --prune`.
2. Verify local `master` equals remote `master` (`0 0`) and the tree is clean.
3. Run the full test suite; stop on any failure.
4. Inspect merged issues/PRs since the last tag
(`git log --oneline <last-tag>..<remote>/master`).
5. Choose the version bump.
6. Create the annotated tag on remote `master` with release notes.
7. Push the tag.
8. Create/update release notes if the forge supports it.
Where present, `scripts/release-tag` automates this with all gates built in
(SemVer, fetch/prune, on-master, clean tree, local==remote master, HEAD on
remote master, no duplicate tag, tests, annotated-only). Safe by default: no
push without `--push`; `--dry-run` changes nothing; `--skip-tests` must be
explicit and warns.
Releases follow SemVer from remote `master` only, after full test suite passes.
See [`templates/release-tag.md`](templates/release-tag.md) and
`scripts/release-tag`.
@@ -0,0 +1,46 @@
# Create-issue controller handoff schema
**Task mode:** `create-issue`
End every create-issue run with a section titled exactly `Controller Handoff`.
Use this canonical field set. Do not omit fields — use `none` or
`not verified in this session` where appropriate.
Do not use legacy fields: `Workspace mutations`, `Mutations: None` (when
mutations occurred).
```md
## Controller Handoff
- Task:
- Repo:
- Role:
- Identity:
- Active profile:
- Runtime context:
- Requested issue task:
- Workflow source:
- Capability proof:
- Duplicate search terms:
- Duplicate search pagination proof:
- Duplicates found:
- Issues created:
- Issues commented:
- Issues edited:
- Issues skipped as duplicates:
- Labels/assignees/milestones changed:
- File edits by issue creator:
- Worktree/index mutations:
- Git ref mutations:
- MCP/Gitea mutations:
- Issue mutations:
- Label/assignment/milestone mutations:
- External-state mutations:
- Read-only diagnostics:
- Blockers:
- Current status:
- Safe next action:
- Safety statement:
```
Identity format: `username / profile` (not personal email unless required — #305).
@@ -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)
@@ -0,0 +1,53 @@
# Reconcile-landed controller handoff schema
**Task mode:** `reconcile-landed-pr`
End every reconciliation run with a section titled exactly `Controller Handoff`.
Use this canonical field set. Do not omit fields — use `none` or
`not verified in this session` where appropriate.
Reject stale author/reviewer fields: `PR number opened`, `Pinned reviewed head`,
`Scratch worktree used`, `Workspace mutations`, `Mutations: None` (when mutations
occurred).
```md
## Controller Handoff
- Task:
- Repo:
- Role:
- Identity:
- Active profile:
- Runtime context:
- Selected PR:
- PR live state:
- Candidate head SHA:
- Target branch:
- Target branch SHA:
- Ancestor proof:
- Linked issue:
- Linked issue live status:
- Eligibility class:
- Capabilities proven:
- Missing capabilities:
- PR comments posted:
- Issue comments posted:
- PRs closed:
- Issues closed:
- File edits by reconciler:
- Worktree/index mutations:
- Git ref mutations:
- MCP/Gitea mutations:
- Reconciliation mutations:
- External-state mutations:
- Read-only diagnostics:
- Blockers:
- Current status:
- Safe next action:
- Safety statement:
- No review/merge confirmation:
```
Identity format: `username / profile` (not personal email unless required — #305).
`git fetch` belongs under `Git ref mutations`, not read-only diagnostics (#297).
@@ -0,0 +1,94 @@
# Review-merge controller handoff schema
**Task mode:** `review-merge-pr`
End every review/merge run with a section titled exactly `Controller Handoff`.
Use this canonical field set. Do not omit fields — use `none` or
`not verified in this session` where appropriate.
Do not use legacy fields: `Pinned reviewed head`, `Scratch worktree used`,
`Workspace mutations`, `Mutations: None` (when mutations occurred).
```md
## Controller Handoff
- Task:
- Repo:
- Role:
- Identity:
- Active profile:
- Runtime context:
- Selected PR:
- Linked issue:
- Eligibility class:
- Queue ordering policy:
- Inventory pagination proof:
- Earlier PRs skipped:
- Candidate head SHA:
- Reviewed head SHA:
- Target branch:
- Target branch SHA:
- Already-landed gate:
- Author-safety result:
- Prior request-changes state:
- Review worktree used:
- Review worktree path:
- Review worktree inside branches:
- Review worktree HEAD state:
- Review worktree dirty before validation:
- Review worktree dirty after validation:
- Baseline worktree used:
- Baseline worktree path:
- Files reviewed:
- Validation:
- Official validation integrity status:
- Terminal review mutation:
- Review decision:
- Merge preflight:
- Merge result:
- Linked issue status:
- Main checkout branch:
- Main checkout dirty state:
- Main checkout updated:
- File edits by reviewer:
- Worktree/index mutations:
- Git ref mutations:
- MCP/Gitea mutations:
- Review mutations:
- Merge mutations:
- Cleanup mutations:
- External-state mutations:
- Read-only diagnostics:
- Blockers:
- Current status:
- Safe next action:
- Safety statement:
```
### Already-landed handoff overrides
When eligibility class is `ALREADY_LANDED_RECONCILE_REQUIRED`:
- Reviewed head SHA: `none`
- Review worktree used: `false`
- Review worktree path: `none`
- Review decision: `none`
- Merge result: `none`
Identity format: `username / profile` (not personal email unless required — #305).
### Queue-status-only runs (no selected PR)
When the run inventories the queue but selects no PR for review:
- Selected PR: `none`
- Already-landed gate, Author-safety result, Merge preflight: `not applicable` or `not run` — never `passed`
- Review worktree detail fields: `not applicable` or `none` when Review worktree used is `false`
- Blockers must not be `none` if the narrative says all open PRs are conflicted, blocked, or unverified
- Inventory pagination proof must cite final-page metadata, not default page-size assumptions
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.
@@ -0,0 +1,73 @@
# Work-issue controller handoff schema
**Task mode:** `work-issue`
End every work-issue run with a section titled exactly `Controller Handoff`.
Use this canonical field set. Do not omit fields — use `none` or
`not verified in this session` where appropriate.
Do not use legacy fields: `Workspace mutations`, `Mutations: None` (when
mutations occurred).
```md
## Controller Handoff
- Task:
- Repo:
- Role:
- Identity:
- Active profile:
- Runtime context:
- Selected issue:
- Eligibility class:
- Issue ordering policy:
- Issue inventory pagination proof:
- Earlier issues skipped:
- Duplicate active work proof:
- Claim/lock state:
- Stable branch:
- Stable branch SHA:
- Branch name:
- Worktree path:
- Worktree inside branches:
- Worktree branch/HEAD state:
- Worktree dirty before implementation:
- Files changed:
- Validation:
- Baseline comparison:
- Commit SHA:
- Push result:
- PR number:
- PR URL:
- PR verification:
- Main checkout branch:
- Main checkout dirty state:
- Main checkout used for task work:
- File edits by author:
- Worktree/index mutations:
- Git ref mutations:
- MCP/Gitea mutations:
- Issue mutations:
- Branch mutations:
- Commit mutations:
- Push mutations:
- PR mutations:
- Cleanup mutations:
- External-state mutations:
- Read-only diagnostics:
- Blockers:
- Current status:
- Safe next action:
- Safety statement:
```
Identity format: `username / profile` (not personal email unless required — #305).
Narrative final report and controller handoff must agree on eligibility class,
selected issue, and mutation ledger categories (#319, #320).
`git fetch` and ref-updating commands belong under `Git ref mutations`, not
`Read-only diagnostics` (#297).
Forbidden claims without proof (#330): `next eligible issue`, `issue claimed`,
`validation passed`, `PR created`, `worktree clean`, `all gates passed`, etc.
@@ -5,6 +5,10 @@ Copy, fill the `<...>` fields, and paste as the task prompt.
```text
Task: merge PR #<pr> for issue #<n> if it is eligible and checks pass.
Load the canonical workflow first:
`skills/llm-project-workflow/workflows/review-merge-pr.md` (task mode: review-merge-pr).
Final report schema: `schemas/review-merge-final-report.md`.
Rules (llm-project-workflow):
- Only an eligible, NON-author reviewer merges. If authenticated user == PR
author → STOP.
@@ -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.
```
@@ -30,6 +30,10 @@ Repo name disambiguation (Gitea-Tools blind review hardening):
commit is not valid corroboration. Author-bound sessions must not present
reviewer queue inventory as a reviewer decision.
Load the canonical workflow first:
`skills/llm-project-workflow/workflows/review-merge-pr.md` (task mode: review-merge-pr).
Final report schema: `schemas/review-merge-final-report.md`.
Rules (llm-project-workflow):
- Review in a SEPARATE detached review worktree, never the author's folder.
- Worktree safety (#233): before checkout, diff, validation, review, or merge,
@@ -5,6 +5,10 @@ Copy, fill the `<...>` fields, and paste as the task prompt.
```text
Task: implement <issue title / one-line goal>.
Load canonical workflow: skills/llm-project-workflow/workflows/work-issue.md
Final report schema: skills/llm-project-workflow/schemas/work-issue-final-report.md
Router: skills/llm-project-workflow/SKILL.md (task mode: work-issue)
Rules (llm-project-workflow):
- No repo changes without a tracking issue. If none exists, create one first;
if it can't be created, stop.
@@ -0,0 +1,666 @@
---
task_mode: create-issue
canonical: true
final_report_schema: ../schemas/create-issue-final-report.md
---
# Create issue workflow (canonical)
**Task mode:** `create-issue`
This file is the canonical issue-creation workflow for Gitea-Tools. Load it
before any issue mutation. Final report schema:
[`schemas/create-issue-final-report.md`](../schemas/create-issue-final-report.md).
**Default task prompt:**
> Create or update Gitea issues in this project only if every identity,
> capability, duplicate-search, issue-scope, final-report, mutation-ledger,
> and proof-wording gate passes.
Do not improvise around the gates. Follow project skills, MCP gates, and
workflow rules exactly.
This is an issue-creation workflow. It is not a reviewer workflow and not an
implementation workflow.
---
## 0. Load the canonical workflow first
Before starting issue creation or issue update work, check whether the project provides a canonical create-issue workflow through a project skill, runbook, or MCP helper.
If available, load it first and report:
* workflow source
* workflow version, commit, or hash
* whether this prompt conflicts with the loaded workflow
If the canonical workflow cannot be loaded and the project requires it, stop and produce a recovery handoff only.
## 1. Mode isolation
This run is `create-issue` mode only.
Do not:
* review PRs
* approve PRs
* request changes
* merge PRs
* close PRs
* close issues unless the user explicitly asks and exact close capability is proven
* implement code
* edit repo files
* create branches
* create commits
* push branches
* create PRs
* run tests unless the canonical workflow explicitly requires validation for issue creation
* perform reviewer-only actions
* perform author/coder-only actions
* perform MCP repair
If the task requires review, merge, issue implementation, or MCP repair mode, stop and produce a handoff for the correct workflow.
Do not mix modes in one run.
## 2. Start with live identity, profile, runtime, and capability checks
Prove:
* authenticated identity
* active profile
* repo/project
* runtime context
* exact capability for reading/searching issues
* exact capability for creating issues, if creating issues
* exact capability for commenting on issues, if commenting on existing issues
* exact capability for editing issues, if editing existing issues
* exact capability for applying labels, if applying labels
* exact capability for assigning issues, if assigning issues
* exact capability for closing issues, only if explicitly requested
A nearby capability does not count.
Examples:
* `create_issue` does not authorize `issue_comment`
* `issue_comment` does not authorize `create_issue`
* `create_pr` does not authorize `create_issue`
* `review_pr` does not authorize `create_issue`
* `merge_pr` does not authorize `issue_comment`
* `gitea.read` does not authorize creating, commenting, editing, labeling, assigning, or closing issues
If exact capability cannot be proven, stop and produce a recovery handoff only.
## 3. Stop immediately on blocked infrastructure
If any of the following appears, stop immediately:
* `infra_stop`
* MCP reconnect failure
* stale capability state
* missing capability
* workspace mismatch
* dirty control checkout, if the canonical workflow treats that as blocking
* broken canonical workflow loading
* failed required preflight
* capability resolver warning that says the current state may be unsafe
* stale or inconsistent runtime context
Do not continue duplicate search, issue creation, issue commenting, issue editing, labeling, assignment, or cleanup.
Produce an executable recovery handoff only.
Blocked recovery handoffs must not include direct issue-create or issue-comment replay commands.
Blocked handoffs must say to rerun the full workflow after the blocker clears.
## 4. Main checkout rule
This workflow should not mutate repo files.
Do not edit files in the main checkout.
Do not create branches.
Do not create commits.
Do not push.
Do not run implementation work.
Do not run reviewer validation.
Reading repository files is allowed only when needed to understand the requested issue and only if the canonical workflow permits it.
If the main checkout is dirty and the project treats dirty control checkout as blocking, stop and produce a recovery handoff.
## 5. No raw MCP repair during issue creation
Do not run `pkill`, kill MCP processes, edit MCP config, restart servers, or perform control-checkout repair during issue creation.
If MCP repair is required, stop issue creation and produce a separate `CONTROL-CHECKOUT REPAIR MODE` handoff.
Do not mix MCP repair mode with create-issue mode.
After repair, rerun the full workflow from the beginning.
## 6. No background task tools
Do not use `schedule`, `manage_task`, background jobs, async waits, delayed task tools, or monitoring tasks during issue creation.
Use direct commands and MCP tools only.
If a required action cannot complete synchronously, stop and produce a recovery handoff.
Do not say “I will check later,” “I will monitor,” or “I will continue in the background.”
## 7. No local Gitea fallback during normal issue creation
During normal issue-creation workflows, do not read Gitea profile secret files.
Do not inspect or open files such as:
* `profiles.json`
* local token stores
* credential files
* local Gitea auth/profile config files
* `.env` files containing Gitea credentials
* keychain dumps
* token helper outputs
Do not run local Gitea helper scripts when MCP tools are available.
Use MCP tools for Gitea operations.
Local fallback is allowed only in explicit recovery mode when MCP is unavailable and identity/profile/capability can be independently proven.
If local fallback is used, report:
* why MCP was unavailable
* exact identity proof
* exact profile proof
* exact repo proof
* exact capability proof
* exact local command used
Do not use local fallback to bypass MCP gates.
## 8. Understand the requested issue work
Before searching or creating issues, restate the requested issue-creation task in operational terms.
Identify:
* target repo/project
* issue topic
* issue type, if known
* whether this is a new issue, duplicate check, issue update, or issue-comment task
* whether the user provided exact title/body text
* whether acceptance criteria were provided
* whether multiple issues are requested
* whether labels, assignees, milestones, or links are requested
* whether any requested action requires capability beyond issue creation
Do not invent missing requirements.
If the request is ambiguous but safe to proceed, make a reasonable best-effort issue with clear assumptions.
If ambiguity would cause unsafe or wrong mutation, stop and ask for clarification or produce a recovery handoff according to project policy.
## 9. Duplicate search before mutation
Before creating any issue, search open and closed issues for duplicates.
Duplicate search must happen before each issue creation unless a batch search clearly covers all proposed issues.
Search terms must include:
* exact proposed issue title
* key noun phrase from the problem
* key workflow/tool name
* key failure phrase or error phrase
* likely alternate wording
* linked PR number, issue number, or file name, if relevant
If the user supplied search terms, use those too.
For each proposed issue, report:
* search terms used
* matching issue numbers/titles
* whether each match is open or closed
* whether any match fully covers the requested issue
* whether any match partially covers the requested issue
* whether a new issue is still needed
Do not create a duplicate issue if an existing issue fully covers the problem.
If a duplicate exists and fully covers the problem, stop issue creation for that topic and report the duplicate.
If a duplicate exists but is missing important acceptance criteria, comment on the existing issue only if exact `issue_comment` capability is proven and the user/task authorizes commenting.
If commenting is not authorized, report the existing issue and the missing criteria in the final handoff.
## 10. Issue inventory pagination rule
If listing/searching issues returns paginated results, follow pagination until the tool proves there are no more pages.
Do not assume search results or issue inventory are complete.
Pagination proof must not rely on assumed default API page size.
Search/inventory is complete only if one of the following is proven:
* the MCP response explicitly says there is no next page / `has_more=false` / final page
* the workflow traversed pages until an empty page or explicit final page was returned
* the tool response includes total-count or pagination metadata proving all relevant issues were returned
* the request explicitly set `page` / `limit` / `per_page`, and the response explicitly proves the server honored that page size and did not truncate results
Do not say “duplicate search complete” merely because the result count is less than an assumed default page size.
If pagination metadata is absent and the tool cannot page, report `ISSUE_SEARCH_PAGINATION_UNPROVEN`.
If duplicate search cannot be trusted, do not create the issue unless the canonical workflow explicitly permits best-effort issue creation with that limitation disclosed.
## 11. Issue creation scope rule
Create only issues within the requested scope.
Do not create extra issues just because related problems are noticed.
Do not create process-hardening issues unless the user explicitly requested process-hardening or the current task is explicitly about workflow/tooling gaps.
Do not create implementation issues during reviewer mode.
Do not create reviewer issues during work-on-issue mode.
Do not create issues in a different repository unless the user explicitly asked and exact capability is proven.
If multiple issues are requested, create only the requested issues and only after duplicate search for each one.
If a proposed issue is too broad, split it only if the user requested splitting or the canonical workflow requires issue granularity.
## 12. Issue content quality rule
Every created issue must be actionable.
Include, when applicable:
* title
* problem statement
* observed evidence
* expected behavior
* required behavior
* acceptance criteria
* affected workflow/tool/files
* safety or security considerations
* duplicate search summary
* related issues or PRs
* non-goals, if useful
Acceptance criteria must be concrete and testable.
Avoid vague issues like:
* “make workflow better”
* “fix LLM behavior”
* “improve process”
* “handle this better”
Instead, describe the exact wall, gate, verifier, test, schema, helper, or prompt change required.
## 13. Issue title rule
Use concise, specific titles.
Good title patterns:
* `Enforce <specific gate>`
* `Add verifier for <specific report/proof problem>`
* `Split <large workflow> into <specific components>`
* `Block <unsafe action> during <workflow mode>`
* `Require <proof type> before <claim/action>`
Avoid titles that are too broad or emotional.
The title should be unique enough that duplicate search can find it later.
## 14. Issue body rule
Issue body must include the full acceptance criteria.
Do not create placeholder issues.
Do not create issues with only a title unless the user explicitly requested title-only creation.
If the user supplied exact issue body text, preserve it unless it contains unsafe instructions, stale facts, or contradictions.
If edits are needed, make the smallest correction necessary and report the correction.
Do not silently change requested meaning.
## 15. Labels, assignees, and metadata
Apply labels, assignees, milestones, or project fields only if:
* the user requested them, or
* the canonical workflow requires them, and
* exact capability is proven.
Do not guess labels if project label policy is unknown.
If labels are useful but capability or policy is unclear, mention recommended labels in the final report instead of applying them.
Do not assign issues to people unless explicitly requested or required by project workflow.
## 16. Comment-on-existing issue rule
Comment on an existing issue only if:
* an existing issue partially covers the requested work, or
* the user asked to add information to an existing issue, or
* the canonical workflow requires duplicate consolidation comments, and
* exact `issue_comment` capability is proven.
Comment must be specific and useful.
Include:
* why the existing issue is relevant
* what acceptance criteria or evidence should be added
* whether this avoids creating a duplicate
Do not comment just to say “duplicate found” unless the project workflow requires it.
Do not close duplicate issues unless explicitly requested and exact close capability is proven.
## 17. No hidden mutations
Do not perform unreported mutations.
Every issue creation, issue comment, issue edit, label change, assignment, milestone change, close/reopen action, or external-state change must be reported.
If a tool call is dry-run-only, confirmation-gated, rejected, or no-op, report it separately from performed mutations.
A dry run is not a mutation.
A rejected call is not a performed mutation.
A successful issue creation is an issue mutation.
A successful issue comment is an issue mutation.
A successful label/assignment/milestone update is an issue mutation or external-state mutation.
## 18. Issue creation gate
Before creating each issue, verify:
* identity is still valid
* active profile is still valid
* runtime context is still safe
* exact `create_issue` capability is still valid
* duplicate search was completed or limitation was explicitly allowed
* proposed title is not a duplicate
* proposed body includes actionable acceptance criteria
* target repo is correct
* no mode switch has occurred
If any gate fails, do not create the issue.
Produce a recovery handoff or duplicate report.
## 19. Issue commenting gate
Before commenting on an existing issue, verify:
* identity is still valid
* active profile is still valid
* runtime context is still safe
* exact `issue_comment` capability is still valid
* target issue number is correct
* comment body is specific and useful
* comment will not duplicate an existing comment
* no mode switch has occurred
If any gate fails, do not comment.
Produce a recovery handoff or report the intended comment as a recommendation only.
## 20. Issue edit/update gate
Before editing an existing issue, verify:
* identity is still valid
* active profile is still valid
* runtime context is still safe
* exact edit capability is still valid
* target issue number is correct
* update is explicitly requested or required by canonical workflow
* update does not erase useful existing content
* no mode switch has occurred
If any gate fails, do not edit.
Prefer commenting over editing unless the user explicitly requested an edit or the canonical workflow requires issue body updates.
## 21. Final report must be precise
Include:
* canonical workflow source/version/hash, if available
* authenticated identity/profile
* repo/project
* runtime context summary
* exact capability proof summary
* requested issue-creation task
* duplicate search terms used
* duplicate search result
* pagination/final-page proof for issue search, if applicable
* issues created, with issue numbers and URLs
* existing issues commented, with issue numbers and URLs
* existing issues edited, with issue numbers and URLs
* issues skipped as duplicates, with issue numbers and titles
* labels/assignees/milestones applied, if any
* blockers, if stopped
* confirmation that no PR review, approval, request-changes, merge, branch, checkout, commit, push, or repo-file mutation was performed
If the report and actual tool/command log disagree, fix the report before final output.
## 22. Final report must distinguish mutation types
Do not use the legacy field `Workspace mutations`.
Use only precise categories:
* File edits by issue creator:
* Worktree/index mutations:
* Git ref mutations:
* MCP/Gitea mutations:
* Issue mutations:
* Label/assignment/milestone mutations:
* External-state mutations:
* Read-only diagnostics:
Use precise wording:
* `File edits by issue creator: none`
* `Worktree/index mutations: none`
* `Git ref mutations: none`
* `Issue mutations: ...`
If no repo files were edited, say:
`File edits by issue creator: none`
If no branches/worktrees were touched, say:
`Worktree/index mutations: none`
If no git refs were updated, say:
`Git ref mutations: none`
Do not hide issue mutations inside vague `MCP/Gitea mutations`.
## 23. Local artifact and report consistency rule
Do not create local walkthrough, notes, markdown, JSON, or report artifacts during issue-creation runs unless the canonical workflow or operator explicitly requires it.
If any file is edited, created, generated, or written, report it under `File edits by issue creator`.
For each file write, report:
* exact path
* whether it was inside the repo
* whether it was tracked or untracked
* why it was created
* whether final status was checked after the write
Do not say `File edits by issue creator: none` if any file write occurred.
Do not write files after the final clean-status check unless you rerun and report a new final clean-status check.
Default behavior: do not create local artifacts during issue creation.
## 24. Forbidden final-report claims unless proven
Do not claim:
* `duplicate search complete`
* `no duplicate found`
* `issue created`
* `issue commented`
* `issue updated`
* `label applied`
* `capability proven`
* `runtime safe`
* `all gates passed`
* `no file edits`
* `no unsafe mutation`
* `no PR mutation`
* `no repo mutation`
* `pagination complete`
* `final page`
* `no next page`
unless the corresponding proof is included.
If anything blocks safe issue creation or issue update, stop immediately and produce an executable recovery handoff.
Do not improvise around the gates.
## 25. Proof wording enforcement
The following phrases are forbidden unless directly supported by current-session evidence:
* duplicate search complete
* no duplicate found
* issue created
* issue commented
* issue updated
* labels applied
* capability proven
* runtime safe
* all gates passed
* no file edits
* no unsafe mutation
* no PR mutation
* no repo mutation
* pagination complete
* final page
* no next page
If the proof comes from prior state rather than a command/tool run in the current session, label it as prior proof, not live proof.
If a tool call was rejected, confirmation-gated, dry-run-only, or no-op, report it separately from performed mutations.
## 26. Final self-check before output
Before final output, check the report for contradictions.
Verify:
* if any file was edited, `File edits by issue creator` is not `none`
* if any worktree was added/removed, `Worktree/index mutations` lists it
* if any fetch happened, `Git ref mutations` lists it
* if any issue was created, `Issue mutations` lists it
* if any issue was commented, `Issue mutations` lists it
* if any issue was edited, `Issue mutations` lists it
* if any labels/assignees/milestones were changed, the correct mutation category lists it
* if duplicate search is claimed complete, pagination/final-page proof is present or limitation is disclosed
* if no duplicate is claimed, search terms and results are present
* if issue created is claimed, issue number and URL are present
* if no PR mutation is claimed, no PR tool/action was used
* if no repo mutation is claimed, no branch/worktree/file/commit/push action occurred
* if all gates passed is claimed, every required gate has proof
If any contradiction exists, fix the final report before output.
## 27. Controller handoff schema
End every run with a controller handoff using this schema.
Do not omit fields. Use `none` or `not verified in this session` where appropriate.
Controller Handoff:
* Task:
* Repo:
* Role:
* Identity:
* Active profile:
* Runtime context:
* Requested issue task:
* Workflow source:
* Capability proof:
* Duplicate search terms:
* Duplicate search pagination proof:
* Duplicates found:
* Issues created:
* Issues commented:
* Issues edited:
* Issues skipped as duplicates:
* Labels/assignees/milestones changed:
* File edits by issue creator:
* Worktree/index mutations:
* Git ref mutations:
* MCP/Gitea mutations:
* Issue mutations:
* Label/assignment/milestone mutations:
* External-state mutations:
* Read-only diagnostics:
* Blockers:
* Current status:
* Safe next action:
* Safety statement:
## 28. Stop conditions summary
Stop immediately and produce a recovery handoff if:
* canonical workflow is required but cannot be loaded
* identity/profile/capability cannot be proven
* runtime context is blocked
* infra stop appears
* MCP reconnect fails
* capability state is stale
* duplicate search cannot be performed and best-effort creation is not allowed
* issue search pagination cannot be proven and best-effort creation is not allowed
* duplicate fully covers the requested issue
* requested issue body is unsafe or not actionable
* target repo cannot be proven
* create_issue capability is missing
* issue_comment capability is missing for a required comment
* issue edit capability is missing for a required edit
* mode switch would be required
* any report contradiction cannot be resolved
Blocked handoffs must not include direct issue-create or issue-comment replay commands.
Blocked handoffs must say to rerun the full workflow after the blocker clears.
Do not improvise around the gates.
@@ -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.
@@ -0,0 +1,409 @@
---
task_mode: reconcile-landed-pr
canonical: true
final_report_schema: ../schemas/reconcile-landed-final-report.md
---
# Reconcile already-landed open PR workflow (canonical)
**Task mode:** `reconcile-landed-pr`
This file is the canonical reconciliation workflow for open PRs whose head SHA
is already an ancestor of the target branch. Load it before any reconciliation
mutation. Final report schema:
[`schemas/reconcile-landed-final-report.md`](../schemas/reconcile-landed-final-report.md).
**Default task prompt:**
> Reconcile already-landed open PRs in this project. Do not review or merge
> normal PRs. Close or comment only when exact capability is proven.
Do not improvise around the gates. Follow project skills, MCP gates, and
workflow rules exactly.
This is a reconciliation workflow. It is not a normal PR review/merge workflow.
---
## 0. Load the canonical workflow first
Before starting reconciliation work, check whether the project provides a
canonical reconcile-landed-PR workflow through a project skill, runbook, or MCP
helper.
If available, load it first and report:
* workflow source
* workflow version, commit, or hash
* whether this prompt conflicts with the loaded workflow
If the canonical workflow cannot be loaded and the project requires it, stop and
produce a recovery handoff only.
## 1. Mode isolation
This run is `reconcile-landed-pr` mode only.
**Do not review or merge normal PRs.**
Do not:
* approve PRs
* request changes on PRs
* merge PRs
* implement code
* edit repo files
* create branches
* create commits
* push branches
* create PRs
* run normal PR validation as review approval input
* perform author/coder implementation work
* perform raw MCP repair
If the task requires review, merge, issue implementation, or MCP repair mode,
stop and produce a handoff for the correct workflow.
Do not mix modes in one run.
## 2. Start with live identity, profile, runtime, and capability checks
Prove:
* authenticated identity
* active profile (reconciler or author with close capabilities, as required)
* repo/project
* runtime context
* exact capability for reading/listing PRs and issues
* exact capability for PR inspect (`gitea.read` / view PR)
* exact capability for issue inspect
* exact capability for PR comment, if commenting
* exact capability for issue comment, if commenting
* exact capability for PR close, if closing PRs
* exact capability for issue close, if closing issues
A nearby capability does not count.
Examples:
* `review_pr` does not authorize PR close
* `merge_pr` does not authorize PR close or issue close
* `create_issue` does not authorize `issue_comment`
* `issue_comment` does not authorize PR close
* `gitea.read` does not authorize close or comment mutations
If exact capability cannot be proven, stop and produce a recovery handoff only.
## 3. Stop immediately on blocked infrastructure
If any of the following appears, stop immediately:
* `infra_stop`
* MCP reconnect failure
* stale capability state
* missing capability
* workspace mismatch
* broken canonical workflow loading
* failed required preflight
* capability resolver warning that says the current state may be unsafe
* stale or inconsistent runtime context
Do not continue inventory, ancestry proof, commenting, closing, or cleanup.
Produce an executable recovery handoff only.
Blocked recovery handoffs must not include direct close or comment replay
commands.
Blocked handoffs must say to rerun the full workflow after the blocker clears.
## 4. Main checkout rule
This workflow should not mutate repo files.
Do not edit files in the main checkout.
Do not create branches, commits, or pushes.
Do not run implementation or reviewer validation worktrees for code edits.
Reading repository files is allowed only when needed to understand
reconciliation scope and only if this workflow permits it.
## 5. No raw MCP repair during reconciliation
Do not run `pkill`, kill MCP processes, edit MCP config, restart servers, or
perform control-checkout repair during reconciliation.
If MCP repair is required, stop and produce a separate `CONTROL-CHECKOUT REPAIR
MODE` handoff.
After repair, rerun the full workflow from the beginning.
## 6. No background task tools
Do not use `schedule`, `manage_task`, background jobs, async waits, delayed task
tools, or monitoring tasks during reconciliation.
Use direct commands and MCP tools only.
If a required action cannot complete synchronously, stop and produce a recovery
handoff.
## 7. No local Gitea fallback during normal reconciliation
During normal reconciliation workflows, do not read Gitea profile secret files.
Do not inspect `profiles.json`, local token stores, credential files, `.env`
Gitea credentials, keychain dumps, or token helper outputs.
Do not run local Gitea helper scripts when MCP tools are available.
Use MCP tools for Gitea operations.
Local fallback is allowed only in explicit recovery mode when MCP is unavailable
and identity/profile/capability can be independently proven.
## 8. Build a complete live open PR inventory
List open PRs for the target repo according to project policy.
Follow pagination until the tool proves there are no more pages.
Do not assume inventory is complete.
Pagination proof must not rely on assumed default API page size.
Inventory is complete only if one of the following is proven:
* the MCP response explicitly says there is no next page / `has_more=false` /
final page
* the workflow traversed pages until an empty page or explicit final page was
returned
* the tool response includes total-count or pagination metadata proving all
relevant PRs were returned
* the request explicitly set `page` / `limit` / `per_page`, and the response
explicitly proves the server honored that page size and did not truncate results
If pagination cannot be proven, report `INVENTORY_PAGINATION_UNPROVEN` and stop
unless project policy allows best-effort reconciliation with that limitation
disclosed.
## 9. Already-landed proof
For each candidate PR, prove whether the PR head SHA is already landed on the
target branch.
**Already-landed proof** must include:
* PR number and title
* candidate head SHA (full 40-hex)
* target branch name
* target branch SHA (full 40-hex) after fetch
* ancestor proof method (`git merge-base --is-ancestor`, equivalent forge API, or
documented project helper)
* ancestor proof result (true/false)
* live PR state (open/closed, merged flag)
Do not classify a PR as already-landed without live ancestor proof.
If ancestry cannot be proven, classify as `ANCESTRY_UNPROVEN` and skip close
mutations.
## 10. Linked issue live verification
If the PR claims to close or link an issue, fetch the linked issue live before
reporting its status.
If the linked issue was not fetched live in the current session, report:
`Linked issue status: not verified in this session`
Do not claim `issue open`, `issue closed`, or `issue resolved` without live
proof.
## 11. Reconciliation selection rules
Select PRs eligible for reconciliation:
* open PR state
* `merged=false` unless project policy says otherwise
* head SHA is ancestor of target branch (already-landed proof passed)
* not selected for normal review/merge in this run
Eligibility class for selected PRs: `ALREADY_LANDED_RECONCILE_REQUIRED`
Do not select PRs that fail already-landed proof for normal review/merge
treatment in this mode.
## 12. Reconciliation comment policy
Post a reconciliation comment only if:
* exact PR-comment or issue-comment capability is proven
* the comment adds durable evidence (ancestor proof summary, recommended close
action, linked issue status)
* the comment will not duplicate an equivalent recent reconciliation comment
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:
* already-landed proof passed in this session
* exact PR-close capability is proven
* PR is still open at mutation time (live re-fetch)
* head SHA still matches the proved candidate head SHA
If PR-close capability is missing, produce a recovery handoff with exact PR,
proof, and required capability. Do not loop forever re-blocking the reviewer
queue.
## 14. Issue close rules
Close a linked issue only if:
* exact issue-close capability is proven
* linked issue was fetched live
* issue resolution is justified by landed content and project policy
* issue is still open at mutation time
If issue-close capability is missing, report the gap in the handoff.
## 15. Missing capability behavior
If any required mutation capability is missing:
* do not improvise with review/merge tools
* do not ask the operator to bypass capability gates
* produce a recovery handoff listing exact missing capabilities
* include safe next action (profile switch, human close, or dedicated reconciler
profile)
## 16. Mutation classification
Use precise mutation categories in the final report:
* File edits by reconciler: (expect `none`)
* Worktree/index mutations:
* Git ref mutations: (`git fetch` belongs here, not read-only diagnostics)
* MCP/Gitea mutations:
* Reconciliation mutations: (PR comment, issue comment, PR close, issue close)
* External-state mutations:
* Read-only diagnostics:
Do not use legacy `Workspace mutations`.
## 17. Identity privacy rule
Report identity as `username / profile` (#305).
Do not disclose personal email in final reports unless explicitly required.
## 18. Precise final report
Include:
* canonical workflow source/version/hash
* authenticated identity/profile
* repo/project
* capability proof summary (separate lines for inspect, comment, PR close,
issue close)
* inventory pagination proof
* selected PR(s) with already-landed proof
* linked issue live status
* mutations performed or blocked
* missing capabilities
* confirmation that no normal review, approval, request-changes, or merge was
performed
## 19. Local artifact and report consistency rule
Do not create local walkthrough, notes, markdown, JSON, or report artifacts
during reconciliation unless explicitly required.
If any file is edited, report under `File edits by reconciler`.
Default: no repo file edits.
## 20. Forbidden unsupported claims unless proven
Do not claim:
* `already-landed`
* `PR closed`
* `issue closed`
* `pagination complete`
* `inventory complete`
* `all gates passed`
* `no unsafe mutation`
unless the corresponding proof is included.
## 21. Proof wording enforcement
Forbidden unless supported by current-session evidence:
* pagination complete
* final page
* no next page
* PR closed
* issue closed
* all gates passed
If proof comes from prior state, label as prior proof, not live proof.
## 22. Final self-check before output
Verify:
* no normal review/merge mutations occurred
* `git fetch` is under Git ref mutations if it occurred
* already-landed proof is present for each selected PR
* handoff uses reconciliation schema, not author/reviewer merge schema
* no contradiction between narrative report and controller handoff
## 23. Controller handoff schema
End every run with `Controller Handoff` per
[`schemas/reconcile-landed-final-report.md`](../schemas/reconcile-landed-final-report.md).
## 24. Stop conditions summary
Stop immediately and produce a recovery handoff if:
* canonical workflow cannot be loaded
* identity/profile/capability cannot be proven
* runtime context is blocked
* `infra_stop` appears
* inventory pagination cannot be proven and best-effort is not allowed
* already-landed proof cannot be completed
* required close capability is missing and mutation was attempted
* live PR/issue state contradicts proof
* any report contradiction cannot be resolved
Do not improvise around the gates.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,920 @@
---
task_mode: work-issue
canonical: true
final_report_schema: ../schemas/work-issue-final-report.md
---
# Work issue workflow (canonical)
**Task mode:** `work-issue`
This file is the canonical author/coder workflow for Gitea-Tools. Load it
before any issue implementation mutation. Final report schema:
[`schemas/work-issue-final-report.md`](../schemas/work-issue-final-report.md).
**Default task prompt:**
> Find the next eligible issue in this project, work on it only if all gates
> pass, and create a PR when complete.
Do not improvise around the gates. Follow project skills, MCP gates, and
workflow rules exactly.
This is an author/coder workflow. It is not a reviewer workflow.
---
## 0. Load the canonical workflow first
Before starting issue work, check whether the project provides a canonical work-on-issue workflow through a project skill, runbook, or MCP helper.
If available, load it first and report:
* workflow source
* workflow version, commit, or hash
* whether this prompt conflicts with the loaded workflow
If the canonical workflow cannot be loaded and the project requires it, stop and produce a recovery handoff only.
## 1. Mode isolation
This run is `work-issue` mode only.
Do not:
* review PRs
* approve PRs
* request changes
* merge PRs
* close PRs unless the PR creation workflow explicitly does so through Gitea automation
* close unrelated issues
* mutate reviewer state
* perform reviewer-only actions
* create process-hardening issues unless explicitly authorized and the workflow switches to issue-creation mode
If the task requires review, merge, issue creation, or MCP repair mode, stop and produce a handoff for the correct workflow.
Do not mix modes in one run.
## 2. Start with live identity, profile, runtime, and capability checks
Prove:
* authenticated identity
* active author/coder profile
* repo/project
* runtime context
* exact capability for reading issues
* exact capability for claiming/locking issues, if available
* exact capability for branch creation, if handled through MCP
* exact capability for pushing branches, if applicable
* exact capability for creating PRs
* exact capability for commenting on issues or PRs, if needed
A nearby capability does not count.
Examples:
* `create_issue` does not authorize `issue_comment`
* `review_pr` does not authorize `merge_pr`
* `create_pr` does not authorize `merge_pr`
* `issue_comment` does not authorize `create_issue`
* `gitea.read` does not authorize issue claim, PR creation, or branch mutation
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:
* `infra_stop`
* MCP reconnect failure
* stale capability state
* dirty control checkout
* dirty task worktree
* missing capability
* workspace mismatch
* stale target branch state
* broken canonical workflow loading
* failed required preflight
* capability resolver warning that says the current state may be unsafe
* stale or inconsistent runtime context
Do not continue issue selection, claiming, implementation, validation, commit, push, PR creation, cleanup, or handoff mutation.
Produce an executable recovery handoff only.
Blocked recovery handoffs must not include direct commit, push, or PR replay commands.
Blocked handoffs must say to rerun the full workflow after the blocker clears.
## 4. Main checkout rule
The main project checkout must stay on `master`, `main`, or `dev`.
Do not do task work in the main checkout.
Do not edit files in the main checkout.
Do not run tests in the main checkout.
Do not commit from the main checkout.
Do not create PRs from the main checkout.
All task work must happen under the projects `branches/` directory.
No exceptions for small fixes, docs, tests, cleanup, conflict resolution, emergencies, or “just one file.”
If the main checkout is dirty before selection, stop and produce a recovery handoff.
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.
## 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.
If MCP repair is required, stop issue work and produce a separate `CONTROL-CHECKOUT REPAIR MODE` handoff.
Do not mix MCP repair mode with work-on-issue mode.
Do not use successful repair as permission to resume the same issue workflow. After repair, rerun the full workflow from the beginning.
## 6. No background task tools
Do not use `schedule`, `manage_task`, background jobs, async waits, delayed task tools, or monitoring tasks during issue work.
Use direct commands and MCP tools only.
If a required action cannot complete synchronously, stop and produce a recovery handoff.
Long synchronous commands, such as a test suite, are allowed only if they are run directly and reported with exact command, working directory, and result.
Do not say “I will check later,” “I will monitor,” or “I will continue in the background.”
## 7. No local Gitea fallback during normal issue work
During normal author/coder workflows, do not read Gitea profile secret files.
Do not inspect or open files such as:
* `profiles.json`
* local token stores
* credential files
* local Gitea auth/profile config files
* `.env` files containing Gitea credentials
* keychain dumps
* token helper outputs
Do not run local Gitea helper scripts when MCP tools are available.
Use MCP tools for Gitea operations.
Local fallback is allowed only in explicit recovery mode when MCP is unavailable and identity/profile/capability can be independently proven.
If local fallback is used, report:
* why MCP was unavailable
* exact identity proof
* exact profile proof
* exact repo proof
* exact capability proof
* exact local command used
Do not use local fallback to bypass MCP gates.
## 8. Build a complete live issue inventory
List open issues according to the projects issue selection policy.
Follow pagination until the tool proves there are no more pages.
Do not assume inventory is complete.
Do not claim `next eligible issue`, `oldest eligible issue`, or complete issue inventory unless pagination is proven.
Pagination proof must not rely on assumed default API page size.
Inventory is complete only if one of the following is proven:
* the MCP response explicitly says there is no next page / `has_more=false` / final page
* the workflow traversed pages until an empty page or explicit final page was returned
* the tool response includes total-count or pagination metadata proving all relevant issues were returned
* the request explicitly set `page` / `limit` / `per_page`, and the response explicitly proves the server honored that page size and did not truncate results
Do not say “inventory complete” merely because the result count is less than an assumed default page size.
For each candidate issue, identify:
* issue number
* title
* labels
* status
* author/requester, if relevant
* assignee/owner, if any
* linked PRs, if any
* dependency/blocker labels, if any
* whether it appears already claimed
* whether it appears already implemented or superseded
* whether it is eligible under project rules
Final report must include pagination/final-page proof.
## 9. Issue selection rules
State the issue ordering policy before selecting an issue.
If the project uses oldest-first, explicitly sort or reason by issue number or created date.
Do not rely on API response order unless the tool proves that order matches the project policy.
Do not pick:
* already-claimed issues
* issues assigned to another active worker
* issues with an open PR already covering the work
* duplicate issues
* blocked issues
* dependency-blocked issues
* already implemented issues
* issues outside the current requested scope
* process-hardening issues unless this run was explicitly started for process-hardening work
* reviewer-only issues if this is author/coder mode
For every earlier issue skipped, report:
* issue number
* current status
* blocking category
* proof used
* whether there is an open PR
* whether there is an active claim
* reason it is not eligible
If eligibility cannot be proven, classify it as:
`ISSUE_ELIGIBILITY_UNVERIFIED`
Then stop or produce a recovery handoff according to project policy.
Do not select an issue based only on memory from a previous session.
## 10. Linked PR / duplicate active work proof
Before claiming or working on an issue, check whether there is already an open PR, branch, or active claim for that issue.
Run `gitea_assess_author_duplicate_work` at these stages and stop when `block` is true:
* `claim` — before `gitea_mark_issue`
* `lock` — before `gitea_lock_issue`
* `worktree` / `edit` — before creating a worktree or editing files
* `commit` — immediately before `git commit`
* `push` — immediately before `git push`
* `create_pr` — immediately before `gitea_create_pr` (also enforced server-side)
`gitea_mark_issue`, `gitea_lock_issue`, and `gitea_create_pr` enforce the same gate server-side and fail closed.
If a concurrent open PR appears after work begins:
* before commit or push — stop and preserve local work without pushing
* after push but before PR creation — produce a reconciliation handoff instead of opening a PR
Final reports must name the duplicate-work outcome (`duplicate PR prevented`, `duplicate branch prevented`, `duplicate commit prevented`, `duplicate push prevented`, `duplicate work not prevented`, or `reconciliation handoff`).
If an open PR already exists for the issue, do not implement duplicate work.
Classify the issue as:
`OPEN_PR_EXISTS`
and skip it only if project policy allows skipping.
If branch naming or PR title convention links issues to branches, search for matching branches or PRs.
Report:
* issue number
* linked/open PRs found
* matching branches found, if checked
* active claims found
* duplicate work status
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.
## 11. Claim or lock the issue before implementation
Claim/lock the issue before implementation if the project provides a claim/lock mechanism.
If claim/lock requires a Gitea mutation, prove exact capability first.
If claim/lock fails, stop.
Do not implement unclaimed work.
If the claim/lock gates are broken, produce a recovery handoff.
Create a tooling issue only if this run is explicitly authorized to switch to issue-creation mode and exact `create_issue` capability is proven.
Report:
* claim mechanism used
* claim result
* claim timestamp, if available
* issue owner/assignee after claim, if available
## 12. Refresh stable branch before branch/worktree creation
Fetch the stable target branch from the remote before creating a task branch or worktree.
Do not rely on stale local `master`, `main`, or `dev`.
Record the fetched stable branch SHA.
If the stable branch cannot be fetched or verified, stop and produce a recovery handoff.
`git fetch`, `git remote update`, and any command that updates refs must be reported under `Git ref mutations`, not read-only diagnostics.
## 13. Branch and worktree ownership rule
Create a fresh session-owned worktree under `branches/`.
Prefer a branch name that includes the issue number, for example:
`feat/issue-<ISSUE_NUMBER>-short-description`
or:
`fix/issue-<ISSUE_NUMBER>-short-description`
Prefer a worktree path like:
`branches/issue-<ISSUE_NUMBER>-short-description`
Before any file edits, prove:
* project root
* current working directory
* main checkout branch
* stable branch
* stable branch SHA
* task branch name
* session-owned worktree path
* worktree path is inside `branches/`
* worktree is not the main checkout
* clean tracked state
* clean untracked state
* worktree HEAD/branch state
Do not reuse an existing worktree unless safe-reuse proof passes.
Safe-reuse proof must include:
* exact worktree path
* worktree is inside `branches/`
* worktree is not the main checkout
* worktree is not owned by another active task/session
* clean tracked state
* clean untracked state
* current branch/head before reset
* reset target SHA
* explicit project policy allowing reuse/reset
Do not run `git reset --hard`, `git clean`, checkout, or other destructive commands unless the worktree is session-owned or safe-reuse proof passes.
If safe-reuse proof cannot be produced, create a fresh session-owned worktree.
## 14. Implementation scope rule
Implement only what is required for the selected issue.
Do not perform opportunistic refactors.
Do not fix unrelated tests unless they are required for the selected issue and clearly documented.
Do not modify reviewer workflow files unless the selected issue explicitly requires workflow changes.
Do not modify Gitea profiles, MCP authorization, tokens, secrets, deployment config, production config, or credentials unless the selected issue explicitly requires it and exact capability/proof gates pass.
Do not introduce provenance markers, agent signatures, temporary files, debug dumps, or generated artifacts unless required.
If implementation uncovers a separate issue, note it in the final report or create a follow-up issue only if exact capability is proven and project policy allows it.
## 15. File edit rule
All edits must happen only inside the session-owned issue worktree.
Do not edit files in the main checkout.
Do not edit files in reviewer worktrees.
Do not edit unrelated worktrees.
Track every edited, created, deleted, or generated file.
If any file is edited, created, generated, or written, report it under `File edits by author`.
For each file write, report:
* exact path
* whether it was inside the repo
* whether it was tracked or untracked
* why it was created
* whether final `git status` was run after the write
Do not say `File edits by author: none` if any file write occurred.
Do not write files after the final clean-status check unless you rerun and report a new final clean-status check.
## 16. Validation rule
Run appropriate validation for the selected issue.
Validation may include:
* targeted tests
* full test suite
* compile checks
* lint checks
* type checks
* diff checks
* secret/provenance checks
* dangerous artifact checks
* project-specific validation
If validation cannot run, explain why and include the exact failure.
Do not hide failures.
Do not claim success if tests failed.
Do not skip required validation silently.
Do not bypass MCP gates.
Report every validation command with:
* exact command
* working directory
* exit code or pass/fail result
* summary count if available
* whether it was targeted, full-suite, compile, lint, diff, secret/provenance, or diagnostic validation
If using bare `pytest`, also report:
* `which pytest`
* `pytest --version`
* whether it resolves to the project venv
Prefer the project venv executable when available.
## 17. Baseline comparison rule
Do not run tests in the main checkout.
If the full suite fails and you need to prove failures are pre-existing, create a clean baseline worktree under `branches/`, such as:
`branches/baseline-master-issue-<ISSUE_NUMBER>`
Baseline comparison must include:
* baseline worktree path
* baseline target SHA
* task branch SHA
* exact command run on both worktrees
* baseline failures
* task branch failures
* proof the failure signatures match
* proof the baseline worktree was clean before and after validation
* proof the issue worktree was clean before and after validation
Do not claim “same as master” unless the clean baseline worktree proof is included.
Do not claim “full-suite failures are pre-existing” unless baseline proof is complete and the failure signatures match.
If full-suite failures differ or proof is incomplete, do not create a PR unless project policy explicitly allows PR creation with documented validation failures.
## 18. Pre-commit review
Before committing, review the actual diff.
Check:
* correctness
* tests
* scope
* security boundaries
* workflow rule compliance
* whether the implementation really satisfies the selected issue
* unrelated changes
* dangerous generated artifacts
* secrets
* provenance markers
* temporary agent files
* debug output
* formatting-only churn
* docs/tests consistency
Run:
* `git status`
* `git diff --stat`
* `git diff`
* project-required diff checks
Do not commit if unrelated or unsafe changes are present.
## 19. Commit rules
Commit only from the session-owned issue worktree.
Do not commit from the main checkout.
Commit only after implementation and required validation pass, unless project policy explicitly allows draft PRs with failing validation.
Commit message must reference the issue number.
Preferred format:
`fix: short summary (Closes #<ISSUE_NUMBER>)`
or:
`feat: short summary (Closes #<ISSUE_NUMBER>)`
Before commit, prove:
* worktree path
* branch name
* selected issue number
* staged files
* diff summary
* validation status
After commit, record:
* commit SHA
* commit message
* changed files
Do not amend, reset, rebase, squash, or force-push unless the project workflow explicitly allows it and the worktree is session-owned.
## 20. Push rules
Push only the session-owned task branch.
Do not push `master`, `main`, `dev`, tags, or unrelated branches.
Before push, prove:
* current branch
* upstream/remote target
* commit SHA being pushed
* selected issue number
* branch name matches the issue
After push, report:
* remote
* branch
* pushed commit SHA
* push result
If push fails, stop and produce a recovery handoff.
## 21. PR creation rules
Create a PR only if implementation and validation pass, unless project policy explicitly allows draft PRs with documented validation failures.
Do not create a PR if:
* issue was not claimed/locked
* issue eligibility was unproven
* duplicate open PR exists
* task branch does not reference the issue
* implementation is incomplete
* validation failed without allowed exception
* worktree is dirty
* secrets/provenance/dangerous artifacts are present
* capability for PR creation is missing
* runtime context is blocked
* authenticated identity/profile changed unexpectedly
PR must reference or close the issue.
PR body must include:
* summary
* linked issue
* files changed
* validation commands and results
* risk
* exact worktree path
* branch name
* commit SHA
* known limitations, if any
Do not merge your own PR.
Do not approve your own PR.
Do not request changes on your own PR.
After PR creation, fetch or view the PR to verify:
* PR number
* PR URL
* PR title
* base branch
* head branch
* linked issue
* head SHA
* open status
## 22. Cleanup rules
Clean only session-owned temporary/baseline worktrees if the project workflow explicitly allows cleanup.
Do not delete unrelated branches/worktrees.
Do not delete the task worktree if the project expects it to remain for handoff unless policy says cleanup is allowed after PR creation.
Do not update the main checkout unless the canonical workflow explicitly allows it.
Any cleanup is a mutation and must be reported.
## 23. Recovery handoff rules
If blocked, produce a recovery handoff with:
* exact blocker
* failed tool/function, if any
* repo/project
* selected issue, if one was safely selected
* eligibility class
* claim/lock state
* branch name, if created
* worktree path, if created
* stable branch and stable branch SHA, if known
* files changed, if any
* validation state
* commit SHA, if committed
* PR number/URL, if created
* exact state reached before stopping
* safe next action
* statement that no unsafe mutation was attempted
Blocked handoffs must not include direct commit, push, or PR replay commands.
Blocked handoffs must say to rerun the full workflow after the blocker clears.
## 24. Final report must be precise
Include:
* canonical workflow source/version/hash, if available
* authenticated identity/profile
* repo/project
* capability proof summary
* issue inventory proof, including pagination/final-page proof
* issue ordering policy used
* selected issue number/title
* eligibility class
* skipped earlier issues and proof, if any
* duplicate active work proof
* claim/lock result
* stable branch and stable branch SHA
* branch name
* worktree path
* worktree inside `branches/`: true/false
* worktree branch/HEAD state
* worktree dirty before implementation: true/false
* files changed
* validation commands and results
* baseline comparison result, if used
* pre-commit diff review result
* commit SHA and commit message, if committed
* push result, if pushed
* PR number and URL, if created
* PR verification result, if created
* cleanup result
* blockers, if stopped
* confirmation that the main checkout was not used for task work
If the report and actual tool/command log disagree, fix the report before final output.
## 25. Final report must distinguish mutation types
Do not use the legacy field `Workspace mutations`.
Use only precise categories:
* File edits by author:
* Worktree/index mutations:
* Git ref mutations:
* MCP/Gitea mutations:
* Issue mutations:
* Branch mutations:
* Commit mutations:
* Push mutations:
* PR mutations:
* Cleanup mutations:
* External-state mutations:
* Read-only diagnostics:
`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`.
Use precise wording:
* `File edits by author: none`
* `Worktree/index mutations: ...`
* `Git ref mutations: ...`
* `MCP/Gitea mutations: ...`
Do not collapse issue, branch, commit, push, PR, cleanup, or external-state mutations into vague wording.
## 26. Forbidden final-report claims unless proven
Do not claim:
* `next eligible issue`
* `oldest eligible issue`
* `issue claimed`
* `no duplicate work`
* `no open PR`
* `worktree clean`
* `validation passed`
* `same as master`
* `full-suite failures are pre-existing`
* `committed`
* `pushed`
* `PR created`
* `issue closed`
* `main checkout untouched`
* `no file edits`
* `no unsafe mutation`
* `all gates passed`
* `target branch up to date`
unless the corresponding proof is included.
If anything blocks safe work or PR creation, stop immediately and produce an executable recovery handoff.
Do not improvise around the gates.
## 27. Proof wording enforcement
The following phrases are forbidden unless directly supported by current-session evidence:
* next eligible issue
* oldest eligible issue
* inventory complete
* no duplicate work
* issue claimed
* worktree clean
* validation passed
* same as master
* full-suite failures are pre-existing
* committed
* pushed
* PR created
* issue closed
* target branch up to date
* all gates passed
* no unsafe mutation
* no file edits
If the proof comes from prior state rather than a command/tool run in the current session, label it as prior proof, not live proof.
If a tool call was rejected, confirmation-gated, dry-run-only, or no-op, report it separately from performed mutations.
## 28. Final self-check before output
Before final output, check the report for contradictions.
Verify:
* if any file was edited, `File edits by author` is not `none`
* if any worktree was added/removed, `Worktree/index mutations` lists it
* if any fetch happened, `Git ref mutations` lists it
* if any issue was claimed/commented/updated, `Issue mutations` lists it
* if any branch was created, `Branch mutations` lists it
* if any commit was created, `Commit mutations` lists it
* if any push occurred, `Push mutations` lists it
* if any PR was created, `PR mutations` lists it
* if any cleanup happened, `Cleanup mutations` lists it
* if any issue/PR external state changed, `External-state mutations` lists it
* if pagination is claimed complete, final-page proof is present
* if same-as-master is claimed, baseline proof is complete
* if selected issue is claimed next eligible, every earlier issue has proof-backed skip reasoning
* if PR created is claimed, PR verification proof is present
* if main checkout untouched is claimed, main checkout status proof is present
If any contradiction exists, fix the final report before output.
## 29. Controller handoff schema
End every run with a controller handoff using this schema.
Do not omit fields. Use `none` or `not verified in this session` where appropriate.
Controller Handoff:
* Task:
* Repo:
* Role:
* Identity:
* Active profile:
* Runtime context:
* Selected issue:
* Eligibility class:
* Issue ordering policy:
* Issue inventory pagination proof:
* Earlier issues skipped:
* Duplicate active work proof:
* Claim/lock state:
* Stable branch:
* Stable branch SHA:
* Branch name:
* Worktree path:
* Worktree inside branches:
* Worktree branch/HEAD state:
* Worktree dirty before implementation:
* Files changed:
* Validation:
* Baseline comparison:
* Commit SHA:
* Push result:
* PR number:
* PR URL:
* PR verification:
* Main checkout branch:
* Main checkout dirty state:
* Main checkout used for task work:
* File edits by author:
* Worktree/index mutations:
* Git ref mutations:
* MCP/Gitea mutations:
* Issue mutations:
* Branch mutations:
* Commit mutations:
* Push mutations:
* PR mutations:
* Cleanup mutations:
* External-state mutations:
* Read-only diagnostics:
* Blockers:
* Current status:
* Safe next action:
* Safety statement:
## 30. Stop conditions summary
Stop immediately and produce a recovery handoff if:
* canonical workflow is required but cannot be loaded
* identity/profile/capability cannot be proven
* runtime context is blocked
* infra stop appears
* MCP reconnect fails
* capability state is stale
* issue inventory pagination cannot be proven
* issue ordering cannot be proven
* issue eligibility cannot be proven
* duplicate active work cannot be checked
* selected issue is already claimed by another worker
* selected issue already has an open PR
* claim/lock fails
* stable branch cannot be fetched
* task worktree cannot be created safely
* task worktree is dirty before implementation
* validation cannot run
* validation fails without allowed exception
* baseline comparison is required but incomplete
* diff review finds unrelated or unsafe changes
* commit fails
* push fails
* PR creation fails
* PR verification fails
* any report contradiction cannot be resolved
Blocked handoffs must not include direct commit, push, or PR replay commands.
Blocked handoffs must say to rerun the full workflow after the blocker clears.
Do not improvise around the gates.
+166
View File
@@ -0,0 +1,166 @@
"""Fail-closed subagent delegation gates (#266).
Subagents can bypass or lose context for worktree, capability, mutation,
retry, and reporting rules. These helpers make delegation an explicit,
provable decision instead of a default: deterministic write tasks stay
inline unless the parent session records why a subagent is needed and
hands the subagent the full gate context it must operate under.
Like ``author_proofs``/``review_proofs``, the helpers are pure (no git,
no API calls): the parent workflow gathers the facts and passes them in,
so the same logic works from prompts, harness assertions, and tests.
Nothing here weakens the review/merge/permission gates — a delegated
subagent is subject to the same gates as its parent.
"""
# AC1: deterministic write workflows a subagent must never run by default.
DETERMINISTIC_WRITE_TASKS = frozenset({
"claim_issue",
"create_branch",
"edit_code",
"commit",
"push_branch",
"create_pr",
"review_pr",
"merge_pr",
"cleanup_branch",
"close_issue",
})
# Read-only delegation that needs no explicit authorization.
READ_ONLY_TASKS = frozenset({
"read_files",
"code_search",
"inventory_prs",
"summarize_issue",
"explore_codebase",
})
# AC3: context a subagent must inherit from the parent session before any
# authorized write delegation may proceed.
REQUIRED_INHERITED_CONTEXT = (
"issue_lock",
"branch",
"worktree_path",
"identity_profile",
"allowed_tool_class",
"command_deny_list",
"validation_ledger_requirement",
"final_report_schema",
)
# AC4: proof fields a subagent final report must carry — the same fields a
# parent workflow's final report requires.
REQUIRED_SUBAGENT_REPORT_FIELDS = (
"identity_profile",
"worktree_path",
"branch",
"changed_files",
"validation_results",
"workspace_mutations",
)
def _clean(value):
return (value or "").strip() if isinstance(value, str) else value
def _classify_task(task_type):
task = _clean(task_type)
if not task:
return "", "unknown"
if task in DETERMINISTIC_WRITE_TASKS:
return task, "deterministic_write"
if task in READ_ONLY_TASKS:
return task, "read_only"
return task, "unknown"
def _missing_context_fields(inherited_context):
context = inherited_context or {}
missing = []
for field in REQUIRED_INHERITED_CONTEXT:
value = context.get(field)
if not isinstance(value, str) or not value.strip():
missing.append(field)
return missing
def assess_subagent_delegation(task_type, *, explicitly_allowed=False,
justification=None, inherited_context=None):
"""Decide whether delegating *task_type* to a subagent may proceed.
Fail closed: unknown tasks are blocked; deterministic write tasks are
blocked unless explicitly allowed (AC1) with a recorded justification
(AC2) and the full inherited gate context (AC3). Read-only delegation
is allowed without explicit authorization.
Returns {'block', 'allowed', 'task_type', 'task_class', 'reasons',
'missing_context'}.
"""
task, task_class = _classify_task(task_type)
reasons = []
missing_context = []
if task_class == "unknown":
reasons.append(
f"task type '{task}' is not a recognized delegation class; "
"run it inline in the parent session (fail closed)"
)
elif task_class == "deterministic_write":
if not explicitly_allowed:
reasons.append(
f"deterministic write task '{task}' must run inline unless "
"subagent use is explicitly allowed by the parent session"
)
if not _clean(justification):
reasons.append(
"no recorded justification for why a subagent is needed; "
"the parent session must record one before delegating"
)
missing_context = _missing_context_fields(inherited_context)
if missing_context:
reasons.append(
"subagent would not inherit required gate context: "
+ ", ".join(missing_context)
)
block = bool(reasons)
return {
"block": block,
"allowed": not block,
"task_type": task,
"task_class": task_class,
"reasons": reasons,
"missing_context": missing_context,
}
def validate_subagent_report(report_fields):
"""AC4: accept subagent output only with the parent-grade proof fields.
*report_fields* maps field name -> reported value. Missing or blank
proof fields make the report invalid (fail closed).
Returns {'valid', 'block', 'reasons', 'missing_fields'}.
"""
reasons = []
report = report_fields or {}
missing = []
for field in REQUIRED_SUBAGENT_REPORT_FIELDS:
value = report.get(field)
if not isinstance(value, str) or not value.strip():
missing.append(field)
if missing:
reasons.append(
"subagent final report missing required proof fields: "
+ ", ".join(missing)
)
valid = not reasons
return {
"valid": valid,
"block": not valid,
"reasons": reasons,
"missing_fields": missing,
}
+68 -1
View File
@@ -28,6 +28,10 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.issue.comment",
"role": "author",
},
"lock_issue": {
"permission": "gitea.issue.comment",
"role": "author",
},
"set_issue_labels": {
"permission": "gitea.issue.comment",
"role": "author",
@@ -68,6 +72,14 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.pr.review",
"role": "reviewer",
},
"pr_queue_cleanup": {
"permission": "gitea.pr.review",
"role": "reviewer",
},
"pr-queue-cleanup": {
"permission": "gitea.pr.review",
"role": "reviewer",
},
"request_changes_pr": {
"permission": "gitea.pr.request_changes",
"role": "reviewer",
@@ -80,6 +92,60 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.branch.delete",
"role": "author",
},
"commit_files": {
"permission": "gitea.repo.commit",
"role": "author",
},
"gitea_commit_files": {
"permission": "gitea.repo.commit",
"role": "author",
},
"reconcile_merged_cleanups": {
"permission": "gitea.read",
"role": "author",
},
"work_issue": {
"permission": "gitea.pr.create",
"role": "author",
},
"work-issue": {
"permission": "gitea.pr.create",
"role": "author",
},
"reconcile_landed_pr": {
"permission": "gitea.read",
"role": "author",
},
"reconcile-landed-pr": {
"permission": "gitea.read",
"role": "author",
},
"reconcile_already_landed_pr": {
"permission": "gitea.pr.close",
"role": "reconciler",
},
# #309: dedicated reconciler path for already-landed open PRs. Exact
# close capabilities only — never review/approve/request_changes/merge.
"reconcile_close_landed_pr": {
"permission": "gitea.pr.close",
"role": "reconciler",
},
"reconcile_close_landed_issue": {
"permission": "gitea.issue.close",
"role": "reconciler",
},
"post_heartbeat": {
"permission": "gitea.issue.comment",
"role": "author",
},
"reconcile_issue_claims": {
"permission": "gitea.read",
"role": "author",
},
"cleanup_stale_claims": {
"permission": "gitea.issue.comment",
"role": "author",
},
}
# Issue-mutating MCP tools and their resolver task keys.
@@ -89,6 +155,7 @@ ISSUE_MUTATION_TOOL_TASKS: dict[str, str] = {
"gitea_create_issue_comment": "comment_issue",
"gitea_mark_issue": "mark_issue",
"gitea_set_issue_labels": "set_issue_labels",
"gitea_commit_files": "commit_files",
}
@@ -110,4 +177,4 @@ def required_role(task: str) -> str:
def tool_required_permission(tool_name: str) -> str:
"""Return the operation an issue-mutating tool must gate on."""
return required_permission(ISSUE_MUTATION_TOOL_TASKS[tool_name])
return required_permission(ISSUE_MUTATION_TOOL_TASKS[tool_name])
+107
View File
@@ -0,0 +1,107 @@
"""Tests for agent temp artifact detection and preflight warnings (#261)."""
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 agent_temp_artifacts
import mcp_server
class TestAgentTempArtifactPatterns(unittest.TestCase):
def test_detects_root_level_encode_emit_inline(self):
porcelain = (
"?? _encode_commit_payload.py\n"
"?? _emit_payload.py\n"
"?? _inline_b64.py\n"
)
self.assertEqual(
agent_temp_artifacts.find_agent_temp_artifacts_from_porcelain(porcelain),
["_emit_payload.py", "_encode_commit_payload.py", "_inline_b64.py"],
)
def test_ignores_nested_and_unrelated_untracked(self):
porcelain = (
"?? scripts/_encode_x.py\n"
"?? README-draft.md\n"
" M task_capability_map.py\n"
)
self.assertEqual(
agent_temp_artifacts.find_agent_temp_artifacts_from_porcelain(porcelain),
[],
)
class TestPreflightWarnings(unittest.TestCase):
def setUp(self):
self._snapshot = (
mcp_server._preflight_whoami_called,
mcp_server._preflight_capability_called,
)
mcp_server._preflight_whoami_called = True
mcp_server._preflight_capability_called = True
def tearDown(self):
mcp_server._preflight_whoami_called, mcp_server._preflight_capability_called = (
self._snapshot
)
@patch(
"mcp_server._get_workspace_porcelain",
return_value="?? _encode_commit_payload.py\n",
)
def test_assess_preflight_surfaces_warning_not_block(self, _porcelain):
status = mcp_server.assess_preflight_status()
self.assertTrue(status["preflight_ready"])
self.assertEqual(status["preflight_block_reasons"], [])
self.assertEqual(len(status["preflight_warnings"]), 1)
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._env_patcher = patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True)
self._env_patcher.start()
def tearDown(self):
self._env_patcher.stop()
@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):
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,
branch_name="docs/issue-261-agent-artifact-cleanup",
remote="prgs",
)
self.assertTrue(result["success"])
self.assertIn("warnings", result)
self.assertIn("_emit_payload.py", result["warnings"][0])
if __name__ == "__main__":
unittest.main()
+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()
+102
View File
@@ -0,0 +1,102 @@
"""Tests for early author duplicate-work gate (#400)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from author_duplicate_work_gate import ( # noqa: E402
ELIGIBILITY_OPEN_PR_EXISTS,
assess_author_duplicate_work,
assess_work_issue_duplicate_prevention_report,
)
def _open_pr(number: int = 397, issue: int = 395) -> dict:
return {
"number": number,
"head": {"ref": f"feat/issue-{issue}-example"},
"title": f"feat: example (Closes #{issue})",
"body": f"Closes #{issue}",
}
class TestAuthorDuplicateWorkGate(unittest.TestCase):
def test_clear_when_no_duplicates(self):
result = assess_author_duplicate_work(
400,
stage="claim",
open_prs=[],
branch_names=["master", "feat/issue-399-other"],
)
self.assertTrue(result["allowed"])
self.assertFalse(result["block"])
def test_open_pr_blocks_claim(self):
result = assess_author_duplicate_work(
395,
stage="claim",
open_prs=[_open_pr()],
branch_names=[],
)
self.assertFalse(result["allowed"])
self.assertEqual(result["eligibility_class"], ELIGIBILITY_OPEN_PR_EXISTS)
def test_matching_branch_blocks_lock_not_create_pr(self):
branches = ["feat/issue-400-early-duplicate-work-gate"]
lock = assess_author_duplicate_work(
400,
stage="lock",
open_prs=[],
branch_names=branches,
)
self.assertFalse(lock["allowed"])
create_pr = assess_author_duplicate_work(
400,
stage="create_pr",
open_prs=[],
branch_names=branches,
matching_branches=[],
)
self.assertTrue(create_pr["allowed"])
def test_open_pr_blocks_create_pr_stage(self):
result = assess_author_duplicate_work(
395,
stage="create_pr",
open_prs=[_open_pr()],
branch_names=["feat/issue-395-proof-backed-review-handoff"],
)
self.assertFalse(result["allowed"])
self.assertEqual(result["outcome"], "duplicate_pr_prevented")
def test_push_stage_blocks_on_concurrent_pr(self):
result = assess_author_duplicate_work(
395,
stage="push",
open_prs=[_open_pr()],
branch_names=[],
)
self.assertFalse(result["allowed"])
self.assertEqual(result["outcome"], "duplicate_push_prevented")
def test_duplicate_prevention_report_requires_outcome(self):
bad = assess_work_issue_duplicate_prevention_report(
"Duplicate work detected for issue #395."
)
self.assertFalse(bad["proven"])
good = assess_work_issue_duplicate_prevention_report(
"Duplicate PR prevented; reconciliation handoff produced."
)
self.assertTrue(good["proven"])
def test_exported_from_review_proofs(self):
from review_proofs import assess_work_issue_duplicate_prevention_report as exported
self.assertTrue(callable(exported))
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 -3
View File
@@ -99,12 +99,16 @@ class TestCapabilityStopTerminal(unittest.TestCase):
self.assertIn("Cannot perform reviewer task", str(ctx.exception))
self.assertIn("review_pr", str(ctx.exception))
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request", return_value={"login": "jcwalker3"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_list_prs_allowed_after_author_task_clears_stale_denial(
self, _auth, _api, _get_all,
self, _auth, _api, mock_fetch,
):
mock_fetch.return_value = ([], {
"page": 1, "per_page": 50, "returned_count": 0,
"has_more": False, "next_page": None, "is_final_page": True,
})
with patch.dict(os.environ, self._env()):
denied = mcp_server.gitea_resolve_task_capability(
task="review_pr", remote="prgs"
@@ -120,7 +124,7 @@ class TestCapabilityStopTerminal(unittest.TestCase):
self.assertFalse(capability_stop_terminal.is_active())
prs = mcp_server.gitea_list_prs(remote="prgs")
self.assertEqual(prs, [])
self.assertEqual(prs["prs"], [])
@patch("mcp_server.api_request", return_value={"login": "jcwalker3"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
+199
View File
@@ -0,0 +1,199 @@
"""Tests for commit_files task capability resolver mapping (#262)."""
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
import task_capability_map
CONFIG = {
"version": 2,
"contexts": {
"ctx": {
"enabled": True,
"gitea": {"enabled": True, "base_url": "https://gitea.example.com"},
}
},
"profiles": {
"commit-author": {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "author-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": ["gitea.read", "gitea.repo.commit"],
"forbidden_operations": ["gitea.pr.create"],
"execution_profile": "commit-author",
},
"pr-only-author": {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "pr-author",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": ["gitea.read", "gitea.pr.create"],
"forbidden_operations": ["gitea.repo.commit"],
"execution_profile": "pr-only-author",
},
"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.repo.commit", "gitea.pr.create", "gitea.branch.push",
],
"execution_profile": "reviewer-profile",
},
},
"rules": {"allow_runtime_switching": False},
}
class CommitFilesCapabilityBase(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()
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))
def tearDown(self):
self._remotes.stop()
mcp_server._IDENTITY_CACHE.clear()
mcp_server._preflight_whoami_called, mcp_server._preflight_capability_called = (
self._preflight_snapshot
)
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",
}
class TestCommitFilesResolver(CommitFilesCapabilityBase):
@patch("mcp_server.api_request", return_value={"login": "author-user"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_commit_files_resolves_to_repo_commit(self, _auth, _api):
with patch.dict(os.environ, self._env("commit-author"), clear=True):
for task in ("commit_files", "gitea_commit_files"):
with self.subTest(task=task):
res = mcp_server.gitea_resolve_task_capability(
task=task, remote="prgs")
self.assertEqual(res["required_operation_permission"],
"gitea.repo.commit")
self.assertEqual(res["required_role_kind"], "author")
self.assertTrue(res["allowed_in_current_session"])
@patch("mcp_server.api_request", return_value={"login": "pr-author"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_create_pr_does_not_satisfy_commit_files(self, _auth, _api):
with patch.dict(os.environ, self._env("pr-only-author"), clear=True):
commit_res = mcp_server.gitea_resolve_task_capability(
task="commit_files", remote="prgs")
pr_res = mcp_server.gitea_resolve_task_capability(
task="create_pr", remote="prgs")
self.assertFalse(commit_res["allowed_in_current_session"])
self.assertTrue(pr_res["allowed_in_current_session"])
self.assertEqual(commit_res["required_operation_permission"],
"gitea.repo.commit")
self.assertEqual(pr_res["required_operation_permission"],
"gitea.pr.create")
@patch("mcp_server.api_request", return_value={"login": "reviewer-user"})
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
def test_reviewer_denied_commit_files(self, _auth, _api):
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
res = mcp_server.gitea_resolve_task_capability(
task="commit_files", remote="prgs")
self.assertFalse(res["allowed_in_current_session"])
self.assertEqual(res["required_operation_permission"],
"gitea.repo.commit")
self.assertTrue(res["different_mcp_namespace_required"])
class TestCommitFilesToolGate(CommitFilesCapabilityBase):
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_allowed_author_proceeds(self, _auth, mock_api, _role):
mock_api.return_value = {
"commit": {"sha": "abc"},
"branch": {"name": "feat/x"},
}
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="author")
with patch.dict(os.environ, self._env("commit-author"), clear=True):
res = mcp_server.gitea_commit_files(
files=[{"operation": "create", "path": "a.txt", "content": "YQ=="}],
message="test",
new_branch="feat/x",
remote="prgs",
)
self.assertTrue(res["success"])
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_reviewer_blocked_with_permission_report(self, _auth, _role):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="author")
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
res = mcp_server.gitea_commit_files(
files=[{"operation": "create", "path": "a.txt", "content": "YQ=="}],
message="test",
remote="prgs",
)
self.assertFalse(res["success"])
self.assertFalse(res.get("performed", True))
self.assertIn("permission_report", res)
self.assertEqual(res["permission_report"]["missing_permission"],
"gitea.repo.commit")
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
def test_preflight_blocks_before_api(self, _role):
env = {**self._env("commit-author"), "GITEA_TEST_FORCE_DIRTY": "1"}
with patch.dict(os.environ, env, clear=True):
with self.assertRaises(RuntimeError) as ctx:
mcp_server.gitea_commit_files(
files=[{"operation": "create", "path": "a.txt", "content": "YQ=="}],
message="test",
remote="prgs",
)
self.assertIn("gitea_whoami", str(ctx.exception))
class TestCommitFilesMapAlignment(unittest.TestCase):
def test_tool_gate_matches_resolver(self):
self.assertEqual(
task_capability_map.tool_required_permission("gitea_commit_files"),
task_capability_map.required_permission("commit_files"),
)
if __name__ == "__main__":
unittest.main()
+251
View File
@@ -0,0 +1,251 @@
"""Regression tests: gitea_commit_files tool gates match resolver and whoami verification.
Covers Issue #262 requirements.
"""
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
import task_capability_map
import gitea_config
CONFIG = {
"version": 2,
"contexts": {
"ctx": {
"enabled": True,
"gitea": {"enabled": True, "base_url": "https://gitea.example.com"},
}
},
"profiles": {
"full-author": {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "author-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": [
"gitea.read", "gitea.issue.create", "gitea.repo.commit"
],
"forbidden_operations": [],
"execution_profile": "full-author",
},
"reviewer-no-commit": {
"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.approve"
],
"forbidden_operations": [
"gitea.repo.commit", "gitea.pr.create", "gitea.branch.push"
],
"execution_profile": "reviewer-no-commit",
},
},
"rules": {"allow_runtime_switching": False},
}
class TestCommitFilesGate(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()
gitea_config._active_profile_override = None
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))
def tearDown(self):
self._remotes.stop()
mcp_server._IDENTITY_CACHE.clear()
gitea_config._active_profile_override = None
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",
}
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_allowed_author_proceeds(self, _auth, mock_api, _role):
mock_api.return_value = {
"commit": {"sha": "abc123commit"},
"branch": {"name": "some-branch"},
}
with patch.dict(os.environ, self._env("full-author"), clear=True):
resolve = mcp_server.gitea_resolve_task_capability(
task="commit_files", remote="prgs"
)
self.assertTrue(resolve["allowed_in_current_session"])
res = mcp_server.gitea_commit_files(
files=[{"operation": "create", "path": "x.txt", "content": "YQ=="}],
message="Add x",
remote="prgs",
)
self.assertTrue(res["success"])
self.assertEqual(res["commit"], "abc123commit")
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
def test_denied_reviewer_blocked(self, _auth, mock_api, _role):
mock_api.return_value = {"login": "reviewer-user"}
with patch.dict(os.environ, self._env("reviewer-no-commit"), clear=True):
resolve = mcp_server.gitea_resolve_task_capability(
task="commit_files", remote="prgs"
)
self.assertFalse(resolve["allowed_in_current_session"])
res = mcp_server.gitea_commit_files(
files=[{"operation": "create", "path": "x.txt", "content": "YQ=="}],
message="Add x",
remote="prgs",
)
self.assertFalse(res["success"])
self.assertFalse(res.get("performed", True))
self.assertIn("permission_report", res)
self.assertEqual(
res["permission_report"]["missing_permission"], "gitea.repo.commit"
)
post_calls = [c for c in mock_api.call_args_list if len(c.args) > 0 and c.args[0] == "POST"]
self.assertFalse(post_calls)
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
def test_unknown_profile_fails_closed(self, _auth, mock_api, _role):
mock_api.return_value = {"login": "reviewer-user"}
with patch.dict(os.environ, self._env("non-existent"), clear=True):
res = mcp_server.gitea_commit_files(
files=[{"operation": "create", "path": "x.txt", "content": "YQ=="}],
message="Add x",
remote="prgs",
)
self.assertFalse(res["success"])
self.assertFalse(res.get("performed", True))
post_calls = [c for c in mock_api.call_args_list if len(c.args) > 0 and c.args[0] == "POST"]
self.assertFalse(post_calls)
class TestPreflightCommitFilesGate(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.orig_whoami_called = mcp_server._preflight_whoami_called
self.orig_capability_called = mcp_server._preflight_capability_called
self.orig_whoami_violation = mcp_server._preflight_whoami_violation
self.orig_capability_violation = mcp_server._preflight_capability_violation
self.orig_resolved_role = mcp_server._preflight_resolved_role
self.orig_process_start = mcp_server._process_start_porcelain
self.orig_whoami_baseline = mcp_server._preflight_whoami_baseline_porcelain
self.orig_capability_baseline = mcp_server._preflight_capability_baseline_porcelain
self.orig_whoami_files = mcp_server._preflight_whoami_violation_files
self.orig_capability_files = mcp_server._preflight_capability_violation_files
self.orig_reviewer_files = mcp_server._preflight_reviewer_violation_files
mcp_server._preflight_whoami_called = False
mcp_server._preflight_capability_called = False
mcp_server._preflight_whoami_violation = False
mcp_server._preflight_capability_violation = False
mcp_server._preflight_resolved_role = None
mcp_server._process_start_porcelain = ""
mcp_server._preflight_whoami_baseline_porcelain = None
mcp_server._preflight_capability_baseline_porcelain = None
mcp_server._preflight_whoami_violation_files = []
mcp_server._preflight_capability_violation_files = []
mcp_server._preflight_reviewer_violation_files = []
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))
def tearDown(self):
self._remotes.stop()
mcp_server._IDENTITY_CACHE.clear()
mcp_server._preflight_whoami_called = self.orig_whoami_called
mcp_server._preflight_capability_called = self.orig_capability_called
mcp_server._preflight_whoami_violation = self.orig_whoami_violation
mcp_server._preflight_capability_violation = self.orig_capability_violation
mcp_server._preflight_resolved_role = self.orig_resolved_role
mcp_server._process_start_porcelain = self.orig_process_start
mcp_server._preflight_whoami_baseline_porcelain = self.orig_whoami_baseline
mcp_server._preflight_capability_baseline_porcelain = self.orig_capability_baseline
mcp_server._preflight_whoami_violation_files = self.orig_whoami_files
mcp_server._preflight_capability_violation_files = self.orig_capability_files
mcp_server._preflight_reviewer_violation_files = self.orig_reviewer_files
self._dir.cleanup()
os.environ.pop("GITEA_TEST_FORCE_DIRTY", None)
os.environ.pop("GITEA_TEST_PORCELAIN", None)
def _env(self, profile: str) -> dict:
return {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": profile,
"GITEA_TOKEN_AUTHOR": "author-pass",
}
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_preflight_not_called_blocks_commit(self, _auth, mock_api):
mock_api.return_value = {"login": "author-user"}
with patch.dict(os.environ, self._env("full-author"), clear=True):
os.environ["GITEA_TEST_PORCELAIN"] = ""
with self.assertRaises(RuntimeError) as ctx:
mcp_server.gitea_commit_files(
files=[{"operation": "create", "path": "x.txt", "content": "YQ=="}],
message="Add x",
remote="prgs",
)
self.assertIn("Identity (gitea_whoami) has not been verified", str(ctx.exception))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_dirty_workspace_before_whoami_blocks_commit(self, _auth, mock_api):
mock_api.return_value = {"login": "author-user"}
with patch.dict(os.environ, self._env("full-author"), clear=True):
os.environ["GITEA_TEST_FORCE_DIRTY"] = "1"
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="author")
with self.assertRaises(RuntimeError) as ctx:
mcp_server.gitea_commit_files(
files=[{"operation": "create", "path": "x.txt", "content": "YQ=="}],
message="Add x",
remote="prgs",
)
self.assertIn("Workspace file edits occurred before gitea_whoami verification", str(ctx.exception))
if __name__ == "__main__":
unittest.main()
+260
View File
@@ -0,0 +1,260 @@
import json
import os
import tempfile
import unittest
from unittest.mock import MagicMock, patch
import gitea_config
import mcp_server
import task_capability_map
CONFIG = {
"version": 2,
"contexts": {
"ctx": {
"enabled": True,
"gitea": {"enabled": True, "base_url": "https://gitea.example.com"},
}
},
"profiles": {
"full-author": {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "author-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": [
"gitea.read",
"gitea.repo.commit",
"gitea.pr.create",
"gitea.branch.push",
],
"forbidden_operations": [
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.review",
],
"execution_profile": "full-author",
},
},
}
class TestCommitPayloads(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()
# Setup temp directories and lock files
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))
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"
self.lock_data = {
"issue_number": 263,
"branch_name": "feat/issue-263-native-commit-payloads",
"remote": "prgs",
"org": "Example-Org",
"repo": "Example-Repo",
"worktree_path": self.locked_worktree_path,
}
with open(self.lock_file_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(self.lock_data))
# Reset preflight status to bypass/pass verification in tests
self.orig_whoami_called = mcp_server._preflight_whoami_called
self.orig_capability_called = mcp_server._preflight_capability_called
mcp_server._preflight_whoami_called = True
mcp_server._preflight_capability_called = True
def tearDown(self):
self._remotes.stop()
mcp_server._IDENTITY_CACHE.clear()
mcp_server._preflight_whoami_called = self.orig_whoami_called
mcp_server._preflight_capability_called = self.orig_capability_called
self._dir.cleanup()
self.locked_worktree_dir.cleanup()
if os.path.exists(self.lock_file_path):
os.remove(self.lock_file_path)
def _env(self, profile: str) -> dict:
return {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": profile,
"GITEA_TOKEN_AUTHOR": "author-pass",
"GITEA_TEST_PORCELAIN": "",
"GITEA_AUTHOR_WORKTREE": self.locked_worktree_path,
}
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_commit_files_content_plain(self, _auth, mock_api):
mock_api.return_value = {
"commit": {"sha": "sha-123"},
"branch": {"name": "feat/issue-263-native-commit-payloads"}
}
with patch.dict(os.environ, self._env("full-author"), clear=True):
res = mcp_server.gitea_commit_files(
files=[
{
"operation": "create",
"path": "hello.txt",
"content_plain": "Hello World!",
}
],
message="Add hello.txt",
remote="prgs",
)
print("DEBUG RES IS:", res)
self.assertTrue(res["success"])
self.assertEqual(res["commit"], "sha-123")
self.assertEqual(res["content_source_proof"][0]["source"], "inline_plain")
# Verify posted base64 content
post_args = mock_api.call_args[0]
payload = post_args[3]
self.assertEqual(payload["files"][0]["content"], "SGVsbG8gV29ybGQh")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_commit_files_workspace_path(self, _auth, mock_api):
mock_api.return_value = {
"commit": {"sha": "sha-456"},
"branch": {"name": "feat/issue-263-native-commit-payloads"}
}
# Create a workspace file
file_relative = "src/code.py"
file_abs = os.path.join(self.locked_worktree_path, file_relative)
os.makedirs(os.path.dirname(file_abs), exist_ok=True)
with open(file_abs, "wb") as fh:
fh.write(b"print('hello')")
with patch.dict(os.environ, self._env("full-author"), clear=True):
res = mcp_server.gitea_commit_files(
files=[
{
"operation": "create",
"path": "src/code.py",
"workspace_path": file_relative,
}
],
message="Add code.py",
remote="prgs",
)
self.assertTrue(res["success"])
self.assertEqual(res["content_source_proof"][0]["source"], "workspace_path")
post_args = mock_api.call_args[0]
payload = post_args[3]
# "print('hello')" in base64 is cHJpbnQoJ2hlbGxvJyk=
self.assertEqual(payload["files"][0]["content"], "cHJpbnQoJ2hlbGxvJyk=")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_commit_files_local_path(self, _auth, mock_api):
mock_api.return_value = {
"commit": {"sha": "sha-789"},
"branch": {"name": "feat/issue-263-native-commit-payloads"}
}
file_abs = os.path.join(self.locked_worktree_path, "local.bin")
with open(file_abs, "wb") as fh:
fh.write(b"\x00\x01\x02\x03\xff")
with patch.dict(os.environ, self._env("full-author"), clear=True):
res = mcp_server.gitea_commit_files(
files=[
{
"operation": "create",
"path": "local.bin",
"local_path": file_abs,
}
],
message="Add local.bin",
remote="prgs",
)
self.assertTrue(res["success"])
self.assertEqual(res["content_source_proof"][0]["source"], "local_path")
post_args = mock_api.call_args[0]
payload = post_args[3]
# b"\x00\x01\x02\x03\xff" in base64 is AAECA/8=
self.assertEqual(payload["files"][0]["content"], "AAECA/8=")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_commit_files_traversal_blocked(self, _auth, mock_api):
# Remove active lock file to ensure it fails on traversal/invalid locks
os.remove(self.lock_file_path)
with patch.dict(os.environ, self._env("full-author"), clear=True):
with self.assertRaises(RuntimeError) as ctx:
mcp_server.gitea_commit_files(
files=[
{
"operation": "create",
"path": "hack.txt",
"workspace_path": "any.txt",
}
],
message="Add hack",
remote="prgs",
)
self.assertIn("Issue lock is missing", str(ctx.exception))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_commit_files_outside_scope_blocked(self, _auth, mock_api):
with patch.dict(os.environ, self._env("full-author"), clear=True):
with self.assertRaises(ValueError) as ctx:
mcp_server.gitea_commit_files(
files=[
{
"operation": "create",
"path": "hack.txt",
"workspace_path": "../outside.txt",
}
],
message="Add hack",
remote="prgs",
)
self.assertIn("falls outside of locked worktree", str(ctx.exception))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_commit_files_multiple_sources_blocked(self, _auth, mock_api):
with patch.dict(os.environ, self._env("full-author"), clear=True):
with self.assertRaises(ValueError) as ctx:
mcp_server.gitea_commit_files(
files=[
{
"operation": "create",
"path": "hello.txt",
"content_plain": "Hello!",
"content": "SGVsbG8=",
}
],
message="Add hello",
remote="prgs",
)
self.assertIn("Multiple content sources specified", str(ctx.exception))
@@ -59,6 +59,22 @@ def test_registration_doc_preserves_boundaries():
assert phrase in text
def test_registration_doc_pins_glitchtip_filing_boundary():
text = " ".join(_doc_text().split())
for phrase in (
"GlitchTip-to-Gitea filing is a separate library-only orchestrator in "
"`mcp-control-plane`",
"real GlitchTip read path",
"dedup before any Gitea create action",
"create/link/skip decisions",
"fail closed on mutation-audit failure before any Gitea create, link, "
"or comment mutation",
"must use a separate write-boundary server/profile",
"must not be exposed by `glitchtip-mcp`",
):
assert phrase in text
def test_registration_doc_separates_jenkins_trigger_from_read_surface():
text = _doc_text()
assert "jenkins_trigger_build" in text
@@ -100,3 +116,25 @@ def test_related_docs_keep_jenkins_trigger_off_read_surface():
assert "jenkins-write-mcp" in text
assert "jenkins_mcp.write_server" in text
assert "jenkins-mcp" in text
def test_glitchtip_filing_contract_doc_covers_issue_153_acceptance():
text = (
REPO_ROOT / "docs" / "architecture" /
"glitchtip-to-gitea-workflow-design.md"
).read_text(encoding="utf-8")
flattened = " ".join(text.split())
for phrase in (
"**Status:** Contract for the library-only implementation in "
"`Scaled-Tech-Consulting/mcp-control-plane` (#57)",
"**Issue:** #153",
"not exposed through `glitchtip-mcp`",
"real GlitchTip read path",
"must not use mocked GlitchTip issue data",
"separate write-boundary server/profile",
"fail-closed mutation audit before create/link/comment actions",
"Deduplication before create",
"Create, link, and skip decisions must be represented in tests",
"Mutation audit fails closed",
):
assert phrase in flattened
+516
View File
@@ -0,0 +1,516 @@
"""Tests for composable final-report validator (#327)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from final_report_validator import ( # noqa: E402
FINAL_REPORT_TASK_KINDS,
assess_final_report_validator,
validator_finding,
)
def _review_handoff(**overrides):
lines = [
"## Controller Handoff",
"",
"- Task: review PR #203",
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
"- Role: reviewer",
"- Identity: sysadmin / prgs-reviewer",
"- Issue/PR: #182 / PR #203",
"- Branch/SHA: feat/x @ 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Files changed: review_proofs.py",
"- Validation: pytest tests/test_review_proofs.py -q in branches/review-203",
"- Mutations: review only",
"- File edits by reviewer: none",
"- Worktree/index mutations: none",
"- Git ref mutations: git fetch prgs master",
"- MCP/Gitea mutations: review submitted",
"- Review mutations: 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",
"- Safety: no self-review; no self-merge",
"- Selected PR: #203",
"- Reviewer eligibility: eligible",
"- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Worktree path: branches/review-203",
"- Worktree dirty: clean",
"- Scratch worktree used: yes (branches/review-203)",
"- Unrelated local mutations: none",
"- Review decision: request_changes",
"- Merge result: not attempted",
"- Linked issue: #182",
"- Linked issue status: open",
"- Cleanup status: none",
]
text = "\n".join(lines)
for key, value in overrides.items():
text = text.replace(f"- {key}:", f"- {key}: {value}")
return text
def _reconcile_handoff(drop=(), **overrides):
lines = [
"## Controller Handoff",
"",
"- Task: reconcile already-landed PR #99",
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
"- Role/profile: reconciler / prgs-reconciler",
"- Identity: sysadmin",
"- Selected PR: #99",
"- PR live state: open",
"- Candidate head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Target branch: master",
"- Target branch SHA: 5e023dc71b0e2b813a0b1eee6b0f42630c47a95c",
"- Ancestor proof: candidate head is ancestor of target branch SHA",
"- Linked issue: #98",
"- Linked issue live status: not verified in this session",
"- Eligibility class: ALREADY_LANDED_RECONCILE_REQUIRED",
"- Capabilities proven: gitea.read, gitea.pr.comment",
"- Missing capabilities: gitea.pr.close",
"- PR comments posted: 1 reconciliation comment on PR #99",
"- Issue comments posted: none",
"- PRs closed: none",
"- Issues closed: none",
"- File edits by reconciler: none",
"- Git ref mutations: git fetch prgs master",
"- Worktree mutations: none",
"- MCP/Gitea mutations: PR comment posted",
"- External-state mutations: none",
"- Read-only diagnostics: gitea_view_pr, gitea_view_issue",
"- Reconciliation mutations: PR comment posted",
"- Current status: reconciliation complete",
"- Blocker: missing gitea.pr.close capability",
"- Safe next action: hand off to a profile with gitea.pr.close",
"- No review/merge confirmation: confirmed",
]
kept = [
line
for line in lines
if not any(line.startswith(f"- {name}:") for name in drop)
]
text = "\n".join(kept)
for key, value in overrides.items():
if f"- {key}:" in text:
text = text.replace(f"- {key}:", f"- {key}: {value}")
return text
class TestValidatorFinding(unittest.TestCase):
def test_finding_shape(self):
finding = validator_finding(
"reviewer.test",
"block",
"Validation",
"missing proof",
"repair validation proof",
)
self.assertEqual(finding["rule_id"], "reviewer.test")
self.assertEqual(finding["severity"], "block")
self.assertEqual(finding["field"], "Validation")
class TestReviewPrRules(unittest.TestCase):
def test_clean_reviewer_report_passes(self):
result = assess_final_report_validator(
_review_handoff(),
"review_pr",
linked_issue_lock={"issue_number": 182, "pr_number": 203},
)
self.assertEqual(result["grade"], "A")
self.assertFalse(result["blocked"])
self.assertEqual(result["findings"], [])
def test_legacy_workspace_mutations_blocks(self):
report = _review_handoff() + "\n- Workspace mutations: none"
result = assess_final_report_validator(
report,
"review_pr",
mutations_observed=True,
)
self.assertEqual(result["grade"], "blocked")
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertIn("reviewer.legacy_workspace_mutations", rule_ids)
def test_vague_mutations_none_blocks(self):
report = _review_handoff().replace(
"- Mutations: review only",
"- Mutations: none",
)
result = assess_final_report_validator(
report,
"review_pr",
mutations_observed=True,
)
self.assertTrue(result["blocked"])
self.assertTrue(
any(f["rule_id"] == "reviewer.vague_mutations_none" for f in result["findings"])
)
def test_bare_pytest_without_cwd_downgrades(self):
report = _review_handoff().replace(
"- Validation: pytest tests/test_review_proofs.py -q in branches/review-203",
"- Validation: pytest passed",
)
result = assess_final_report_validator(report, "review_pr")
self.assertEqual(result["grade"], "downgraded")
self.assertTrue(
any(f["rule_id"] == "reviewer.validation_command_proof" for f in result["findings"])
)
def test_linked_issue_mismatch_blocks(self):
report = _review_handoff().replace("- Linked issue: #182", "- Linked issue: #999")
result = assess_final_report_validator(
report,
"review_pr",
linked_issue_lock={"issue_number": 182, "pr_number": 203},
)
self.assertTrue(result["blocked"])
self.assertTrue(
any(f["rule_id"] == "reviewer.linked_issue_mismatch" for f in result["findings"])
)
def test_approval_after_full_suite_failure_blocks_without_baseline(self):
report = (
_review_handoff()
.replace("- Review decision: request_changes", "- Review decision: approve")
+ "\nSubmitted 'approve' after full suite failed with 3 failed tests."
)
result = assess_final_report_validator(report, "review_pr")
self.assertTrue(result["blocked"])
self.assertTrue(
any(
f["rule_id"] == "reviewer.baseline_on_full_suite_failure"
for f in result["findings"]
)
)
def test_same_as_master_without_baseline_blocks(self):
report = _review_handoff() + "\nFailures are same as master baseline."
result = assess_final_report_validator(report, "review_pr")
self.assertTrue(result["blocked"])
self.assertTrue(
any(f["rule_id"] == "reviewer.main_checkout_baseline" for f in result["findings"])
)
def test_already_landed_eligible_wording_blocks(self):
report = (
_review_handoff()
+ "\nPR is already landed and eligible for review next."
)
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_oldest_eligible_pr_blocks(self):
report = (
_review_handoff()
+ "\nAlready landed.\nOldest eligible PR: PR #278."
)
result = assess_final_report_validator(report, "review_pr")
self.assertTrue(result["blocked"])
self.assertTrue(
any(
f["rule_id"] == "reviewer.already_landed_eligible_wording"
for f in result["findings"]
)
)
def test_already_landed_gate_blocks_review_terminal_states(self):
cases = {
"APPROVED": ("- Review decision: request_changes", "- Review decision: APPROVED"),
"MERGED": ("- Merge result: not attempted", "- Merge result: MERGED"),
"READY_TO_MERGE": ("- Current status: PR open", "- Current status: READY_TO_MERGE"),
}
for state, (old, new) in cases.items():
with self.subTest(state=state):
report = (
_review_handoff()
.replace("- Reviewer eligibility: eligible", "- Reviewer eligibility: blocked")
.replace(old, new)
+ "\n- Already-landed gate: fired"
+ "\n- Ancestor proof: passed"
+ "\n- Eligibility class: ALREADY_LANDED_RECONCILE_REQUIRED"
)
result = assess_final_report_validator(report, "review_pr")
self.assertTrue(result["blocked"])
self.assertTrue(
any(
f["rule_id"] == "reviewer.already_landed_review_state"
for f in result["findings"]
)
)
def test_target_branch_up_to_date_requires_fetch_and_sha(self):
report = (
_review_handoff()
.replace("- Git ref mutations: git fetch prgs master", "- Git ref mutations: none")
+ "\nTarget branch up to date."
)
result = assess_final_report_validator(report, "review_pr")
self.assertTrue(result["blocked"])
self.assertTrue(
any(
f["rule_id"] == "reviewer.target_branch_freshness_proof"
for f in result["findings"]
)
)
def test_target_branch_up_to_date_with_fetch_and_sha_passes(self):
report = (
_review_handoff()
+ "\n- Target branch SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9"
+ "\nTarget branch up to date."
)
result = assess_final_report_validator(report, "review_pr")
self.assertEqual(result["grade"], "A")
def test_git_fetch_must_not_be_readonly_only(self):
report = (
_review_handoff()
.replace("- Git ref mutations: git fetch prgs master", "- Git ref mutations: none")
.replace(
"- Read-only diagnostics: issue and PR metadata inspected",
"- Read-only diagnostics: git fetch prgs master",
)
)
result = assess_final_report_validator(
report,
"review_pr",
action_log=[{"command": "git fetch prgs master", "performed": True}],
)
self.assertTrue(result["blocked"])
self.assertTrue(
any(f["rule_id"] == "reviewer.git_fetch_readonly" for f in result["findings"])
)
class TestReconciliationRules(unittest.TestCase):
def test_clean_reconcile_report_passes(self):
result = assess_final_report_validator(
_reconcile_handoff(),
"reconcile_already_landed",
)
self.assertEqual(result["grade"], "A")
def test_stale_author_field_blocks(self):
report = _reconcile_handoff() + "\n- PR number opened: #12"
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertTrue(result["blocked"])
self.assertTrue(
any(
f["rule_id"] == "reconcile.stale_author_reviewer_fields"
for f in result["findings"]
)
)
def test_eligible_wording_for_landed_pr_blocks(self):
report = _reconcile_handoff() + "\nPR already landed and eligible for merge."
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertTrue(result["blocked"])
def test_linked_issue_status_without_live_proof_downgrades(self):
report = _reconcile_handoff().replace(
"- Linked issue live status: not verified in this session",
"- Linked issue live status: closed",
)
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertEqual(result["grade"], "downgraded")
self.assertTrue(
any(f["rule_id"] == "reconcile.linked_issue_live_status" for f in result["findings"])
)
def test_inventory_complete_requires_pagination_proof(self):
report = _reconcile_handoff() + "\nInventory complete for open PRs."
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertEqual(result["grade"], "downgraded")
self.assertTrue(
any(f["rule_id"] == "reconcile.inventory_pagination_proof" for f in result["findings"])
)
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")
self.assertTrue(result["blocked"])
self.assertEqual(result["findings"][0]["rule_id"], "shared.unknown_task_kind")
def test_review_proofs_reexport(self):
from review_proofs import assess_final_report_validator as exported
self.assertTrue(callable(exported))
result = exported(_review_handoff(), "review_pr")
self.assertIn("grade", result)
def test_supported_task_kinds_include_review_and_reconcile(self):
self.assertIn("review_pr", FINAL_REPORT_TASK_KINDS)
self.assertIn("reconcile_already_landed", FINAL_REPORT_TASK_KINDS)
if __name__ == "__main__":
unittest.main()
+101
View File
@@ -0,0 +1,101 @@
"""Git ref mutations must be reported, never hidden as diagnostics (#297).
``git fetch`` updates remote-tracking refs even though it edits no files.
Reports that ran fetch (or any ref-updating command) must carry a
``Git ref mutations`` entry and may not claim ``Git/worktree mutations:
None`` or classify the fetch as read-only diagnostics.
"""
import sys
import unittest
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import review_proofs
def _assess(report, commands):
return review_proofs.assess_git_ref_mutation_report(
report, command_log=commands)
class TestGitRefMutationDetection(unittest.TestCase):
def test_fetch_only_review_with_proper_ledger_passes(self):
report = "\n".join([
"Git ref mutations: fetched prgs/master",
"Review decision: APPROVE",
])
res = _assess(report, ["git fetch prgs master"])
self.assertTrue(res["proven"], res["reasons"])
self.assertEqual(res["ref_mutating_commands"], ["git fetch prgs master"])
def test_fetch_without_ref_mutation_line_is_blocked(self):
report = "Review decision: APPROVE\nMutations: None\n"
res = _assess(report, ["git fetch prgs master"])
self.assertFalse(res["proven"])
self.assertTrue(
any("git ref mutations" in r.lower() for r in res["reasons"]))
def test_fetch_with_remote_branch_requires_fetched_target_in_report(self):
report = "Git ref mutations: some refs updated\n"
res = _assess(report, ["git fetch prgs master"])
self.assertFalse(res["proven"])
self.assertTrue(
any("prgs/master" in r for r in res["reasons"]))
def test_git_worktree_mutations_none_rejected_when_fetch_occurred(self):
report = "\n".join([
"Git ref mutations: fetched prgs/master",
"Git/worktree mutations: None",
])
res = _assess(report, ["git fetch prgs master"])
self.assertFalse(res["proven"])
self.assertTrue(
any("git/worktree mutations" in r.lower() for r in res["reasons"]))
def test_fetch_classified_as_read_only_diagnostics_rejected(self):
report = "\n".join([
"Git ref mutations: fetched prgs/master",
"Read-only diagnostics: git fetch prgs master, git status",
])
res = _assess(report, ["git fetch prgs master"])
self.assertFalse(res["proven"])
self.assertTrue(
any("read-only" in r.lower() for r in res["reasons"]))
def test_worktree_creation_is_not_a_ref_mutation(self):
report = "Worktree mutations: created branches/review-pr9\n"
res = _assess(report, ["git worktree add branches/review-pr9 prgs/master"])
self.assertTrue(res["proven"], res["reasons"])
self.assertEqual(res["ref_mutating_commands"], [])
def test_merge_command_counts_as_ref_mutation(self):
report = "Review decision: APPROVE\n"
res = _assess(report, ["git merge --no-ff feat/x"])
self.assertFalse(res["proven"])
self.assertIn("git merge --no-ff feat/x", res["ref_mutating_commands"])
def test_cleanup_branch_delete_counts_as_ref_mutation(self):
report = "Cleanup mutations: deleted branch feat/x\n"
res = _assess(report, ["git branch -d feat/x"])
self.assertFalse(res["proven"])
self.assertIn("git branch -d feat/x", res["ref_mutating_commands"])
def test_no_mutation_blocked_handoff_passes(self):
report = "\n".join([
"Git/worktree mutations: None",
"Current status: blocked handoff, no mutations performed",
])
res = _assess(report, ["git status --porcelain", "git log --oneline -1"])
self.assertTrue(res["proven"], res["reasons"])
self.assertEqual(res["ref_mutating_commands"], [])
def test_command_log_dict_entries_supported(self):
report = "Git ref mutations: fetched prgs/master\n"
res = _assess(report, [{"command": "git fetch prgs master"}])
self.assertTrue(res["proven"], res["reasons"])
self.assertEqual(
res["ref_mutating_commands"], ["git fetch prgs master"])
if __name__ == "__main__":
unittest.main()
+93 -5
View File
@@ -1,9 +1,12 @@
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from unittest.mock import patch
import capability_stop_terminal
import role_session_router
from role_session_router import python_bytes_have_conflict_markers
from mcp_server import gitea_route_task_session, gitea_resolve_task_capability
@@ -117,7 +120,7 @@ class TestMCPHealth(unittest.TestCase):
stderr_text,
)
@patch("role_session_router.check_mid_merge", return_value=True)
@patch("role_session_router.assess_infra_stop")
@patch(
"mcp_server.get_profile",
return_value={
@@ -125,12 +128,23 @@ class TestMCPHealth(unittest.TestCase):
"allowed_operations": ["gitea.pr.review"],
},
)
def test_route_task_session_blocks_during_merge(self, mock_profile, mock_check):
def test_route_task_session_blocks_during_merge(self, mock_profile, mock_assess):
mock_assess.return_value = {
"infra_stop": True,
"project_root": "/repo",
"conflict_file": None,
"mid_merge": True,
"infra_stop_reasons": [
"mid-merge markers detected in /repo/.git/MERGE_HEAD (project_root=/repo)"
],
}
res = gitea_route_task_session("review_pr")
self.assertEqual(res["route_result"], "infra_stop")
self.assertIn("infra_stop", res["message"])
self.assertTrue(res.get("infra_stop_assessment", {}).get("infra_stop"))
mock_assess.assert_called()
@patch("role_session_router.check_mid_merge", return_value=True)
@patch("role_session_router.assess_infra_stop")
@patch(
"mcp_server.get_profile",
return_value={
@@ -139,9 +153,83 @@ class TestMCPHealth(unittest.TestCase):
},
)
def test_resolve_task_capability_blocks_during_merge(
self, mock_profile, mock_check
self, mock_profile, mock_assess
):
mock_assess.return_value = {
"infra_stop": True,
"project_root": "/repo",
"conflict_file": "/repo/gitea_mcp_server.py",
"mid_merge": False,
"infra_stop_reasons": [
"conflict markers detected in /repo/gitea_mcp_server.py (project_root=/repo)"
],
}
res = gitea_resolve_task_capability("review_pr")
self.assertTrue(res["infra_stop"])
self.assertFalse(res["allowed_in_current_session"])
self.assertIn("infra_stop", res["exact_safe_next_action"])
self.assertIn("infra_stop", res["exact_safe_next_action"])
self.assertEqual(
res["infra_stop_assessment"]["conflict_file"],
"/repo/gitea_mcp_server.py",
)
@patch("role_session_router.assess_infra_stop")
@patch("mcp_server._authenticated_username", return_value="reviewer-user")
@patch(
"mcp_server.get_profile",
return_value={
"profile_name": "prgs-reviewer",
"allowed_operations": ["gitea.pr.review", "gitea.pr.approve"],
},
)
def test_resolve_task_capability_clears_stale_terminal_when_infra_clean(
self, mock_profile, _username, mock_assess
):
mock_assess.return_value = {
"infra_stop": False,
"project_root": "/repo",
"conflict_file": None,
"mid_merge": False,
"infra_stop_reasons": [],
}
capability_stop_terminal.enter_from_capability_result({
"requested_task": "review_pr",
"required_role_kind": "reviewer",
"stop_required": True,
"active_profile": "prgs-reviewer",
"active_identity": "reviewer-user",
"exact_safe_next_action": "blocked",
})
self.assertTrue(capability_stop_terminal.is_active())
res = gitea_resolve_task_capability("review_pr", remote="prgs")
self.assertFalse(capability_stop_terminal.is_active())
self.assertTrue(res["allowed_in_current_session"])
class TestInfraStopLiveAssessment(unittest.TestCase):
def setUp(self):
self.tempdir = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.tempdir, ignore_errors=True)
def test_clean_project_root_is_not_infra_stop(self):
infra = role_session_router.assess_infra_stop(self.tempdir)
self.assertFalse(infra["infra_stop"])
self.assertIsNone(infra["conflict_file"])
def test_conflict_present_then_resolved_recomputes_allowed(self):
conflict_path = os.path.join(self.tempdir, "conflicted.py")
with open(conflict_path, "wb") as handle:
handle.write(b"<<<<<<< HEAD\n")
blocked = role_session_router.assess_infra_stop(self.tempdir)
self.assertTrue(blocked["infra_stop"])
self.assertEqual(
os.path.realpath(blocked["conflict_file"]),
os.path.realpath(conflict_path),
)
os.remove(conflict_path)
cleared = role_session_router.assess_infra_stop(self.tempdir)
self.assertFalse(cleared["infra_stop"])
self.assertIsNone(cleared["conflict_file"])
+102
View File
@@ -0,0 +1,102 @@
"""Identity-disclosure checks for workflow reports (#305).
Workflow reports sometimes included the authenticated user's personal
email even though username/profile identity is sufficient (observed:
``Identity: jcwalker3 / jcwalker3@yahoo.com`` in a reconciliation
handoff). These tests pin the no-email identity summary helper and the
final-report validator that flags unnecessary email disclosure.
"""
import sys
import unittest
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import review_proofs
class TestIdentitySummary(unittest.TestCase):
def test_author_summary_uses_username_and_profile_only(self):
summary = review_proofs.format_identity_summary(
"jcwalker3", "prgs-author")
self.assertEqual(summary, "jcwalker3 / prgs-author")
self.assertNotIn("@", summary)
def test_reviewer_summary_uses_username_and_profile_only(self):
summary = review_proofs.format_identity_summary(
"sysadmin", "prgs-reviewer")
self.assertEqual(summary, "sysadmin / prgs-reviewer")
def test_summary_appends_role_and_remote_without_email(self):
summary = review_proofs.format_identity_summary(
"jcwalker3", "prgs-author", role="author", remote="prgs")
self.assertIn("jcwalker3 / prgs-author", summary)
self.assertIn("author", summary)
self.assertIn("prgs", summary)
self.assertNotIn("@", summary)
def test_summary_never_leaks_email_passed_as_username(self):
"""Defense in depth: an email in the username slot is reduced."""
summary = review_proofs.format_identity_summary(
"[email protected]", "prgs-author")
self.assertNotIn("@", summary)
self.assertIn("jcwalker3", summary)
class TestEmailDisclosureAssessment(unittest.TestCase):
def test_report_without_email_passes(self):
report = "\n".join([
"Controller Handoff",
"Identity: jcwalker3 / prgs-author",
"Role: author",
])
res = review_proofs.assess_email_disclosure(report)
self.assertTrue(res["proven"])
self.assertFalse(res["flagged"])
self.assertEqual(res["emails"], [])
self.assertEqual(res["reasons"], [])
def test_unnecessary_email_is_flagged(self):
report = "\n".join([
"Controller Handoff",
"Identity: jcwalker3 / [email protected]",
])
res = review_proofs.assess_email_disclosure(report)
self.assertFalse(res["proven"])
self.assertTrue(res["flagged"])
self.assertIn("[email protected]", res["emails"])
self.assertTrue(res["reasons"])
self.assertFalse(res["justified"])
def test_multiple_emails_all_reported(self):
report = (
"Identity: [email protected] author\n"
"Reviewer: [email protected]\n"
)
res = review_proofs.assess_email_disclosure(report)
self.assertTrue(res["flagged"])
self.assertEqual(
sorted(res["emails"]),
["[email protected]", "[email protected]"],
)
def test_justified_email_with_explanation_is_not_flagged(self):
report = "\n".join([
"Identity: jcwalker3 / prgs-author",
"Contact email: [email protected]",
"Email required because two accounts share the username and the",
"address is necessary to disambiguate identity.",
])
res = review_proofs.assess_email_disclosure(report)
self.assertFalse(res["flagged"])
self.assertTrue(res["justified"])
self.assertIn("[email protected]", res["emails"])
def test_email_without_justification_language_is_flagged(self):
report = "Contact email: [email protected] just in case.\n"
res = review_proofs.assess_email_disclosure(report)
self.assertTrue(res["flagged"])
self.assertFalse(res["justified"])
if __name__ == "__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()
+27 -2
View File
@@ -36,7 +36,32 @@ class TestIssueLockWorktreeAssessment(unittest.TestCase):
porcelain_status="",
)
self.assertFalse(result["proven"])
self.assertIn("issue lock must be taken from base branch", result["reasons"][0])
self.assertIn("base-equivalence could not be proven", result["reasons"][0])
def test_base_equivalent_feature_branch_passes(self):
result = issue_lock_worktree.assess_issue_lock_worktree(
worktree_path="/repo/branches/issue-275",
current_branch="feat/issue-275-claim-lock-branches-worktree",
porcelain_status="",
base_equivalent=True,
inspected_git_root="/repo/branches/issue-275",
base_branch="origin/master",
)
self.assertTrue(result["proven"])
self.assertFalse(result["block"])
self.assertEqual(result["base_branch"], "origin/master")
def test_non_base_equivalent_branch_fails(self):
result = issue_lock_worktree.assess_issue_lock_worktree(
worktree_path="/repo/branches/issue-275",
current_branch="feat/issue-275-claim-lock-branches-worktree",
porcelain_status="",
base_equivalent=False,
inspected_git_root="/repo/branches/issue-275",
base_branch=None,
)
self.assertFalse(result["proven"])
self.assertIn("must be base-equivalent", result["reasons"][0])
def test_untracked_files_do_not_block(self):
result = issue_lock_worktree.assess_issue_lock_worktree(
@@ -96,4 +121,4 @@ class TestPrWorktreeMatch(unittest.TestCase):
if __name__ == "__main__":
unittest.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 {}
+118
View File
@@ -0,0 +1,118 @@
"""Tests for linked-issue consistency verifier (#314).
Reviewer reports must verify the linked issue live for the selected PR;
stale issue numbers from a previous PR must not leak into the final
report, and linked-issue status must never be claimed without live proof.
"""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from review_proofs import assess_linked_issue_consistency # noqa: E402
class TestCorrectLinkedIssue(unittest.TestCase):
def test_matching_linked_issue_passes(self):
report = (
"Selected PR: 280\n"
"Linked issue status: Issue #275 open, closes on merge\n"
)
result = assess_linked_issue_consistency(
report, selected_pr=280, linked_issues=[275]
)
self.assertTrue(result["complete"])
self.assertFalse(result["downgraded"])
self.assertEqual(result["reasons"], [])
def test_multiple_linked_issues_all_reported_passes(self):
report = (
"Linked issue status: Issue #275 and Issue #276 both open\n"
)
result = assess_linked_issue_consistency(
report, selected_pr=280, linked_issues=[275, 276]
)
self.assertTrue(result["complete"])
def test_multiple_linked_issues_one_missing_fails(self):
report = "Linked issue status: Issue #275 open\n"
result = assess_linked_issue_consistency(
report, selected_pr=280, linked_issues=[275, 276]
)
self.assertFalse(result["complete"])
self.assertTrue(
any("276" in r for r in result["reasons"])
)
class TestStaleIssueLeak(unittest.TestCase):
def test_stale_issue_in_status_field_fails(self):
# #314 observed behavior: PR #280 links Issue #275 but the report
# carries Issue #260 from the previous PR.
report = "Linked issue status: Issue #260 open\n"
result = assess_linked_issue_consistency(
report, selected_pr=280, linked_issues=[275]
)
self.assertFalse(result["complete"])
self.assertTrue(any("260" in r for r in result["reasons"]))
def test_stale_issue_in_diagnostics_fails(self):
report = (
"Linked issue status: Issue #275 open\n"
"Read-only diagnostics: viewed Issue #260 status\n"
)
result = assess_linked_issue_consistency(
report, selected_pr=280, linked_issues=[275]
)
self.assertFalse(result["complete"])
self.assertTrue(
any("stale" in r.lower() and "260" in r for r in result["reasons"])
)
def test_selected_pr_number_mention_is_not_stale(self):
# "PR #280" mentions must not be confused with issue mentions.
report = (
"Selected PR: 280\n"
"Validation: ran tests at PR #280 head\n"
"Linked issue status: Issue #275 open\n"
)
result = assess_linked_issue_consistency(
report, selected_pr=280, linked_issues=[275]
)
self.assertTrue(result["complete"])
class TestMissingProof(unittest.TestCase):
def test_status_claim_without_live_proof_fails(self):
report = "Linked issue status: Issue #275 open\n"
result = assess_linked_issue_consistency(
report, selected_pr=280, linked_issues=None
)
self.assertFalse(result["complete"])
self.assertTrue(
any("live proof" in r.lower() for r in result["reasons"])
)
def test_unverified_wording_without_proof_passes(self):
report = (
"Linked issue status: not verified in this session\n"
)
result = assess_linked_issue_consistency(
report, selected_pr=280, linked_issues=None
)
self.assertTrue(result["complete"])
def test_missing_field_fails(self):
report = "Selected PR: 280\n"
result = assess_linked_issue_consistency(
report, selected_pr=280, linked_issues=[275]
)
self.assertFalse(result["complete"])
self.assertTrue(
any("linked issue status" in r.lower() for r in result["reasons"])
)
if __name__ == "__main__":
unittest.main()
+153
View File
@@ -0,0 +1,153 @@
"""Tests for gitea_list_prs pagination metadata (#340)."""
import os
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import gitea_auth # noqa: E402
from mcp_server import gitea_list_prs # noqa: E402
from review_proofs import assess_list_prs_pagination_proof # noqa: E402
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
URL = "https://gitea.example.com/api/v1/repos/o/r/pulls?state=open"
SAMPLE_PR = {
"number": 1,
"title": "PR 1",
"state": "open",
"head": {"ref": "branch1"},
"base": {"ref": "master"},
"mergeable": True,
"updated_at": "2026-07-05T12:00:00Z",
}
class TestApiFetchPage(unittest.TestCase):
@patch("gitea_auth.api_request")
def test_final_page_metadata(self, mock_req):
mock_req.return_value = [SAMPLE_PR]
items, meta = gitea_auth.api_fetch_page(URL, FAKE_AUTH, page=1, limit=50)
self.assertEqual(len(items), 1)
self.assertFalse(meta["has_more"])
self.assertTrue(meta["is_final_page"])
self.assertIsNone(meta["next_page"])
@patch("gitea_auth.api_request")
def test_full_page_has_more(self, mock_req):
mock_req.return_value = [{"id": i} for i in range(2)]
items, meta = gitea_auth.api_fetch_page(URL, FAKE_AUTH, page=1, limit=2)
self.assertEqual(len(items), 2)
self.assertTrue(meta["has_more"])
self.assertFalse(meta["is_final_page"])
self.assertEqual(meta["next_page"], 2)
class TestGiteaListPrsPagination(unittest.TestCase):
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_explicit_page_returns_has_more(self, _auth, mock_fetch):
mock_fetch.return_value = (
[SAMPLE_PR],
{
"page": 1,
"per_page": 50,
"returned_count": 1,
"has_more": False,
"next_page": None,
"is_final_page": True,
},
)
with patch.dict(os.environ, {}, clear=True):
result = gitea_list_prs(page=1, per_page=50)
self.assertEqual(len(result["prs"]), 1)
self.assertFalse(result["pagination"]["inventory_complete"])
self.assertTrue(result["pagination"]["is_final_page"])
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_multi_page_fetch_all_marks_inventory_complete(self, _auth, mock_fetch):
mock_fetch.side_effect = [
(
[dict(SAMPLE_PR, number=i) for i in (1, 2)],
{
"page": 1,
"per_page": 2,
"returned_count": 2,
"has_more": True,
"next_page": 2,
"is_final_page": False,
},
),
(
[dict(SAMPLE_PR, number=3)],
{
"page": 2,
"per_page": 2,
"returned_count": 1,
"has_more": False,
"next_page": None,
"is_final_page": True,
},
),
]
with patch.dict(os.environ, {}, clear=True):
result = gitea_list_prs(per_page=2)
self.assertEqual([p["number"] for p in result["prs"]], [1, 2, 3])
self.assertTrue(result["pagination"]["inventory_complete"])
self.assertEqual(result["pagination"]["pages_fetched"], 2)
self.assertEqual(result["pagination"]["total_count"], 3)
class TestListPrsPaginationProof(unittest.TestCase):
def test_wrapped_final_page_passes_completeness_claim(self):
response = {
"prs": [],
"pagination": {
"page": 1,
"per_page": 50,
"returned_count": 0,
"has_more": False,
"next_page": None,
"is_final_page": True,
"inventory_complete": True,
"pages_fetched": 1,
"total_count": 0,
},
}
result = assess_list_prs_pagination_proof(
response, inventory_complete_claimed=True
)
self.assertTrue(result["proven"])
def test_bare_list_blocks_completeness_claim(self):
result = assess_list_prs_pagination_proof(
[{"number": 1}], inventory_complete_claimed=True
)
self.assertFalse(result["proven"])
self.assertIn("pagination metadata", " ".join(result["reasons"]))
def test_single_page_without_complete_blocks_claim(self):
response = {
"prs": [{"number": 1}],
"pagination": {
"page": 1,
"per_page": 50,
"returned_count": 1,
"has_more": True,
"next_page": 2,
"is_final_page": False,
"inventory_complete": False,
"pages_fetched": 1,
"total_count": None,
},
}
result = assess_list_prs_pagination_proof(
response, inventory_complete_claimed=True
)
self.assertFalse(result["proven"])
if __name__ == "__main__":
unittest.main()
+298
View File
@@ -0,0 +1,298 @@
"""Doc-contract checks for task-specific LLM workflow split (#333, #334)."""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SKILL_DIR = REPO_ROOT / "skills" / "llm-project-workflow"
SKILL = (SKILL_DIR / "SKILL.md").read_text(encoding="utf-8")
def test_skill_md_exists():
assert SKILL_DIR.joinpath("SKILL.md").is_file()
def test_all_workflow_files_exist():
for name in (
"review-merge-pr.md",
"reconcile-landed-pr.md",
"create-issue.md",
"work-issue.md",
"pr-queue-cleanup.md",
):
assert (SKILL_DIR / "workflows" / name).is_file(), name
def test_skill_references_all_workflow_files():
for name in (
"workflows/review-merge-pr.md",
"workflows/reconcile-landed-pr.md",
"workflows/create-issue.md",
"workflows/work-issue.md",
"workflows/pr-queue-cleanup.md",
):
assert name in SKILL
def test_skill_contains_mode_isolation_language():
assert "## Mode isolation" in SKILL
assert "review-merge-pr" in SKILL
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()
def test_skill_is_router_not_monolithic_review_body():
assert "This skill is a **router**" in SKILL or "router" in SKILL.lower()
assert "## F. Review workflow" not in SKILL
assert "gitea_mark_final_review_decision" not in SKILL
assert "gitea_submit_pr_review" not in SKILL
def test_skill_still_declares_controller_handoff_contract():
assert "## Controller Handoff" in SKILL
assert "assess_controller_handoff" in SKILL
assert "## Global LLM Worktree Rule" in SKILL
def test_review_merge_workflow_contract():
text = (SKILL_DIR / "workflows" / "review-merge-pr.md").read_text(encoding="utf-8")
assert "canonical: true" in text
assert "## 0. Load the canonical workflow first" in text
assert "## 26A. Terminal review mutation hard-stop" in text
assert "## 11A. Skipped PRs are read-only" in text
assert "## 35. Duplicate request-changes prevention" in text
assert "## 37. Controller handoff schema" in text
assert "INVENTORY_PAGINATION_UNPROVEN" in text
assert "ALREADY_LANDED_RECONCILE_REQUIRED" in text
def test_review_merge_final_report_schema_exists():
path = SKILL_DIR / "schemas" / "review-merge-final-report.md"
text = path.read_text(encoding="utf-8")
assert "Controller Handoff" in text
assert "Candidate head SHA:" in text
assert "Terminal review mutation:" in text
assert "Workspace mutations" in text
def test_reconcile_landed_workflow_contract():
text = (SKILL_DIR / "workflows" / "reconcile-landed-pr.md").read_text(encoding="utf-8")
assert "canonical: true" in text
assert "Do not review or merge normal PRs" in text
assert "Already-landed proof" in text
# Issue #302: partial reconciliation policy must stay documented.
assert "## 12A. Partial reconciliation policy (#302)" in text
assert "comment-then-stop" in text
assert "PARTIAL_RECONCILE_COMMENT_THEN_STOP" in text
assert "RECOVERY_HANDOFF_ONLY" in text
assert "resolve_partial_reconciliation_plan" in text
def test_create_issue_workflow_contract():
text = (SKILL_DIR / "workflows" / "create-issue.md").read_text(encoding="utf-8")
assert "canonical: true" in text
assert "## 9. Duplicate search before mutation" in text
def test_author_duplicate_work_gate_exported():
from review_proofs import assess_work_issue_duplicate_prevention_report
assert callable(assess_work_issue_duplicate_prevention_report)
def test_work_issue_workflow_contract():
text = (SKILL_DIR / "workflows" / "work-issue.md").read_text(encoding="utf-8")
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():
for name in (
"review-merge-final-report.md",
"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
def test_review_pr_template_references_workflow():
text = (SKILL_DIR / "templates" / "review-pr.md").read_text(encoding="utf-8")
assert "workflows/review-merge-pr.md" in text
def test_merge_pr_template_references_workflow():
text = (SKILL_DIR / "templates" / "merge-pr.md").read_text(encoding="utf-8")
assert "workflows/review-merge-pr.md" in text
def test_start_issue_template_references_work_issue_workflow():
text = (SKILL_DIR / "templates" / "start-issue.md").read_text(encoding="utf-8")
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
assert callable(assess_reviewer_fallback_report)
def test_final_report_validator_exported():
from review_proofs import assess_final_report_validator
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_prior_blocker_skip_verifier_exported():
from review_proofs import assess_prior_blocker_skip_proof
assert callable(assess_prior_blocker_skip_proof)
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_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
+227 -37
View File
@@ -24,6 +24,7 @@ from mcp_server import ( # noqa: E402
gitea_merge_pr,
gitea_review_pr,
gitea_delete_branch,
gitea_reconcile_merged_cleanups,
gitea_edit_pr,
gitea_get_file,
gitea_commit_files,
@@ -47,6 +48,21 @@ import mcp_server
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
_NO_BLOCKER_FEEDBACK = {
"success": True,
"has_blocking_change_requests": False,
"review_feedback_stale": False,
}
def _mark_request_changes_ready(pr_number=8, **kwargs):
"""Mark a request_changes decision ready with the #332 duplicate-
suppression feedback fetch stubbed to 'no existing blocker'."""
with patch("mcp_server.gitea_get_pr_review_feedback",
return_value=dict(_NO_BLOCKER_FEEDBACK)):
return gitea_mark_final_review_decision(
pr_number, "request_changes", **kwargs)
def _formal_review(reviewer, verdict, sha="abc123", review_id=1):
return {
@@ -305,15 +321,17 @@ class TestMarkIssue(unittest.TestCase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_start_adds_label(self, _auth, mock_api):
# First call: get labels; second call: add label
# labels, add label, post claim heartbeat comment
mock_api.side_effect = [
[{"id": 10, "name": "status:in-progress"}],
[{"name": "status:in-progress"}],
{"id": 99},
]
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
result = gitea_mark_issue(issue_number=5, action="start")
self.assertTrue(result["success"])
self.assertIn("claimed", result["message"])
self.assertTrue(result.get("heartbeat_posted"))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@@ -399,40 +417,49 @@ class TestMirrorRefs(unittest.TestCase):
# ---------------------------------------------------------------------------
class TestListPRs(unittest.TestCase):
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_list_prs(self, _auth, mock_api):
mock_api.return_value = [
{
def test_list_prs(self, _auth, mock_fetch):
mock_fetch.return_value = (
[{
"number": 1, "title": "PR 1", "state": "open",
"head": {"ref": "branch1"}, "base": {"ref": "main"},
"html_url": "http://url1", "mergeable": True,
"updated_at": "2026-07-05T12:00:00Z"
}
]
}],
{
"page": 1, "per_page": 50, "returned_count": 1,
"has_more": False, "next_page": None, "is_final_page": True,
},
)
with patch.dict(os.environ, {}, clear=True):
result = gitea_list_prs()
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["number"], 1)
self.assertEqual(result[0]["head"], "branch1")
self.assertNotIn("url", result[0])
self.assertEqual(len(result["prs"]), 1)
self.assertEqual(result["prs"][0]["number"], 1)
self.assertEqual(result["prs"][0]["head"], "branch1")
self.assertNotIn("url", result["prs"][0])
self.assertTrue(result["pagination"]["inventory_complete"])
# Staleness fields for queue reconciliation (#166)
self.assertEqual(result[0]["updated_at"], "2026-07-05T12:00:00Z")
self.assertEqual(result["prs"][0]["updated_at"], "2026-07-05T12:00:00Z")
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_list_prs_reveal_opt_in_includes_url(self, _auth, mock_api):
mock_api.return_value = [
{
def test_list_prs_reveal_opt_in_includes_url(self, _auth, mock_fetch):
mock_fetch.return_value = (
[{
"number": 1, "title": "PR 1", "state": "open",
"head": {"ref": "branch1"}, "base": {"ref": "main"},
"html_url": "http://url1", "mergeable": True,
"updated_at": "2026-07-05T12:00:00Z"
}
]
}],
{
"page": 1, "per_page": 50, "returned_count": 1,
"has_more": False, "next_page": None, "is_final_page": True,
},
)
with patch.dict(os.environ, {"GITEA_MCP_REVEAL_ENDPOINTS": "1"}, clear=True):
result = gitea_list_prs()
self.assertEqual(result[0]["url"], "http://url1")
self.assertEqual(result["prs"][0]["url"], "http://url1")
# ---------------------------------------------------------------------------
@@ -990,6 +1017,60 @@ class TestDeleteBranch(unittest.TestCase):
self.assertIn("feat%2Fbranch", url)
# ---------------------------------------------------------------------------
# Reconcile merged cleanups (#269)
# ---------------------------------------------------------------------------
READ_ENV = {
"GITEA_ALLOWED_OPERATIONS": "gitea.read",
}
class TestReconcileMergedCleanups(unittest.TestCase):
@patch("mcp_server.api_get_all")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_dry_run_builds_report_without_mutations(self, _auth, mock_api_get_all):
mock_api_get_all.side_effect = [
[
{
"number": 50,
"title": "merged feature",
"body": "Closes #50",
"merged": True,
"merged_at": "2026-07-06T12:00:00Z",
"merge_commit_sha": "deadbeef",
"head": {"ref": "feat/issue-50-example", "sha": "cafebabe"},
}
],
[],
]
with patch.dict(os.environ, READ_ENV, clear=True):
with patch(
"mcp_server._remote_branch_exists",
return_value=True,
):
with patch(
"mcp_server.merged_cleanup_reconcile.is_head_ancestor_of_ref",
return_value=True,
):
result = gitea_reconcile_merged_cleanups(
dry_run=True,
remote="prgs",
limit=10,
)
self.assertTrue(result["success"])
self.assertTrue(result["dry_run"])
self.assertFalse(result["executed"])
self.assertEqual(result["merged_pr_count"], 1)
self.assertEqual(result["entries"][0]["issue_number"], 50)
def test_execute_requires_confirmation(self):
with patch.dict(os.environ, READ_ENV, clear=True):
with self.assertRaises(ValueError) as ctx:
gitea_reconcile_merged_cleanups(dry_run=False, execute_confirmed=False)
self.assertIn("execute_confirmed", str(ctx.exception))
# ---------------------------------------------------------------------------
# Edit PR
# ---------------------------------------------------------------------------
@@ -1071,9 +1152,11 @@ class TestGetFile(unittest.TestCase):
# ---------------------------------------------------------------------------
class TestCommitFiles(unittest.TestCase):
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_commit_files_success(self, _auth, mock_api):
def test_commit_files_success(self, _auth, mock_api, _role):
mock_api.return_value = {
"commit": {"sha": "commit-sha-123"},
"branch": {"name": "test-branch"}
@@ -1081,11 +1164,15 @@ class TestCommitFiles(unittest.TestCase):
files = [
{"operation": "create", "path": "test.txt", "content": "SGVsbG8="}
]
result = gitea_commit_files(
files=files,
message="Initial commit",
new_branch="test-branch"
)
env = {
"GITEA_ALLOWED_OPERATIONS": "gitea.repo.commit",
}
with patch.dict(os.environ, env, clear=True):
result = gitea_commit_files(
files=files,
message="Initial commit",
new_branch="test-branch"
)
self.assertTrue(result["success"])
self.assertEqual(result["commit"], "commit-sha-123")
self.assertEqual(result["branch"], "test-branch")
@@ -1769,7 +1856,7 @@ class TestSubmitPrReview(unittest.TestCase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_request_changes_succeeds_when_eligible(self, _auth, mock_api):
gitea_mark_final_review_decision(8, "request_changes", remote="prgs")
_mark_request_changes_ready(remote="prgs")
mock_api.side_effect = [
{"login": "reviewer-bot"}, self._pr("author-bot"),
{"id": 9, "state": "REQUEST_CHANGES"},
@@ -1792,7 +1879,7 @@ class TestSubmitPrReview(unittest.TestCase):
self.assertEqual(post_calls[0].args[3]["event"], "REQUEST_CHANGES")
def test_request_changes_blocked_without_eligibility(self):
gitea_mark_final_review_decision(8, "request_changes", remote="prgs")
_mark_request_changes_ready(remote="prgs")
with patch("mcp_server.get_auth_header", return_value=FAKE_AUTH) as _a, \
patch("mcp_server.api_request") as mock_api:
mock_api.side_effect = [{"login": "reviewer-bot"}, self._pr("author-bot")]
@@ -2074,7 +2161,7 @@ class TestSubmitPrReview(unittest.TestCase):
reason="operator approved correcting mistaken approve",
operator_authorized=True,
)
gitea_mark_final_review_decision(8, "request_changes", remote="prgs")
_mark_request_changes_ready(remote="prgs")
second = gitea_submit_pr_review(
pr_number=8, action="request_changes", remote="prgs",
final_review_decision_ready=True,
@@ -2931,20 +3018,31 @@ class TestVerifyMutationAuthority(unittest.TestCase):
mcp_server.verify_mutation_authority("prgs")
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",
}
class TestIssueLocking(unittest.TestCase):
"""Test issue locking and PR gating constraints."""
@staticmethod
def _clean_master_git_state():
return {"current_branch": "master", "porcelain_status": ""}
def setUp(self):
self._env_patcher = patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True)
self._env_patcher.start()
def tearDown(self):
self._env_patcher.stop()
if os.path.exists(ISSUE_LOCK_FILE):
os.remove(ISSUE_LOCK_FILE)
@patch(
"mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value={"current_branch": "master", "porcelain_status": ""},
return_value=_clean_master_git_state_for_lock(),
)
@patch("mcp_server.api_get_all")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@@ -2952,10 +3050,18 @@ class TestIssueLocking(unittest.TestCase):
mock_api.return_value = [] # no open PRs
res = gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
self.assertTrue(res["success"])
self.assertEqual(res["work_lease"]["operation_type"], "author_issue_work")
self.assertEqual(res["work_lease"]["issue_number"], 196)
self.assertEqual(res["work_lease"]["branch"], "feat/issue-196-mutations")
self.assertIn("created_at", res["work_lease"])
self.assertIn("expires_at", res["work_lease"])
self.assertIn("last_heartbeat_at", res["work_lease"])
self.assertEqual(res["work_lease"]["claimant"]["profile"], "gitea-default")
self.assertTrue(os.path.exists(ISSUE_LOCK_FILE))
with open(ISSUE_LOCK_FILE, encoding="utf-8") as f:
lock = json.load(f)
self.assertIn("worktree_path", lock)
self.assertIn("work_lease", lock)
def test_lock_issue_mismatch_branch_fails(self):
with self.assertRaises(ValueError) as ctx:
@@ -2964,7 +3070,7 @@ class TestIssueLocking(unittest.TestCase):
@patch(
"mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value={"current_branch": "master", "porcelain_status": ""},
return_value=_clean_master_git_state_for_lock(),
)
@patch("mcp_server.api_get_all")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@@ -2981,7 +3087,7 @@ class TestIssueLocking(unittest.TestCase):
@patch(
"mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value={"current_branch": "master", "porcelain_status": ""},
return_value=_clean_master_git_state_for_lock(),
)
@patch("mcp_server.api_get_all")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@@ -2996,13 +3102,58 @@ class TestIssueLocking(unittest.TestCase):
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
self.assertIn("already tied to an open PR", str(ctx.exception))
@patch(
"mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value=_clean_master_git_state_for_lock(),
)
@patch("mcp_server.api_get_all")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_lock_issue_reused_by_remote_branch(self, _auth, mock_api, _git_state):
mock_api.side_effect = [
[],
[{"name": "feat/issue-196-existing-work"}],
]
with self.assertRaises(ValueError) as ctx:
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
self.assertIn("already has matching branch", str(ctx.exception))
def test_lock_issue_blocks_active_same_operation_lease(self):
with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
json.dump({
"issue_number": 196,
"branch_name": "feat/issue-196-other-work",
"worktree_path": "/tmp/other-worktree",
"work_lease": {
"operation_type": "author_issue_work",
"expires_at": "2999-01-01T00:00:00Z",
},
}, f)
with self.assertRaises(RuntimeError) as ctx:
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
self.assertIn("already has an active author_issue_work lease", str(ctx.exception))
def test_lock_issue_blocks_expired_same_operation_lease_for_recovery(self):
with open(ISSUE_LOCK_FILE, "w", encoding="utf-8") as f:
json.dump({
"issue_number": 196,
"branch_name": "feat/issue-196-other-work",
"worktree_path": "/tmp/other-worktree",
"work_lease": {
"operation_type": "author_issue_work",
"expires_at": "2000-01-01T00:00:00Z",
},
}, f)
with self.assertRaises(RuntimeError) as ctx:
gitea_lock_issue(issue_number=196, branch_name="feat/issue-196-mutations", remote="prgs")
self.assertIn("Recovery review is required before takeover", str(ctx.exception))
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_lock_from_clean_scratch_worktree(self, _auth, _api):
scratch = "/tmp/gitea-tools-author-scratch/issue-249-clean"
with patch(
"mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value={"current_branch": "master", "porcelain_status": ""},
return_value=_clean_master_git_state_for_lock(),
) as mock_git:
res = gitea_lock_issue(
issue_number=249,
@@ -3022,6 +3173,7 @@ class TestIssueLocking(unittest.TestCase):
return_value={
"current_branch": "master",
"porcelain_status": " M gitea_mcp_server.py\n",
"base_equivalent": True,
},
):
with self.assertRaises(RuntimeError) as ctx:
@@ -3041,6 +3193,7 @@ class TestIssueLocking(unittest.TestCase):
return_value={
"current_branch": "feat/issue-243-forbidden-git-gaps",
"porcelain_status": "",
"base_equivalent": False,
},
):
with self.assertRaises(RuntimeError) as ctx:
@@ -3050,7 +3203,7 @@ class TestIssueLocking(unittest.TestCase):
remote="prgs",
worktree_path="/tmp/scratch/wt",
)
self.assertIn("issue lock must be taken from base branch", str(ctx.exception))
self.assertIn("base-equivalent", str(ctx.exception))
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@@ -3193,7 +3346,12 @@ class TestPreflightVerification(unittest.TestCase):
mcp_server._preflight_whoami_violation_files = self.orig_whoami_files
mcp_server._preflight_capability_violation_files = self.orig_capability_files
mcp_server._preflight_reviewer_violation_files = self.orig_reviewer_files
for key in ("GITEA_TEST_FORCE_DIRTY", "GITEA_TEST_PORCELAIN"):
for key in (
"GITEA_TEST_FORCE_DIRTY",
"GITEA_TEST_PORCELAIN",
"GITEA_ACTIVE_WORKTREE",
"GITEA_AUTHOR_WORKTREE",
):
if key in os.environ:
del os.environ[key]
@@ -3283,3 +3441,35 @@ class TestPreflightVerification(unittest.TestCase):
status = mcp_server.assess_preflight_status()
self.assertFalse(status["preflight_ready"])
self.assertIn("gitea_whoami", status["preflight_block_reasons"][0])
def test_declared_clean_task_worktree_ignores_control_checkout_violation(self):
"""#275: active branches/ worktree is the inspected mutation workspace."""
import mcp_server
mcp_server._preflight_whoami_called = True
mcp_server._preflight_capability_called = True
mcp_server._preflight_whoami_violation = True
mcp_server._preflight_whoami_violation_files = ["mcp_server.py"]
os.environ["GITEA_TEST_PORCELAIN"] = ""
worktree = "/repo/branches/issue-275-clean"
status = mcp_server.assess_preflight_status(worktree_path=worktree)
self.assertTrue(status["preflight_ready"])
self.assertEqual(status["preflight_block_reasons"], [])
self.assertIn("preflight_workspace", status)
mcp_server.verify_preflight_purity(worktree_path=worktree)
def test_declared_dirty_task_worktree_reports_workspace_diagnostics(self):
"""#275: dirty failures name the inspected task workspace, not just files."""
import mcp_server
mcp_server._preflight_whoami_called = True
mcp_server._preflight_capability_called = True
os.environ["GITEA_TEST_PORCELAIN"] = " M task_file.py\n"
worktree = "/repo/branches/issue-275-dirty"
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity(worktree_path=worktree)
msg = str(ctx.exception)
self.assertIn("active task workspace root", msg)
self.assertIn("inspected git root", msg)
self.assertIn("dirty files: task_file.py", msg)
self.assertIn("dirty scope:", msg)
+159
View File
@@ -0,0 +1,159 @@
"""Tests for merged PR cleanup reconciliation (#269)."""
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 merged_cleanup_reconcile as mcr # noqa: E402
class TestMergedCleanupAssessment(unittest.TestCase):
def test_extract_linked_issue_from_closes(self):
issue = mcr.extract_linked_issue(
"feat: cleanup (Closes #269)",
"No other refs",
)
self.assertEqual(issue, 269)
def test_merged_remote_branch_safe_when_gates_pass(self):
result = mcr.assess_remote_branch_cleanup(
pr_number=42,
head_branch="feat/issue-269-merged-pr-cleanup-reconcile",
merged=True,
remote_branch_exists=True,
open_pr_heads=set(),
head_on_master=True,
delete_capability_allowed=True,
active_lock=False,
)
self.assertTrue(result["safe_to_delete_remote"])
self.assertEqual(result["block_reasons"], [])
def test_open_pr_blocks_remote_delete(self):
result = mcr.assess_remote_branch_cleanup(
pr_number=42,
head_branch="feat/issue-9-example",
merged=True,
remote_branch_exists=True,
open_pr_heads={"feat/issue-9-example"},
head_on_master=True,
delete_capability_allowed=True,
active_lock=False,
)
self.assertFalse(result["safe_to_delete_remote"])
self.assertIn("open PR", result["block_reasons"][0])
def test_protected_branch_blocks_remote_delete(self):
result = mcr.assess_remote_branch_cleanup(
pr_number=1,
head_branch="master",
merged=True,
remote_branch_exists=True,
open_pr_heads=set(),
head_on_master=True,
delete_capability_allowed=True,
active_lock=False,
)
self.assertFalse(result["safe_to_delete_remote"])
self.assertIn("protected", result["block_reasons"][0])
def test_active_lock_blocks_remote_and_worktree_cleanup(self):
remote = mcr.assess_remote_branch_cleanup(
pr_number=42,
head_branch="feat/issue-9-example",
merged=True,
remote_branch_exists=True,
open_pr_heads=set(),
head_on_master=True,
delete_capability_allowed=True,
active_lock=True,
)
local = mcr.assess_local_worktree_cleanup(
pr_number=42,
head_branch="feat/issue-9-example",
merged=True,
worktree_state={
"exists": True,
"clean": True,
"current_branch": "feat/issue-9-example",
"worktree_path": "/repo/branches/feat-issue-9-example",
},
active_lock=True,
)
self.assertFalse(remote["safe_to_delete_remote"])
self.assertFalse(local["safe_to_remove_worktree"])
self.assertIn("active issue lock", remote["block_reasons"][0])
def test_dirty_worktree_blocks_local_cleanup(self):
result = mcr.assess_local_worktree_cleanup(
pr_number=42,
head_branch="feat/issue-9-example",
merged=True,
worktree_state={
"exists": True,
"clean": False,
"dirty_files": ["gitea_mcp_server.py"],
"current_branch": "feat/issue-9-example",
"worktree_path": "/repo/branches/feat-issue-9-example",
},
active_lock=False,
)
self.assertFalse(result["safe_to_remove_worktree"])
self.assertIn("tracked edits", result["block_reasons"][0])
class TestMergedCleanupReport(unittest.TestCase):
def test_build_report_skips_unmerged_closed_prs(self):
with tempfile.TemporaryDirectory() as tmp:
report = mcr.build_reconciliation_report(
project_root=tmp,
closed_prs=[
{
"number": 10,
"title": "closed not merged",
"body": "Closes #10",
"head": {"ref": "feat/issue-10-x"},
"merged_at": None,
},
{
"number": 11,
"title": "merged",
"body": "Closes #11",
"head": {"ref": "feat/issue-11-x"},
"merged_at": "2026-07-06T12:00:00Z",
"merge_commit_sha": "abc123",
},
],
open_prs=[],
remote_branch_exists={"feat/issue-11-x": True},
head_on_master={11: True},
delete_capability_allowed=False,
)
self.assertEqual(report["merged_pr_count"], 1)
entry = report["entries"][0]
self.assertEqual(entry["pr_number"], 11)
self.assertEqual(entry["issue_number"], 11)
self.assertFalse(entry["remote_branch"]["safe_to_delete_remote"])
self.assertIn("delete_branch capability", entry["remote_branch"]["block_reasons"][0])
def test_has_active_issue_lock_reads_lock_file(self):
with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as handle:
handle.write('{"branch_name": "feat/issue-9-example"}')
lock_path = handle.name
try:
self.assertTrue(
mcr.has_active_issue_lock("feat/issue-9-example", lock_path=lock_path)
)
self.assertFalse(
mcr.has_active_issue_lock("feat/other-branch", lock_path=lock_path)
)
finally:
os.remove(lock_path)
if __name__ == "__main__":
unittest.main()
+102
View File
@@ -0,0 +1,102 @@
"""Tests for final-report mutation ledger verifier (#331)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from review_proofs import assess_mutation_ledger_report # noqa: E402
class TestMutationLedgerReport(unittest.TestCase):
def test_none_claim_with_performed_edit_blocks(self):
report = (
"Review decision: request_changes.\n"
"File edits by reviewer: none\n"
)
action_log = [
{"action": "Edited", "path": "walkthrough.md", "tracked": False},
]
result = assess_mutation_ledger_report(report, action_log=action_log)
self.assertFalse(result["proven"])
self.assertTrue(result["file_edits_claimed_none"])
def test_reported_edit_passes(self):
report = (
"File edits by reviewer: Edited walkthrough.md (untracked)\n"
"Mutation ledger: Edited walkthrough.md untracked in review worktree\n"
)
action_log = [
{
"action": "Edited",
"path": "walkthrough.md",
"tracked": False,
"in_repo": True,
},
]
result = assess_mutation_ledger_report(
report,
action_log=action_log,
walkthrough_explicitly_requested=True,
)
self.assertTrue(result["proven"])
def test_unreported_mutation_blocks(self):
report = "File edits by reviewer: none\n"
action_log = [{"action": "Wrote", "path": "/tmp/review-notes.txt"}]
result = assess_mutation_ledger_report(report, action_log=action_log)
self.assertFalse(result["proven"])
self.assertIn("/tmp/review-notes.txt", result["unreported_paths"])
def test_outside_repo_requires_label(self):
report = (
"File edits by reviewer: Wrote /tmp/review-notes.txt\n"
)
action_log = [
{
"action": "Wrote",
"path": "/tmp/review-notes.txt",
"outside_repo": True,
},
]
result = assess_mutation_ledger_report(report, action_log=action_log)
self.assertFalse(result["proven"])
self.assertIn("outside repo", " ".join(result["reasons"]).lower())
def test_gated_rejection_excluded_from_performed(self):
report = "File edits by reviewer: none\nRejected gated calls: submit_pr_review blocked\n"
action_log = [
{
"action": "Edited",
"path": "walkthrough.md",
"performed": False,
"gated_rejected": True,
},
]
result = assess_mutation_ledger_report(report, action_log=action_log)
self.assertTrue(result["proven"])
self.assertEqual(result["performed_mutations"], [])
def test_post_status_artifact_requires_final_git_status(self):
report = (
"File edits by reviewer: Created scratch/notes.md (untracked)\n"
)
action_log = [
{
"action": "Created",
"path": "scratch/notes.md",
"tracked": False,
"after_git_status": True,
},
]
result = assess_mutation_ledger_report(
report,
action_log=action_log,
final_git_status_reported=False,
)
self.assertFalse(result["proven"])
self.assertIn("final git status", " ".join(result["reasons"]).lower())
if __name__ == "__main__":
unittest.main()
+121
View File
@@ -0,0 +1,121 @@
"""Tests for native MCP preference gate and shell circuit breaker (#270)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import native_mcp_preference as nmp # noqa: E402
class TestShellHealthCircuitBreaker(unittest.TestCase):
def setUp(self):
nmp.clear_shell_health_for_tests()
def test_two_spawn_failures_hard_stop(self):
nmp.record_shell_spawn_outcome(exit_code=-1, stdout="", stderr="")
status = nmp.record_shell_spawn_outcome(exit_code=-1, stdout="", stderr="")
self.assertTrue(status["hard_stopped"])
self.assertFalse(status["shell_use_allowed"])
self.assertEqual(status["consecutive_spawn_failures"], 2)
def test_success_resets_breaker(self):
nmp.record_shell_spawn_outcome(exit_code=-1, stdout="", stderr="")
status = nmp.record_shell_spawn_outcome(exit_code=0, stdout="ok", stderr="")
self.assertFalse(status["hard_stopped"])
self.assertEqual(status["consecutive_spawn_failures"], 0)
class TestNativeMcpPreferenceGate(unittest.TestCase):
def setUp(self):
nmp.clear_shell_health_for_tests()
def test_mcp_available_blocks_local_script(self):
result = nmp.assess_gitea_operation_path(
task="create_pr",
command_or_detail="python create_pr.py --remote prgs --title T --head h",
mcp_available=True,
mcp_tool_visible=True,
)
self.assertTrue(result["block"])
self.assertEqual(result["path_kind"], "shell_script")
def test_mcp_native_path_allowed(self):
result = nmp.assess_gitea_operation_path(
task="comment_issue",
path_kind="mcp_native",
mcp_available=True,
mcp_tool_visible=True,
)
self.assertFalse(result["block"])
self.assertTrue(result["allowed"])
def test_mcp_unavailable_requires_terminal_report(self):
result = nmp.assess_gitea_operation_path(
task="merge_pr",
path_kind="shell_script",
mcp_available=False,
mcp_tool_visible=False,
)
self.assertTrue(result["block"])
self.assertTrue(
any("terminal recovery report" in r for r in result["reasons"])
)
self.assertIn(nmp.TERMINAL_REPORT_HEADING, result["terminal_report"])
def test_recovery_fallback_allowed_with_proof(self):
result = nmp.assess_gitea_operation_path(
task="merge_pr",
path_kind="shell_script",
command_or_detail="Recovery mode: MCP tools unavailable in this session.",
mcp_available=False,
recovery_mode=True,
recovery_proof_complete=True,
)
self.assertFalse(result["block"])
def test_cli_auth_divergence_blocked(self):
result = nmp.assess_gitea_operation_path(
task="create_pr",
command_or_detail="GITEA_MCP_PROFILE=prgs-reviewer python create_pr.py",
mcp_available=True,
active_profile="prgs-author",
)
self.assertTrue(result["block"])
self.assertTrue(any("CLI auth divergence" in r for r in result["reasons"]))
def test_unsafe_helper_fallback_denied(self):
result = nmp.assess_gitea_operation_path(
task="commit_files",
command_or_detail="python _encode_payload.py",
mcp_available=True,
mcp_tool_visible=True,
)
self.assertTrue(result["block"])
self.assertEqual(result["path_kind"], "unsafe_helper")
def test_reviewer_mcp_server_touch_blocked(self):
result = nmp.assess_gitea_operation_path(
task="review_pr",
command_or_detail="pkill -f gitea_mcp_server.py",
mcp_available=True,
role="reviewer",
active_profile="prgs-reviewer",
)
self.assertTrue(result["block"])
self.assertEqual(result["path_kind"], "mcp_server_touch")
def test_hard_stopped_shell_blocks_non_mcp_path(self):
nmp.record_shell_spawn_outcome(exit_code=-1, stdout="", stderr="")
nmp.record_shell_spawn_outcome(exit_code=-1, stdout="", stderr="")
result = nmp.assess_gitea_operation_path(
task="lock_issue",
path_kind="shell_script",
mcp_available=True,
)
self.assertTrue(result["block"])
self.assertTrue(any("circuit breaker" in r for r in result["reasons"]))
if __name__ == "__main__":
unittest.main()
+3 -1
View File
@@ -133,7 +133,9 @@ class TestControlPlaneGuide(GuideTestBase):
for key in ("hard_stops", "fail_closed", "head_sha_pinning",
"merge_confirmation", "redaction", "separation",
"profile_switching", "identity_verification",
"work_selection", "global_worktree"):
"work_selection", "global_worktree",
"shell_spawn_hard_stop", "subagent_tool_budget",
"subagent_delegation"):
self.assertIn(key, rules)
self.assertIn("MERGE PR", json.dumps(rules["merge_confirmation"]))
self.assertTrue(rules["hard_stops"])
+269
View File
@@ -0,0 +1,269 @@
"""Tests for PR-only queue cleanup mode (#390)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from pr_queue_cleanup import (
CLEANUP_FORBIDDEN_TASKS,
CLEANUP_WORKFLOW_PATH,
CONTINUE_TO_SAME_PR_MERGE,
STOP_AFTER_DECISION,
STOP_AFTER_MERGE,
STOP_AFTER_MERGE_BLOCKER,
STOP_AFTER_REQUEST_CHANGES,
STOP_APPROVED_MERGE_GATES_FAILED,
STOP_APPROVED_NO_MERGE_AUTH,
STOP_GATE_NOT_PROVEN,
assess_pr_queue_cleanup_report,
check_cleanup_task_allowed,
resolve_cleanup_run_state,
)
from review_proofs import assess_pr_queue_cleanup_report as proofs_assess
from role_session_router import REVIEWER_TASKS, route_task_session
from task_capability_map import required_permission, required_role
class TestCleanupCapabilityMapping(unittest.TestCase):
def test_cleanup_task_is_reviewer_role(self):
for key in ("pr_queue_cleanup", "pr-queue-cleanup"):
self.assertEqual(required_permission(key), "gitea.pr.review")
self.assertEqual(required_role(key), "reviewer")
def test_cleanup_task_registered_as_reviewer_route(self):
self.assertIn("pr_queue_cleanup", REVIEWER_TASKS)
def test_author_session_gets_wrong_role_stop(self):
result = route_task_session(
"pr_queue_cleanup",
active_profile="prgs-author",
active_role_kind="author",
allowed_in_current_session=False,
)
self.assertEqual(result["route_result"], "wrong_role_stop")
self.assertFalse(result["downstream_allowed"])
def test_reviewer_session_allowed(self):
result = route_task_session(
"pr_queue_cleanup",
active_profile="prgs-reviewer",
active_role_kind="reviewer",
allowed_in_current_session=True,
)
self.assertEqual(result["route_result"], "allowed_current_session")
self.assertTrue(result["downstream_allowed"])
class TestCleanupTaskGate(unittest.TestCase):
def test_author_tasks_blocked(self):
for task in (
"claim_issue",
"mark_issue",
"lock_issue",
"create_issue",
"create_branch",
"push_branch",
"create_pr",
"commit_files",
):
allowed, reasons = check_cleanup_task_allowed(task)
self.assertFalse(allowed, task)
self.assertTrue(reasons, task)
def test_review_task_allowed(self):
allowed, reasons = check_cleanup_task_allowed("review_pr")
self.assertTrue(allowed)
self.assertEqual(reasons, [])
def test_forbidden_set_covers_issue_filing_and_impl(self):
self.assertIn("create_issue", CLEANUP_FORBIDDEN_TASKS)
self.assertIn("create_branch", CLEANUP_FORBIDDEN_TASKS)
self.assertIn("commit_files", CLEANUP_FORBIDDEN_TASKS)
class TestCleanupRunState(unittest.TestCase):
def test_request_changes_stops_run(self):
state = resolve_cleanup_run_state("request_changes")
self.assertEqual(state["outcome"], STOP_AFTER_REQUEST_CHANGES)
self.assertFalse(state["further_mutation_allowed"])
def test_comment_and_skip_stop_run(self):
for decision in ("comment", "skip"):
state = resolve_cleanup_run_state(decision)
self.assertEqual(state["outcome"], STOP_AFTER_DECISION)
self.assertFalse(state["further_mutation_allowed"])
def test_approved_without_merge_auth_stops(self):
state = resolve_cleanup_run_state("approved", merge_gates_passed=True)
self.assertEqual(state["outcome"], STOP_APPROVED_NO_MERGE_AUTH)
self.assertFalse(state["further_mutation_allowed"])
def test_approved_with_merge_auth_continues_to_merge(self):
state = resolve_cleanup_run_state(
"approved",
merge_authorized_for_pr=True,
merge_gates_passed=True,
)
self.assertEqual(state["outcome"], CONTINUE_TO_SAME_PR_MERGE)
self.assertTrue(state["further_mutation_allowed"])
self.assertEqual(state["allowed_mutation"], "merge same PR only")
def test_approved_with_auth_but_failed_gates_stops(self):
state = resolve_cleanup_run_state(
"approved",
merge_authorized_for_pr=True,
merge_gates_passed=False,
)
self.assertEqual(state["outcome"], STOP_APPROVED_MERGE_GATES_FAILED)
self.assertFalse(state["further_mutation_allowed"])
def test_merge_completed_stops(self):
state = resolve_cleanup_run_state(
"approved",
merge_authorized_for_pr=True,
merge_gates_passed=True,
merge_completed=True,
)
self.assertEqual(state["outcome"], STOP_AFTER_MERGE)
self.assertFalse(state["further_mutation_allowed"])
def test_merge_blocker_stops(self):
state = resolve_cleanup_run_state(
"approved",
merge_authorized_for_pr=True,
merge_gates_passed=True,
merge_blocker=True,
)
self.assertEqual(state["outcome"], STOP_AFTER_MERGE_BLOCKER)
self.assertFalse(state["further_mutation_allowed"])
def test_unknown_decision_fails_closed(self):
state = resolve_cleanup_run_state("ship_it")
self.assertEqual(state["outcome"], STOP_GATE_NOT_PROVEN)
self.assertFalse(state["further_mutation_allowed"])
def _clean_report(**overrides):
lines = {
"task": "Task: pr-queue-cleanup",
"workflow": f"Workflow source: {CLEANUP_WORKFLOW_PATH} (hash abc123def456)",
"pagination": (
"PR inventory pagination proof: inventory_complete=true, final "
"page, total_count 15"
),
"selected": "Selected PR: #371",
"decision": "Review decision: approved",
"merge_auth": "Merge authorized for PR: false",
"merge_result": "Merge result: not attempted",
"next": "Next suggested PR: #372",
"issue_mut": "Issue mutations: none",
"branch_mut": "Branch mutations: none",
}
lines.update(overrides)
return "\n".join(v for v in lines.values() if v)
class TestCleanupReportVerifier(unittest.TestCase):
def test_clean_single_pr_report_passes(self):
result = assess_pr_queue_cleanup_report(_clean_report())
self.assertTrue(result["proven"], result["reasons"])
self.assertFalse(result["block"])
def test_approved_and_merged_with_authorization_passes(self):
report = _clean_report(
merge_auth="Merge authorized for PR: true",
merge_result="Merge result: merged 0123456789ab",
)
result = assess_pr_queue_cleanup_report(report)
self.assertTrue(result["proven"], result["reasons"])
def test_request_changes_then_stop_passes(self):
report = _clean_report(decision="Review decision: request_changes")
result = assess_pr_queue_cleanup_report(report)
self.assertTrue(result["proven"], result["reasons"])
def test_multi_pr_selection_blocked(self):
report = _clean_report() + "\nSelected PR: #403\n"
result = assess_pr_queue_cleanup_report(report)
self.assertFalse(result["proven"])
self.assertTrue(
any("multiple prs" in r.lower() for r in result["reasons"]),
result["reasons"],
)
def test_two_terminal_mutations_blocked(self):
report = _clean_report() + "\nReview decision: request_changes"
result = assess_pr_queue_cleanup_report(report)
self.assertFalse(result["proven"])
self.assertTrue(
any("terminal review mutations" in r for r in result["reasons"]),
result["reasons"],
)
def test_missing_pagination_proof_blocked(self):
report = _clean_report(
pagination="PR inventory pagination proof: inventory listed"
)
result = assess_pr_queue_cleanup_report(report)
self.assertFalse(result["proven"])
self.assertTrue(
any("pagination" in r.lower() for r in result["reasons"]),
result["reasons"],
)
def test_missing_next_suggested_pr_blocked(self):
report = _clean_report(next="")
result = assess_pr_queue_cleanup_report(report)
self.assertFalse(result["proven"])
self.assertTrue(
any("Next suggested PR" in r for r in result["reasons"]),
result["reasons"],
)
def test_merge_without_authorization_blocked(self):
report = _clean_report(
merge_result="Merge result: merged abc123abc123",
)
result = assess_pr_queue_cleanup_report(report)
self.assertFalse(result["proven"])
self.assertTrue(
any("authorization" in r.lower() for r in result["reasons"]),
result["reasons"],
)
def test_issue_mutations_forbidden(self):
report = _clean_report(issue_mut="Issue mutations: gitea_mark_issue")
result = assess_pr_queue_cleanup_report(report)
self.assertFalse(result["proven"])
self.assertTrue(
any("issue mutations" in r.lower() for r in result["reasons"]),
result["reasons"],
)
def test_branch_mutations_forbidden(self):
report = _clean_report(branch_mut="Branch mutations: created feat/x")
result = assess_pr_queue_cleanup_report(report)
self.assertFalse(result["proven"])
self.assertTrue(
any("branch mutations" in r.lower() for r in result["reasons"]),
result["reasons"],
)
def test_missing_workflow_citation_blocked(self):
report = _clean_report(workflow="Workflow source: none")
result = assess_pr_queue_cleanup_report(report)
self.assertFalse(result["proven"])
self.assertTrue(
any("pr-queue-cleanup.md" in r for r in result["reasons"]),
result["reasons"],
)
def test_review_proofs_wrapper_matches_module(self):
direct = assess_pr_queue_cleanup_report(_clean_report())
wrapped = proofs_assess(_clean_report())
self.assertEqual(direct["proven"], wrapped["proven"])
if __name__ == "__main__":
unittest.main()
+67 -55
View File
@@ -16,38 +16,50 @@ import gitea_config
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
def _final_page_fetch(items):
return (items, {
"page": 1,
"per_page": 50,
"returned_count": len(items),
"has_more": False,
"next_page": None,
"is_final_page": True,
})
class TestPRQueueInventory(unittest.TestCase):
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_author_profile_can_list_open_prs(self, mock_get_profile, _auth, mock_get_all):
def test_author_profile_can_list_open_prs(self, mock_get_profile, _auth, mock_fetch):
mock_get_profile.return_value = {
"profile_name": "gitea-author",
"allowed_operations": ["read", "gitea.pr.comment"],
"forbidden_operations": [],
"base_url": None,
}
mock_get_all.return_value = [
mock_fetch.return_value = _final_page_fetch([
{"number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1", "sha": "abc1"}, "base": {"ref": "master"}, "mergeable": True}
]
])
result = gitea_list_prs(state="open", remote="prgs")
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["number"], 1)
self.assertEqual(len(result["prs"]), 1)
self.assertEqual(result["prs"][0]["number"], 1)
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
@patch("gitea_config.is_runtime_switching_enabled", return_value=True)
def test_author_profile_can_produce_pending_reviewer_queue_report(self, mock_switch, mock_get_profile, _auth, mock_api, mock_get_all):
def test_author_profile_can_produce_pending_reviewer_queue_report(self, mock_switch, mock_get_profile, _auth, mock_api, mock_fetch):
mock_get_profile.return_value = {
"profile_name": "gitea-author",
"allowed_operations": ["read"],
"forbidden_operations": [],
"base_url": None,
}
mock_get_all.return_value = [
mock_fetch.return_value = _final_page_fetch([
{
"number": 1,
"title": "PR 1",
@@ -72,7 +84,7 @@ class TestPRQueueInventory(unittest.TestCase):
"body": "",
"updated_at": "2026-07-05T11:00:00Z"
}
]
])
mock_api.return_value = {"login": "jcwalker3"} # whoami
result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs")
@@ -86,18 +98,18 @@ class TestPRQueueInventory(unittest.TestCase):
# updated_at surfaced for reconciliation (#166)
self.assertIn("Updated: 2026-07-05T10:00:00Z", result["message"])
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_author_profile_never_calls_mutation_tools_during_inventory(self, mock_get_profile, _auth, mock_api, mock_get_all):
def test_author_profile_never_calls_mutation_tools_during_inventory(self, mock_get_profile, _auth, mock_api, mock_fetch):
mock_get_profile.return_value = {
"profile_name": "gitea-author",
"allowed_operations": ["read"],
"forbidden_operations": [],
"base_url": None,
}
mock_get_all.return_value = []
mock_fetch.return_value = _final_page_fetch([])
mock_api.return_value = {"login": "jcwalker3"}
gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs")
@@ -105,20 +117,20 @@ class TestPRQueueInventory(unittest.TestCase):
method = call.args[0]
self.assertEqual(method, "GET")
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_reviewer_profile_can_proceed_from_inventory_to_review_gates(self, mock_get_profile, _auth, mock_api, mock_get_all):
def test_reviewer_profile_can_proceed_from_inventory_to_review_gates(self, mock_get_profile, _auth, mock_api, mock_fetch):
mock_get_profile.return_value = {
"profile_name": "gitea-reviewer",
"allowed_operations": ["read", "approve", "review"],
"forbidden_operations": [],
"base_url": None,
}
mock_get_all.return_value = [
mock_fetch.return_value = _final_page_fetch([
{"number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1", "sha": "abc1"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "other_user"}}
]
])
# mock_api: inventory whoami, eligibility whoami, eligibility PR,
# POST review (#244: state + visible-verdict GET reviews).
mock_api.side_effect = [
@@ -152,19 +164,19 @@ class TestPRQueueInventory(unittest.TestCase):
self.assertIn("Repository:", result["message"])
self.assertIn("Successfully submitted review", result["message"])
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
@patch("gitea_config.is_runtime_switching_enabled", return_value=False)
def test_static_runtime_rejects_or_hides_profile_activation(self, mock_switch, mock_get_profile, _auth, mock_api, mock_get_all):
def test_static_runtime_rejects_or_hides_profile_activation(self, mock_switch, mock_get_profile, _auth, mock_api, mock_fetch):
mock_get_profile.return_value = {
"profile_name": "gitea-author",
"allowed_operations": ["read"],
"forbidden_operations": [],
"base_url": None,
}
mock_get_all.return_value = []
mock_fetch.return_value = _final_page_fetch([])
mock_api.return_value = {"login": "jcwalker3"}
result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs")
@@ -200,11 +212,11 @@ class TestPRQueueInventory(unittest.TestCase):
self.assertFalse(result["eligible"])
self.assertEqual(mock_api.call_count, 0)
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_inventory_failure_output_is_redacted_no_raw_exception_leak(self, mock_get_profile, _auth, mock_api, mock_get_all):
def test_inventory_failure_output_is_redacted_no_raw_exception_leak(self, mock_get_profile, _auth, mock_api, mock_fetch):
"""Inventory failure path must use redaction and not leak raw exception text."""
mock_get_profile.return_value = {
"profile_name": "gitea-author",
@@ -213,7 +225,7 @@ class TestPRQueueInventory(unittest.TestCase):
"base_url": None,
}
# Exception contains something that should be redacted or not leaked raw
mock_get_all.side_effect = Exception("connection error token supersecrettoken123 at https://internal.example/repo")
mock_fetch.side_effect = Exception("connection error token supersecrettoken123 at https://internal.example/repo")
mock_api.return_value = {"login": "jcwalker3"}
result = gitea_review_pr(pr_number=42, event="APPROVE", remote="prgs")
@@ -228,11 +240,11 @@ class TestPRQueueInventory(unittest.TestCase):
# uses project's redaction pattern (token prefix -> [REDACTED])
# even if url leaks, the exception is redacted per pattern
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_inventory_failure_output_redacts_real_service_urls(self, mock_get_profile, _auth, mock_api, mock_get_all):
def test_inventory_failure_output_redacts_real_service_urls(self, mock_get_profile, _auth, mock_api, mock_fetch):
"""Failure paths must fully redact real service hostnames and URLs from inventory output."""
mock_get_profile.return_value = {
"profile_name": "gitea-author",
@@ -240,7 +252,7 @@ class TestPRQueueInventory(unittest.TestCase):
"forbidden_operations": [],
"base_url": None,
}
mock_get_all.side_effect = Exception("failed to connect to https://gitea.prgs.cc/api/v1/repos/x")
mock_fetch.side_effect = Exception("failed to connect to https://gitea.prgs.cc/api/v1/repos/x")
mock_api.return_value = {"login": "jcwalker3"}
result = gitea_review_pr(pr_number=42, event="APPROVE", remote="prgs")
@@ -250,11 +262,11 @@ class TestPRQueueInventory(unittest.TestCase):
self.assertIn("[REDACTED_URL]", msg)
self.assertNotIn("gitea.prgs.cc", msg)
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_author_profiles_can_perform_read_only_pr_inventory(self, mock_get_profile, _auth, mock_api, mock_get_all):
def test_author_profiles_can_perform_read_only_pr_inventory(self, mock_get_profile, _auth, mock_api, mock_fetch):
"""Author profiles with read can perform read-only PR inventory (report produced, no mutations)."""
mock_get_profile.return_value = {
"profile_name": "gitea-author",
@@ -262,9 +274,9 @@ class TestPRQueueInventory(unittest.TestCase):
"forbidden_operations": [],
"base_url": None,
}
mock_get_all.return_value = [
mock_fetch.return_value = _final_page_fetch([
{"number": 7, "title": "Some PR", "state": "open", "head": {"ref": "f", "sha": "def"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "someone"}, "labels": [], "body": ""}
]
])
mock_api.return_value = {"login": "jcwalker3"}
result = gitea_review_pr(pr_number=7, event="APPROVE", remote="prgs")
@@ -278,12 +290,12 @@ class TestPRQueueInventory(unittest.TestCase):
self.assertEqual(call.args[0], "GET")
@patch("mcp_server._local_git_remote_url")
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_empty_inventory_runs_trust_gate_and_blocks_without_trusted_empty(
self, mock_get_profile, _auth, mock_api, mock_get_all, mock_local_url
self, mock_get_profile, _auth, mock_api, mock_fetch, mock_local_url
):
mock_get_profile.return_value = {
"profile_name": "gitea-author",
@@ -291,7 +303,7 @@ class TestPRQueueInventory(unittest.TestCase):
"forbidden_operations": [],
"base_url": None,
}
mock_get_all.return_value = []
mock_fetch.return_value = _final_page_fetch([])
mock_api.return_value = {"login": "jcwalker3"}
mock_local_url.return_value = None
@@ -309,12 +321,12 @@ class TestPRQueueInventory(unittest.TestCase):
self.assertNotIn("Open PRs found: 0 (trusted_empty)", msg)
@patch("mcp_server._local_git_remote_url")
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_empty_inventory_trusted_empty_when_gate_passes(
self, mock_get_profile, _auth, mock_api, mock_get_all, mock_local_url
self, mock_get_profile, _auth, mock_api, mock_fetch, mock_local_url
):
mock_get_profile.return_value = {
"profile_name": "gitea-author",
@@ -322,7 +334,7 @@ class TestPRQueueInventory(unittest.TestCase):
"forbidden_operations": [],
"base_url": None,
}
mock_get_all.return_value = []
mock_fetch.return_value = _final_page_fetch([])
mock_api.return_value = {"login": "jcwalker3"}
mock_local_url.return_value = (
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
@@ -341,11 +353,11 @@ class TestPRQueueInventory(unittest.TestCase):
self.assertIn("Open PRs found: 0 (trusted_empty)", msg)
@patch("mcp_server._local_git_remote_url")
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_author_profiles_cannot_approve_request_changes_merge_or_bypass_gates(self, mock_get_profile, _auth, mock_api, mock_get_all, mock_local_url):
def test_author_profiles_cannot_approve_request_changes_merge_or_bypass_gates(self, mock_get_profile, _auth, mock_api, mock_fetch, mock_local_url):
"""Author profiles still cannot approve, request_changes, merge, or bypass gates even with inventory."""
mock_local_url.return_value = (
"https://gitea.prgs.cc/Scaled-Tech-Consulting/Gitea-Tools.git"
@@ -357,7 +369,7 @@ class TestPRQueueInventory(unittest.TestCase):
"forbidden_operations": [],
"base_url": None,
}
mock_get_all.return_value = []
mock_fetch.return_value = _final_page_fetch([])
mock_api.return_value = {"login": "jcwalker3"}
result = gitea_review_pr(pr_number=1, event=event, remote="prgs")
@@ -377,10 +389,10 @@ class TestPRQueueInventory(unittest.TestCase):
# Tests use direct list/view calls + inventory path to ensure fields and
# live data are available for detection without trusting prior handoffs.
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_r5_s1_stale_prior_handoff_merged_but_live_pr_open(self, mock_get_profile, _auth, mock_get_all):
def test_r5_s1_stale_prior_handoff_merged_but_live_pr_open(self, mock_get_profile, _auth, mock_fetch):
"""Scenario 1: stale prior handoff says merged but live PR list says open."""
mock_get_profile.return_value = {
"profile_name": "gitea-author",
@@ -388,17 +400,17 @@ class TestPRQueueInventory(unittest.TestCase):
"forbidden_operations": [],
"base_url": None,
}
mock_get_all.return_value = [{
mock_fetch.return_value = _final_page_fetch([{
"number": 99, "title": "Stale merge claim", "state": "open",
"head": {"ref": "f99", "sha": "sha99"}, "base": {"ref": "master"},
"mergeable": True, "user": {"login": "other"},
"labels": [], "body": "Fixes #200",
"updated_at": "2026-07-05T22:00:00Z"
}]
}])
prs = gitea_list_prs(state="open", remote="prgs")
self.assertEqual(len(prs), 1)
self.assertEqual(prs[0]["state"], "open")
self.assertIn("updated_at", prs[0]) # enables staleness check vs prior "merged"
self.assertEqual(len(prs["prs"]), 1)
self.assertEqual(prs["prs"][0]["state"], "open")
self.assertIn("updated_at", prs["prs"][0]) # enables staleness check vs prior "merged"
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@@ -424,11 +436,11 @@ class TestPRQueueInventory(unittest.TestCase):
self.assertIsNotNone(pr.get("closed_at"))
# live closed would contradict prior "open" handoff
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_r5_s3_list_prs_and_view_pr_disagree(self, mock_get_profile, _auth, mock_api, mock_get_all):
def test_r5_s3_list_prs_and_view_pr_disagree(self, mock_get_profile, _auth, mock_api, mock_fetch):
"""Scenario 3: gitea_list_prs and gitea_view_pr disagree on state/details."""
mock_get_profile.return_value = {
"profile_name": "gitea-author",
@@ -437,12 +449,12 @@ class TestPRQueueInventory(unittest.TestCase):
"base_url": None,
}
# list says open
mock_get_all.return_value = [{
mock_fetch.return_value = _final_page_fetch([{
"number": 77, "title": "Disagree", "state": "open",
"head": {"ref": "f77", "sha": "s77"}, "base": {"ref": "master"},
"mergeable": True, "user": {"login": "x"},
"labels": [], "body": "", "updated_at": "2026-07-05T23:00:00Z"
}]
}])
# view says closed (simulating inconsistency)
mock_api.return_value = {
"number": 77, "title": "Disagree", "state": "closed",
@@ -453,7 +465,7 @@ class TestPRQueueInventory(unittest.TestCase):
}
listed = gitea_list_prs(state="open", remote="prgs")
viewed = gitea_view_pr(pr_number=77, remote="prgs")
self.assertEqual(listed[0]["state"], "open")
self.assertEqual(listed["prs"][0]["state"], "open")
self.assertEqual(viewed["state"], "closed")
# caller must reconcile; mismatch is hard blocker per rules
@@ -483,11 +495,11 @@ class TestPRQueueInventory(unittest.TestCase):
self.assertIsNone(pr.get("merge_commit_sha"))
# post-merge view must surface missing commit for detection
@patch("mcp_server.api_get_all")
@patch("mcp_server.api_fetch_page")
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_r5_s5_linked_issue_remains_open_after_claimed_merge(self, mock_get_profile, _auth, mock_api, mock_get_all):
def test_r5_s5_linked_issue_remains_open_after_claimed_merge(self, mock_get_profile, _auth, mock_api, mock_fetch):
"""Scenario 5: linked issue remains open after claimed merge."""
mock_get_profile.return_value = {
"profile_name": "gitea-author",
@@ -496,12 +508,12 @@ class TestPRQueueInventory(unittest.TestCase):
"base_url": None,
}
# PR claims merge, links #300
mock_get_all.return_value = [{
mock_fetch.return_value = _final_page_fetch([{
"number": 300, "title": "PR for #300", "state": "closed",
"head": {"ref": "f300", "sha": "s3"}, "base": {"ref": "master"},
"mergeable": True, "user": {"login": "u"},
"labels": [], "body": "Fixes #300", "updated_at": "2026-07-05T20:20:00Z"
}]
}])
# But linked issue (via list_issues or view) still open - here we use view_pr body to confirm link
# For this test we assert the PR data shows link, caller must check issue state live
mock_api.return_value = {
+93
View File
@@ -0,0 +1,93 @@
"""Doc-contract checks for proof wording enforcement (#330)."""
import unittest
from review_proofs import assess_proof_wording, build_final_report
class TestProofWording(unittest.TestCase):
def test_rejects_inventory_complete_without_pagination_proof(self):
result = assess_proof_wording(
"Open PR inventory is complete because only 3 PRs were returned."
)
self.assertFalse(result["proven"])
self.assertTrue(result["block"])
def test_rejects_default_page_size_assumption(self):
result = assess_proof_wording(
"Inventory exhaustive: fewer than the default Gitea page size returned."
)
self.assertFalse(result["proven"])
self.assertIn("default_page_size_assumption", result["flagged_rules"])
def test_allows_inventory_complete_with_finality_metadata(self):
result = assess_proof_wording(
"Inventory complete. pagination_complete: true, is_final_page: true"
)
self.assertTrue(result["proven"])
def test_allows_inventory_complete_with_session_evidence(self):
result = assess_proof_wording(
"Queue inventory complete.",
session_evidence={"pagination_complete": True},
)
self.assertTrue(result["proven"])
def test_rejects_live_proof_without_session_evidence(self):
result = assess_proof_wording("Live proof confirms mergeable state.")
self.assertFalse(result["proven"])
self.assertIn("live_proof", result["flagged_rules"])
def test_allows_prior_blocker_reuse_wording(self):
result = assess_proof_wording(
"Prior blocker reused; head SHA unchanged since blocking review. "
"Live proof not claimed for this skip."
)
self.assertTrue(result["proven"])
def test_allows_live_proof_with_current_session_evidence(self):
result = assess_proof_wording(
"Live proof: gitea_view_pr revalidated in the current session."
)
self.assertTrue(result["proven"])
def test_rejects_same_as_master_without_baseline_proof(self):
result = assess_proof_wording("Diff is same as master.")
self.assertFalse(result["proven"])
self.assertIn("same_as_master", result["flagged_rules"])
def test_allows_same_as_master_with_baseline_wording(self):
result = assess_proof_wording(
"Clean baseline worktree proof: diff base matches master."
)
self.assertTrue(result["proven"])
def test_rejects_file_edits_none_without_ledger(self):
result = assess_proof_wording("Workspace mutations: file edits none.")
self.assertFalse(result["proven"])
self.assertIn("no_file_edits", result["flagged_rules"])
def test_allows_file_edits_none_with_mutation_ledger(self):
result = assess_proof_wording(
"Mutation ledger: tracked file edits: none",
session_evidence={"mutation_ledger_clean": True},
)
self.assertTrue(result["proven"])
def test_build_final_report_downgrades_on_proof_wording_violation(self):
report = build_final_report(
{"proven": True},
{"complete": True},
{"claimable": True, "verdict": "strong"},
{"status": "clean"},
True,
False,
True,
report_text="Inventory complete after listing 4 open PRs.",
)
self.assertEqual(report["grade"], "downgraded")
self.assertFalse(report["proof_wording_proven"])
self.assertTrue(report["proof_wording_violations"])
if __name__ == "__main__":
unittest.main()
+91
View File
@@ -0,0 +1,91 @@
"""Doc-contract checks for queue-status report verifier (#339)."""
import unittest
from review_proofs import assess_queue_status_report, build_final_report
BAD_QUEUE_STATUS_REPORT = """
## PR Queue Status
Loaded workflows/review-merge-pr.md. Inventory complete because gitea_list_prs
returned 6 open PRs, fewer than the default Gitea page size.
PR #286 skipped: prior diagnostic worktree simulation showed merge conflicts.
Live blocker proof for all skipped PRs.
All open PRs are conflicted or blocked. Blockers: none.
## Controller Handoff
- Task: review queue status
- Repo: Scaled-Tech-Consulting/Gitea-Tools
- Role: reviewer
- Identity: reviewer1 / prgs-reviewer
- Selected PR: none
- Inventory pagination proof: assumed complete (6 < 50)
- Already-landed gate: passed
- Author-safety result: passed
- Merge preflight: passed
- Review worktree used: false
- Review worktree dirty before validation: false
- Blockers: none
- Current status: queue inspected; no PR selected
"""
class TestQueueStatusReport(unittest.TestCase):
def test_bad_fixture_fails_closed(self):
result = assess_queue_status_report(BAD_QUEUE_STATUS_REPORT)
self.assertFalse(result["proven"])
self.assertTrue(result["block"])
self.assertTrue(result["queue_status_only"])
joined = " ".join(result["violations"]).lower()
self.assertIn("already-landed gate", joined)
self.assertIn("author-safety", joined)
self.assertIn("merge preflight", joined)
self.assertIn("prior diagnostic", joined)
self.assertIn("blockers", joined)
def test_passes_with_not_applicable_gates(self):
report = """
## Controller Handoff
- Selected PR: none
- Inventory pagination proof: is_final_page: true, has_more: false
- Already-landed gate: not applicable
- Author-safety result: not run
- Merge preflight: not applicable
- Review worktree used: false
- Review worktree dirty before validation: not applicable
- Blockers: all PRs skipped pending reviewer
"""
result = assess_queue_status_report(report)
self.assertTrue(result["proven"])
def test_rejects_passed_gates_without_selected_pr(self):
report = """
## Controller Handoff
- Selected PR: none
- Already-landed gate: passed
- Blockers: PR #1 conflicted
"""
result = assess_queue_status_report(report)
self.assertFalse(result["proven"])
self.assertIn("already-landed gate", " ".join(result["violations"]).lower())
def test_build_final_report_downgrades_bad_queue_status(self):
report = build_final_report(
{"proven": True},
{"complete": True},
{"claimable": True, "verdict": "strong"},
{"status": "clean"},
True,
False,
True,
report_text=BAD_QUEUE_STATUS_REPORT,
)
self.assertEqual(report["grade"], "downgraded")
self.assertFalse(report["queue_status_report_proven"])
self.assertTrue(report["queue_status_violations"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,149 @@
"""Tests for gitea_reconcile_already_landed_pr MCP tool (#310)."""
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import mcp_server
from mcp_server import gitea_reconcile_already_landed_pr
RECONCILER_PROFILE = {
"profile_name": "prgs-reconciler",
"allowed_operations": [
"gitea.read",
"gitea.pr.comment",
"gitea.issue.comment",
"gitea.issue.close",
"gitea.pr.close",
],
"forbidden_operations": [
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.create",
"gitea.branch.push",
],
"audit_label": "prgs-reconciler",
}
AUTHOR_PROFILE = {
"profile_name": "prgs-author",
"allowed_operations": [
"gitea.read",
"gitea.pr.create",
"gitea.issue.comment",
],
"forbidden_operations": ["gitea.pr.close", "gitea.pr.approve", "gitea.pr.merge"],
"audit_label": "prgs-author",
}
OPEN_LANDED_PR = {
"number": 278,
"title": "Already landed (Closes #263)",
"body": "",
"state": "open",
"head": {"ref": "feat/issue-263", "sha": "2a544b78d1371925761d16f1c451bb3b2984470e"},
"base": {"ref": "master"},
}
class TestReconcileAlreadyLandedPrTool(unittest.TestCase):
def setUp(self):
self.mock_api = patch("mcp_server.api_request").start()
self.mock_auth = patch(
"mcp_server.get_auth_header", return_value="token test"
).start()
patch("mcp_server.verify_preflight_purity").start()
patch("gitea_audit.audit_enabled", return_value=False).start()
mcp_server._IDENTITY_CACHE.clear()
def tearDown(self):
patch.stopall()
mcp_server._IDENTITY_CACHE.clear()
def _set_profile(self, profile):
patch("mcp_server.get_profile", return_value=profile).start()
def test_close_blocked_without_pr_close_capability(self):
self._set_profile(AUTHOR_PROFILE)
res = gitea_reconcile_already_landed_pr(
pr_number=278,
close_pr=True,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertFalse(res["performed"])
self.assertEqual(res["required_permission"], "gitea.pr.close")
self.mock_api.assert_not_called()
def test_not_landed_pr_close_denied_after_live_fetch(self):
self._set_profile(RECONCILER_PROFILE)
self.mock_api.return_value = OPEN_LANDED_PR
with patch(
"mcp_server.already_landed_reconcile.assess_already_landed_reconciliation",
return_value={
"pr_state": "open",
"candidate_head_sha": OPEN_LANDED_PR["head"]["sha"],
"target_branch": "master",
"target_branch_sha": "deadbeef",
"ancestor_proof": False,
"eligibility_class": "NOT_ALREADY_LANDED",
"linked_issue": 263,
"close_allowed": False,
"git_ref_mutations": ["git fetch prgs master"],
"reasons": ["not ancestor"],
},
):
res = gitea_reconcile_already_landed_pr(
pr_number=278,
close_pr=True,
remote="prgs",
)
self.assertFalse(res["success"])
self.assertFalse(res.get("pr_closed"))
patch_calls = [
c for c in self.mock_api.call_args_list if c.args[0] == "PATCH"
]
self.assertEqual(patch_calls, [])
def test_already_landed_close_allowed_with_capability(self):
self._set_profile(RECONCILER_PROFILE)
def api_side_effect(method, url, auth, payload=None):
if method == "GET" and "/pulls/278" in url:
return dict(OPEN_LANDED_PR)
if method == "PATCH" and "/pulls/278" in url:
closed = dict(OPEN_LANDED_PR)
closed["state"] = "closed"
return closed
return {}
self.mock_api.side_effect = api_side_effect
with patch(
"mcp_server.already_landed_reconcile.assess_already_landed_reconciliation",
return_value={
"pr_state": "open",
"candidate_head_sha": OPEN_LANDED_PR["head"]["sha"],
"target_branch": "master",
"target_branch_sha": "e017d80256217f17b0f421cd051cfa5cbcf5ae0f",
"ancestor_proof": True,
"eligibility_class": "ALREADY_LANDED_RECONCILE_REQUIRED",
"linked_issue": 263,
"close_allowed": True,
"git_ref_mutations": ["git fetch prgs master"],
"reasons": [],
},
):
res = gitea_reconcile_already_landed_pr(
pr_number=278,
close_pr=True,
remote="prgs",
)
self.assertTrue(res["success"])
self.assertTrue(res["performed"])
self.assertTrue(res["pr_closed"])
self.assertEqual(res["eligibility_class"], "ALREADY_LANDED_RECONCILE_REQUIRED")
if __name__ == "__main__":
unittest.main()
+188
View File
@@ -0,0 +1,188 @@
"""Issue #309: dedicated reconciler capability to close already-landed PRs.
The reconciler path must prove exact ``gitea.pr.close`` capability, must
never imply review/approve/request-changes/merge capability, and may close
a PR only after full already-landed proof.
"""
import sys
import unittest
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import role_session_router # noqa: E402
import task_capability_map # noqa: E402
from review_proofs import assess_reconciler_close_gate # noqa: E402
PINNED = "0fdc8f582026b72a229d59a172c0a63ac4aaeaf9"
OTHER = "a4060c5de00f2b1c9e88f4f6f0f3f9a7b2c1d0e9"
class TestReconcilerCapabilityMap(unittest.TestCase):
def test_reconcile_close_pr_task_maps_to_pr_close(self):
self.assertEqual(
task_capability_map.required_permission(
"reconcile_close_landed_pr"),
"gitea.pr.close",
)
self.assertEqual(
task_capability_map.required_role("reconcile_close_landed_pr"),
"reconciler",
)
def test_reconcile_close_issue_task_maps_to_issue_close(self):
self.assertEqual(
task_capability_map.required_permission(
"reconcile_close_landed_issue"),
"gitea.issue.close",
)
self.assertEqual(
task_capability_map.required_role(
"reconcile_close_landed_issue"),
"reconciler",
)
def test_reconciler_tasks_do_not_grant_review_permissions(self):
for task in ("reconcile_close_landed_pr",
"reconcile_close_landed_issue"):
permission = task_capability_map.required_permission(task)
self.assertNotIn("review", permission)
self.assertNotIn("approve", permission)
self.assertNotIn("merge", permission)
self.assertNotIn("request_changes", permission)
class TestReconcilerRouting(unittest.TestCase):
def test_reconciler_task_requires_reconciler_role(self):
self.assertEqual(
role_session_router.required_role_for_task(
"reconcile_close_landed_pr"),
"reconciler",
)
def test_reconciler_task_blocked_in_author_session(self):
result = role_session_router.route_task_session(
"reconcile_close_landed_pr",
active_profile="prgs-author",
active_role_kind="author",
allowed_in_current_session=False,
)
self.assertEqual(result["route_result"], "wrong_role_stop")
self.assertFalse(result["downstream_allowed"])
def test_reconciler_task_allowed_in_matching_session(self):
result = role_session_router.route_task_session(
"reconcile_close_landed_pr",
active_profile="prgs-reconciler",
active_role_kind="reconciler",
allowed_in_current_session=True,
)
self.assertEqual(result["route_result"], "allowed_current_session")
self.assertTrue(result["downstream_allowed"])
class TestReconcilerCloseGate(unittest.TestCase):
def _gate(self, **overrides):
kwargs = {
"pr_number": 278,
"pr_state": "open",
"candidate_head_sha": PINNED,
"live_head_sha": PINNED,
"target_branch": "master",
"target_branch_sha": OTHER,
"head_is_ancestor_of_target": True,
"close_pr_capability": True,
"close_issue_capability": False,
"linked_issue_state": "closed",
}
kwargs.update(overrides)
return assess_reconciler_close_gate(**kwargs)
def test_already_landed_open_pr_close_allowed(self):
result = self._gate()
self.assertEqual(result["outcome"], "CLOSE_ALLOWED")
self.assertTrue(result["pr_close_allowed"])
def test_not_landed_pr_cannot_be_closed(self):
result = self._gate(head_is_ancestor_of_target=False)
self.assertEqual(result["outcome"], "NOT_LANDED_CLOSE_BLOCKED")
self.assertFalse(result["pr_close_allowed"])
def test_unchecked_ancestry_blocks_close(self):
result = self._gate(head_is_ancestor_of_target=None)
self.assertEqual(result["outcome"], "GATE_NOT_PROVEN")
self.assertFalse(result["pr_close_allowed"])
def test_stale_target_branch_sha_blocks_close(self):
result = self._gate(target_branch_sha=None)
self.assertEqual(result["outcome"], "GATE_NOT_PROVEN")
self.assertFalse(result["pr_close_allowed"])
def test_closed_pr_state_blocks_close(self):
result = self._gate(pr_state="closed")
self.assertEqual(result["outcome"], "GATE_NOT_PROVEN")
self.assertFalse(result["pr_close_allowed"])
def test_changed_head_blocks_close(self):
result = self._gate(live_head_sha=OTHER)
self.assertEqual(result["outcome"], "GATE_NOT_PROVEN")
self.assertFalse(result["pr_close_allowed"])
def test_missing_close_capability_produces_recovery_handoff(self):
result = self._gate(close_pr_capability=False)
self.assertEqual(result["outcome"], "RECOVERY_HANDOFF_REQUIRED")
self.assertFalse(result["pr_close_allowed"])
self.assertTrue(any(
"gitea.pr.close" in reason for reason in result["reasons"]
))
def test_linked_issue_already_closed_skips_issue_close(self):
result = self._gate(linked_issue_state="closed",
close_issue_capability=True)
self.assertFalse(result["issue_close_allowed"])
def test_linked_open_issue_close_requires_capability(self):
allowed = self._gate(
linked_issue_state="open",
issue_resolved_by_landing=True,
close_issue_capability=True,
)
self.assertTrue(allowed["issue_close_allowed"])
blocked = self._gate(
linked_issue_state="open",
issue_resolved_by_landing=True,
close_issue_capability=False,
)
self.assertFalse(blocked["issue_close_allowed"])
def test_gate_never_allows_review_mutations(self):
for result in (
self._gate(),
self._gate(head_is_ancestor_of_target=False),
self._gate(close_pr_capability=False),
):
self.assertFalse(result["review_allowed"])
self.assertFalse(result["approve_allowed"])
self.assertFalse(result["request_changes_allowed"])
self.assertFalse(result["merge_allowed"])
def test_report_fields_listed_for_close(self):
result = self._gate()
for field in (
"identity/profile",
"close capability proof",
"PR live state",
"candidate head SHA",
"target branch SHA",
"ancestor proof",
"linked issue status",
"PR close result",
"issue close result",
"no review/merge confirmation",
):
self.assertIn(field, result["required_report_fields"])
if __name__ == "__main__":
unittest.main()
+87
View File
@@ -0,0 +1,87 @@
"""Tests for reconciler profile model (#304)."""
import os
import sys
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import gitea_mcp_server as mcp_server
import migrate_profiles
import reconciler_profile
PRGS_RECONCILER_ALLOWED = [
"gitea.read",
"gitea.pr.comment",
"gitea.issue.comment",
"gitea.issue.close",
"gitea.pr.close",
]
PRGS_RECONCILER_FORBIDDEN = [
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.create",
"gitea.branch.push",
]
class TestReconcilerProfileModel(unittest.TestCase):
def test_prgs_reconciler_shape_valid(self):
result = reconciler_profile.assess_reconciler_profile(
PRGS_RECONCILER_ALLOWED,
PRGS_RECONCILER_FORBIDDEN,
)
self.assertTrue(result["is_reconciler_profile"])
self.assertTrue(result["valid"])
self.assertEqual(result["reasons"], [])
def test_author_profile_not_reconciler(self):
allowed = [
"gitea.read",
"gitea.pr.create",
"gitea.branch.push",
"gitea.issue.comment",
]
forbidden = ["gitea.pr.approve", "gitea.pr.merge"]
self.assertFalse(
reconciler_profile.is_reconciler_profile(allowed, forbidden)
)
def test_reviewer_profile_not_reconciler(self):
allowed = [
"gitea.read",
"gitea.pr.review",
"gitea.pr.approve",
"gitea.pr.merge",
]
forbidden = ["gitea.pr.create", "gitea.branch.push"]
self.assertFalse(
reconciler_profile.is_reconciler_profile(allowed, forbidden)
)
def test_broad_close_on_author_invalid(self):
allowed = PRGS_RECONCILER_ALLOWED + ["gitea.pr.create"]
result = reconciler_profile.assess_reconciler_profile(
allowed,
PRGS_RECONCILER_FORBIDDEN,
)
self.assertFalse(result["valid"])
self.assertFalse(result["is_reconciler_profile"])
def test_role_kind_detects_reconciler(self):
role = mcp_server._role_kind(
PRGS_RECONCILER_ALLOWED,
PRGS_RECONCILER_FORBIDDEN,
)
self.assertEqual(role, "reconciler")
def test_migrate_infer_role_reconciler(self):
self.assertEqual(
migrate_profiles.infer_role("prgs-reconciler", "prgs-reconciler"),
"reconciler",
)
if __name__ == "__main__":
unittest.main()
+162
View File
@@ -0,0 +1,162 @@
"""Tests for the dedicated reconciliation controller-handoff schema (#303).
Already-landed PR reconciliation runs must emit the reconciliation
schema, not stale author/reviewer handoff fields; observed git ref
mutations (fetch) must be reported, and legacy fields fail validation.
"""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from review_proofs import assess_reconciliation_handoff # noqa: E402
def _good_handoff(**overrides):
fields = {
"Task": "Reconcile already-landed open PRs",
"Repo": "prgs/Scaled-Tech-Consulting/Gitea-Tools",
"Role/profile": "prgs-reconciler",
"Identity": "jcwalker3",
"Selected PR": "278",
"PR live state": "open",
"Candidate head SHA": "2a544b7",
"Target branch": "master",
"Target branch SHA": "fa0cb12",
"Ancestor proof": "candidate head is ancestor of target branch SHA",
"Linked issue": "263",
"Linked issue live status": "open (fetched live this session)",
"Eligibility class": "ALREADY_LANDED_RECONCILE_REQUIRED",
"Capabilities proven": "gitea.pr.comment",
"Missing capabilities": "gitea.pr.close",
"PR comments posted": "1 (PR #278 reconciliation notice)",
"Issue comments posted": "none",
"PRs closed": "none",
"Issues closed": "none",
"Git ref mutations": "git fetch prgs master",
"Worktree mutations": "none",
"MCP/Gitea mutations": "PR comment on #278",
"External-state mutations": "none",
"Read-only diagnostics": "gitea_view_pr 278, gitea_view_issue 263",
"Blocker": "PR close capability missing",
"Safe next action": "operator closes PR #278 or grants close capability",
"No review/merge confirmation": "no review or merge mutations performed",
}
fields.update(overrides)
lines = ["Controller Handoff"]
lines += [f"- {k}: {v}" for k, v in fields.items() if v is not None]
return "\n".join(lines) + "\n"
class TestReconciliationSchemaPasses(unittest.TestCase):
def test_blocked_reconciliation_passes(self):
result = assess_reconciliation_handoff(
_good_handoff(),
observed_commands=["git fetch prgs master"],
)
self.assertTrue(result["complete"])
self.assertEqual(result["reasons"], [])
def test_successful_pr_close_passes(self):
report = _good_handoff(**{
"Missing capabilities": "none",
"PRs closed": "PR #278",
"Blocker": "none",
"Safe next action": "none; reconciliation complete",
})
result = assess_reconciliation_handoff(
report, observed_commands=["git fetch prgs master"]
)
self.assertTrue(result["complete"])
def test_comment_only_reconciliation_passes(self):
report = _good_handoff(**{
"Git ref mutations": "none",
})
result = assess_reconciliation_handoff(report, observed_commands=[])
self.assertTrue(result["complete"])
def test_noop_already_closed_passes(self):
report = _good_handoff(**{
"PR live state": "closed",
"PR comments posted": "none",
"MCP/Gitea mutations": "none",
"Git ref mutations": "none",
"Blocker": "none",
"Safe next action": "none; PR already closed",
})
result = assess_reconciliation_handoff(report, observed_commands=[])
self.assertTrue(result["complete"])
class TestSchemaViolationsFail(unittest.TestCase):
def test_missing_linked_issue_proof_fails(self):
report = _good_handoff(**{"Linked issue live status": None})
result = assess_reconciliation_handoff(report, observed_commands=[])
self.assertFalse(result["complete"])
self.assertTrue(
any("linked issue live status" in r.lower()
for r in result["reasons"])
)
def test_wrong_eligibility_class_fails(self):
report = _good_handoff(**{"Eligibility class": "REVIEW_ELIGIBLE"})
result = assess_reconciliation_handoff(report, observed_commands=[])
self.assertFalse(result["complete"])
def test_missing_handoff_section_fails(self):
result = assess_reconciliation_handoff(
"no handoff here", observed_commands=[]
)
self.assertFalse(result["complete"])
class TestStaleFieldsFail(unittest.TestCase):
def test_pr_number_opened_fails(self):
report = _good_handoff() + "- PR number opened: 278\n"
result = assess_reconciliation_handoff(report, observed_commands=[])
self.assertFalse(result["complete"])
self.assertTrue(
any("pr number opened" in r.lower() for r in result["reasons"])
)
def test_pinned_reviewed_head_fails(self):
report = _good_handoff() + "- Pinned reviewed head: 2a544b7\n"
result = assess_reconciliation_handoff(report, observed_commands=[])
self.assertFalse(result["complete"])
def test_scratch_worktree_used_fails(self):
report = _good_handoff() + "- Scratch worktree used: False\n"
result = assess_reconciliation_handoff(report, observed_commands=[])
self.assertFalse(result["complete"])
def test_workspace_mutations_fails(self):
report = _good_handoff() + "- Workspace mutations: None\n"
result = assess_reconciliation_handoff(report, observed_commands=[])
self.assertFalse(result["complete"])
def test_author_lock_fields_fail(self):
report = (
_good_handoff()
+ "- Issue lock proof: locked before diff\n"
+ "- Claim/comment status: claimed\n"
)
result = assess_reconciliation_handoff(report, observed_commands=[])
self.assertFalse(result["complete"])
class TestObservedFetchMustBeReported(unittest.TestCase):
def test_fetch_with_ref_mutations_none_fails(self):
report = _good_handoff(**{"Git ref mutations": "none"})
result = assess_reconciliation_handoff(
report, observed_commands=["git fetch prgs master"]
)
self.assertFalse(result["complete"])
self.assertTrue(
any("git ref mutation" in r.lower() for r in result["reasons"])
)
if __name__ == "__main__":
unittest.main()
+106
View File
@@ -0,0 +1,106 @@
"""Tests for reconciliation MCP assessment tools (#301)."""
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import mcp_server
from mcp_server import (
gitea_assess_already_landed_reconciliation,
gitea_scan_already_landed_open_prs,
)
AUTHOR_PROFILE = {
"profile_name": "prgs-author",
"allowed_operations": [
"gitea.read",
"gitea.pr.comment",
"gitea.issue.comment",
"gitea.issue.close",
],
"forbidden_operations": ["gitea.pr.close"],
"audit_label": "prgs-author",
}
OPEN_PR = {
"number": 278,
"title": "Landed (Closes #263)",
"body": "",
"state": "open",
"head": {"ref": "feat/x", "sha": "a" * 40},
"base": {"ref": "master"},
}
class TestReconciliationMcpTools(unittest.TestCase):
def setUp(self):
self.mock_api = patch("mcp_server.api_request").start()
self.mock_auth = patch(
"mcp_server.get_auth_header", return_value="token test"
).start()
patch("mcp_server.get_profile", return_value=AUTHOR_PROFILE).start()
mcp_server._IDENTITY_CACHE.clear()
def tearDown(self):
patch.stopall()
mcp_server._IDENTITY_CACHE.clear()
def test_assess_returns_plan_without_mutations(self):
self.mock_api.return_value = OPEN_PR
with patch(
"mcp_server.reconciliation_workflow.assess_open_pr_reconciliation",
return_value={
"reconciliation_allowed": True,
"eligibility_class": "ALREADY_LANDED_RECONCILE_REQUIRED",
"candidate_head_sha": OPEN_PR["head"]["sha"],
"target_branch_sha": "deadbeef",
"linked_issue": 263,
"review_merge_allowed": False,
},
):
res = gitea_assess_already_landed_reconciliation(
pr_number=278, remote="prgs"
)
self.assertTrue(res["success"])
self.assertFalse(res["performed"])
self.assertEqual(res["plan"]["outcome"], "PARTIAL_RECONCILE_COMMENT_THEN_STOP")
self.assertFalse(res["review_merge_allowed"])
patch_calls = [
c for c in self.mock_api.call_args_list if c.args[0] == "PATCH"
]
self.assertEqual(patch_calls, [])
def test_scan_returns_candidates_with_pagination(self):
with patch(
"mcp_server.api_fetch_page",
return_value=([OPEN_PR], {
"page": 1,
"per_page": 50,
"is_final_page": True,
"has_more": False,
"next_page": None,
}),
), patch(
"mcp_server.reconciliation_workflow.fetch_target_branch",
return_value={
"success": True,
"target_branch_sha": "deadbeef",
"git_fetch_command": "git fetch prgs master",
},
), patch(
"mcp_server.reconciliation_workflow.assess_open_pr_reconciliation",
return_value={
"pr_number": 278,
"eligibility_class": "ALREADY_LANDED_RECONCILE_REQUIRED",
"reconciliation_allowed": True,
},
):
res = gitea_scan_already_landed_open_prs(remote="prgs", limit=50)
self.assertTrue(res["success"])
self.assertEqual(res["candidate_count"], 1)
self.assertTrue(res["pagination"]["inventory_complete"])
if __name__ == "__main__":
unittest.main()
+149
View File
@@ -0,0 +1,149 @@
"""Tests for already-landed reconciliation workflow helpers (#301)."""
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import reconciliation_workflow
from review_proofs import assess_reconcile_workflow_source
class TestReconciliationAssessment(unittest.TestCase):
def test_already_landed_open_pr(self):
with patch(
"reconciliation_workflow.is_head_ancestor_of_ref",
return_value=True,
):
assessment = reconciliation_workflow.assess_open_pr_reconciliation(
pr={
"number": 278,
"state": "open",
"title": "Fix (Closes #263)",
"body": "",
"head": {"ref": "feat/x", "sha": "abc" * 13 + "a"},
"base": {"ref": "master"},
},
project_root="/tmp/repo",
remote="prgs",
target_branch="master",
target_fetch={
"success": True,
"target_ref": "prgs/master",
"target_branch_sha": "deadbeef",
"git_fetch_command": "git fetch prgs master",
},
)
self.assertTrue(assessment["reconciliation_allowed"])
self.assertEqual(
assessment["eligibility_class"],
reconciliation_workflow.ELIGIBILITY_ALREADY_LANDED,
)
def test_not_landed_pr(self):
with patch(
"reconciliation_workflow.is_head_ancestor_of_ref",
return_value=False,
):
assessment = reconciliation_workflow.assess_open_pr_reconciliation(
pr={
"number": 1,
"state": "open",
"title": "WIP",
"body": "",
"head": {"ref": "feat/y", "sha": "fff" * 13 + "f"},
"base": {"ref": "master"},
},
project_root="/tmp/repo",
remote="prgs",
target_branch="master",
target_fetch={
"success": True,
"target_ref": "prgs/master",
"target_branch_sha": "deadbeef",
},
)
self.assertFalse(assessment["reconciliation_allowed"])
class TestReconciliationPlan(unittest.TestCase):
def test_full_close_when_close_pr_allowed(self):
plan = reconciliation_workflow.resolve_reconciliation_plan(
assessment={
"reconciliation_allowed": True,
"eligibility_class": reconciliation_workflow.ELIGIBILITY_ALREADY_LANDED,
},
capabilities={
"close_pr": True,
"comment_pr": True,
"close_issue": True,
"comment_issue": True,
},
)
self.assertEqual(
plan["outcome"],
reconciliation_workflow.OUTCOME_FULL_RECONCILE,
)
self.assertTrue(plan["close_pr_allowed"])
self.assertFalse(plan["review_merge_allowed"])
def test_comment_then_stop_without_close(self):
plan = reconciliation_workflow.resolve_reconciliation_plan(
assessment={
"reconciliation_allowed": True,
"eligibility_class": reconciliation_workflow.ELIGIBILITY_ALREADY_LANDED,
},
capabilities={
"close_pr": False,
"comment_pr": True,
},
)
self.assertEqual(
plan["outcome"],
reconciliation_workflow.OUTCOME_PARTIAL_COMMENT,
)
def test_recovery_handoff_without_comment_or_close(self):
plan = reconciliation_workflow.resolve_reconciliation_plan(
assessment={
"reconciliation_allowed": True,
"eligibility_class": reconciliation_workflow.ELIGIBILITY_ALREADY_LANDED,
},
capabilities={"close_pr": False, "comment_pr": False},
)
self.assertEqual(
plan["outcome"],
reconciliation_workflow.OUTCOME_RECOVERY_HANDOFF,
)
def test_not_landed_no_action(self):
plan = reconciliation_workflow.resolve_reconciliation_plan(
assessment={
"reconciliation_allowed": False,
"eligibility_class": reconciliation_workflow.ELIGIBILITY_NOT_LANDED,
"reasons": ["not ancestor"],
},
capabilities={"close_pr": True},
)
self.assertEqual(
plan["outcome"],
reconciliation_workflow.OUTCOME_NOT_LANDED,
)
class TestReconcileWorkflowSource(unittest.TestCase):
def test_valid_report_passes(self):
report = (
"Task mode: reconcile-landed-pr\n"
"Workflow source: workflows/reconcile-landed-pr.md\n"
)
result = assess_reconcile_workflow_source(report)
self.assertTrue(result["complete"])
def test_missing_workflow_fails(self):
result = assess_reconcile_workflow_source("Task mode: reconcile-landed-pr")
self.assertFalse(result["complete"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,104 @@
"""Tests for REQUEST_CHANGES override proof before approval (#326)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from review_proofs import assess_request_changes_approval_proof # noqa: E402
HEAD_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
HEAD_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
def _feedback(**overrides):
base = {
"success": True,
"current_head_sha": HEAD_A,
"has_blocking_change_requests": False,
"author_pushed_after_request_changes": False,
"reviews": [],
}
base.update(overrides)
return base
def _blocking_review(**overrides):
entry = {
"reviewer": "reviewer-a",
"verdict": "REQUEST_CHANGES",
"body": "Tests fail on pinned head; fix test_commit_files_gate.",
"submitted_at": "2026-07-07T01:00:00-05:00",
"reviewed_head_sha": HEAD_A,
"dismissed": False,
"stale": False,
}
entry.update(overrides)
return entry
class TestRequestChangesApprovalProof(unittest.TestCase):
def test_missing_feedback_blocks_approval(self):
result = assess_request_changes_approval_proof(None)
self.assertFalse(result["approve_allowed"])
self.assertTrue(result["block"])
def test_no_prior_request_changes_allows_approval(self):
feedback = _feedback(
reviews=[{
"reviewer": "sysadmin",
"verdict": "APPROVED",
"body": "LGTM",
"submitted_at": "2026-07-07T02:00:00-05:00",
"reviewed_head_sha": HEAD_A,
"dismissed": False,
}]
)
result = assess_request_changes_approval_proof(feedback)
self.assertTrue(result["approve_allowed"])
self.assertIsNone(result["blocking_review"])
def test_changed_head_since_blocker_allows_approval(self):
feedback = _feedback(
current_head_sha=HEAD_B,
author_pushed_after_request_changes=True,
has_blocking_change_requests=True,
reviews=[_blocking_review(reviewed_head_sha=HEAD_A)],
)
result = assess_request_changes_approval_proof(feedback)
self.assertTrue(result["approve_allowed"])
self.assertTrue(result["head_changed_since_blocker"])
def test_unchanged_head_without_override_blocks_approval(self):
feedback = _feedback(
has_blocking_change_requests=True,
reviews=[_blocking_review()],
)
result = assess_request_changes_approval_proof(feedback)
self.assertFalse(result["approve_allowed"])
self.assertIn("override_reason", " ".join(result["reasons"]))
def test_unchanged_head_with_valid_override_allows_approval(self):
blocker = _blocking_review()
feedback = _feedback(
has_blocking_change_requests=True,
reviews=[blocker],
)
report = (
"Blocker text: Tests fail on pinned head; fix test_commit_files_gate.\n"
"Override: wrong_validation_environment — CI used stale worktree."
)
result = assess_request_changes_approval_proof(
feedback,
override_reason="wrong_validation_environment",
override_explanation="CI used stale worktree; local rerun passed.",
report_text=report,
)
self.assertTrue(result["approve_allowed"])
self.assertEqual(
result["blocking_review"]["blocking_reviewer"], "reviewer-a"
)
if __name__ == "__main__":
unittest.main()
+71
View File
@@ -121,6 +121,32 @@ class TestResolveTaskCapability(unittest.TestCase):
self.assertTrue(res["allowed_in_current_session"])
self.assertFalse(res["different_mcp_namespace_required"])
@patch("mcp_server.api_request", return_value={"login": "author-user"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_resolve_work_issue_author_profile_allowed(self, _auth, _api):
with patch.dict(os.environ, self._env("author-profile")):
for task in ("work_issue", "work-issue"):
res = mcp_server.gitea_resolve_task_capability(
task=task, remote="prgs"
)
self.assertEqual(res["requested_task"], task)
self.assertEqual(
res["required_operation_permission"], "gitea.pr.create"
)
self.assertEqual(res["required_role_kind"], "author")
self.assertTrue(res["allowed_in_current_session"])
@patch("mcp_server.api_request", return_value={"login": "author-user"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_resolve_work_issue_reviewer_profile_blocked(self, _auth, _api):
with patch.dict(os.environ, self._env("reviewer-profile")):
res = mcp_server.gitea_resolve_task_capability(
task="work_issue", remote="prgs"
)
self.assertFalse(res["allowed_in_current_session"])
self.assertEqual(res["required_role_kind"], "author")
self.assertTrue(res["different_mcp_namespace_required"])
@patch("mcp_server.api_request", return_value={"login": "author-user"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_resolve_issue_comment_task(self, _auth, _api):
@@ -141,6 +167,17 @@ class TestResolveTaskCapability(unittest.TestCase):
self.assertTrue(res["allowed_in_current_session"])
self.assertIn("gitea.issue.comment", res["active_profile_allowed_operations"])
@patch("mcp_server.api_request", return_value={"login": "author-user"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_resolve_lock_issue_author_profile_allowed(self, _auth, _api):
# #275: lock_issue must be an exact resolver task for claim/lock gates.
with patch.dict(os.environ, self._env("author-profile")):
res = mcp_server.gitea_resolve_task_capability(task="lock_issue", remote="prgs")
self.assertEqual(res["requested_task"], "lock_issue")
self.assertEqual(res["required_operation_permission"], "gitea.issue.comment")
self.assertTrue(res["allowed_in_current_session"])
self.assertIn("gitea.issue.comment", res["active_profile_allowed_operations"])
def test_resolve_unknown_task_fails_closed(self):
with patch.dict(os.environ, self._env("author-profile")):
with self.assertRaises(ValueError):
@@ -264,6 +301,40 @@ class TestResolveTaskCapability(unittest.TestCase):
self.assertEqual(res["required_role_kind"], "author")
self.assertIn("author", res["exact_safe_next_action"].lower())
@patch("mcp_server.api_request", return_value={"login": "author-user"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_resolve_reconcile_already_landed_pr_requires_close(self, _auth, _api):
with patch.dict(os.environ, self._env("author-profile")):
res = mcp_server.gitea_resolve_task_capability(
task="reconcile_already_landed_pr", remote="prgs"
)
self.assertEqual(res["requested_task"], "reconcile_already_landed_pr")
self.assertEqual(res["required_operation_permission"], "gitea.pr.close")
self.assertEqual(res["required_role_kind"], "reconciler")
self.assertFalse(res["allowed_in_current_session"])
@patch("mcp_server.api_request", return_value={"login": "author-user"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_resolve_pr_queue_cleanup_author_profile_blocked(self, _auth, _api):
with patch.dict(os.environ, self._env("author-profile")):
res = mcp_server.gitea_resolve_task_capability(
task="pr_queue_cleanup", remote="prgs"
)
self.assertEqual(res["required_role_kind"], "reviewer")
self.assertFalse(res["allowed_in_current_session"])
self.assertTrue(res["stop_required"])
@patch("mcp_server.api_request", return_value={"login": "reviewer-user"})
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
def test_resolve_pr_queue_cleanup_reviewer_profile_allowed(self, _auth, _api):
with patch.dict(os.environ, self._env("reviewer-profile")):
res = mcp_server.gitea_resolve_task_capability(
task="pr_queue_cleanup", remote="prgs"
)
self.assertEqual(res["required_operation_permission"], "gitea.pr.review")
self.assertTrue(res["allowed_in_current_session"])
self.assertFalse(res["stop_required"])
@patch("mcp_server.api_request", return_value={"login": "author-user"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_close_pr_known_and_lookalike_tasks_still_fail_closed(self, _auth, _api):
+158
View File
@@ -0,0 +1,158 @@
"""Tests for reviewer final-report schema verification (#391)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from review_final_report_schema import assess_review_final_report_schema # noqa: E402
def _minimal_review_report(**overrides):
lines = [
"## Controller Handoff",
"",
"- Task: review PR #203",
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
"- Role: reviewer",
"- Identity: sysadmin / prgs-reviewer",
"- Issue/PR: #182 / PR #203",
"- Branch/SHA: feat/x @ 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Files changed: review_proofs.py",
"- Validation: pytest tests/test_review_proofs.py -q in branches/review-203",
"- Mutations: review only",
"- File edits by reviewer: none",
"- Worktree/index mutations: none",
"- Git ref mutations: git fetch prgs master",
"- MCP/Gitea mutations: review submitted",
"- Review mutations: request_changes submitted",
"- Merge mutations: none",
"- Cleanup mutations: none",
"- External-state mutations: none",
"- Read-only diagnostics: gitea_view_pr, gitea_view_issue",
"- Current status: PR open",
"- Blockers: none",
"- Next: await author",
"- Safety: no self-review; no self-merge",
"- Selected PR: #203",
"- Reviewer eligibility: eligible",
"- Candidate head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Worktree path: branches/review-203",
"- Worktree dirty: clean",
"- Unrelated local mutations: none",
"- Review decision: request_changes",
"- Merge result: not attempted",
"- Linked issue: #182",
"- Linked issue live status: open (gitea_view_issue)",
"- Cleanup status: none",
]
text = "\n".join(lines)
for key, value in overrides.items():
prefix = f"- {key}:"
replaced = False
new_lines = []
for line in text.splitlines():
if line.strip().startswith(prefix):
new_lines.append(f"{prefix} {value}")
replaced = True
else:
new_lines.append(line)
text = "\n".join(new_lines)
if not replaced:
text += f"\n{prefix} {value}"
return text
class TestReviewFinalReportSchema(unittest.TestCase):
def test_clean_report_passes(self):
result = assess_review_final_report_schema(
_minimal_review_report(),
linked_issue_lock={"issue_number": 182, "pr_number": 203},
)
self.assertFalse(result["blocked"])
self.assertIn(result["grade"], ("A", "downgraded"))
def test_legacy_pinned_reviewed_head_blocks(self):
report = _minimal_review_report(**{"Pinned reviewed head": "abc123"})
result = assess_review_final_report_schema(report)
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertIn("reviewer.legacy_stale_field", rule_ids)
def test_reviewed_head_without_validation_blocks(self):
report = _minimal_review_report().replace(
"- Validation: pytest tests/test_review_proofs.py -q in branches/review-203",
"- Validation: not run",
)
report += "\n- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9"
result = assess_review_final_report_schema(report)
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertIn("reviewer.reviewed_head_without_validation", rule_ids)
def test_merged_without_proof_blocks(self):
report = _minimal_review_report() + "\n\nPR #203 merged successfully."
result = assess_review_final_report_schema(report)
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertIn("reviewer.merged_without_proof", rule_ids)
def test_issue_closed_without_live_proof_blocks(self):
report = _minimal_review_report(
**{
"Linked issue live status": "closed",
"Read-only diagnostics": "gitea_view_pr only",
}
)
result = assess_review_final_report_schema(report)
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertIn("reviewer.issue_closed_without_live_proof", rule_ids)
def test_full_suite_pass_with_ignored_tests_blocks(self):
report = (
_minimal_review_report()
+ "\nFull test suite passed with 0 failed; some tests ignored."
)
result = assess_review_final_report_schema(report)
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertIn("reviewer.full_suite_pass_ignored_tests", rule_ids)
def test_blocked_handoff_replay_blocks(self):
report = (
_minimal_review_report(**{"Blockers": "capability stop"})
+ "\nSafe next action: run gitea_merge_pr to finish."
)
result = assess_review_final_report_schema(report)
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertIn("reviewer.blocked_handoff_replay", rule_ids)
def test_narrative_handoff_drift_blocks(self):
report = (
_minimal_review_report()
+ "\n## Summary\nVerdict: APPROVE this PR."
)
result = assess_review_final_report_schema(report)
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertIn("reviewer.narrative_handoff_drift", rule_ids)
def test_pending_vs_approved_blocks(self):
report = _minimal_review_report(
**{"Review decision": "PENDING official review submitted approve"}
)
result = assess_review_final_report_schema(report)
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertIn("reviewer.pending_vs_approved", rule_ids)
def test_exported_from_review_proofs(self):
from review_proofs import assess_review_final_report_schema as exported
self.assertTrue(callable(exported))
class TestMcpValidateReviewFinalReport(unittest.TestCase):
def test_mcp_tool_callable(self):
import gitea_mcp_server
result = gitea_mcp_server.gitea_validate_review_final_report(
_minimal_review_report()
)
self.assertIn("grade", result)
self.assertIn("findings", result)
self.assertFalse(result["blocked"])
+121
View File
@@ -0,0 +1,121 @@
"""Tests for enforced PR review/merge state machine (#290)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import review_merge_state_machine as rmsm # noqa: E402
FULL_REVIEW_COMPLETION = {state: True for state in rmsm.REVIEW_MERGE_STATES}
def _completion_through(state_name: str) -> dict[str, bool]:
idx = rmsm.state_index(state_name)
return {state: i <= idx for i, state in enumerate(rmsm.REVIEW_MERGE_STATES)}
class TestWorkflowBlockers(unittest.TestCase):
def test_infra_stop_blocks_all_states(self):
result = rmsm.assess_workflow_blockers(infra_stop=True)
self.assertTrue(result["block"])
self.assertEqual(result["forbidden_states"], list(rmsm.REVIEW_MERGE_STATES))
def test_infra_stop_forbids_advancement(self):
result = rmsm.assess_state_advancement(
{},
target_state="INVENTORY",
infra_stop=True,
)
self.assertTrue(result["block"])
self.assertFalse(result["allowed"])
class TestStateAdvancement(unittest.TestCase):
def test_cannot_skip_inventory(self):
result = rmsm.assess_state_advancement(
{"PRECHECK": True},
target_state="SELECT_PR",
)
self.assertTrue(result["block"])
self.assertEqual(result["next_allowed_state"], "INVENTORY")
def test_sequential_advance_allowed(self):
completion = _completion_through("INVENTORY")
result = rmsm.assess_state_advancement(
completion,
target_state="SELECT_PR",
)
self.assertFalse(result["block"])
self.assertTrue(result["allowed"])
class TestApproveAndMergeGates(unittest.TestCase):
def test_approve_blocked_without_validate(self):
completion = _completion_through("PIN_HEAD_SHA")
result = rmsm.can_approve(completion)
self.assertTrue(result["block"])
self.assertIn("VALIDATE", ",".join(result["missing_states"]))
def test_approve_allowed_when_review_path_complete(self):
completion = _completion_through("REVIEW_DECISION")
result = rmsm.can_approve(completion)
self.assertFalse(result["block"])
self.assertTrue(result["allowed"])
def test_merge_blocked_without_pre_merge_gates(self):
completion = _completion_through("APPROVE_OR_REQUEST_CHANGES")
result = rmsm.can_merge(completion)
self.assertTrue(result["block"])
self.assertTrue(result["missing_pre_merge_gates"])
def test_merge_allowed_with_pre_merge_gates(self):
completion = _completion_through("PRE_MERGE_RECHECK")
gates = {gate: True for gate in rmsm._PRE_MERGE_REQUIRED_GATES}
result = rmsm.can_merge(completion, pre_merge_gates=gates)
self.assertFalse(result["block"])
self.assertTrue(result["allowed"])
def test_merge_blocked_when_head_sha_gate_fails(self):
completion = _completion_through("PRE_MERGE_RECHECK")
gates = {gate: True for gate in rmsm._PRE_MERGE_REQUIRED_GATES}
gates["reviewed_head_sha_unchanged"] = False
result = rmsm.can_merge(completion, pre_merge_gates=gates)
self.assertTrue(result["block"])
self.assertIn("reviewed_head_sha_unchanged", result["missing_pre_merge_gates"])
class TestRecoveryHandoff(unittest.TestCase):
def test_blocked_handoff_without_replay_passes(self):
report = (
"Blocked recovery handoff.\n"
"Restart the full workflow after infra_stop clears.\n"
"No approve/merge performed."
)
result = rmsm.assess_blocked_recovery_handoff(report)
self.assertFalse(result["block"])
def test_blocked_handoff_with_merge_replay_fails(self):
report = "Please merge PR #12 now using gitea_merge_pr."
result = rmsm.assess_blocked_recovery_handoff(report)
self.assertTrue(result["block"])
class TestFinalReportClaims(unittest.TestCase):
def test_ready_to_merge_claim_without_gates_fails(self):
result = rmsm.assess_final_report_state_claims(
"PR is ready to merge after validation.",
state_completion=_completion_through("VALIDATE"),
)
self.assertTrue(result["block"])
def test_reviewed_claim_without_validate_fails(self):
result = rmsm.assess_final_report_state_claims(
"PR reviewed and looks good.",
state_completion={"PRECHECK": True},
)
self.assertTrue(result["block"])
if __name__ == "__main__":
unittest.main()
File diff suppressed because it is too large Load Diff
+108
View File
@@ -0,0 +1,108 @@
"""Tests for canonical review-workflow citation and version proof (#296).
PR review/merge reports must prove they loaded the canonical workflow
(workflows/review-merge-pr.md) and cite the workflow version/hash used;
a stale or missing hash fails so prompt drift is caught.
"""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from review_proofs import ( # noqa: E402
assess_review_workflow_source,
compute_workflow_hash,
)
CANONICAL_TEXT = "# review-merge-pr workflow v1\nstep one\n"
CANONICAL_HASH = compute_workflow_hash(CANONICAL_TEXT)
def _report(citation=True, version=CANONICAL_HASH):
lines = ["Task: review next eligible PR"]
if citation:
lines.append(
"Workflow source: skills/llm-project-workflow/"
"workflows/review-merge-pr.md (loaded via mcp_get_skill_guide)"
)
if version is not None:
lines.append(f"- Workflow version: {version}")
return "\n".join(lines) + "\n"
class TestComputeWorkflowHash(unittest.TestCase):
def test_deterministic(self):
self.assertEqual(
compute_workflow_hash(CANONICAL_TEXT),
compute_workflow_hash(CANONICAL_TEXT),
)
def test_changes_with_content(self):
self.assertNotEqual(
compute_workflow_hash(CANONICAL_TEXT),
compute_workflow_hash(CANONICAL_TEXT + "extra rule\n"),
)
def test_short_hex(self):
h = compute_workflow_hash(CANONICAL_TEXT)
self.assertEqual(len(h), 12)
int(h, 16) # raises if not hex
class TestReviewWorkflowSource(unittest.TestCase):
def test_citation_and_matching_hash_passes(self):
result = assess_review_workflow_source(
_report(), canonical_hash=CANONICAL_HASH
)
self.assertTrue(result["complete"])
self.assertEqual(result["reasons"], [])
def test_missing_citation_fails(self):
result = assess_review_workflow_source(
_report(citation=False), canonical_hash=CANONICAL_HASH
)
self.assertFalse(result["complete"])
self.assertTrue(
any("review-merge-pr" in r.lower() for r in result["reasons"])
)
def test_missing_version_field_fails(self):
result = assess_review_workflow_source(
_report(version=None), canonical_hash=CANONICAL_HASH
)
self.assertFalse(result["complete"])
self.assertTrue(
any("workflow version" in r.lower() for r in result["reasons"])
)
def test_stale_hash_fails(self):
result = assess_review_workflow_source(
_report(version="deadbeef0000"), canonical_hash=CANONICAL_HASH
)
self.assertFalse(result["complete"])
self.assertTrue(
any("stale" in r.lower() or "does not match" in r.lower()
for r in result["reasons"])
)
def test_version_none_fails(self):
result = assess_review_workflow_source(
_report(version="none"), canonical_hash=CANONICAL_HASH
)
self.assertFalse(result["complete"])
def test_without_canonical_hash_field_still_required(self):
# No canonical hash supplied (e.g. offline validation): citation
# and a populated version field are still required.
self.assertTrue(
assess_review_workflow_source(_report())["complete"]
)
self.assertFalse(
assess_review_workflow_source(_report(version=None))["complete"]
)
if __name__ == "__main__":
unittest.main()

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