Compare commits

...
Author SHA1 Message Date
sysadminandClaude Opus 4.8 00efda0cfb fix: resolve conflicts for PR #490
Merge prgs/master; unify preflight read-survival tests (#469 + #470 contract).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 04:09:13 -04:00
sysadmin ce61e424f6 Merge pull request 'fix: preserve capability preflight across safe reads (Closes #469)' (#502) from fix/issue-469-preflight-read-survival into master 2026-07-08 03:01:48 -05:00
sysadminandClaude Opus 4.8 2e4ebc6434 fix: resolve conflicts for PR #490
Merge prgs/master into feat/issue-470-preflight-contract, keeping stacked-PR
lock base support (#484) and explicit task binding on lock_issue preflight (#470).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 03:59:51 -04:00
sysadmin f89949cea9 Merge pull request 'fix: exempt reconciler close_pr from branches-only worktree guard (Closes #468)' (#472) from feat/issue-468-reconciler-close-guard into master 2026-07-08 02:48:57 -05:00
sysadmin b3edd53185 Merge pull request 'fix: guard PR/issue comment listing against non-list API payloads (Closes #485)' (#487) from feat/issue-485-lease-comments-non-list-guard into master 2026-07-08 02:46:03 -05:00
sysadmin 9ada4762c5 Merge pull request 'feat: expose explicit adoption proof in gitea_lock_issue response (Closes #477)' (#481) from feat/issue-477-lock-adoption-proof into master 2026-07-08 02:31:47 -05:00
sysadminandClaude Opus 4.8 3bce0a55fb test: reset preflight globals in read-survival setUp (#469)
Prevents cross-test leakage that masked the missing-capability fail-closed case.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 03:23:12 -04:00
sysadmin 48cf3a334b Merge pull request 'feat: require validation cwd and HEAD proof in reviewer reports (Closes #398)' (#411) from feat/issue-398-validation-cwd-proof into master 2026-07-08 02:09:11 -05:00
sysadmin 974463163d Merge pull request 'feat: add first-class stacked PR support to author workflow (Closes #484)' (#489) from feat/issue-484-stacked-pr-support into master 2026-07-08 01:41:19 -05:00
sysadminandClaude Opus 4.8 a8bcbdbcf2 fix: clarify capability preflight lifetime across safe reads (Closes #470)
Add preflight_contract helpers with explicit re-resolve error messages,
document the read-only tool set and consumption rules, and extend tests
for close_pr sequencing and task replacement.

Validation: ./venv/bin/python -m pytest tests/test_preflight_read_survival.py tests/test_mcp_server.py::TestPreflightVerification -q

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 02:28:07 -04:00
sysadminandClaude Opus 4.8 2d4ab4e54e test: reset preflight globals in read-survival setUp (#469)
Prevents cross-test leakage that masked the missing-capability fail-closed case.

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

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-08 02:26:02 -04:00
sysadminandClaude Opus 4.8 0cbd1fc801 feat: add first-class stacked PR support to author workflow (#484)
Normal author work is unchanged: gitea_lock_issue still requires the worktree
to be base-equivalent to master/main/dev, and gitea_create_pr still targets a
normal base branch. A *stacked* PR (based on another unmerged PR's branch) is a
new explicit, proof-backed path — it never bypasses the issue lock.

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

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

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

Closes #484.

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

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

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

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

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

Closes #471.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-07-07 11:22:00 -04:00
sysadmin 6220304f8c fix: resolve conflicts for PR #397
Merge prgs/master into feat/issue-395-proof-backed-review-handoff; keep
both proof-backed handoff (#395) and already-landed classification (#295)
downgrade gates in review_proofs.py.
2026-07-07 11:21:22 -04:00
sysadmin 6a37f7c8d5 fix: resolve conflicts for PR #388
Merge prgs/master into feat/issue-304-reconciler-profile; keep both
reconciler_profile and reconciliation_workflow imports in gitea_mcp_server.py.
2026-07-07 11:19:57 -04:00
sysadmin fcb9944378 Merge pull request 'feat: add reviewer final-report schema MCP validator (Closes #391)' (#394) from feat/issue-391-review-final-report-schema into master 2026-07-07 10:15:41 -05:00
sysadmin f75f2327f5 fix: resolve conflicts for PR #385
Merge prgs/master into feat/issue-139-role-aware-work-issue-routing and
keep both work-issue task routing entries and reconcile-landed-pr entries
in task_capability_map.py.
2026-07-07 11:13:45 -04:00
sysadmin 9f4dd4c39b Merge pull request 'feat: enforce canonical reconciliation handoff schema (Closes #307)' (#387) from feat/issue-307-canonical-reconciliation-handoff into master 2026-07-07 10:09:50 -05:00
sysadmin 6e212c0de7 Merge pull request 'feat: enforce author work leases in issue lock preflight (Closes #267)' (#386) from feat/issue-267-work-leases into master 2026-07-07 09:52:37 -05:00
sysadmin d446a31442 Merge pull request 'feat: add reconciliation workflow MCP tools for already-landed PRs (Closes #301)' (#384) from feat/issue-301-reconciliation-workflow into master 2026-07-07 09:42:31 -05:00
sysadmin b11160f236 Merge pull request 'feat: classify already-landed PRs as reconciliation-only (Closes #295)' (#382) from feat/issue-295-already-landed-classification into master 2026-07-07 09:37:23 -05:00
sysadmin d814a9ca81 fix: resolve conflicts for PR #388
Merge prgs/master; unify reconciler profile docs (#304) with master's
already-landed close tool guidance (#310).
2026-07-07 10:34:57 -04:00
sysadmin 10e64f6683 fix: resolve conflicts for PR #385
Merge prgs/master; keep work-issue author routing (#139) alongside
reconciler task entries from master (#309/#310).
2026-07-07 10:34:23 -04:00
sysadmin 5513bdf2aa fix: resolve conflicts for PR #384
Merge prgs/master; keep read-only reconciliation tools (#301) alongside
master's gitea_reconcile_already_landed_pr close path (#310). Unify task
routing: reconcile_landed_pr stays author/read-only; reconcile-landed-pr
and reconcile_already_landed_pr stay reconciler.
2026-07-07 10:33:42 -04:00
sysadmin 273eba7fb3 Merge pull request 'feat: enforce branches-only author mutation worktree guard (Closes #274)' (#374) from feat/issue-274-branches-only-worktrees into master 2026-07-07 09:33:18 -05:00
sysadmin cd61272837 fix: resolve conflicts for PR #374
Merge prgs/master into feat/issue-274-branches-only-worktrees; keep both
already_landed_reconcile and author_mutation_worktree imports. Allow
branches/ worktrees when PROJECT_ROOT is the session worktree; align
commit-payload tests with branches-only guard (#274).
2026-07-07 10:31:21 -04:00
sysadmin 7fbb1bf34a Merge pull request 'feat: require pagination proof in reconciliation PR inventory (Closes #308)' (#376) from feat/issue-308-reconcile-inventory-pagination into master 2026-07-07 09:26:02 -05:00
sysadmin c1d85b621a feat: require proof-backed claims in reviewer handoff reports (Closes #395) 2026-07-07 10:24:54 -04:00
sysadmin 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 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 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
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 7ef4c53cd8 Merge remote-tracking branch 'prgs/master' into feat/issue-304-reconciler-profile 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 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 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
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 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 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 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
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 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 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 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
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 d0f52cbf19 fix: resolve conflicts for PR #376 against current master 2026-07-07 09:17:33 -04: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 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
sysadmin 0cc3ed8868 feat: enforce author work leases (Closes #267) 2026-07-07 05:35:44 -04:00
93 changed files with 14316 additions and 362 deletions
+232
View File
@@ -0,0 +1,232 @@
"""Branches-only author mutation worktree guard (#274).
Author/coder mutations must run from a session-owned worktree under the
project's ``branches/`` directory, never from the stable control checkout.
"""
from __future__ import annotations
import os
import subprocess
BASE_BRANCHES = frozenset({"master", "main", "dev"})
ACTIVE_WORKTREE_ENV = "GITEA_ACTIVE_WORKTREE"
AUTHOR_WORKTREE_ENV = "GITEA_AUTHOR_WORKTREE"
def _normalize_path(path: str) -> str:
return (path or "").replace("\\", "/").rstrip("/")
def is_path_under_branches(path: str, project_root: str | None = None) -> bool:
"""True when *path* resolves inside ``<project_root>/branches/``."""
normalized = _normalize_path(path)
if not normalized:
return False
if "/branches/" in f"{normalized}/":
return True
if normalized.endswith("/branches"):
return True
if project_root:
root = _normalize_path(os.path.realpath(project_root))
real = _normalize_path(os.path.realpath(path))
if real.startswith(f"{root}/"):
rel = real[len(root) + 1 :]
return rel == "branches" or rel.startswith("branches/")
return False
def resolve_mutation_workspace(
worktree_path: str | None,
project_root: str,
*,
active_worktree_env: str | None = None,
author_worktree_env: str | None = None,
) -> str:
"""Resolve the workspace path inspected before author mutations."""
for candidate in (worktree_path, active_worktree_env, author_worktree_env):
text = (candidate or "").strip()
if text:
return os.path.realpath(os.path.abspath(text))
return os.path.realpath(project_root)
def _realpath_git_common_dir(workspace_path: str, common_dir: str) -> str:
"""Resolve ``git rev-parse --git-common-dir`` relative to *workspace_path*."""
raw = (common_dir or "").strip()
if not raw:
return raw
if os.path.isabs(raw):
return os.path.realpath(raw)
return os.path.realpath(os.path.join(workspace_path, raw))
def resolve_canonical_repo_root(workspace_path: str, fallback_project_root: str) -> str:
"""Return the stable repository root for *workspace_path* via git metadata (#460)."""
path = (workspace_path or "").strip()
fallback = os.path.realpath(fallback_project_root)
if not path:
return fallback
try:
res = subprocess.run(
["git", "-C", path, "rev-parse", "--git-common-dir"],
capture_output=True,
text=True,
check=True,
)
common = _realpath_git_common_dir(path, res.stdout)
except Exception:
return fallback
if common.endswith(f"{os.sep}.git"):
return os.path.dirname(common)
if os.path.basename(common) == ".git":
return os.path.dirname(common)
return fallback
def resolve_author_mutation_context(
worktree_path: str | None,
process_project_root: str,
*,
active_worktree_env: str | None = None,
author_worktree_env: str | None = None,
) -> dict:
"""Shared workspace resolution for runtime_context and mutation guards (#460)."""
workspace = resolve_mutation_workspace(
worktree_path,
process_project_root,
active_worktree_env=active_worktree_env,
author_worktree_env=author_worktree_env,
)
process_root = os.path.realpath(process_project_root)
# Canonical repository identity comes from the MCP process checkout (#460),
# not from the declared task workspace being validated.
canonical_root = resolve_canonical_repo_root(process_root, process_root)
return {
"workspace_path": workspace,
"process_project_root": process_root,
"canonical_repo_root": canonical_root,
"roots_aligned": canonical_root == process_root,
}
def assess_workspace_repo_membership(
*,
workspace_path: str,
canonical_repo_root: str,
) -> dict:
"""Fail closed when *workspace_path* is not a git worktree of *canonical_repo_root*."""
workspace = os.path.realpath(workspace_path)
root = os.path.realpath(canonical_repo_root)
reasons: list[str] = []
if not os.path.exists(workspace):
reasons.append(f"worktree path '{workspace}' does not exist")
return _membership_assessment(False, reasons, workspace, root, None)
if not os.path.isdir(workspace):
reasons.append(f"worktree path '{workspace}' is not a directory")
return _membership_assessment(False, reasons, workspace, root, None)
try:
res = subprocess.run(
["git", "-C", workspace, "rev-parse", "--git-common-dir"],
capture_output=True,
text=True,
check=True,
)
common_dir = _realpath_git_common_dir(workspace, res.stdout)
except Exception:
reasons.append(f"worktree '{workspace}' is not a valid git repository")
return _membership_assessment(False, reasons, workspace, root, None)
expected_dir = os.path.realpath(os.path.join(root, ".git"))
if common_dir != expected_dir:
reasons.append(
f"worktree '{workspace}' does not belong to the target repository '{root}'"
)
return _membership_assessment(not reasons, reasons, workspace, root, common_dir)
def _membership_assessment(
proven: bool,
reasons: list[str],
workspace: str,
root: str,
common_dir: str | None,
) -> dict:
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"workspace_path": workspace,
"canonical_repo_root": root,
"git_common_dir": common_dir,
}
def format_workspace_repo_membership_error(assessment: dict) -> str:
workspace = assessment.get("workspace_path") or "(unknown)"
root = assessment.get("canonical_repo_root") or "(unknown)"
reasons = "; ".join(assessment.get("reasons") or ["unknown repository membership violation"])
return (
f"Branches-only mutation guard (#274): {reasons} (fail closed). "
f"canonical repository root: {root}; workspace: {workspace}."
)
def assess_author_mutation_worktree(
*,
workspace_path: str,
project_root: str,
current_branch: str | None = None,
base_branches: frozenset[str] | None = None,
) -> dict:
"""Fail closed when author mutations are not rooted under ``branches/``."""
bases = base_branches or BASE_BRANCHES
reasons: list[str] = []
root = os.path.realpath(project_root)
workspace = os.path.realpath(workspace_path)
branch = (current_branch or "").strip()
under_branches = is_path_under_branches(workspace, root)
if not under_branches:
if workspace == root:
reasons.append(
"author mutation blocked: workspace is the stable control checkout; "
"create or switch to a session-owned worktree under branches/"
)
else:
reasons.append(
f"author mutation blocked: workspace '{workspace}' is not under "
f"'{root}/branches/'; create a branches/<task> worktree first"
)
if not under_branches and workspace == root and branch and branch not in bases:
reasons.append(
f"control checkout drift: branch '{branch}' is not a stable base "
f"branch ({'/'.join(sorted(bases))})"
)
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"project_root": root,
"workspace_path": workspace,
"under_branches": is_path_under_branches(workspace, root),
"current_branch": branch or None,
}
def format_author_mutation_worktree_error(assessment: dict) -> str:
"""Single RuntimeError message for MCP preflight gates."""
workspace = assessment.get("workspace_path") or "(unknown)"
root = assessment.get("project_root") or "(unknown)"
reasons = "; ".join(assessment.get("reasons") or ["unknown branches-only violation"])
return (
f"Branches-only mutation guard (#274): {reasons}. "
f"project root: {root}; workspace: {workspace}. "
"Create a session-owned worktree under branches/ before mutating."
)
+1 -1
View File
@@ -23,7 +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 (#310) |
| `gitea-reconciler` | a reconciler profile | close already-landed open PRs after ancestry proof (#304 profile; #310 close tool) |
Properties:
+13 -6
View File
@@ -226,11 +226,12 @@ 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 (#310)
## Reconciler profile for already-landed open PRs (#304 / #310)
Normal author and reviewer profiles must not gain broad 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:
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`
@@ -238,8 +239,14 @@ dedicated reconciler profile such as `prgs-reconciler` with:
- `gitea.issue.close`
- `gitea.pr.close`
Use the `gitea-reconciler` MCP namespace (static profile launch) and the
`gitea_reconcile_already_landed_pr` tool. The resolver task is
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.
+93 -1
View File
@@ -273,7 +273,80 @@ Never create a parallel branch or PR for the same issue unless the old branch
is proven abandoned and the takeover is recorded.
Gitea-Tools lease gates: `gitea_lock_issue` (fail-closed before author
mutations), `status:in-progress`, and claim comments. Full portable wording:
mutations), `status:in-progress`, and claim comments. `gitea_lock_issue`
records an `author_issue_work` lease in a keyed lock file under
`GITEA_ISSUE_LOCK_DIR` (default `~/.cache/gitea-tools/issue-locks`), one file
per `remote` + `org` + `repo` + `issue_number`. The current MCP session binds
its active lock through a per-process pointer so concurrent repos/issues never
share one overwrite-prone slot (#443).
Each lock payload includes issue number, optional PR number, branch, worktree
path, claimant identity/profile, created timestamp, expiry timestamp, and last
heartbeat timestamp. An active same-issue/same-operation lease blocks duplicate
work. An expired lease still blocks takeover until a recovery review records why
the prior work is abandoned, completed, or unsafe to continue.
**Stacked PRs (#484).** By default the lock worktree must be base-equivalent to
`master`/`main`/`dev` — ordinary work is unchanged. A *stacked* PR (deliberately
based on another unmerged PR's branch) is an explicit, opt-in path: pass
`stacked_base_branch` **and** `stacked_base_pr` to `gitea_lock_issue`. The lock
fails closed unless that branch is owned by a live **open** PR whose number
matches `stacked_base_pr`, so arbitrary or stale branches cannot be used as
bases. When approved, the lock payload records
`approved_stacked_base = {branch, pr_number, verified_open}` and the worktree may
be base-equivalent to that branch instead of master. `gitea_create_pr` then
allows `base = <that branch>` only when it matches the recorded approval, the
dependency PR is **still open**, and the PR body documents the stack:
- `Stacked on PR #<X> / issue #<Y>`
- `Base branch: <feature-branch>`
- `Head branch: <this-issue-branch>`
- `Do not merge before PR #<X>` (merge ordering)
- retarget/rebase to `master` after the dependency lands, if required
Stacked support never bypasses the issue lock — the base is recorded *on* the
lock and re-verified at PR time. A merged/closed dependency base fails closed;
retarget onto `master` or re-lock against a live base.
**Do not manually seed `/tmp/gitea_issue_lock.json` or any lock file as a normal
recovery path.** That global slot is deprecated and can clobber unrelated live
leases (#438). After an MCP restart, call `gitea_lock_issue` again — own-branch
adoption rebinds the session when the issue's exact branch already exists (#442).
`gitea_create_pr` resolves the durable keyed lock by session pointer or by
matching `head` branch without unsafe manual seeding.
**Issue-lock recovery (#447):** Do not manually seed, restore, or delete
`/tmp/gitea_issue_lock.json` as a normal recovery path. That file is global
shared state and manual writes can clobber another session's live lease. Use
`sanctioned recovery` instead:
1. `gitea_lock_issue` on a clean `branches/` worktree (normal path).
2. Own-branch adoption via #442 when the issue's exact branch is already pushed.
3. Operator override only when explicitly authorized — record
`External-state mutations` and `operator override proof` in the final report.
**Adoption proof in the live lock response (#477):** when `gitea_lock_issue`
adopts an existing own branch, the response carries an `adoption` block with
citable fields — `adoption_decision` (`ADOPT`), `adopted` (`true`),
`adopted_branch`, `adopted_branch_head`, `matcher_summary` (boundary-safe reason
the branch qualified), `competing_branch_check`, and `safe_next_action`. A normal
lock instead returns an `adoption_check` block with `adoption_decision`
(`NO_MATCH`) and `adopted: false`, so a non-adoption response can never be misread
as claiming adoption. Recovery reports should quote the live lock response
`adoption`/`adoption_check` block directly instead of inferring adoption from
separate offline checks.
`gitea_create_pr` rejects lock files that lack sanctioned `lock_provenance`
metadata. Final-report validation blocks handoffs that hide lock read/write/delete
under `External-state mutations: none` or mix author PR creation with reviewer
approval in one run. See also #438 (global lock redesign).
Remote branches matching the issue number are also treated as active work unless
the recovery review proves the branch is abandoned or superseded. Never delete
or clean up a branch when it has an active lease, dirty worktree, open PR, or is
the only copy of unmerged work.
Full portable wording:
[`skills/llm-project-workflow/SKILL.md`](../skills/llm-project-workflow/SKILL.md).
## Global LLM Worktree Rule
@@ -419,6 +492,25 @@ 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.
## Capability preflight lifetime (#470)
After `gitea_resolve_task_capability(task=…)` proves the mutation is allowed,
interleaved **read-only** calls preserve that proof until a gated mutation
consumes it:
- Safe reads: `gitea_whoami`, `gitea_view_pr`, `gitea_view_issue`, `gitea_list_*`,
`gitea_get_runtime_context`, `gitea_check_pr_eligibility`, and related
read-only inventory/eligibility tools (see `preflight_contract.py`).
- Each mutation consumes the proof once; call `gitea_resolve_task_capability`
again immediately before the next mutation on the same task.
- Resolving capability for a **different** task replaces the prior task binding.
- Workspace edits before resolve, profile switches, or a dirty whoami baseline
invalidate proof (fail closed).
If a mutation fails with “capability has not been resolved” or “task mismatch”,
re-run `gitea_resolve_task_capability(task="<mutation>")` immediately before
retrying — do not guess or skip the resolve step.
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
+89
View File
@@ -0,0 +1,89 @@
# Internal web UI — local development (#426)
Read-only MVP skeleton for the MCP Control Plane operator console. Gitea,
MCP capability gates, and `skills/llm-project-workflow/` remain the source of
truth; this UI only provides route stubs and layout.
## Prerequisites
- Python 3.11+ with project dependencies installed (`pip install -r requirements.txt`)
- No secrets in repo, config, or client bundle
## Start the server
From the repository root (or an issue worktree):
```bash
./scripts/run-webui
```
Or directly:
```bash
python3 -m webui
```
Optional environment variables:
| Variable | Default | Purpose |
|----------|---------|---------|
| `WEBUI_HOST` | `127.0.0.1` | Bind address (keep local for MVP) |
| `WEBUI_PORT` | `8765` | Listen port |
## Routes (MVP)
| Path | Description |
|------|-------------|
| `/` | Home / operator overview |
| `/health` | JSON liveness (`status`, `service`, `mode`, `timestamp`) |
| `/queue` | Live PR and issue queue dashboard (#429) |
| `/api/queue` | JSON queue export with pagination metadata |
| `/projects` | Project registry list (#427) |
| `/projects/{id}` | Project detail + onboarding checklist |
| `/api/projects` | JSON registry export |
| `/prompts` | Prompt library with per-prompt copy buttons (#428) |
| `/api/prompts` | JSON prompt export with workflow hashes |
| `/runtime` | Stub — MCP runtime health (#430) |
| `/audit` | Stub — report audit paste (#431) |
| `/worktrees` | Stub — hygiene dashboard (#432) |
| `/leases` | Stub — lease visibility (#433) |
All routes are GET-only. POST/PUT/PATCH/DELETE return `405` with
`read-only-mvp`.
## Project registry (#427)
Versioned registry file: `webui/data/projects.registry.json` (schema version `1`).
Override path with `WEBUI_PROJECT_REGISTRY` when operators keep a machine-local
copy outside git. The registry stores repo identity, remotes, profile names,
workflow/schema path references, and onboarding checklist steps — never tokens
or credentials.
Seed entry: **Gitea-Tools** on `https://gitea.prgs.cc` with `prgs-author`,
`prgs-reviewer`, and `prgs-reconciler` profiles.
## Prompt library (#428)
Prompts are generated at load time from canonical workflow files under
`skills/llm-project-workflow/workflows/`. SHA-256 hashes are computed from
`WEBUI_REPO_ROOT` (defaults to the repository root). Prompt bodies are short
copy/paste starters; canonical workflow files remain the only full policy
source.
## Live queue dashboard (#429)
`/queue` loads open PRs and issues for the default registry project (seed:
**Gitea-Tools** on `https://gitea.prgs.cc`) using existing `gitea_auth` read
credentials. The UI surfaces pagination proof (returned count, pages fetched,
`has_more`, `inventory_complete`) and classification badges (`claimed`,
`blocked`, `in-review`, `duplicate`) when evidence exists.
If credentials are missing or the fetch fails, the page shows an explicit error
instead of an empty queue (fail closed).
## Tests
```bash
pytest tests/test_webui_skeleton.py tests/test_webui_project_registry.py tests/test_webui_prompt_library.py tests/test_webui_queue_dashboard.py -q
```
+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.
+251 -5
View File
@@ -11,6 +11,7 @@ import inspect
import re
from typing import Any, Callable
import issue_lock_provenance
from review_proofs import (
HANDOFF_HEADING,
assess_controller_handoff,
@@ -20,6 +21,7 @@ from review_proofs import (
assess_review_mutation_final_report,
assess_validation_report,
)
from validation_status_vocabulary import assess_validation_status_vocabulary
FINAL_REPORT_TASK_KINDS = frozenset({
"review_pr",
@@ -92,7 +94,8 @@ _ALREADY_LANDED_GATE_FIRED_RE = re.compile(
re.IGNORECASE,
)
_ELIGIBLE_REVIEW_RE = re.compile(
r"eligible for (?:review|merge)|ready for (?:review|merge)|next eligible pr",
r"eligible for (?:review|merge)|ready for (?:review|merge)|"
r"(?:next|oldest)\s+eligible\s+pr",
re.IGNORECASE,
)
_APPROVED_STATE_RE = re.compile(r"\bapproved?\b", re.IGNORECASE)
@@ -121,11 +124,37 @@ _RECONCILE_STALE_FIELDS = (
"scratch worktree used",
"review decision",
"merge result",
"issue lock proof",
"claim/comment status",
"workspace mutations",
)
# Issue #307: reconciliation handoffs must carry the full canonical schema.
_RECONCILE_REQUIRED_FIELDS = (
"Task",
"Repo",
"Role/profile",
"Identity",
"Selected PR",
"Eligibility class",
"PR live state",
"Candidate head SHA",
"Target branch",
"Target branch SHA",
"Ancestor proof",
"Linked issue",
"Linked issue live status",
"Eligibility class",
"Capabilities proven",
"Missing capabilities",
"PR comments posted",
"Issue comments posted",
"PRs closed",
"Issues closed",
"Git ref mutations",
"Worktree mutations",
"MCP/Gitea mutations",
"External-state mutations",
"Read-only diagnostics",
"Blocker",
"Safe next action",
"No review/merge confirmation",
)
@@ -414,19 +443,130 @@ def _rule_reviewer_git_fetch_readonly(
"classify git fetch under Git ref mutations, not read-only diagnostics",
)
]
if not git_ref_reported and _GIT_FETCH_RE.search(text):
if not git_ref_reported:
# Issue #307: an observed git fetch (report text or action log)
# must be listed under Git ref mutations.
return [
validator_finding(
"reviewer.git_fetch_readonly",
"downgrade",
"Git ref mutations",
"git fetch mentioned without Git ref mutation classification",
"git fetch occurred without Git ref mutation classification",
"record git fetch under Git ref mutations",
)
]
return []
def _rule_reviewer_validation_failure_history(
report_text: str,
*,
validation_session: dict | None = None,
) -> list[dict[str, str]]:
from reviewer_validation_failure_history import (
assess_validation_failure_history_report,
)
session = validation_session or {}
observed = session.get("observed_failures") or []
if not observed:
return []
result = assess_validation_failure_history_report(
report_text,
validation_session=session,
)
if result.get("proven"):
return []
severity = "block" if result.get("violations") else "downgrade"
return [
validator_finding(
"reviewer.validation_failure_history",
severity,
"Validation failure history",
reason,
result.get("safe_next_action")
or "document every observed validation failure before claiming pass",
)
for reason in (result.get("violations") or result.get("reasons") or ["incomplete"])
]
def _rule_reviewer_validation_cwd_proof(
report_text: str,
*,
validation_session: dict | None = None,
) -> list[dict[str, str]]:
from reviewer_validation_cwd_proof import assess_validation_cwd_proof_report
session = validation_session or {}
claims = (
session.get("validation_ran")
or session.get("command")
or session.get("baseline_validation_ran")
)
if not claims and "validation command:" not in (report_text or "").lower():
return []
result = assess_validation_cwd_proof_report(
report_text,
validation_session=session,
)
if result.get("proven") or not result.get("claims_validation"):
return []
severity = "block" if result.get("violations") else "downgrade"
return [
validator_finding(
"reviewer.validation_cwd_proof",
severity,
"Validation cwd/HEAD proof",
reason,
result.get("safe_next_action")
or "document pwd, HEAD SHA, and explicit cwd before validation",
)
for reason in (result.get("violations") or result.get("reasons") or ["incomplete"])
]
def _rule_reviewer_stale_head_proof(report_text: str) -> list[dict[str, str]]:
from pr_work_lease import assess_reviewer_stale_head_final_report
result = assess_reviewer_stale_head_final_report(report_text)
if result.get("proven"):
return []
return _findings_from_reasons(
"reviewer.stale_head_proof",
result.get("reasons") or [],
field="Stale-head proof",
severity="block",
safe_next_action=(
"state reviewed head SHA, live head before approval/merge, and "
"whether any push occurred during validation"
),
)
def _rule_conflict_fix_push_proof(report_text: str) -> list[dict[str, str]]:
from pr_work_lease import assess_conflict_fix_final_report
text = report_text or ""
if "conflict-fix" not in text.lower() and "conflict fix" not in text.lower():
return []
result = assess_conflict_fix_final_report(text)
if result.get("proven"):
return []
return _findings_from_reasons(
"author.conflict_fix_push_proof",
result.get("reasons") or [],
field="Conflict-fix push proof",
severity="block",
safe_next_action=(
"state branch head before/after push, reviewer lease status, "
"fast-forward status, and whether any reviewer was active"
),
)
def _rule_reviewer_validation_command(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if not _BARE_PYTEST_RE.search(text):
@@ -535,6 +675,34 @@ def _rule_reviewer_main_checkout_baseline(report_text: str) -> list[dict[str, st
]
def _rule_reviewer_validation_status_vocabulary(
report_text: str,
*,
action_log: list[dict] | None = None,
) -> list[dict[str, str]]:
text = report_text or ""
if not re.search(
r"validation status|pr-head validation status|official validation status",
text,
re.IGNORECASE,
):
return []
result = assess_validation_status_vocabulary(
text,
command_log=action_log,
)
if not result.get("block"):
return []
return _findings_from_reasons(
"reviewer.validation_status_vocabulary",
result.get("reasons") or [],
field="Validation status",
severity="block",
safe_next_action=result.get("safe_next_action")
or "use a validation status that matches the proof path executed",
)
def _rule_reviewer_main_checkout_path(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if "baseline worktree path" not in text.lower():
@@ -668,10 +836,18 @@ def _rule_reconcile_controller_handoff(report_text: str) -> list[dict[str, str]]
return []
def _rule_reconcile_stale_author_fields(report_text: str) -> list[dict[str, str]]:
def _rule_reconcile_stale_author_fields(
report_text: str,
*,
session_pr_opened: bool = False,
) -> list[dict[str, str]]:
text = (report_text or "").lower()
findings = []
for field in _RECONCILE_STALE_FIELDS:
if field == "pr number opened" and session_pr_opened:
# Issue #307: allowed only when a PR was actually opened
# in this session.
continue
if f"{field}:" in text:
findings.append(
validator_finding(
@@ -799,6 +975,54 @@ def _rule_reviewer_mutation_ledger(
)
def _rule_shared_issue_lock_external_state(report_text: str) -> list[dict[str, str]]:
result = issue_lock_provenance.assess_issue_lock_external_state_report(report_text)
if result.get("proven"):
return []
return _findings_from_reasons(
"shared.issue_lock_external_state",
result.get("reasons") or [],
field="External-state mutations",
severity="block",
safe_next_action=(
"disclose gitea_issue_lock.json read/write/delete under "
"External-state mutations; never claim none after lock seeding"
),
)
def _rule_shared_manual_lock_pr_override(report_text: str) -> list[dict[str, str]]:
result = issue_lock_provenance.assess_manual_lock_pr_without_override(report_text)
if result.get("proven"):
return []
return _findings_from_reasons(
"shared.manual_lock_pr_override",
result.get("reasons") or [],
field="External-state mutations",
severity="block",
safe_next_action=(
"use gitea_lock_issue or #442 adoption instead of manual lock seeding; "
"if operator override was authorized, cite override proof"
),
)
def _rule_shared_author_reviewer_same_run(report_text: str) -> list[dict[str, str]]:
result = issue_lock_provenance.assess_author_reviewer_same_run_report(report_text)
if result.get("proven"):
return []
return _findings_from_reasons(
"shared.author_reviewer_same_run",
result.get("reasons") or [],
field="Review mutations",
severity="block",
safe_next_action=(
"split author PR creation and reviewer approval across separate "
"sessions and handoffs"
),
)
def _rule_reviewer_review_mutation(
report_text: str,
*,
@@ -818,18 +1042,28 @@ def _rule_reviewer_review_mutation(
)
_SHARED_ISSUE_LOCK_RULES = (
_rule_shared_issue_lock_external_state,
_rule_shared_manual_lock_pr_override,
_rule_shared_author_reviewer_same_run,
)
_RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
"review_pr": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
_rule_reviewer_legacy_workspace_mutations,
_rule_reviewer_vague_mutations_none,
_rule_reviewer_mutation_categories,
_rule_reviewer_git_fetch_readonly,
_rule_reviewer_validation_command,
_rule_reviewer_validation_failure_history,
_rule_reviewer_validation_cwd_proof,
_rule_reviewer_validation_structured,
_rule_reviewer_linked_issue,
_rule_reviewer_baseline_on_failure,
_rule_reviewer_validation_status_vocabulary,
_rule_reviewer_main_checkout_baseline,
_rule_reviewer_main_checkout_path,
_rule_reviewer_already_landed_eligible,
@@ -837,10 +1071,12 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
_rule_reviewer_target_branch_freshness,
_rule_reviewer_mutation_ledger,
_rule_reviewer_review_mutation,
_rule_reviewer_stale_head_proof,
],
"reconcile_already_landed": [
_rule_reconcile_controller_handoff,
_rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
_rule_reconcile_stale_author_fields,
_rule_reconcile_eligible_reviewed,
_rule_reconcile_linked_issue_live,
@@ -852,25 +1088,31 @@ _RULES_BY_TASK: dict[str, list[Callable[..., list[dict[str, str]]]]] = {
"author_issue": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
_rule_reviewer_vague_mutations_none,
],
"work_issue": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
_rule_reviewer_vague_mutations_none,
_rule_conflict_fix_push_proof,
],
"issue_filing": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
],
"inventory": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
_rule_reconcile_pagination_proof,
],
"issue_selection": [
_rule_shared_controller_handoff,
_rule_shared_email_disclosure,
*_SHARED_ISSUE_LOCK_RULES,
],
}
@@ -932,6 +1174,8 @@ def assess_final_report_validator(
mutations_observed: bool = False,
local_edits: bool = False,
issue_filing_lock: dict | None = None,
session_pr_opened: bool = False,
validation_session: dict | None = None,
) -> dict[str, Any]:
"""Validate final-report text against task-specific proof rules (#327).
@@ -986,6 +1230,8 @@ def assess_final_report_validator(
"action_log": action_log,
"mutations_observed": mutations_observed,
"local_edits": local_edits,
"session_pr_opened": session_pr_opened,
"validation_session": validation_session,
}
for rule in _RULES_BY_TASK.get(normalized_kind, ()):
+1563 -122
View File
File diff suppressed because it is too large Load Diff
+272
View File
@@ -0,0 +1,272 @@
"""Own-branch lock adoption / recovery for ``gitea_lock_issue`` (#442 / #443).
When an issue's own already-pushed branch exists, lock reacquisition must be
allowed (adoption) instead of being treated as #400 duplicate competing work.
This module isolates the pure decision so it can be unit-tested apart from the
MCP server's live Gitea calls.
Adoption is granted only for the issue's *exact* requested branch. Any other
branch that merely contains the same ``issue-<n>`` marker is competing work and
stays fail-closed. Open-PR, competing-live-lock, capability, and worktree
safety checks are enforced by the caller before this decision is consulted;
this module additionally records whether they passed for proof purposes.
"""
from __future__ import annotations
import re
ADOPT = "adopt_existing_branch"
BLOCK_COMPETING = "block_competing_branch"
NO_MATCH = "no_matching_branch"
# Citable decision labels aligned with the ``assess_own_branch_adoption``
# outcomes, surfaced verbatim in the live ``gitea_lock_issue`` response so
# recovery reports (#473-style) can quote the lock tool output directly
# instead of inferring adoption from separate offline checks (#477).
DECISION_LABELS = {
ADOPT: "ADOPT",
BLOCK_COMPETING: "BLOCK_COMPETING",
NO_MATCH: "NO_MATCH",
}
_SAFE_NEXT_ACTIONS = {
ADOPT: (
"Own existing branch adopted for lock recovery; proceed to "
"gitea_create_pr for this issue and cite this adoption proof."
),
BLOCK_COMPETING: (
"Competing same-issue branch(es) exist; resolve branch ownership "
"before locking. No adoption performed (fail closed)."
),
NO_MATCH: (
"No existing branch carries this issue marker; normal lock path "
"applied. No adoption performed."
),
}
def decision_label(outcome: str) -> str:
"""Map an ``assess_own_branch_adoption`` outcome to its citable label."""
return DECISION_LABELS.get(outcome, "UNKNOWN")
def safe_next_action(outcome: str) -> str:
"""Return the safe next action string for an adoption *outcome*."""
return _SAFE_NEXT_ACTIONS.get(
outcome, "Unknown adoption outcome; treat as fail closed."
)
def _branch_name(entry) -> str:
if isinstance(entry, dict):
return str(entry.get("name") or "")
return str(entry or "")
def _branch_sha(entry) -> str | None:
if isinstance(entry, dict):
sha = entry.get("commit_sha")
if sha:
return str(sha)
return None
def _branch_carries_issue_marker(branch_name: str, issue_number: int) -> bool:
"""Return True when *branch_name* references issue *issue_number* exactly.
Uses a numeric word-boundary so ``issue-42`` does not match inside
``issue-420`` (AC6 / #440).
"""
name = (branch_name or "").strip()
if not name:
return False
pattern = rf"(?:^|/)issue-{int(issue_number)}(?![0-9])"
return re.search(pattern, name) is not None
def assess_own_branch_adoption(
*,
issue_number: int,
requested_branch: str,
existing_branches,
) -> dict:
"""Decide whether an existing matching branch is adoptable.
Args:
issue_number: The tracking issue number being locked.
requested_branch: The exact branch the caller wants to lock.
existing_branches: Iterable of remote branch entries — either names or
dicts with ``name`` and optional ``commit_sha``.
Returns:
dict with:
* ``outcome`` — one of ADOPT / BLOCK_COMPETING / NO_MATCH
* ``adopt`` (bool), ``block`` (bool)
* ``reason`` (str)
* ``matched_branch`` (str | None), ``matched_head_sha`` (str | None)
* ``competing_branches`` (list[str])
ADOPT: the issue's exact branch exists and no other same-issue branch does.
BLOCK_COMPETING: at least one same-issue branch is not the requested branch.
NO_MATCH: no branch carries the issue marker — normal lock path applies.
"""
requested = (requested_branch or "").strip()
matches: list[tuple[str, str | None]] = []
for entry in existing_branches or []:
name = _branch_name(entry).strip()
if _branch_carries_issue_marker(name, issue_number):
matches.append((name, _branch_sha(entry)))
competing = sorted({name for name, _ in matches if name != requested})
exact = [(name, sha) for name, sha in matches if name == requested]
# Fail closed whenever any non-requested same-issue branch exists, even if
# the requested branch is also present: ownership is then ambiguous.
if competing:
return {
"outcome": BLOCK_COMPETING,
"adopt": False,
"block": True,
"reason": (
f"issue #{issue_number} already has matching branch(es) "
f"{competing} that are not the requested branch "
f"'{requested}' (fail closed)"
),
"matched_branch": None,
"matched_head_sha": None,
"competing_branches": competing,
}
if exact:
name, sha = exact[0]
return {
"outcome": ADOPT,
"adopt": True,
"block": False,
"reason": (
f"existing branch '{name}' is the exact requested branch for "
f"issue #{issue_number}; adopting it for lock recovery"
),
"matched_branch": name,
"matched_head_sha": sha,
"competing_branches": [],
}
return {
"outcome": NO_MATCH,
"adopt": False,
"block": False,
"reason": f"no existing branch matches issue #{issue_number}",
"matched_branch": None,
"matched_head_sha": None,
"competing_branches": [],
}
def _matcher_summary(issue_number: int, assessment: dict) -> str:
"""Explain, citably, why the assessed branch did or did not qualify.
Names the numeric word-boundary rule so reports can show that
``issue-42`` was not matched inside ``issue-420`` (#440 / #477 AC3).
"""
outcome = assessment.get("outcome")
matched = assessment.get("matched_branch")
competing = assessment.get("competing_branches") or []
if outcome == ADOPT and matched:
return (
f"branch '{matched}' exactly matches the issue-{int(issue_number)} "
f"marker (numeric word-boundary; 'issue-{int(issue_number)}' is not "
f"matched inside 'issue-{int(issue_number)}0')"
)
if outcome == BLOCK_COMPETING:
return (
f"competing same-issue branch(es) {competing} carry the "
f"issue-{int(issue_number)} marker but are not the requested "
f"branch; ownership is ambiguous (fail closed)"
)
return (
f"no existing branch carries the issue-{int(issue_number)} marker "
f"under the numeric word-boundary rule"
)
def _competing_branch_check(assessment: dict) -> dict:
"""Structured competing-branch verdict for the proof block."""
competing = list(assessment.get("competing_branches") or [])
return {
"result": "blocked" if competing else "clear",
"competing_branches": competing,
}
def build_adoption_proof(
*,
issue_number: int,
branch_name: str,
assessment: dict,
open_pr_checked: bool,
competing_lock_checked: bool,
lock_file_path: str,
lock_file_status: str,
) -> dict:
"""Assemble the proof block returned by ``gitea_lock_issue`` on adoption.
Requirement #4: adoption results must carry issue number, branch name,
branch head commit, adoption reason, no-existing-PR proof, no-competing-
live-lock proof, and lock file path/status.
#477: additionally surface explicit, citable adoption-proof fields tied to
the ``assess_own_branch_adoption`` outcome (``adoption_decision``,
``adopted``, ``adopted_branch``, ``adopted_branch_head``,
``matcher_summary``, ``competing_branch_check``, ``safe_next_action``) so a
recovery session can quote the live lock response directly. The explicit
fields are populated for any outcome; ``adopted_branch`` /
``adopted_branch_head`` are set only when the outcome is ADOPT so a
non-adoption proof can never be misread as claiming adoption.
"""
outcome = assessment.get("outcome")
adopted = outcome == ADOPT
return {
"issue_number": issue_number,
"branch_name": branch_name,
"branch_head_commit": assessment.get("matched_head_sha"),
"adoption_reason": assessment.get("reason"),
"no_existing_pr_proof": bool(open_pr_checked),
"no_competing_live_lock_proof": bool(competing_lock_checked),
"lock_file_path": lock_file_path,
"lock_file_status": lock_file_status,
# Explicit citable fields (#477).
"adoption_decision": decision_label(outcome),
"adopted": adopted,
"adopted_branch": branch_name if adopted else None,
"adopted_branch_head": assessment.get("matched_head_sha") if adopted else None,
"matcher_summary": _matcher_summary(issue_number, assessment),
"competing_branch_check": _competing_branch_check(assessment),
"safe_next_action": safe_next_action(outcome),
}
def build_non_adoption_lock_proof(*, issue_number: int, branch_name: str) -> dict:
"""Safe, adoption-free proof metadata for a normal (NO_MATCH) lock.
Requirement #477 AC2: non-adoption lock responses must stay clear and must
not imply adoption. This returns explicit ``adopted: False`` metadata with
the ``NO_MATCH`` decision so a normal lock response can carry citable proof
without ever asserting a branch was adopted.
"""
return {
"issue_number": issue_number,
"branch_name": branch_name,
"adoption_decision": DECISION_LABELS[NO_MATCH],
"adopted": False,
"adopted_branch": None,
"adopted_branch_head": None,
"matcher_summary": (
f"no existing branch carries the issue-{int(issue_number)} marker; "
f"normal lock path (no adoption)"
),
"competing_branch_check": {"result": "clear", "competing_branches": []},
"safe_next_action": safe_next_action(NO_MATCH),
}
+269
View File
@@ -0,0 +1,269 @@
"""Issue-lock provenance and external-state disclosure (#447).
Sanctioned locks are written only by ``gitea_lock_issue`` (or adoption recovery
#442). Manual seeding of ``/tmp/gitea_issue_lock.json`` is unsafe and must be
blocked at PR creation unless explicit operator override proof is recorded.
"""
from __future__ import annotations
import os
import re
from datetime import datetime, timezone
ISSUE_LOCK_FILE = os.environ.get("GITEA_ISSUE_LOCK_FILE", "/tmp/gitea_issue_lock.json")
SOURCE_LOCK_ISSUE = "gitea_lock_issue"
SOURCE_LOCK_ADOPTION = "gitea_lock_issue_adoption"
SOURCE_OPERATOR_OVERRIDE = "operator_override"
SANCTIONED_LOCK_SOURCES = frozenset({
SOURCE_LOCK_ISSUE,
SOURCE_LOCK_ADOPTION,
SOURCE_OPERATOR_OVERRIDE,
})
_OPERATOR_OVERRIDE_ENV = "GITEA_ISSUE_LOCK_OPERATOR_OVERRIDE"
_ISSUE_LOCK_PATH_RE = re.compile(
r"(?:/tmp/)?gitea_issue_lock\.json",
re.IGNORECASE,
)
_LOCK_SEED_RE = re.compile(
r"(?:seed(?:ed|ing)?|restor(?:e|ed|ing)|wrote|written|write|programmatically|"
r"hand[- ]forg|manual(?:ly)?).{0,80}gitea_issue_lock",
re.IGNORECASE | re.DOTALL,
)
_LOCK_REMOVE_RE = re.compile(
r"(?:\brm\b|remove|deleted?|unlink).{0,80}gitea_issue_lock",
re.IGNORECASE | re.DOTALL,
)
_LOCK_READ_RE = re.compile(
r"(?:read|loaded?|parsed?).{0,80}gitea_issue_lock",
re.IGNORECASE | re.DOTALL,
)
_EXTERNAL_NONE_RE = re.compile(
r"external[- ]state mutations\s*:\s*none\b",
re.IGNORECASE,
)
_EXTERNAL_FIELD_RE = re.compile(
r"external[- ]state mutations\s*:\s*(.+)$",
re.IGNORECASE | re.MULTILINE,
)
_CLEANUP_ONLY_RE = re.compile(
r"cleanup mutations\s*:\s*(?:none|lock removed|removed issue lock)",
re.IGNORECASE,
)
_PR_CREATED_RE = re.compile(
r"(?:\bgitea_create_pr\b|PR\s*#\s*\d+\s+created|created\s+PR\s*#|opened\s+PR\s*#|"
r"PR\s+creation\s+(?:succeeded|complete))",
re.IGNORECASE,
)
_REVIEW_APPROVE_RE = re.compile(
r"(?:submitted\s+(?:['\"]approve['\"]|approve\s+review)|"
r"review decision\s*:\s*approve|approved\s+PR\s*#|gitea_review_pr.*approve)",
re.IGNORECASE,
)
_OVERRIDE_PROOF_RE = re.compile(
r"operator[- ]override\s+proof\s*:\s*(.+)$",
re.IGNORECASE | re.MULTILINE,
)
def _utc_now_iso() -> str:
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
def build_sanctioned_lock_provenance(
*,
tool: str,
source: str = SOURCE_LOCK_ISSUE,
claimant: dict | None = None,
adoption: dict | None = None,
) -> dict:
"""Return provenance metadata stored with a sanctioned lock write."""
entry = {
"source": source,
"written_at": _utc_now_iso(),
"written_by_tool": tool,
"lock_file_path": ISSUE_LOCK_FILE,
}
if claimant:
entry["claimant"] = claimant
if adoption:
entry["adoption"] = adoption
return entry
def operator_override_requested() -> bool:
return os.environ.get(_OPERATOR_OVERRIDE_ENV, "").strip().lower() in {
"1",
"true",
"yes",
}
def build_operator_override_provenance(*, reason: str, claimant: dict | None = None) -> dict:
text = (reason or "").strip()
if not text:
raise ValueError(
"operator override requires a non-empty override reason (fail closed)"
)
entry = build_sanctioned_lock_provenance(
tool="operator_override",
source=SOURCE_OPERATOR_OVERRIDE,
claimant=claimant,
)
entry["override_reason"] = text
return entry
def assess_lock_file_for_create_pr(lock_data: dict | None) -> dict:
"""Fail closed when lock file lacks sanctioned provenance (#447)."""
data = lock_data if isinstance(lock_data, dict) else {}
reasons: list[str] = []
provenance = data.get("lock_provenance")
if not isinstance(provenance, dict):
reasons.append(
"issue lock file lacks sanctioned lock_provenance; manual seeding is "
"not a normal recovery path — call gitea_lock_issue or use #442 adoption"
)
return _provenance_result(False, reasons, provenance)
source = str(provenance.get("source") or "").strip()
if source not in SANCTIONED_LOCK_SOURCES:
reasons.append(
f"issue lock provenance source '{source or '(missing)'}' is not sanctioned"
)
if source == SOURCE_OPERATOR_OVERRIDE and not str(
provenance.get("override_reason") or ""
).strip():
reasons.append(
"operator_override lock provenance requires override_reason proof"
)
if not data.get("work_lease"):
reasons.append("issue lock file missing work_lease metadata")
if not str(provenance.get("written_by_tool") or "").strip():
reasons.append("issue lock provenance missing written_by_tool")
proven = not reasons
return _provenance_result(proven, reasons, provenance)
def _provenance_result(proven: bool, reasons: list[str], provenance: dict | None) -> dict:
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"lock_provenance": provenance,
}
def format_lock_provenance_error(assessment: dict) -> str:
reasons = "; ".join(assessment.get("reasons") or ["unknown lock provenance violation"])
return f"Issue lock provenance guard (#447): {reasons} (fail closed)"
def _lock_activity_detected(text: str) -> dict[str, bool]:
body = text or ""
return {
"seed_or_restore": bool(_LOCK_SEED_RE.search(body)),
"remove": bool(_LOCK_REMOVE_RE.search(body)),
"read": bool(_LOCK_READ_RE.search(body)),
}
def _external_state_discloses_lock(text: str) -> bool:
match = _EXTERNAL_FIELD_RE.search(text or "")
if not match:
return False
value = (match.group(1) or "").strip().lower()
if value in {"", "none", "n/a"}:
return False
return "lock" in value or "gitea_issue_lock" in value or "issue-lock" in value
def assess_issue_lock_external_state_report(report_text: str) -> dict:
"""Require explicit external-state disclosure for issue-lock mutations (#447)."""
text = report_text or ""
activity = _lock_activity_detected(text)
if not any(activity.values()):
return {"proven": True, "block": False, "reasons": [], "activity": activity}
reasons: list[str] = []
disclosed = _external_state_discloses_lock(text)
if activity["seed_or_restore"] and _EXTERNAL_NONE_RE.search(text):
reasons.append(
"report mentions seeding/restoring gitea_issue_lock.json but claims "
"External-state mutations: none"
)
elif activity["seed_or_restore"] and not disclosed:
reasons.append(
"report mentions issue-lock file activity but External-state mutations "
"does not disclose read/write of gitea_issue_lock.json"
)
if activity["remove"]:
if _EXTERNAL_NONE_RE.search(text):
reasons.append(
"report mentions removing gitea_issue_lock.json but claims "
"External-state mutations: none"
)
elif not disclosed and _CLEANUP_ONLY_RE.search(text):
reasons.append(
"report removes issue lock but classifies it as cleanup only; "
"record under External-state mutations"
)
elif not disclosed:
reasons.append(
"report mentions deleting issue lock without External-state "
"mutation disclosure"
)
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"activity": activity,
}
def assess_manual_lock_pr_without_override(report_text: str) -> dict:
"""Block reports that created a PR via manual lock seed without override proof."""
text = report_text or ""
seeded = bool(_LOCK_SEED_RE.search(text))
created = bool(_PR_CREATED_RE.search(text))
if not (seeded and created):
return {"proven": True, "block": False, "reasons": []}
if _OVERRIDE_PROOF_RE.search(text):
return {"proven": True, "block": False, "reasons": []}
return {
"proven": False,
"block": True,
"reasons": [
"report created/opened a PR after manual issue-lock seeding without "
"operator override proof"
],
}
def assess_author_reviewer_same_run_report(report_text: str) -> dict:
"""Reviewer handoff must not create and approve the same PR in one run (#447)."""
text = report_text or ""
if not (_PR_CREATED_RE.search(text) and _REVIEW_APPROVE_RE.search(text)):
return {"proven": True, "block": False, "reasons": []}
return {
"proven": False,
"block": True,
"reasons": [
"report mixes author-side PR creation and reviewer approval in one "
"final handoff; split author and reviewer sessions"
],
}
+612
View File
@@ -0,0 +1,612 @@
"""Keyed, persistent issue-lock storage (#443) with flock hardening (#438).
Replaces the single global ``/tmp/gitea_issue_lock.json`` slot with per-issue
lock files under ``GITEA_ISSUE_LOCK_DIR`` (default
``~/.cache/gitea-tools/issue-locks``). Each MCP session binds its active lock
via a per-process pointer file so concurrent repos/issues never clobber each
other. Acquisition is serialized per issue with ``fcntl.flock``.
"""
from __future__ import annotations
import errno
import fcntl
import json
import os
import re
import tempfile
from contextlib import contextmanager
from datetime import datetime, timedelta, timezone
from typing import Any
LOCK_DIR_ENV = "GITEA_ISSUE_LOCK_DIR"
DEFAULT_LOCK_DIR = os.path.expanduser("~/.cache/gitea-tools/issue-locks")
WORK_LEASE_TTL_HOURS = 4
AUTHOR_ISSUE_WORK_LEASE = "author_issue_work"
_SAFE_SEGMENT_RE = re.compile(r"[^A-Za-z0-9._+-]+")
class LockContentionError(RuntimeError):
"""Raised when an exclusive per-issue lock cannot be acquired."""
def default_lock_dir() -> str:
raw = (os.environ.get(LOCK_DIR_ENV) or DEFAULT_LOCK_DIR).strip()
return raw or DEFAULT_LOCK_DIR
def _sanitize_segment(value: str) -> str:
text = (value or "").strip()
if not text:
return "_"
return _SAFE_SEGMENT_RE.sub("_", text)
def lock_key(
*,
remote: str,
org: str,
repo: str,
issue_number: int,
) -> str:
return "-".join(
_sanitize_segment(part)
for part in (remote, org, repo, str(issue_number))
)
def lock_file_path(
*,
remote: str,
org: str,
repo: str,
issue_number: int,
lock_dir: str | None = None,
) -> str:
root = (lock_dir or default_lock_dir()).strip()
return os.path.join(root, f"{lock_key(remote=remote, org=org, repo=repo, issue_number=issue_number)}.json")
def session_pointer_path(lock_dir: str | None = None) -> str:
root = (lock_dir or default_lock_dir()).strip()
return os.path.join(root, f"session-{os.getpid()}.json")
def _ensure_lock_dir(lock_dir: str | None = None) -> str:
root = (lock_dir or default_lock_dir()).strip()
os.makedirs(root, mode=0o700, exist_ok=True)
return root
def flock_path(json_path: str) -> str:
return f"{json_path}.lock"
def is_process_alive(pid: int | None) -> bool:
if not pid or pid <= 0:
return False
try:
os.kill(int(pid), 0)
return True
except OSError as exc:
return exc.errno != errno.ESRCH
except (TypeError, ValueError):
return False
@contextmanager
def _exclusive_file_lock(lock_path: str):
os.makedirs(os.path.dirname(lock_path) or ".", exist_ok=True)
fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
try:
try:
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc:
raise LockContentionError(
f"could not acquire exclusive lock on '{lock_path}'"
) from exc
yield fd
finally:
try:
fcntl.flock(fd, fcntl.LOCK_UN)
finally:
os.close(fd)
def read_lock_file(path: str) -> dict[str, Any] | None:
lock_path = (path or "").strip()
if not lock_path or not os.path.exists(lock_path):
return None
try:
with open(lock_path, encoding="utf-8") as handle:
data = json.load(handle)
except (OSError, json.JSONDecodeError):
return None
return data if isinstance(data, dict) else None
def save_lock_file(path: str, data: dict[str, Any]) -> None:
lock_path = (path or "").strip()
if not lock_path:
raise ValueError("lock path is required (fail closed)")
parent = os.path.dirname(lock_path) or "."
os.makedirs(parent, mode=0o700, exist_ok=True)
payload = json.dumps(data, indent=2, sort_keys=True) + "\n"
fd, temp_path = tempfile.mkstemp(prefix=".lock-", suffix=".json", dir=parent)
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
handle.write(payload)
handle.flush()
os.fsync(handle.fileno())
os.replace(temp_path, lock_path)
finally:
if os.path.exists(temp_path):
try:
os.remove(temp_path)
except OSError:
pass
def bind_session_lock(lock_data: dict[str, Any], lock_dir: str | None = None) -> str:
"""Persist a keyed lock and bind it to the current process session."""
remote = str(lock_data.get("remote") or "")
org = str(lock_data.get("org") or "")
repo = str(lock_data.get("repo") or "")
issue_number = int(lock_data.get("issue_number") or 0)
if not remote or not org or not repo or issue_number <= 0:
raise ValueError("lock record must include remote, org, repo, and issue_number")
root = _ensure_lock_dir(lock_dir)
path = lock_file_path(
remote=remote,
org=org,
repo=repo,
issue_number=issue_number,
lock_dir=root,
)
record = dict(lock_data)
record["lock_file_path"] = path
record["session_pid"] = os.getpid()
record.setdefault("pid", os.getpid())
pointer = {
"pid": os.getpid(),
"lock_file_path": path,
"issue_number": issue_number,
"branch_name": record.get("branch_name"),
"remote": remote,
"org": org,
"repo": repo,
}
sentinel = flock_path(path)
try:
with _exclusive_file_lock(sentinel):
existing = read_lock_file(path)
overwrite_block = assess_foreign_lock_overwrite(existing, record)
if overwrite_block:
raise RuntimeError(overwrite_block)
lease_block = assess_same_issue_lease_conflict(
existing,
issue_number=issue_number,
branch_name=str(record.get("branch_name") or ""),
worktree_path=str(record.get("worktree_path") or ""),
)
if lease_block:
raise RuntimeError(lease_block)
save_lock_file(path, record)
save_lock_file(session_pointer_path(root), pointer)
except LockContentionError as exc:
competing = read_lock_file(path)
if competing:
owner_pid = competing.get("session_pid") or competing.get("pid")
raise RuntimeError(
f"Issue #{issue_number} lock contention: {exc}; competing owner "
f"pid={owner_pid} (fail closed)"
) from exc
raise RuntimeError(f"Issue #{issue_number} lock contention: {exc} (fail closed)") from exc
return path
def read_session_issue_lock(lock_dir: str | None = None) -> dict[str, Any] | None:
root = (lock_dir or default_lock_dir()).strip()
pointer = read_lock_file(session_pointer_path(root))
if not pointer:
return None
lock_path = str(pointer.get("lock_file_path") or "").strip()
if not lock_path:
return None
return read_lock_file(lock_path)
def load_issue_lock(
*,
remote: str,
org: str,
repo: str,
issue_number: int,
lock_dir: str | None = None,
) -> dict[str, Any] | None:
return read_lock_file(
lock_file_path(
remote=remote,
org=org,
repo=repo,
issue_number=issue_number,
lock_dir=lock_dir,
)
)
def iter_lock_files(lock_dir: str | None = None) -> list[str]:
root = (lock_dir or default_lock_dir()).strip()
if not os.path.isdir(root):
return []
paths: list[str] = []
for name in os.listdir(root):
if not name.endswith(".json") or name.startswith("session-"):
continue
paths.append(os.path.join(root, name))
return sorted(paths)
def find_lock_for_branch(
*,
remote: str,
org: str,
repo: str,
branch_name: str,
lock_dir: str | None = None,
) -> dict[str, Any] | None:
target = (branch_name or "").strip()
if not target:
return None
for path in iter_lock_files(lock_dir):
lock = read_lock_file(path)
if not lock:
continue
if (
str(lock.get("remote") or "") == remote
and str(lock.get("org") or "") == org
and str(lock.get("repo") or "") == repo
and str(lock.get("branch_name") or "").strip() == target
):
lock = dict(lock)
lock.setdefault("lock_file_path", path)
return lock
return None
def _lease_now(now: datetime | None = None) -> datetime:
return now or datetime.now(timezone.utc)
def _parse_lease_timestamp(value: str | None) -> datetime | None:
text = (value or "").strip()
if not text:
return None
try:
return datetime.fromisoformat(text.replace("Z", "+00:00")).astimezone(timezone.utc)
except ValueError:
return None
def lease_expires_at(lock: dict[str, Any] | None) -> datetime | None:
if not lock:
return None
lease = lock.get("work_lease")
if not isinstance(lease, dict):
return None
return _parse_lease_timestamp(lease.get("expires_at"))
def is_lease_expired(lock: dict[str, Any] | None, *, now: datetime | None = None) -> bool:
expires = lease_expires_at(lock)
if expires is None:
return False
return expires <= _lease_now(now)
def is_lease_live(lock: dict[str, Any] | None, *, now: datetime | None = None) -> bool:
return assess_lock_freshness(lock, now=now)["live"]
def assess_lock_freshness(
lock_data: dict[str, Any] | None,
*,
now: datetime | None = None,
) -> dict[str, Any]:
"""Classify a lock as live, expired, stale, or absent."""
current = _lease_now(now)
if not lock_data:
return {
"status": "absent",
"live": False,
"stale": False,
"reason": "no lock record",
}
expires_at = lease_expires_at(lock_data)
lease = lock_data.get("work_lease")
heartbeat_at = _parse_lease_timestamp(lock_data.get("last_heartbeat_at"))
if heartbeat_at is None and isinstance(lease, dict):
heartbeat_at = _parse_lease_timestamp(lease.get("last_heartbeat_at"))
pid = lock_data.get("session_pid")
if pid is None:
pid = lock_data.get("pid")
pid_alive = is_process_alive(pid) if pid is not None else False
if expires_at and expires_at <= current:
return {
"status": "expired",
"live": False,
"stale": True,
"reason": f"lease expired at {expires_at.isoformat()}",
"pid_alive": pid_alive,
}
if pid is not None and not pid_alive:
return {
"status": "stale",
"live": False,
"stale": True,
"reason": f"owner pid {pid} is not alive",
"pid_alive": False,
}
return {
"status": "live",
"live": True,
"stale": False,
"reason": "lock heartbeat and lease are fresh",
"pid_alive": pid_alive,
"heartbeat_at": heartbeat_at.isoformat() if heartbeat_at else None,
"expires_at": expires_at.isoformat() if expires_at else None,
}
def _same_realpath(left: str | None, right: str | None) -> bool:
if not left or not right:
return False
try:
return os.path.realpath(left) == os.path.realpath(right)
except OSError:
return left == right
def assess_same_issue_lease_conflict(
existing_lock: dict[str, Any] | None,
*,
issue_number: int,
branch_name: str,
worktree_path: str,
operation_type: str = AUTHOR_ISSUE_WORK_LEASE,
now: datetime | None = None,
) -> str | None:
"""Return a fail-closed error when a competing live lease blocks acquisition."""
if not existing_lock:
return None
existing_issue = existing_lock.get("issue_number")
lease = existing_lock.get("work_lease")
existing_operation = (
lease.get("operation_type")
if isinstance(lease, dict)
else AUTHOR_ISSUE_WORK_LEASE
)
if existing_issue != issue_number or existing_operation != operation_type:
return None
existing_branch = existing_lock.get("branch_name")
existing_worktree = existing_lock.get("worktree_path")
same_owner = (
existing_branch == branch_name
and _same_realpath(str(existing_worktree or ""), worktree_path)
)
if is_lease_expired(existing_lock, now=now):
return (
f"Issue #{issue_number} has an expired {operation_type} lease on "
f"branch '{existing_branch}' from worktree '{existing_worktree}'. "
"Recovery review is required before takeover (fail closed)"
)
if same_owner:
return None
return (
f"Issue #{issue_number} already has an active {operation_type} lease on "
f"branch '{existing_branch}' from worktree '{existing_worktree}' "
"(fail closed)"
)
def assess_foreign_lock_overwrite(
existing_lock: dict[str, Any] | None,
incoming_lock: dict[str, Any],
*,
now: datetime | None = None,
) -> str | None:
"""Block writes that would clobber an unrelated live lease on the same key."""
if not existing_lock:
return None
same_issue = existing_lock.get("issue_number") == incoming_lock.get("issue_number")
same_branch = existing_lock.get("branch_name") == incoming_lock.get("branch_name")
same_worktree = _same_realpath(
str(existing_lock.get("worktree_path") or ""),
str(incoming_lock.get("worktree_path") or ""),
)
if same_issue and same_branch and same_worktree:
return None
if not is_lease_live(existing_lock, now=now):
return None
return (
"Refusing to overwrite a live foreign issue lock "
f"(issue #{existing_lock.get('issue_number')}, "
f"branch '{existing_lock.get('branch_name')}', "
f"worktree '{existing_lock.get('worktree_path')}') (fail closed)"
)
def find_live_lock_for_branch(
branch_name: str,
lock_dir: str | None = None,
) -> dict[str, Any] | None:
target = (branch_name or "").strip()
if not target:
return None
for path in iter_lock_files(lock_dir):
lock = read_lock_file(path)
if not lock:
continue
if str(lock.get("branch_name") or "").strip() != target:
continue
if not is_lease_live(lock):
continue
record = dict(lock)
record.setdefault("lock_file_path", path)
return record
return None
def resolve_locked_branch_for_session(
branch_name: str | None = None,
lock_dir: str | None = None,
) -> str:
if branch_name:
lock = find_live_lock_for_branch(branch_name, lock_dir)
if lock:
return str(lock.get("branch_name") or "")
lock = read_session_issue_lock(lock_dir)
return str((lock or {}).get("branch_name") or "")
def has_active_issue_lock(
branch: str,
*,
lock_dir: str | None = None,
) -> bool:
target = (branch or "").strip()
if not target:
return False
for path in iter_lock_files(lock_dir):
lock = read_lock_file(path)
if not lock:
continue
if str(lock.get("branch_name") or "").strip() != target:
continue
if is_lease_live(lock):
return True
return False
def verify_lock_for_mutation(
lock_data: dict[str, Any] | None,
*,
issue_number: int | None = None,
branch_name: str | None = None,
worktree_path: str | None = None,
) -> dict[str, Any]:
"""Re-check lock ownership immediately before a mutation (#438)."""
reasons: list[str] = []
if not lock_data:
return {"proven": False, "block": True, "reasons": ["issue lock is missing (fail closed)"]}
freshness = assess_lock_freshness(lock_data)
if not freshness["live"]:
reasons.append(f"issue lock is not live: {freshness['reason']} (fail closed)")
if issue_number is not None and lock_data.get("issue_number") != issue_number:
reasons.append(
f"issue lock targets #{lock_data.get('issue_number')}, expected #{issue_number} (fail closed)"
)
if branch_name is not None and lock_data.get("branch_name") != branch_name:
reasons.append(
f"issue lock branch '{lock_data.get('branch_name')}' does not match "
f"'{branch_name}' (fail closed)"
)
if worktree_path is not None:
locked = os.path.realpath(str(lock_data.get("worktree_path") or ""))
declared = os.path.realpath(worktree_path)
if locked != declared:
reasons.append(
f"issue lock worktree '{locked}' does not match declared '{declared}' (fail closed)"
)
return {
"proven": not reasons,
"block": bool(reasons),
"reasons": reasons,
"freshness": freshness,
"lock_proof": format_lock_proof(lock_data, freshness=freshness),
}
def list_live_locks(
*,
lock_dir: str | None = None,
now: datetime | None = None,
) -> list[dict[str, Any]]:
"""Return live per-issue locks for queue visibility."""
live: list[dict[str, Any]] = []
for path in iter_lock_files(lock_dir):
record = read_lock_file(path)
if not record:
continue
freshness = assess_lock_freshness(record, now=now)
if not freshness["live"]:
continue
live.append(
{
"issue_number": record.get("issue_number"),
"branch_name": record.get("branch_name"),
"remote": record.get("remote"),
"org": record.get("org"),
"repo": record.get("repo"),
"worktree_path": record.get("worktree_path"),
"pid": record.get("session_pid") or record.get("pid"),
"claimant": (
record.get("claimant")
or (record.get("work_lease") or {}).get("claimant")
),
"freshness": freshness,
"lock_path": record.get("lock_file_path") or path,
}
)
return live
def format_lock_proof(
lock_data: dict[str, Any] | None,
*,
freshness: dict[str, Any] | None = None,
competing_live_locks: list[dict[str, Any]] | None = None,
released: bool | None = None,
) -> str:
"""Canonical issue-lock proof string for final reports."""
if not lock_data:
return "issue lock proof: not acquired"
fresh = freshness or assess_lock_freshness(lock_data)
owner = lock_data.get("claimant") or {}
if not owner and isinstance(lock_data.get("work_lease"), dict):
owner = lock_data["work_lease"].get("claimant") or {}
parts = [
"issue lock proof:",
f"acquired issue #{lock_data.get('issue_number')}",
f"branch {lock_data.get('branch_name')}",
f"owner {owner.get('profile') or 'unknown'}",
f"pid {lock_data.get('session_pid') or lock_data.get('pid')}",
f"freshness {fresh.get('status')}",
]
if competing_live_locks is not None:
parts.append(
"no competing live lock"
if not competing_live_locks
else f"competing live locks {len(competing_live_locks)}"
)
if released is True:
parts.append("lock released")
elif released is False:
parts.append("lock retained")
return "; ".join(parts)
+26 -5
View File
@@ -30,8 +30,16 @@ def resolve_author_worktree_path(
return os.path.realpath(os.path.abspath(path))
def read_worktree_git_state(worktree_path: str) -> dict:
"""Read branch name and porcelain status from a git worktree."""
def read_worktree_git_state(
worktree_path: str,
extra_bases: tuple[str, ...] | list[str] = (),
) -> dict:
"""Read branch name and porcelain status from a git worktree.
``extra_bases`` names additional branches (e.g. an approved stacked base)
that may anchor base-equivalence in addition to master/main/dev. When empty
(the default), only the normal base branches are considered.
"""
path = (worktree_path or "").strip()
if not path:
return {"current_branch": None, "porcelain_status": ""}
@@ -63,7 +71,7 @@ def read_worktree_git_state(worktree_path: str) -> dict:
check=False,
)
head_sha = (head_res.stdout or "").strip() if head_res.returncode == 0 else None
base_branch, base_sha = _find_matching_base_ref(path, head_sha)
base_branch, base_sha = _find_matching_base_ref(path, head_sha, extra_bases)
return {
"current_branch": current_branch,
"porcelain_status": status_res.stdout or "",
@@ -203,13 +211,26 @@ def _assessment(
}
def _find_matching_base_ref(path: str, head_sha: str | None) -> tuple[str | None, str | None]:
"""Return the stable branch ref whose commit matches HEAD, if any."""
def _find_matching_base_ref(
path: str,
head_sha: str | None,
extra_bases: tuple[str, ...] | list[str] = (),
) -> tuple[str | None, str | None]:
"""Return the stable branch ref whose commit matches HEAD, if any.
Normal base branches (master/main/dev) are always considered. ``extra_bases``
adds explicitly-approved stacked bases; each is checked as a local ref and via
the ``prgs``/``origin`` remotes.
"""
if not head_sha:
return None, None
candidates: list[str] = []
for branch in sorted(BASE_BRANCHES):
candidates.extend((f"origin/{branch}", branch))
for branch in extra_bases:
name = (branch or "").strip()
if name:
candidates.extend((f"prgs/{name}", f"origin/{name}", name))
for ref in candidates:
res = subprocess.run(
["git", "-C", path, "rev-parse", "--verify", ref],
+180
View File
@@ -0,0 +1,180 @@
"""Early duplicate-work detection for author work-issue sessions (#400)."""
from __future__ import annotations
from typing import Any
import issue_claim_heartbeat as claim_hb
PHASE_LOCK = "lock_issue"
PHASE_COMMIT = "commit"
PHASE_PUSH = "push"
PHASE_CREATE_PR = "create_pr"
OUTCOME_DUPLICATE_PR_PREVENTED = "duplicate_pr_prevented"
OUTCOME_DUPLICATE_BRANCH_PREVENTED = "duplicate_branch_prevented"
OUTCOME_DUPLICATE_COMMIT_PREVENTED = "duplicate_commit_prevented"
OUTCOME_DUPLICATE_WORK_NOT_PREVENTED = "duplicate_work_not_prevented"
_ACTIVE_CLAIM_STATUSES = frozenset({"active", "awaiting_review"})
def _issue_pattern(issue_number: int) -> str:
return f"issue-{int(issue_number)}"
def _linked_open_pr(issue_number: int, open_prs: list[dict]) -> dict | None:
return claim_hb._linked_open_pr(issue_number, open_prs)
def _matching_branches(
issue_number: int,
branch_names: list[str],
*,
locked_branch: str | None = None,
) -> list[str]:
pattern = _issue_pattern(issue_number)
matches = [
name for name in (branch_names or [])
if pattern in (name or "").lower()
]
if locked_branch:
locked = locked_branch.strip()
matches = [name for name in matches if name != locked]
return matches
def assess_work_issue_duplicate_gate(
issue_number: int,
*,
open_prs: list[dict] | None = None,
branch_names: list[str] | None = None,
claim_entry: dict | None = None,
locked_branch: str | None = None,
phase: str = PHASE_LOCK,
) -> dict[str, Any]:
"""Fail closed when duplicate work is already in flight for an issue."""
reasons: list[str] = []
outcome = OUTCOME_DUPLICATE_WORK_NOT_PREVENTED
prs = list(open_prs or [])
branches = list(branch_names or [])
pattern = _issue_pattern(issue_number)
linked = _linked_open_pr(issue_number, prs)
if linked:
reasons.append(
f"open PR #{linked.get('number')} already covers issue "
f"#{issue_number} (fail closed)"
)
outcome = OUTCOME_DUPLICATE_PR_PREVENTED
conflicting_branches = _matching_branches(
issue_number, branches, locked_branch=locked_branch
)
if conflicting_branches:
names = ", ".join(conflicting_branches[:5])
reasons.append(
f"remote branch(es) already match issue pattern '{pattern}': "
f"{names} (fail closed)"
)
if outcome == OUTCOME_DUPLICATE_WORK_NOT_PREVENTED:
outcome = OUTCOME_DUPLICATE_BRANCH_PREVENTED
entry = claim_entry or {}
if entry.get("linked_open_pr") and not linked:
reasons.append(
f"claim inventory reports open PR #{entry['linked_open_pr']} "
f"for issue #{issue_number} (fail closed)"
)
outcome = OUTCOME_DUPLICATE_PR_PREVENTED
status = (entry.get("status") or "").strip().lower()
if status in _ACTIVE_CLAIM_STATUSES and not linked:
heartbeat = entry.get("latest_heartbeat") or {}
claim_branch = (heartbeat.get("branch") or "").strip()
if locked_branch and claim_branch and claim_branch != locked_branch:
reasons.append(
f"active claim lease on branch '{claim_branch}' blocks "
f"work on '{locked_branch}' for issue #{issue_number} "
"(fail closed)"
)
if outcome == OUTCOME_DUPLICATE_WORK_NOT_PREVENTED:
outcome = OUTCOME_DUPLICATE_BRANCH_PREVENTED
elif not locked_branch and status == "active":
reasons.append(
f"issue #{issue_number} has an active claim lease "
"(fail closed)"
)
if outcome == OUTCOME_DUPLICATE_WORK_NOT_PREVENTED:
outcome = OUTCOME_DUPLICATE_BRANCH_PREVENTED
if phase in {PHASE_COMMIT, PHASE_PUSH} and reasons:
if outcome == OUTCOME_DUPLICATE_PR_PREVENTED:
outcome = OUTCOME_DUPLICATE_COMMIT_PREVENTED
elif outcome == OUTCOME_DUPLICATE_BRANCH_PREVENTED:
outcome = OUTCOME_DUPLICATE_COMMIT_PREVENTED
block = bool(reasons)
return {
"block": block,
"performed": not block,
"issue_number": issue_number,
"phase": phase,
"outcome": outcome,
"linked_open_pr": linked.get("number") if linked else entry.get("linked_open_pr"),
"conflicting_branches": conflicting_branches,
"claim_status": status or None,
"reasons": reasons,
"safe_next_action": (
"stop before mutating; preserve local work and produce a "
"reconciliation handoff if a concurrent PR appeared after push"
if block and phase == PHASE_CREATE_PR
else "stop before mutating; do not commit or push duplicate work"
if block
else "proceed"
),
}
def assess_work_issue_duplicate_report(report_text: str) -> dict[str, Any]:
"""Require explicit duplicate-work outcome wording in work-issue reports."""
text = (report_text or "").lower()
markers = {
OUTCOME_DUPLICATE_PR_PREVENTED: (
"duplicate pr prevented",
"duplicate_pr_prevented",
),
OUTCOME_DUPLICATE_BRANCH_PREVENTED: (
"duplicate branch prevented",
"duplicate_branch_prevented",
),
OUTCOME_DUPLICATE_COMMIT_PREVENTED: (
"duplicate commit prevented",
"duplicate_commit_prevented",
),
OUTCOME_DUPLICATE_WORK_NOT_PREVENTED: (
"duplicate work not prevented",
"duplicate_work_not_prevented",
"no duplicate work",
),
}
matched = [
key for key, phrases in markers.items()
if any(phrase in text for phrase in phrases)
]
if len(matched) != 1:
return {
"complete": False,
"downgraded": True,
"reasons": [
"work-issue report must state exactly one duplicate-work "
"outcome (duplicate PR/branch/commit prevented, or "
"duplicate work not prevented)"
],
}
return {
"complete": True,
"downgraded": False,
"outcome": matched[0],
"reasons": [],
}
+61
View File
@@ -0,0 +1,61 @@
"""Merge approval must pin the current PR head SHA (#471).
Formal APPROVED reviews that predate the live PR head must not satisfy
``gitea_merge_pr`` eligibility. Pure assessment helpers are isolated here
for hermetic unit tests apart from MCP HTTP calls.
"""
from __future__ import annotations
def assess_merge_approval_head(
*,
current_head_sha: str | None,
latest_by_reviewer: dict,
) -> dict:
"""Return whether a visible approval applies to the live PR head.
Args:
current_head_sha: Current PR head commit SHA.
latest_by_reviewer: Map of reviewer login → review entry dicts with
``verdict``, ``dismissed``, and ``reviewed_head_sha`` keys.
Returns:
dict with ``approval_at_current_head``, ``latest_approved_head_sha``,
and ``stale_approval_block_reason`` (set when merge must fail closed).
"""
current = (current_head_sha or "").strip()
approved_entries = [
entry
for entry in (latest_by_reviewer or {}).values()
if (entry.get("verdict") or "").upper() == "APPROVED"
and not entry.get("dismissed")
]
at_current = any(
(entry.get("reviewed_head_sha") or "").strip() == current
for entry in approved_entries
if current
)
latest_approved = None
if approved_entries:
latest_entry = sorted(
approved_entries,
key=lambda entry: (
entry.get("submitted_at") or "",
entry.get("reviewed_head_sha") or "",
),
)[-1]
latest_approved = (latest_entry.get("reviewed_head_sha") or "").strip() or None
reason = None
if approved_entries and not at_current:
reason = (
f"stale approval: approved SHA '{latest_approved}' does not match "
f"current live PR head SHA '{current or '(unknown)'}' (fail closed); "
"required next action: re-review PR at current head before merge"
)
return {
"approval_at_current_head": at_current,
"latest_approved_head_sha": latest_approved,
"stale_approval_block_reason": reason,
}
+10 -14
View File
@@ -13,9 +13,9 @@ import subprocess
from typing import Any
from reviewer_worktree import parse_dirty_tracked_files
import issue_lock_store
PROTECTED_BRANCHES = frozenset({"master", "main", "dev"})
ISSUE_LOCK_FILE = os.environ.get("GITEA_ISSUE_LOCK_FILE", "/tmp/gitea_issue_lock.json")
CLOSES_FIXES_RE = re.compile(r"\b(?:closes|fixes)\s+#(\d+)\b", re.IGNORECASE)
@@ -37,22 +37,18 @@ def resolve_worktree_path(project_root: str, branch: str) -> str:
def read_issue_lock(path: str | None = None) -> dict[str, Any] | None:
lock_path = (path or ISSUE_LOCK_FILE).strip()
if not lock_path or not os.path.exists(lock_path):
return None
try:
with open(lock_path, encoding="utf-8") as handle:
data = json.load(handle)
except (OSError, json.JSONDecodeError):
return None
return data if isinstance(data, dict) else None
if path:
return issue_lock_store.read_lock_file(path.strip())
return issue_lock_store.read_session_issue_lock()
def has_active_issue_lock(branch: str, lock_path: str | None = None) -> bool:
lock = read_issue_lock(lock_path)
if not lock:
return False
return (lock.get("branch_name") or "").strip() == (branch or "").strip()
if lock_path:
lock = issue_lock_store.read_lock_file(lock_path.strip())
if not lock:
return False
return (lock.get("branch_name") or "").strip() == (branch or "").strip()
return issue_lock_store.has_active_issue_lock(branch)
def collect_open_pr_heads(open_prs: list[dict[str, Any]]) -> set[str]:
+482
View File
@@ -0,0 +1,482 @@
"""Conflict-fix and reviewer PR work leases (#399, #407 reader).
Structured PR/issue comments prove exclusive phases so author conflict-fix
pushes cannot race reviewer validation/approval/merge on the same head.
"""
from __future__ import annotations
import re
from datetime import datetime, timedelta, timezone
from typing import Any
REVIEWER_LEASE_MARKER = "<!-- mcp-review-lease:v1 -->"
CONFLICT_FIX_LEASE_MARKER = "<!-- mcp-conflict-fix-lease:v1 -->"
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
_FIELD_RE = re.compile(
r"^\s*([a-z_]+)\s*:\s*(.+?)\s*$",
re.IGNORECASE | re.MULTILINE,
)
_TERMINAL_REVIEWER_PHASES = frozenset({"done", "released", "blocked"})
_ACTIVE_REVIEWER_PHASES = frozenset({
"claimed",
"validating",
"approved",
"request-changes",
"merging",
})
_TERMINAL_CONFLICT_FIX_PHASES = frozenset({"released", "blocked", "done"})
_ACTIVE_CONFLICT_FIX_PHASES = frozenset({"claimed", "pushing", "pushed"})
DEFAULT_CONFLICT_FIX_TTL_MINUTES = 120
DEFAULT_REVIEWER_LEASE_TTL_MINUTES = 120
def _parse_timestamp(value: str | None) -> datetime | None:
if not value:
return None
text = value.strip()
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _normalize_sha(value: str | None) -> str | None:
text = (value or "").strip().lower()
if not text:
return None
return text if _FULL_SHA.match(text) else None
def _parse_pr_ref(value: str | None) -> int | None:
digits = re.sub(r"[^\d]", "", value or "")
return int(digits) if digits.isdigit() else None
def _parse_marker_comment(body: str, marker: str) -> dict[str, str] | None:
text = body or ""
if marker not in text:
return None
fields: dict[str, str] = {}
for match in _FIELD_RE.finditer(text):
fields[match.group(1).strip().lower()] = match.group(2).strip()
return fields or None
def parse_reviewer_lease_comment(body: str) -> dict[str, Any] | None:
fields = _parse_marker_comment(body, REVIEWER_LEASE_MARKER)
if not fields:
return None
return {
"lease_kind": "reviewer",
"pr_number": _parse_pr_ref(fields.get("pr")),
"issue_number": _parse_pr_ref(fields.get("issue")),
"reviewer_identity": fields.get("reviewer_identity"),
"profile": fields.get("profile"),
"session_id": fields.get("session_id"),
"worktree": fields.get("worktree"),
"phase": (fields.get("phase") or "").strip().lower() or None,
"candidate_head": _normalize_sha(fields.get("candidate_head")),
"target_branch": fields.get("target_branch"),
"target_branch_sha": _normalize_sha(fields.get("target_branch_sha")),
"last_activity": fields.get("last_activity"),
"expires_at": fields.get("expires_at"),
"blocker": fields.get("blocker"),
"raw_fields": fields,
}
def parse_conflict_fix_lease_comment(body: str) -> dict[str, Any] | None:
fields = _parse_marker_comment(body, CONFLICT_FIX_LEASE_MARKER)
if not fields:
return None
ff = (fields.get("fast_forward") or "").strip().lower()
reviewer_active = (fields.get("reviewer_active") or "").strip().lower()
return {
"lease_kind": "conflict_fix",
"pr_number": _parse_pr_ref(fields.get("pr")),
"branch": fields.get("branch"),
"worktree": fields.get("worktree"),
"profile": fields.get("profile"),
"session_id": fields.get("session_id"),
"phase": (fields.get("phase") or "").strip().lower() or None,
"head_before": _normalize_sha(fields.get("head_before")),
"head_after": _normalize_sha(fields.get("head_after")),
"expires_at": fields.get("expires_at"),
"reviewer_active": reviewer_active in {"yes", "true", "1"},
"fast_forward": ff in {"yes", "true", "1"},
"raw_fields": fields,
}
def _comment_entries(comments: list[dict], *, pr_number: int | None) -> list[dict]:
entries: list[dict] = []
for comment in comments or []:
body = comment.get("body") or ""
for parser in (parse_reviewer_lease_comment, parse_conflict_fix_lease_comment):
parsed = parser(body)
if not parsed:
continue
if pr_number is not None and parsed.get("pr_number") not in (None, pr_number):
continue
entries.append({
**parsed,
"comment_id": comment.get("id"),
"author": (comment.get("user") or {}).get("login") or comment.get("author"),
"created_at": comment.get("created_at"),
"updated_at": comment.get("updated_at"),
})
break
return entries
def _lease_expired(lease: dict, *, now: datetime) -> bool:
expires_at = _parse_timestamp(lease.get("expires_at"))
return bool(expires_at and expires_at <= now)
def _lease_phase_active(lease: dict, *, active_phases: frozenset[str]) -> bool:
phase = (lease.get("phase") or "").strip().lower()
if phase in _TERMINAL_REVIEWER_PHASES or phase in _TERMINAL_CONFLICT_FIX_PHASES:
return False
return phase in active_phases or bool(phase and phase not in (
_TERMINAL_REVIEWER_PHASES | _TERMINAL_CONFLICT_FIX_PHASES
))
def find_active_reviewer_lease(
comments: list[dict],
*,
pr_number: int,
now: datetime | None = None,
) -> dict[str, Any] | None:
"""Return the newest unexpired reviewer lease for *pr_number*, if any."""
now = now or datetime.now(timezone.utc)
candidates = [
entry for entry in _comment_entries(comments, pr_number=pr_number)
if entry.get("lease_kind") == "reviewer"
]
for lease in reversed(candidates):
if _lease_expired(lease, now=now):
continue
phase = (lease.get("phase") or "").strip().lower()
if phase in _TERMINAL_REVIEWER_PHASES:
continue
if phase in _ACTIVE_REVIEWER_PHASES or phase:
return lease
return None
def find_active_conflict_fix_lease(
comments: list[dict],
*,
pr_number: int,
now: datetime | None = None,
) -> dict[str, Any] | None:
"""Return the newest unexpired conflict-fix lease for *pr_number*, if any."""
now = now or datetime.now(timezone.utc)
candidates = [
entry for entry in _comment_entries(comments, pr_number=pr_number)
if entry.get("lease_kind") == "conflict_fix"
]
for lease in reversed(candidates):
if _lease_expired(lease, now=now):
continue
phase = (lease.get("phase") or "").strip().lower()
if phase in _TERMINAL_CONFLICT_FIX_PHASES:
continue
if phase in _ACTIVE_CONFLICT_FIX_PHASES or phase:
return lease
return None
def format_conflict_fix_lease_body(
*,
pr_number: int,
branch: str,
worktree: str,
profile: str,
head_before: str,
phase: str = "claimed",
session_id: str = "unknown",
expires_at: datetime | None = None,
reviewer_active: bool = False,
) -> str:
expires = expires_at or (
datetime.now(timezone.utc) + timedelta(minutes=DEFAULT_CONFLICT_FIX_TTL_MINUTES)
)
expires_text = expires.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
"+00:00", "Z"
)
lines = [
CONFLICT_FIX_LEASE_MARKER,
f"pr: #{pr_number}",
f"branch: {branch}",
f"worktree: {worktree}",
f"profile: {profile}",
f"session_id: {session_id}",
f"phase: {phase}",
f"head_before: {head_before}",
f"expires_at: {expires_text}",
f"reviewer_active: {'yes' if reviewer_active else 'no'}",
]
return "\n".join(lines)
def assess_head_sha_equality(
reviewed_head_sha: str | None,
live_head_sha: str | None,
) -> dict[str, Any]:
"""Fail closed when reviewed and live PR heads differ."""
reviewed = _normalize_sha(reviewed_head_sha)
live = _normalize_sha(live_head_sha)
reasons: list[str] = []
if not reviewed or not live:
reasons.append(
"reviewed/live head SHA missing or not full 40-hex; fail closed"
)
elif reviewed != live:
reasons.append(
"PR head changed after validation; re-pin and re-validate before "
"approval or merge"
)
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"reviewed_head_sha": reviewed,
"live_head_sha": live,
"head_changed": bool(reviewed and live and reviewed != live),
}
def assess_conflict_fix_push(
*,
pr_number: int,
comments: list[dict],
branch_head_before: str | None,
branch_head_after: str | None,
worktree_path: str | None,
push_cwd: str | None,
is_fast_forward: bool | None,
now: datetime | None = None,
) -> dict[str, Any]:
"""Author pre-push gate: block when a reviewer holds an active lease."""
now = now or datetime.now(timezone.utc)
reasons: list[str] = []
reviewer_lease = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
conflict_lease = find_active_conflict_fix_lease(comments, pr_number=pr_number, now=now)
if reviewer_lease:
reasons.append(
f"active reviewer lease on PR #{pr_number} "
f"(phase={reviewer_lease.get('phase')}); author push blocked"
)
head_before = _normalize_sha(branch_head_before)
head_after = _normalize_sha(branch_head_after)
if not head_before:
reasons.append("branch head before push missing or invalid SHA")
if head_after and head_before and head_before == head_after:
reasons.append("branch head unchanged; no push to perform")
worktree = (worktree_path or "").strip()
cwd = (push_cwd or "").strip()
if not worktree:
reasons.append("worktree path required for conflict-fix push proof")
elif cwd and worktree and not cwd.rstrip("/").endswith(worktree.rstrip("/").split("/")[-1]):
if worktree not in cwd:
reasons.append(
f"push cwd '{cwd}' does not match session worktree '{worktree}'"
)
if is_fast_forward is False:
reasons.append("non-fast-forward push rejected for conflict-fix (fail closed)")
if conflict_lease and conflict_lease.get("phase") == "pushing":
owner = conflict_lease.get("worktree")
if owner and worktree and owner != worktree:
reasons.append(
f"sibling conflict-fix lease active from worktree '{owner}'"
)
push_allowed = not reasons
return {
"push_allowed": push_allowed,
"block": not push_allowed,
"reasons": reasons,
"active_reviewer_lease": reviewer_lease,
"active_conflict_fix_lease": conflict_lease,
"branch_head_before": head_before,
"branch_head_after": head_after,
"reviewer_was_active": bool(reviewer_lease),
"fast_forward": is_fast_forward,
}
def assess_reviewer_mutation_blocked(
*,
pr_number: int,
comments: list[dict],
reviewed_head_sha: str | None,
live_head_sha: str | None,
mutation: str,
now: datetime | None = None,
) -> dict[str, Any]:
"""Reviewer gate: block when conflict-fix lease active or head moved."""
now = now or datetime.now(timezone.utc)
reasons: list[str] = []
conflict_lease = find_active_conflict_fix_lease(comments, pr_number=pr_number, now=now)
if conflict_lease and (conflict_lease.get("phase") or "") in _ACTIVE_CONFLICT_FIX_PHASES:
reasons.append(
f"active conflict-fix lease on PR #{pr_number} "
f"(phase={conflict_lease.get('phase')}); reviewer {mutation} blocked"
)
head_check = assess_head_sha_equality(reviewed_head_sha, live_head_sha)
if head_check["block"]:
reasons.extend(head_check["reasons"])
if not _normalize_sha(reviewed_head_sha):
reasons.append(
f"reviewed head SHA required before reviewer {mutation} (fail closed)"
)
allowed = not reasons
return {
"mutation_allowed": allowed,
"block": not allowed,
"reasons": reasons,
"active_conflict_fix_lease": conflict_lease,
"head_check": head_check,
"reviewed_head_sha": head_check.get("reviewed_head_sha"),
"live_head_sha": head_check.get("live_head_sha"),
"push_during_validation": bool(
conflict_lease and conflict_lease.get("phase") in {"pushing", "pushed"}
),
}
_REVIEWED_HEAD_RE = re.compile(
r"reviewed head sha\s*:\s*([0-9a-f]{40})",
re.IGNORECASE,
)
_LIVE_HEAD_BEFORE_APPROVAL_RE = re.compile(
r"(?:live head sha before approval|final live head sha before approval)\s*:\s*([0-9a-f]{40})",
re.IGNORECASE,
)
_LIVE_HEAD_BEFORE_MERGE_RE = re.compile(
r"(?:live head sha before merge|final live head sha before merge)\s*:\s*([0-9a-f]{40})",
re.IGNORECASE,
)
_PUSH_DURING_VALIDATION_RE = re.compile(
r"push(?:es)? occurred during validation\s*:\s*(yes|no|true|false)",
re.IGNORECASE,
)
_CONFLICT_HEAD_BEFORE_RE = re.compile(
r"branch head before push\s*:\s*([0-9a-f]{40})",
re.IGNORECASE,
)
_CONFLICT_HEAD_AFTER_RE = re.compile(
r"branch head after push\s*:\s*([0-9a-f]{40})",
re.IGNORECASE,
)
_REVIEWER_LEASE_STATUS_RE = re.compile(
r"active reviewer lease status\s*:\s*(.+)$",
re.IGNORECASE | re.MULTILINE,
)
_FAST_FORWARD_RE = re.compile(
r"whether push was fast-forward\s*:\s*(yes|no|true|false)",
re.IGNORECASE,
)
_REVIEWER_ACTIVE_RE = re.compile(
r"whether any reviewer was active\s*:\s*(yes|no|true|false)",
re.IGNORECASE,
)
def assess_reviewer_stale_head_final_report(report_text: str) -> dict[str, Any]:
"""Final-report proof for reviewed vs live head SHAs (#399 AC 6)."""
text = report_text or ""
reasons: list[str] = []
reviewed = _normalize_sha(_REVIEWED_HEAD_RE.search(text).group(1) if _REVIEWED_HEAD_RE.search(text) else None)
live_approval = _normalize_sha(
_LIVE_HEAD_BEFORE_APPROVAL_RE.search(text).group(1)
if _LIVE_HEAD_BEFORE_APPROVAL_RE.search(text)
else None
)
live_merge = _normalize_sha(
_LIVE_HEAD_BEFORE_MERGE_RE.search(text).group(1)
if _LIVE_HEAD_BEFORE_MERGE_RE.search(text)
else None
)
push_during = _PUSH_DURING_VALIDATION_RE.search(text)
if not reviewed:
reasons.append("reviewed head SHA not stated in final report")
if not live_approval:
reasons.append("final live head SHA before approval not stated")
if not live_merge:
reasons.append("final live head SHA before merge not stated")
if not push_during:
reasons.append("whether push occurred during validation not stated")
elif reviewed and live_approval and reviewed != live_approval:
reasons.append("live head before approval differs from reviewed head SHA")
elif reviewed and live_merge and reviewed != live_merge:
reasons.append("live head before merge differs from reviewed head SHA")
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"reviewed_head_sha": reviewed,
"live_head_sha_before_approval": live_approval,
"live_head_sha_before_merge": live_merge,
"push_during_validation": (push_during.group(1).lower() if push_during else None),
}
def assess_conflict_fix_final_report(report_text: str) -> dict[str, Any]:
"""Final-report proof for conflict-fix push sessions (#399 AC 7)."""
text = report_text or ""
reasons: list[str] = []
head_before = _normalize_sha(
_CONFLICT_HEAD_BEFORE_RE.search(text).group(1)
if _CONFLICT_HEAD_BEFORE_RE.search(text)
else None
)
head_after = _normalize_sha(
_CONFLICT_HEAD_AFTER_RE.search(text).group(1)
if _CONFLICT_HEAD_AFTER_RE.search(text)
else None
)
if not head_before:
reasons.append("branch head before push not stated")
if not head_after:
reasons.append("branch head after push not stated")
if not _REVIEWER_LEASE_STATUS_RE.search(text):
reasons.append("active reviewer lease status not stated")
if not _FAST_FORWARD_RE.search(text):
reasons.append("whether push was fast-forward not stated")
if not _REVIEWER_ACTIVE_RE.search(text):
reasons.append("whether any reviewer was active not stated")
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"branch_head_before": head_before,
"branch_head_after": head_after,
}
+66
View File
@@ -0,0 +1,66 @@
"""Capability preflight lifetime contract (#470).
Defines which read-only MCP tools preserve an existing capability proof and the
canonical sequencing operators must follow before mutations.
"""
from __future__ import annotations
# Read-only tools that must not invalidate capability proof (#470).
# gitea_whoami is read-only but re-pins identity; capability is preserved when
# identity was already verified clean (#469).
READ_ONLY_PREFLIGHT_TOOLS = frozenset({
"gitea_whoami",
"gitea_get_authenticated_user",
"gitea_get_current_user",
"gitea_view_pr",
"gitea_view_issue",
"gitea_list_prs",
"gitea_list_issues",
"gitea_list_issue_comments",
"gitea_get_runtime_context",
"gitea_resolve_task_capability",
"gitea_check_pr_eligibility",
"gitea_get_pr_review_feedback",
"gitea_assess_work_issue_duplicate",
"gitea_route_task_session",
"gitea_audit_config",
"gitea_list_labels",
"gitea_get_profile",
"gitea_list_profiles",
})
PREFLIGHT_CONTRACT_SUMMARY = (
"Capability preflight is session-scoped per MCP process, profile, and task. "
"After gitea_resolve_task_capability(task=...), interleaved read-only calls "
"(whoami, view_*, list_*, get_runtime_context, eligibility checks) preserve "
"the proof until a gated mutation consumes it. Each mutation consumes the proof "
"once; re-resolve immediately before the next mutation. A new resolve for a "
"different task replaces the prior task binding. Profile/session changes or "
"workspace edits before resolve invalidate proof."
)
def format_missing_capability_error(task: str | None = None) -> str:
base = (
"Pre-flight order violation: Task capability "
"(gitea_resolve_task_capability) has not been resolved (fail closed)"
)
if task:
return (
f"{base}. Re-run gitea_resolve_task_capability(task=\"{task}\") "
"immediately before this mutation."
)
return (
f"{base}. Re-run gitea_resolve_task_capability for the mutation task "
"immediately before acting."
)
def format_task_mismatch_error(resolved: str, required: str) -> str:
return (
"Pre-flight task mismatch: "
f"resolved '{resolved}' but mutation requires '{required}' (fail closed). "
f"Re-run gitea_resolve_task_capability(task=\"{required}\") "
"immediately before this mutation."
)
+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,
}
+321
View File
@@ -0,0 +1,321 @@
"""Reviewer final-report schema verification before session output (#391).
Composes the composable ``assess_final_report_validator`` (#327) with
review-specific schema gates required before a reviewer session completes.
"""
from __future__ import annotations
import re
from typing import Any
from final_report_validator import (
assess_final_report_validator,
validator_finding,
_handoff_fields,
)
_LEGACY_STALE_FIELDS = (
"pinned reviewed head",
"scratch worktree used",
"workspace mutations",
)
_REVIEWED_HEAD_RE = re.compile(
r"(?:pinned reviewed head|reviewed head sha)\s*:\s*([0-9a-f]{7,40})",
re.IGNORECASE,
)
_VALIDATION_PASS_RE = re.compile(
r"validation\s*:\s*(?:pass|passed|strong|ok|green)",
re.IGNORECASE,
)
_MERGED_CLAIM_RE = re.compile(
r"\b(?:merged|merge result\s*:\s*merged|pr\s+#\d+\s+merged)\b",
re.IGNORECASE,
)
_MERGE_RESULT_RE = re.compile(r"merge result\s*:", re.IGNORECASE)
_ANCESTRY_PROOF_RE = re.compile(
r"(?:ancestor proof|target branch sha|merge commit)",
re.IGNORECASE,
)
_ISSUE_CLOSED_RE = re.compile(
r"(?:linked issue(?:\s+live)?\s+status\s*:\s*closed|issue\s+#\d+\s+closed)",
re.IGNORECASE,
)
_LIVE_ISSUE_PROOF_RE = re.compile(
r"gitea_view_issue|(?:issue|linked issue).{0,40}fetched live|live fetch proof",
re.IGNORECASE,
)
_FULL_SUITE_PASS_RE = re.compile(
r"(?:full suite passed|full test suite passed|\d+\s+passed,\s*0\s+failed)",
re.IGNORECASE,
)
_TESTS_IGNORED_RE = re.compile(
r"(?:ignored tests|tests?\s+ignored|skipped tests|not run|tests?\s+skipped)",
re.IGNORECASE,
)
_BLOCKED_HANDOFF_RE = re.compile(
r"(?:blocker\s*:|recovery handoff|infra_stop|capability stop|mutation blocked)",
re.IGNORECASE,
)
_REPLAY_COMMAND_RE = re.compile(
r"(?:gitea_submit_pr_review|gitea_merge_pr|gitea_review_pr|"
r"submitted\s+['\"]approve['\"]|replay approve|replay merge)",
re.IGNORECASE,
)
_PENDING_RE = re.compile(r"\bPENDING\b", re.IGNORECASE)
_APPROVED_RE = re.compile(
r"(?:review decision\s*:\s*approve|submitted\s+approve|official review submitted)",
re.IGNORECASE,
)
_DRY_RUN_RE = re.compile(r"dry[- ]run", re.IGNORECASE)
_FINDING_READY_RE = re.compile(
r"(?:finding ready|ready to submit|not yet submitted)",
re.IGNORECASE,
)
_NARRATIVE_APPROVE_RE = re.compile(
r"(?:^|\n)\s*(?:##\s+)?(?:summary|verdict|recommendation)\b[^\n]*\bapprove\b",
re.IGNORECASE | re.MULTILINE,
)
_HANDOFF_DECISION_RE = re.compile(
r"review decision\s*:\s*(\w+)",
re.IGNORECASE,
)
def _rule_legacy_stale_fields(report_text: str) -> list[dict[str, str]]:
fields = _handoff_fields(report_text)
findings: list[dict[str, str]] = []
for stale in _LEGACY_STALE_FIELDS:
if stale in fields and fields[stale].lower() not in {"", "none", "n/a"}:
findings.append(
validator_finding(
"reviewer.legacy_stale_field",
"block",
stale.title(),
f"legacy or stale handoff field '{stale}' must not appear in "
"canonical reviewer final reports",
"remove stale fields; use Candidate head SHA and precise "
"mutation categories instead",
)
)
return findings
def _rule_reviewed_head_without_validation(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if not _REVIEWED_HEAD_RE.search(text):
return []
if _VALIDATION_PASS_RE.search(text):
return []
return [
validator_finding(
"reviewer.reviewed_head_without_validation",
"block",
"Pinned reviewed head",
"reviewed/pinned head SHA reported without validation pass proof",
"document validation command, cwd, and pass result before pinning head SHA",
)
]
def _rule_merged_without_proof(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if not _MERGED_CLAIM_RE.search(text):
return []
if _MERGE_RESULT_RE.search(text) and _ANCESTRY_PROOF_RE.search(text):
return []
return [
validator_finding(
"reviewer.merged_without_proof",
"block",
"Merge result",
"merged outcome claimed without merge result and target ancestry proof",
"include Merge result, target branch SHA, and ancestor proof before claiming merged",
)
]
def _rule_issue_closed_without_live_proof(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if not _ISSUE_CLOSED_RE.search(text):
return []
fields = _handoff_fields(text)
status_value = fields.get("linked issue live status", "")
readonly_value = fields.get("read-only diagnostics", "")
proof_blob = f"{status_value} {readonly_value}".lower()
if _LIVE_ISSUE_PROOF_RE.search(proof_blob):
return []
return [
validator_finding(
"reviewer.issue_closed_without_live_proof",
"block",
"Linked issue",
"issue closed claimed without live linked-issue verification proof",
"fetch linked issue live (gitea_view_issue) and record Linked issue live status",
)
]
def _rule_full_suite_pass_ignored_tests(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if not (_FULL_SUITE_PASS_RE.search(text) and _TESTS_IGNORED_RE.search(text)):
return []
if re.search(r"explicitly\s+(?:ignored|skipped)", text, re.IGNORECASE):
return []
return [
validator_finding(
"reviewer.full_suite_pass_ignored_tests",
"block",
"Validation",
"full suite pass claimed while tests were ignored or skipped without "
"explicit disclosure",
"state which tests were ignored/skipped or downgrade validation verdict",
)
]
def _rule_blocked_handoff_replay(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
if not _BLOCKED_HANDOFF_RE.search(text):
return []
if not _REPLAY_COMMAND_RE.search(text):
return []
return [
validator_finding(
"reviewer.blocked_handoff_replay",
"block",
"Safe next action",
"blocked recovery handoff includes direct approve or merge replay commands",
"restart the full workflow after the blocker clears; do not replay mutations",
)
]
def _rule_narrative_handoff_drift(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
narrative_approve = bool(_NARRATIVE_APPROVE_RE.search(text))
decision_match = _HANDOFF_DECISION_RE.search(text)
if not narrative_approve or not decision_match:
return []
handoff_decision = decision_match.group(1).strip().lower()
if handoff_decision in {"approve", "approved"}:
return []
return [
validator_finding(
"reviewer.narrative_handoff_drift",
"block",
"Review decision",
f"narrative approves PR but controller handoff says '{handoff_decision}'",
"align narrative summary with controller handoff Review decision field",
)
]
def _rule_review_state_ambiguous(report_text: str) -> list[dict[str, str]]:
text = report_text or ""
findings: list[dict[str, str]] = []
if _PENDING_RE.search(text) and _APPROVED_RE.search(text):
findings.append(
validator_finding(
"reviewer.pending_vs_approved",
"block",
"Review decision",
"report conflates PENDING review state with APPROVED outcome",
"distinguish official submitted review from pending or ready-to-submit state",
)
)
if _DRY_RUN_RE.search(text) and _APPROVED_RE.search(text):
if "dry-run only" not in text.lower():
findings.append(
validator_finding(
"reviewer.dry_run_vs_submitted",
"block",
"Review mutations",
"dry-run language mixed with official approve/submitted claims",
"mark dry-run explicitly or document the live review mutation proof",
)
)
if _FINDING_READY_RE.search(text) and _APPROVED_RE.search(text):
findings.append(
validator_finding(
"reviewer.finding_ready_vs_submitted",
"downgrade",
"Review decision",
"finding-ready wording combined with submitted-approve claims",
"state whether review was submitted live or only prepared for submission",
)
)
return findings
_SCHEMA_RULES = (
_rule_legacy_stale_fields,
_rule_reviewed_head_without_validation,
_rule_merged_without_proof,
_rule_issue_closed_without_live_proof,
_rule_full_suite_pass_ignored_tests,
_rule_blocked_handoff_replay,
_rule_narrative_handoff_drift,
_rule_review_state_ambiguous,
)
def _merge_validator_results(
base: dict[str, Any],
extra_findings: list[dict[str, str]],
) -> dict[str, Any]:
findings = list(base.get("findings") or []) + extra_findings
blocked = base.get("blocked") or any(f["severity"] == "block" for f in extra_findings)
downgraded = base.get("downgraded") or any(
f["severity"] == "downgrade" for f in extra_findings
)
grade = base.get("grade", "A")
if blocked:
grade = "blocked"
elif downgraded and grade == "A":
grade = "downgraded"
reasons = [f"{f['rule_id']}: {f['reason']}" for f in findings]
safe_next = base.get("safe_next_action") or "none"
if extra_findings:
safe_next = extra_findings[0].get("safe_next_action", safe_next)
return {
**base,
"grade": grade,
"blocked": blocked,
"downgraded": downgraded,
"findings": findings,
"reasons": reasons,
"safe_next_action": safe_next,
"complete": grade == "A",
"schema_rules_applied": len(_SCHEMA_RULES),
}
def assess_review_final_report_schema(
report_text: str,
*,
review_decision_lock: dict | None = None,
linked_issue_lock: dict | None = None,
validation_report: dict | None = None,
action_log: list[dict] | None = None,
mutations_observed: bool = False,
local_edits: bool = False,
validation_session: dict | None = None,
) -> dict[str, Any]:
"""Validate reviewer final report text before session completion (#391)."""
base = assess_final_report_validator(
report_text,
"review_pr",
review_decision_lock=review_decision_lock,
linked_issue_lock=linked_issue_lock,
validation_report=validation_report,
action_log=action_log,
mutations_observed=mutations_observed,
local_edits=local_edits,
validation_session=validation_session,
)
extra: list[dict[str, str]] = []
for rule in _SCHEMA_RULES:
extra.extend(rule(report_text))
return _merge_validator_results(base, extra)
+234
View File
@@ -18,6 +18,11 @@ import hashlib
import re
import issue_duplicate_gate
from reconciliation_workflow import (
RECONCILE_TASK_MARKERS,
RECONCILE_WORKFLOW_MARKERS,
assess_reconcile_workflow_source,
)
from reviewer_worktree import assess_reviewer_worktree_proof
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$")
@@ -1710,6 +1715,11 @@ def build_final_report(checkout_proof, inventory, validation, contamination,
if report_text
else {"proven": True, "block": False, "reasons": [], "violations": []}
)
reconcile_inventory = (
assess_reconcile_inventory_report(report_text)
if report_text and _RECONCILE_INVENTORY_HINT.search(report_text)
else {"proven": True, "block": False, "reasons": []}
)
reconcile_linked_issue = (
assess_reconcile_linked_issue_report(report_text)
if report_text and _RECONCILE_LINKED_ISSUE_HINT.search(report_text)
@@ -1725,6 +1735,11 @@ def build_final_report(checkout_proof, inventory, validation, contamination,
if report_text
else {"proven": True, "block": False, "reasons": []}
)
proof_backed_handoff = (
assess_proof_backed_handoff_report(report_text)
if report_text and _PROOF_BACKED_HANDOFF_HINT.search(report_text)
else {"proven": True, "block": False, "reasons": []}
)
if baseline_validation is None and report_text:
baseline_validation = assess_reviewer_baseline_validation_proof(
report_text=report_text,
@@ -1737,6 +1752,17 @@ def build_final_report(checkout_proof, inventory, validation, contamination,
"reasons": [],
"violations": [],
}
if report_text:
already_landed_classification = assess_already_landed_classification_report(
report_text
)
else:
already_landed_classification = {
"proven": True,
"block": False,
"reasons": [],
"violations": [],
}
contamination_status = contamination.get("status", "unknown")
checkout_proven = bool(checkout_proof.get("proven"))
@@ -1886,6 +1912,11 @@ def build_final_report(checkout_proof, inventory, validation, contamination,
"queue-status report violates loaded workflow proof gates (#339)"
)
downgrade_reasons.extend(queue_status_report.get("reasons", []))
if not reconcile_inventory.get("proven"):
downgrade_reasons.append(
"reconciliation PR inventory lacks pagination proof (#308)"
)
downgrade_reasons.extend(reconcile_inventory.get("reasons", []))
if not reconcile_linked_issue.get("proven"):
downgrade_reasons.append(
"reconciliation linked issue status lacks live fetch proof (#300)"
@@ -1911,6 +1942,16 @@ def build_final_report(checkout_proof, inventory, validation, contamination,
"reviewer baseline validation proof missing or failed (#325)"
)
downgrade_reasons.extend(baseline_validation.get("reasons", []))
if not proof_backed_handoff.get("proven"):
downgrade_reasons.append(
"proof-sensitive handoff claims lack command/tool evidence (#395)"
)
downgrade_reasons.extend(proof_backed_handoff.get("reasons", []))
if not already_landed_classification.get("proven"):
downgrade_reasons.append(
"already-landed classification wording missing or invalid (#295)"
)
downgrade_reasons.extend(already_landed_classification.get("reasons", []))
merge_allowed = (
identity_eligible
@@ -1999,6 +2040,10 @@ def build_final_report(checkout_proof, inventory, validation, contamination,
"queue_status_violations": list(
queue_status_report.get("violations") or []
),
"reconcile_inventory_proven": bool(reconcile_inventory.get("proven")),
"reconcile_inventory_violations": list(
reconcile_inventory.get("reasons") or []
),
"reconcile_linked_issue_proven": bool(reconcile_linked_issue.get("proven")),
"reconcile_linked_issue_violations": list(
reconcile_linked_issue.get("reasons") or []
@@ -2021,6 +2066,12 @@ def build_final_report(checkout_proof, inventory, validation, contamination,
"baseline_validation_violations": list(
baseline_validation.get("violations") or []
),
"already_landed_classification_proven": bool(
already_landed_classification.get("proven")
),
"already_landed_classification_violations": list(
already_landed_classification.get("reasons") or []
),
}
@@ -2287,6 +2338,30 @@ CREATE_ISSUE_FORBIDDEN_CROSS_MODE_FIELDS = (
("Terminal review mutation", ("terminal review mutation",)),
)
WORK_ISSUE_WORKFLOW_MARKERS = (
"workflows/work-issue.md",
"work-issue.md",
)
WORK_ISSUE_TASK_MARKERS = (
"work-issue",
"work issue",
)
WORK_ISSUE_FORBIDDEN_CROSS_MODE_FIELDS = (
("Selected PR", ("selected pr",)),
("Review decision", ("review decision",)),
("Merge result", ("merge result",)),
("Merge preflight", ("merge preflight",)),
("Already-landed gate", ("already-landed gate",)),
("Pinned reviewed head", ("pinned reviewed head",)),
("Terminal review mutation", ("terminal review mutation",)),
("Duplicate search terms", ("duplicate search terms",)),
("Issues created", ("issues created",)),
("Issues skipped as duplicates", ("issues skipped as duplicates",)),
("Requested issue task", ("requested issue task",)),
)
def _handoff_section_lines(report_text):
"""Return the lines of the Controller Handoff section, or None."""
@@ -3479,6 +3554,107 @@ def assess_create_issue_workflow_source(report_text: str) -> dict:
}
def assess_work_issue_workflow_source(report_text: str) -> dict:
"""#139: work-issue reports must cite the canonical workflow file."""
lower = (report_text or "").lower()
reasons = []
if not any(marker in lower for marker in WORK_ISSUE_WORKFLOW_MARKERS):
reasons.append(
"work-issue report missing workflow source "
"(workflows/work-issue.md)"
)
if not any(marker in lower for marker in WORK_ISSUE_TASK_MARKERS):
reasons.append(
"work-issue report missing task mode declaration (work-issue)"
)
return {
"complete": not reasons,
"downgraded": bool(reasons),
"reasons": reasons,
}
def assess_work_issue_mode_isolation(report_text: str) -> dict:
"""#139: reject review-merge/create-issue handoff fields in work-issue runs."""
section = _handoff_section_lines(report_text)
reasons = []
if section is None:
return {
"complete": True,
"downgraded": False,
"reasons": [],
}
labels = []
for line in section:
stripped = line.strip().lstrip("-*").strip()
if ":" in stripped:
labels.append(stripped.split(":", 1)[0].strip().lower())
for name, aliases in WORK_ISSUE_FORBIDDEN_CROSS_MODE_FIELDS:
if any(
label.startswith(alias)
for label in labels
for alias in aliases
):
reasons.append(
f"work-issue handoff must not include cross-mode field "
f"'{name}'"
)
legacy_workspace = any(
label.startswith("workspace mutations") for label in labels
)
precise_categories = any(
label.startswith("file edits by author")
for label in labels
)
if legacy_workspace and not precise_categories:
reasons.append(
"work-issue handoff must use precise mutation categories "
"instead of legacy 'Workspace mutations'"
)
return {
"complete": not reasons,
"downgraded": bool(reasons),
"reasons": reasons,
}
def assess_work_issue_final_report(report_text: str) -> dict:
"""#139: composite verifier for work-issue final reports."""
from issue_work_duplicate_gate import assess_work_issue_duplicate_report
checks = {
"workflow_source": assess_work_issue_workflow_source(report_text),
"mode_isolation": assess_work_issue_mode_isolation(report_text),
"duplicate_work_outcome": assess_work_issue_duplicate_report(report_text),
}
reasons = []
downgraded = False
for name, result in checks.items():
verdict = result.get("verdict")
if verdict in ("missing", "incomplete"):
downgraded = True
reasons.extend(result.get("reasons") or [])
elif result.get("downgraded") or not result.get("complete", True):
downgraded = True
reasons.extend(
f"{name}: {r}" for r in (result.get("reasons") or [])
)
grade = "A" if not downgraded else "downgraded"
return {
"grade": grade,
"downgraded": downgraded,
"checks": checks,
"reasons": reasons,
"complete": not downgraded,
}
def assess_create_issue_mode_isolation(report_text: str) -> dict:
"""#337: reject work-issue/review-merge handoff fields in create-issue runs."""
section = _handoff_section_lines(report_text)
@@ -4895,12 +5071,25 @@ def assess_final_report_validator(report_text, task_kind, **kwargs):
return _validate(report_text, task_kind, **kwargs)
def assess_review_final_report_schema(report_text, **kwargs):
"""#391: reviewer final-report schema verification before session output."""
from review_final_report_schema import assess_review_final_report_schema as _assess
return _assess(report_text, **kwargs)
_QUEUE_STATUS_REPORT_HINT = re.compile(
r"queue[- ]status|selected pr:\s*none|no pr selected|"
r"queue[- ]status[- ]only",
re.I,
)
_RECONCILE_INVENTORY_HINT = re.compile(
r"already[- ]landed reconciliation|reconcile[- ]landed|"
r"open pr inventory|inventory pagination proof",
re.I,
)
_RECONCILE_LINKED_ISSUE_HINT = re.compile(
r"already[- ]landed|reconcil|linked issue(?:\s+live)?\s+status",
re.I,
@@ -4912,6 +5101,12 @@ _PR_QUEUE_CLEANUP_REPORT_HINT = re.compile(
re.I,
)
_PROOF_BACKED_HANDOFF_HINT = re.compile(
r"task:\s*review-merge-pr|task mode:\s*review-merge-pr|"
r"review-merge-pr|controller handoff",
re.I,
)
def assess_pr_queue_cleanup_report(report_text: str | None) -> dict:
"""#390: validate PR-only cleanup final reports (one PR per run)."""
@@ -5311,6 +5506,31 @@ def assess_validation_integrity_report(report_text, **kwargs):
return _assess(report_text, **kwargs)
def assess_validation_failure_history_report(report_text, **kwargs):
"""#396: final reports must account for transient validation failures."""
from reviewer_validation_failure_history import (
assess_validation_failure_history_report as _assess,
)
return _assess(report_text, **kwargs)
def assess_validation_cwd_proof_report(report_text, **kwargs):
"""#398: validation commands require explicit worktree cwd and HEAD proof."""
from reviewer_validation_cwd_proof import assess_validation_cwd_proof_report as _assess
return _assess(report_text, **kwargs)
def assess_already_landed_classification_report(report_text, **kwargs):
"""#295: already-landed PRs are reconciliation-only, not review eligible."""
from reviewer_already_landed_classification import (
assess_already_landed_classification_report as _assess,
)
return _assess(report_text, **kwargs)
def assess_prior_blocker_skip_proof(report_text, **kwargs):
"""#318: require live blocker proof before skipping earlier open PRs."""
from reviewer_blocker_skip import assess_prior_blocker_skip_proof as _assess
@@ -5334,6 +5554,13 @@ def assess_validation_worktree_edit_report(report_text, **kwargs):
return _assess(report_text, **kwargs)
def assess_reconcile_inventory_report(report_text, **kwargs):
"""#308: prove PR inventory pagination in reconciliation reports."""
from reviewer_reconcile_inventory import assess_reconcile_inventory_report as _assess
return _assess(report_text, **kwargs)
def assess_reconcile_linked_issue_report(report_text, **kwargs):
"""#300: prove linked issue status was fetched live in reconciliation handoffs."""
from reviewer_reconcile_linked_issue import assess_reconcile_linked_issue_report as _assess
@@ -5374,3 +5601,10 @@ def assess_worktree_ownership_report(report_text, **kwargs):
from reviewer_worktree_ownership import assess_worktree_ownership_report as _assess
return _assess(report_text, **kwargs)
def assess_proof_backed_handoff_report(report_text, **kwargs):
"""#395: proof-sensitive review handoff claims require explicit evidence."""
from reviewer_proof_backed_handoff import assess_proof_backed_handoff_report as _assess
return _assess(report_text, **kwargs)
+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"
),
}
+382
View File
@@ -0,0 +1,382 @@
"""Per-PR reviewer leases for safe parallel review sessions (#407)."""
from __future__ import annotations
import os
import re
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
MARKER = "<!-- mcp-review-lease:v1 -->"
_FIELD_RE = re.compile(
r"^\s*([a-z_]+)\s*:\s*(.+?)\s*$",
re.IGNORECASE | re.MULTILINE,
)
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
_TERMINAL_PHASES = frozenset({"done", "released", "blocked"})
_ACTIVE_PHASES = frozenset({
"claimed",
"validating",
"approved",
"request-changes",
"merging",
})
DEFAULT_LEASE_TTL_MINUTES = 120
STALE_WARNING_MINUTES = 30
RECLAIMABLE_MINUTES = 60
_SESSION_LEASE: dict[str, Any] | None = None
def _parse_timestamp(value: str | None) -> datetime | None:
if not value:
return None
text = value.strip()
if text.endswith("Z"):
text = text[:-1] + "+00:00"
try:
parsed = datetime.fromisoformat(text)
except ValueError:
return None
if parsed.tzinfo is None:
return parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
def _normalize_sha(value: str | None) -> str | None:
text = (value or "").strip().lower()
return text if text and _FULL_SHA.match(text) else None
def _parse_pr_ref(value: str | None) -> int | None:
digits = re.sub(r"[^\d]", "", value or "")
return int(digits) if digits.isdigit() else None
def new_session_id() -> str:
return f"{os.getpid()}-{uuid.uuid4().hex[:12]}"
def format_lease_body(
*,
repo: str,
pr_number: int,
issue_number: int | None,
reviewer_identity: str,
profile: str,
session_id: str,
worktree: str,
phase: str,
candidate_head: str | None,
target_branch: str,
target_branch_sha: str | None,
last_activity: datetime | None = None,
expires_at: datetime | None = None,
blocker: str = "none",
) -> str:
now = last_activity or datetime.now(timezone.utc)
expires = expires_at or (now + timedelta(minutes=DEFAULT_LEASE_TTL_MINUTES))
last_text = now.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
"+00:00", "Z"
)
expires_text = expires.astimezone(timezone.utc).replace(microsecond=0).isoformat().replace(
"+00:00", "Z"
)
issue_text = f"#{issue_number}" if issue_number else "none"
lines = [
MARKER,
f"repo: {repo}",
f"pr: #{pr_number}",
f"issue: {issue_text}",
f"reviewer_identity: {reviewer_identity}",
f"profile: {profile}",
f"session_id: {session_id}",
f"worktree: {worktree}",
f"phase: {phase}",
f"candidate_head: {candidate_head or 'none'}",
f"target_branch: {target_branch}",
f"target_branch_sha: {target_branch_sha or 'none'}",
f"last_activity: {last_text}",
f"expires_at: {expires_text}",
f"blocker: {blocker}",
]
return "\n".join(lines)
def parse_lease_comment(body: str) -> dict[str, Any] | None:
text = body or ""
if MARKER not in text:
return None
fields: dict[str, str] = {}
for match in _FIELD_RE.finditer(text):
fields[match.group(1).strip().lower()] = match.group(2).strip()
if not fields:
return None
return {
"repo": fields.get("repo"),
"pr_number": _parse_pr_ref(fields.get("pr")),
"issue_number": _parse_pr_ref(fields.get("issue")),
"reviewer_identity": fields.get("reviewer_identity"),
"profile": fields.get("profile"),
"session_id": fields.get("session_id"),
"worktree": fields.get("worktree"),
"phase": (fields.get("phase") or "").strip().lower() or None,
"candidate_head": _normalize_sha(fields.get("candidate_head")),
"target_branch": fields.get("target_branch"),
"target_branch_sha": _normalize_sha(fields.get("target_branch_sha")),
"last_activity": fields.get("last_activity"),
"expires_at": fields.get("expires_at"),
"blocker": fields.get("blocker"),
"raw_fields": fields,
}
def _lease_entries(comments: list[dict], *, pr_number: int) -> list[dict]:
entries: list[dict] = []
for comment in comments or []:
parsed = parse_lease_comment(comment.get("body") or "")
if not parsed:
continue
if parsed.get("pr_number") not in (None, pr_number):
continue
entries.append({
**parsed,
"comment_id": comment.get("id"),
"author": (comment.get("user") or {}).get("login") or comment.get("author"),
"created_at": comment.get("created_at"),
"updated_at": comment.get("updated_at"),
})
return entries
def _lease_expired(lease: dict, *, now: datetime) -> bool:
expires_at = _parse_timestamp(lease.get("expires_at"))
return bool(expires_at and expires_at <= now)
def _minutes_since_activity(lease: dict, *, now: datetime) -> float | None:
last = _parse_timestamp(lease.get("last_activity"))
if not last:
return None
return (now - last).total_seconds() / 60.0
def classify_lease_freshness(lease: dict, *, now: datetime | None = None) -> str:
"""Return active, stale_warning, reclaimable, expired, or terminal."""
now = now or datetime.now(timezone.utc)
phase = (lease.get("phase") or "").strip().lower()
if phase in _TERMINAL_PHASES:
return "terminal"
if _lease_expired(lease, now=now):
return "expired"
minutes = _minutes_since_activity(lease, now=now)
if minutes is None:
return "active"
if minutes >= RECLAIMABLE_MINUTES:
return "reclaimable"
if minutes >= STALE_WARNING_MINUTES:
return "stale_warning"
return "active"
def find_active_reviewer_lease(
comments: list[dict],
*,
pr_number: int,
now: datetime | None = None,
) -> dict[str, Any] | None:
"""Newest non-terminal, unexpired lease for *pr_number*."""
now = now or datetime.now(timezone.utc)
for lease in reversed(_lease_entries(comments, pr_number=pr_number)):
phase = (lease.get("phase") or "").strip().lower()
if phase in _TERMINAL_PHASES:
continue
if _lease_expired(lease, now=now):
continue
if phase in _ACTIVE_PHASES or phase:
lease = dict(lease)
lease["freshness"] = classify_lease_freshness(lease, now=now)
return lease
return None
def assess_acquire_lease(
comments: list[dict],
*,
pr_number: int,
reviewer_identity: str,
profile: str,
session_id: str,
repo: str,
issue_number: int | None,
worktree: str,
candidate_head: str | None,
target_branch: str,
target_branch_sha: str | None,
now: datetime | None = None,
) -> dict[str, Any]:
"""Fail closed when another session holds an active lease."""
now = now or datetime.now(timezone.utc)
reasons: list[str] = []
existing = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
if existing:
owner_session = (existing.get("session_id") or "").strip()
freshness = existing.get("freshness") or classify_lease_freshness(existing, now=now)
if owner_session and owner_session != session_id and freshness in {
"active", "stale_warning"
}:
reasons.append(
f"PR #{pr_number} already has active reviewer lease "
f"(session_id={owner_session}, phase={existing.get('phase')})"
)
elif owner_session and owner_session != session_id and freshness == "reclaimable":
reasons.append(
f"PR #{pr_number} lease is reclaimable but still held by "
f"session_id={owner_session}; explicit reclaim not implemented "
"(fail closed)"
)
if not (reviewer_identity or "").strip():
reasons.append("reviewer identity required for lease acquisition")
if not (session_id or "").strip():
reasons.append("session_id required for lease acquisition")
if not (worktree or "").strip():
reasons.append("worktree path required for lease acquisition")
allowed = not reasons
body = None
if allowed:
body = format_lease_body(
repo=repo,
pr_number=pr_number,
issue_number=issue_number,
reviewer_identity=reviewer_identity,
profile=profile,
session_id=session_id,
worktree=worktree,
phase="claimed",
candidate_head=candidate_head,
target_branch=target_branch,
target_branch_sha=target_branch_sha,
last_activity=now,
)
return {
"acquire_allowed": allowed,
"reasons": reasons,
"existing_lease": existing,
"lease_body": body,
"session_id": session_id,
}
def record_session_lease(lease: dict[str, Any]) -> dict[str, Any]:
global _SESSION_LEASE
_SESSION_LEASE = dict(lease)
return dict(_SESSION_LEASE)
def clear_session_lease() -> None:
global _SESSION_LEASE
_SESSION_LEASE = None
def get_session_lease() -> dict[str, Any] | None:
return dict(_SESSION_LEASE) if _SESSION_LEASE else None
def assess_mutation_lease_gate(
*,
pr_number: int,
comments: list[dict],
reviewer_identity: str,
session_id: str | None,
mutation: str,
live_head_sha: str | None,
pinned_head_sha: str | None,
now: datetime | None = None,
) -> dict[str, Any]:
"""Reviewer mutations require an owned, current PR lease."""
now = now or datetime.now(timezone.utc)
reasons: list[str] = []
session = get_session_lease()
active = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
if not session:
reasons.append(
f"no in-session reviewer lease recorded; acquire via "
f"gitea_acquire_reviewer_pr_lease before {mutation}"
)
elif session.get("pr_number") != pr_number:
reasons.append(
f"session lease is for PR #{session.get('pr_number')}, not #{pr_number}"
)
elif (session.get("session_id") or "") != (session_id or session.get("session_id")):
reasons.append("session lease session_id mismatch (fail closed)")
if active:
owner = (active.get("session_id") or "").strip()
if owner and session_id and owner != session_id:
reasons.append(
f"active PR lease owned by session_id={owner}; current session "
f"cannot {mutation}"
)
pinned = _normalize_sha(pinned_head_sha)
live = _normalize_sha(live_head_sha)
lease_head = active.get("candidate_head")
if pinned and live and pinned != live:
reasons.append(
"PR head changed during lease; stop and re-validate before "
f"reviewer {mutation}"
)
if lease_head and live and lease_head != live:
reasons.append(
"live PR head differs from lease candidate_head; refresh lease "
f"before {mutation}"
)
freshness = active.get("freshness") or classify_lease_freshness(active, now=now)
if freshness in {"expired", "reclaimable"}:
reasons.append(f"reviewer lease freshness is '{freshness}' (fail closed)")
else:
reasons.append(f"no active reviewer lease found on PR #{pr_number}")
allowed = not reasons
return {
"mutation_allowed": allowed,
"block": not allowed,
"reasons": reasons,
"active_lease": active,
"session_lease": session,
}
def assess_lease_inventory(
comments_by_pr: dict[int, list[dict]],
*,
now: datetime | None = None,
) -> dict[str, Any]:
"""Summarize lease states across PR comment threads."""
now = now or datetime.now(timezone.utc)
active: list[dict] = []
stale: list[dict] = []
reclaimable: list[dict] = []
for pr_number, comments in (comments_by_pr or {}).items():
lease = find_active_reviewer_lease(comments, pr_number=pr_number, now=now)
if not lease:
continue
freshness = lease.get("freshness") or classify_lease_freshness(lease, now=now)
entry = {"pr_number": pr_number, "session_id": lease.get("session_id"), "freshness": freshness}
if freshness == "stale_warning":
stale.append(entry)
elif freshness == "reclaimable":
reclaimable.append(entry)
else:
active.append(entry)
return {
"active_review_leases": active,
"stale_review_leases": stale,
"reclaimable_review_leases": reclaimable,
}
+217
View File
@@ -0,0 +1,217 @@
"""Proof-backed reviewer handoff claim verifier (#395)."""
from __future__ import annotations
import re
from typing import Any
_PAGINATION_FINALITY = re.compile(
r"has_more\s*:\s*false|is_final_page\s*:\s*true|"
r"inventory_complete\s*:\s*true|pages_fetched|total_count\s*:",
re.I,
)
_PAGE_SIZE_ONLY = re.compile(
r"(?:less|fewer) than (?:the )?(?:default )?page[- ]?size|"
r"under (?:the )?50[- ]?(?:item )?limit|"
r"returned \d+ (?:open )?prs?.*less than 50",
re.I,
)
_INVENTORY_COMPLETE_CLAIM = re.compile(
r"\b(?:inventory (?:is )?complete|inventory exhaustive|"
r"complete (?:pr )?inventory)\b",
re.I,
)
_SKIP_CLAIM = re.compile(
r"\b(?:earlier prs? skipped|skipped (?:earlier )?pr|"
r"skip(?:ped)? pr #?\d+|non-mergeable|prior request.changes)\b",
re.I,
)
_CONFLICT_PROOF = re.compile(
r"merge simulation|git merge --no-commit|conflicting files|"
r"conflict proof|merge_exit|non-mergeable",
re.I,
)
_BASELINE_CLAIM = re.compile(
r"\b(?:baseline (?:validation|worktree|comparison)|"
r"same as master|pre-existing (?:on )?master|"
r"failure signatures match)\b",
re.I,
)
_BASELINE_PATH = re.compile(r"baseline worktree path\s*:\s*(\S+)", re.I)
_BASELINE_SHA = re.compile(r"baseline (?:target )?sha\s*:\s*([0-9a-f]{7,40})", re.I)
_BASELINE_DIRTY_BEFORE = re.compile(
r"baseline.*dirty before|dirty before.*baseline", re.I
)
_BASELINE_DIRTY_AFTER = re.compile(
r"baseline.*dirty after|dirty after.*baseline", re.I
)
_BASELINE_COMMAND = re.compile(
r"baseline.*(?:validation )?command|pytest.*baseline", re.I
)
_BASELINE_RESULT = re.compile(
r"baseline.*(?:validation )?result|baseline_exit|baseline failures", re.I
)
_MASTER_INTEGRATION = re.compile(
r"\b(?:merged master into|merge(?:d)? (?:remote[- ]tracking )?branch.*master|"
r"master integration|integrated master|rebase.*master)\b",
re.I,
)
_CLEANUP_CLAIM = re.compile(
r"\b(?:worktree(?:s)? (?:were )?cleaned|cleanup (?:result|mutations)|"
r"removed (?:session[- ]owned )?worktree|git worktree remove)\b",
re.I,
)
_WORKTREE_LIST = re.compile(r"git worktree list|worktree list proof", re.I)
_PROOF_SOURCE = re.compile(
r"proof source\s*:\s*(command|mcp metadata|prior blocker|not checked)",
re.I,
)
_HANDOFF_SECTION = re.compile(r"^##\s*Controller Handoff\s*$", re.I | re.M)
def _handoff_fields(report_text: str) -> dict[str, str]:
text = report_text or ""
match = _HANDOFF_SECTION.search(text)
if not match:
return {}
fields: dict[str, str] = {}
for line in text[match.end() :].splitlines():
stripped = line.strip().lstrip("-*").strip()
if ":" not in stripped:
continue
key, value = stripped.split(":", 1)
fields[key.strip().lower()] = value.strip()
return fields
def _command_text(entry: Any) -> str:
if isinstance(entry, dict):
return str(entry.get("command") or entry.get("tool") or "").strip()
return str(entry or "").strip()
def _action_log_has(action_log: list | None, pattern: re.Pattern[str]) -> bool:
for entry in action_log or []:
blob = " ".join(
filter(
None,
[
_command_text(entry),
str(entry.get("result") or "") if isinstance(entry, dict) else "",
str(entry.get("reason") or "") if isinstance(entry, dict) else "",
],
)
)
if pattern.search(blob):
return True
return False
def assess_proof_backed_handoff_report(
report_text: str,
*,
action_log: list | None = None,
inventory_session: dict | None = None,
baseline_session: dict | None = None,
skip_session: list[dict] | None = None,
) -> dict[str, Any]:
"""Require explicit command/tool evidence for proof-sensitive review claims (#395)."""
text = report_text or ""
fields = _handoff_fields(text)
reasons: list[str] = []
inventory = dict(inventory_session or {})
baseline = dict(baseline_session or {})
skips = list(skip_session or [])
pagination_field = fields.get("inventory pagination proof", "")
if _INVENTORY_COMPLETE_CLAIM.search(text) or _PAGE_SIZE_ONLY.search(text):
has_meta = bool(
inventory.get("inventory_complete")
or inventory.get("pagination_complete")
or _PAGINATION_FINALITY.search(pagination_field)
or _PAGINATION_FINALITY.search(text)
or _action_log_has(action_log, re.compile(r"gitea_list_prs", re.I))
)
if _PAGE_SIZE_ONLY.search(text) and not has_meta:
reasons.append(
"inventory completeness claimed from page-size assumption only; "
"require has_more=false, is_final_page=true, or inventory_complete=true"
)
elif _INVENTORY_COMPLETE_CLAIM.search(text) and not has_meta:
reasons.append(
"inventory complete claim lacks explicit pagination metadata proof"
)
if _SKIP_CLAIM.search(text) or fields.get("earlier prs skipped", "").lower() not in {
"",
"none",
"n/a",
}:
skip_proof = bool(
skips
or _CONFLICT_PROOF.search(text)
or _action_log_has(
action_log, re.compile(r"git merge --no-commit|gitea_get_pr_review_feedback", re.I)
)
)
if not skip_proof:
reasons.append(
"earlier PR skip claim lacks command/tool conflict or blocker proof"
)
if _BASELINE_CLAIM.search(text) or fields.get("baseline worktree used", "").lower() == "true":
baseline_ok = bool(
baseline.get("complete")
or (
_BASELINE_PATH.search(text)
and _BASELINE_SHA.search(text)
and (_BASELINE_DIRTY_BEFORE.search(text) or baseline.get("clean_before"))
and (_BASELINE_COMMAND.search(text) or baseline.get("command"))
and (_BASELINE_RESULT.search(text) or baseline.get("result"))
and (_BASELINE_DIRTY_AFTER.search(text) or baseline.get("clean_after"))
)
)
if not baseline_ok:
reasons.append(
"baseline validation claim missing worktree path, target SHA, "
"dirty-before/after status, command, or result proof"
)
if _MASTER_INTEGRATION.search(text):
if not _action_log_has(
action_log,
re.compile(r"git merge.*master|git rebase.*master", re.I),
) and not re.search(r"merge_exit\s*=\s*\d+|merge simulation", text, re.I):
reasons.append(
"master integration claim lacks exact merge/rebase command and result"
)
if _CLEANUP_CLAIM.search(text) or fields.get("cleanup mutations", "").lower() not in {
"",
"none",
"n/a",
}:
if not (_WORKTREE_LIST.search(text) or _action_log_has(action_log, _WORKTREE_LIST)):
reasons.append(
"cleanup claim lacks final git worktree list or equivalent proof"
)
if re.search(r"\blive proof\b", text, re.I) and not _PROOF_SOURCE.search(text):
if not action_log:
reasons.append(
"live proof wording requires proof source classification "
"(command, MCP metadata, prior blocker, or not checked)"
)
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": reasons,
"safe_next_action": (
"cite explicit command/tool evidence or structured MCP pagination metadata "
"for each proof-sensitive claim"
if not proven
else "proceed"
),
}
+162
View File
@@ -0,0 +1,162 @@
"""Reconciliation PR inventory pagination proof verifier (#308)."""
from __future__ import annotations
import re
from typing import Any
_COMPLETE_SCAN_CLAIM_RE = re.compile(
r"\b(?:all already[- ]landed(?:\s+prs?)?(?:\s+were)?\s+found|"
r"complete queue scan|all open prs? (?:were )?(?:found|checked|inventoried|listed)|"
r"inventory (?:is )?complete|exhaustive (?:pr )?inventory|"
r"no additional already[- ]landed|scanned (?:the )?(?:full )?open pr queue)\b",
re.I,
)
_FINALITY_RE = re.compile(
r"is_final_page\s*:\s*true|has_more\s*:\s*false|no next page|final[- ]page|"
r"pagination_complete\s*:\s*true|inventory pagination proof\s*:\s*(?!assumed|none\b).+",
re.I,
)
_REQUESTED_PAGE_SIZE_RE = re.compile(
r"requested page size\s*:\s*(\d+)|page[- ]?size\s*(?:=|:)\s*(\d+)",
re.I,
)
_PAGES_FETCHED_RE = re.compile(
r"pages? fetched\s*:\s*(\d+)|page count\s*:\s*(\d+)",
re.I,
)
_RETURNED_PER_PAGE_RE = re.compile(
r"returned pr count(?: per page)?\s*:|open pr count per page|"
r"returned \d+ open prs? per page|per[- ]page counts?\s*:",
re.I,
)
_EXACT_LIMIT_RETURN_RE = re.compile(
r"returned\s+(\d+)\s+open prs?|listed\s+(\d+)\s+open prs?",
re.I,
)
_DEFAULT_PAGE_SIZE_ASSUMPTION = re.compile(
r"(?:less|fewer) than (?:the )?(?:default )?(?:gitea )?page[- ]?(?:size|limit)|"
r"default (?:gitea )?page[- ]?size|assumed complete",
re.I,
)
def _int_from_groups(match: re.Match[str] | None) -> int | None:
if not match:
return None
for group in match.groups():
if group:
return int(group)
return None
def _pagination_proven(text: str, session: dict) -> bool:
if session.get("pagination_complete") or session.get("inventory_complete"):
return True
if _FINALITY_RE.search(text):
return True
pages = session.get("pages_fetched")
if isinstance(pages, int) and pages >= 1 and session.get("is_final_page"):
return True
return False
def _metadata_present(text: str, session: dict) -> list[str]:
missing: list[str] = []
has_page_size = bool(
_REQUESTED_PAGE_SIZE_RE.search(text)
or session.get("requested_page_size")
)
has_pages_fetched = bool(
_PAGES_FETCHED_RE.search(text) or session.get("pages_fetched")
)
has_returned_counts = bool(
_RETURNED_PER_PAGE_RE.search(text) or session.get("returned_per_page")
)
if not has_page_size:
missing.append("requested page size")
if not has_pages_fetched:
missing.append("page count fetched")
if not has_returned_counts:
missing.append("returned PR count per page")
if not _pagination_proven(text, session):
missing.append("final-page/no-next-page proof")
return missing
def assess_reconcile_inventory_report(
report_text: str,
*,
inventory_session: dict | None = None,
) -> dict[str, Any]:
"""Validate PR inventory pagination proof in reconciliation reports (#308)."""
text = report_text or ""
session = dict(inventory_session or {})
reasons: list[str] = []
claims_scan = bool(_COMPLETE_SCAN_CLAIM_RE.search(text))
lists_open_prs = bool(
re.search(r"open prs? listed|listed open prs?|pr inventory", text, re.I)
)
if not claims_scan and not lists_open_prs and not session.get("inventory_required"):
return {
"proven": True,
"block": False,
"reasons": [],
"inventory_claimed": False,
"safe_next_action": "proceed",
}
if _DEFAULT_PAGE_SIZE_ASSUMPTION.search(text) and not _pagination_proven(text, session):
reasons.append(
"reconciliation inventory assumed complete from page-size guess "
"without final-page proof (#308)"
)
if claims_scan and not _pagination_proven(text, session):
reasons.append(
"complete queue scan or all already-landed claim requires "
"pagination finality proof (#308)"
)
missing = _metadata_present(text, session)
if (claims_scan or lists_open_prs) and missing:
reasons.append(
"reconciliation inventory missing pagination metadata: "
+ ", ".join(missing)
)
requested = session.get("requested_page_size")
returned = session.get("returned_page_size")
if isinstance(requested, int) and isinstance(returned, int):
if returned >= requested > 0 and not _pagination_proven(text, session):
reasons.append(
"exactly-full first reconciliation page without final-page proof (#308)"
)
else:
for match in _EXACT_LIMIT_RETURN_RE.finditer(text):
count = _int_from_groups(match)
if count in {10, 20, 50} and not _pagination_proven(text, session):
reasons.append(
"exactly-full first reconciliation page without final-page proof (#308)"
)
break
proven = not reasons
return {
"proven": proven,
"block": not proven,
"reasons": list(dict.fromkeys(reasons)),
"inventory_claimed": claims_scan or lists_open_prs,
"pagination_proven": _pagination_proven(text, session),
"safe_next_action": (
"include requested page size, pages fetched, per-page counts, and "
"final-page/no-next-page proof before claiming complete scan"
if reasons
else "proceed"
),
}
+233
View File
@@ -0,0 +1,233 @@
"""Explicit worktree and cwd proof for PR review validation (#398)."""
from __future__ import annotations
import re
from typing import Any
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
_PWD_RE = re.compile(
r"(?:^|\n)\s*(?:pwd|working\s+directory|cwd)\s*:\s*(\S+)",
re.IGNORECASE,
)
_HEAD_RE = re.compile(
r"(?:git\s+rev-parse\s+head|observed\s+head\s+sha)\s*:\s*([0-9a-f]{7,40})",
re.IGNORECASE | re.MULTILINE,
)
_EXPECTED_HEAD_RE = re.compile(
r"(?:expected\s+(?:pr\s+)?head\s+sha|candidate\s+head\s+sha|pinned\s+head)\s*:\s*([0-9a-f]{7,40})",
re.IGNORECASE,
)
_STATUS_RE = re.compile(
r"git\s+status\s+(?:--short\s+--branch|--short|-sb)\s*:\s*(.+)",
re.IGNORECASE,
)
_VALIDATION_CMD_RE = re.compile(
r"validation\s+command\s*:\s*(.+)",
re.IGNORECASE,
)
_GIT_C_CMD_RE = re.compile(r"git\s+-C\s+\S+", re.IGNORECASE)
_CD_CMD_RE = re.compile(r"(?:^|&&\s*)cd\s+\S+", re.IGNORECASE)
_BASELINE_CWD_RE = re.compile(
r"baseline\s+(?:worktree|working\s+directory|cwd)\s*:\s*(\S+)",
re.IGNORECASE,
)
_BASELINE_SHA_RE = re.compile(
r"baseline\s+(?:target\s+)?sha\s*:\s*([0-9a-f]{7,40})",
re.IGNORECASE,
)
_BASELINE_CMD_RE = re.compile(
r"baseline\s+validation\s+command\s*:\s*(.+)",
re.IGNORECASE,
)
def _normalize_path(path: str) -> str:
return (path or "").replace("\\", "/").rstrip("/")
def _path_under_branches(path: str, project_root: str | None = None) -> bool:
normalized = _normalize_path(path)
if not normalized:
return False
if "/branches/" in f"{normalized}/":
return True
if normalized.endswith("/branches"):
return True
if project_root:
root = _normalize_path(project_root)
if normalized.startswith(f"{root}/"):
rel = normalized[len(root) + 1 :]
return rel == "branches" or rel.startswith("branches/")
return False
def _expand_sha(sha: str) -> str:
return (sha or "").strip().lower()
def _sha_matches(expected: str, observed: str) -> bool:
exp = _expand_sha(expected)
obs = _expand_sha(observed)
if not exp or not obs:
return False
if len(exp) == 40 and len(obs) == 40:
return exp == obs
return obs.startswith(exp) or exp.startswith(obs)
def _command_has_explicit_cwd(command: str, cwd: str) -> bool:
text = (command or "").strip()
if not text:
return False
if _GIT_C_CMD_RE.search(text):
return True
if _CD_CMD_RE.search(text):
return True
if cwd and cwd in text:
return True
return False
def assess_validation_cwd_proof_report(
report_text: str,
*,
validation_session: dict | None = None,
project_root: str | None = None,
) -> dict[str, Any]:
"""Require cwd/HEAD proof before reviewer validation claims (#398)."""
text = report_text or ""
session = dict(validation_session or {})
reasons: list[str] = []
violations: list[str] = []
claims_validation = bool(
session.get("validation_ran")
or _VALIDATION_CMD_RE.search(text)
or session.get("command")
)
if not claims_validation:
return {
"proven": True,
"block": False,
"claims_validation": False,
"reasons": [],
"violations": [],
"safe_next_action": "proceed",
}
expected_head = (
session.get("expected_head_sha")
or session.get("candidate_head_sha")
or ""
).strip()
if not expected_head:
match = _EXPECTED_HEAD_RE.search(text)
expected_head = (match.group(1) if match else "").strip()
observed_head = (session.get("observed_head_sha") or "").strip()
if not observed_head:
match = _HEAD_RE.search(text)
observed_head = (match.group(1) if match else "").strip()
cwd = (
session.get("working_directory")
or session.get("cwd")
or session.get("pwd")
or ""
).strip()
if not cwd:
match = _PWD_RE.search(text)
cwd = (match.group(1) if match else "").strip().rstrip(",.;")
command = (session.get("command") or "").strip()
if not command:
match = _VALIDATION_CMD_RE.search(text)
command = (match.group(1) if match else "").strip().rstrip(".;")
if not cwd:
reasons.append(
"validation claimed without pwd/working-directory proof (#398)"
)
elif not _path_under_branches(cwd, project_root):
violations.append(
f"validation cwd {cwd!r} is not under branches/ (#398)"
)
reasons.append(
"reviewer validation must run from a branches/ worktree, "
"not the main checkout (#398)"
)
if not observed_head:
reasons.append(
"validation claimed without git rev-parse HEAD / observed HEAD SHA "
"proof (#398)"
)
elif expected_head and not _sha_matches(expected_head, observed_head):
violations.append(
f"observed HEAD {observed_head} does not match expected "
f"PR head {expected_head} (#398)"
)
reasons.append("validation HEAD SHA must match pinned PR head (#398)")
if not _STATUS_RE.search(text) and session.get("git_status") is None:
reasons.append(
"validation claimed without git status --short --branch proof (#398)"
)
if command and cwd and not _command_has_explicit_cwd(command, cwd):
if session.get("tool_working_directory") is not True:
reasons.append(
"validation command must use git -C <worktree>, "
"cd <worktree> && ..., or tool-provided cwd metadata (#398)"
)
baseline_ran = bool(
session.get("baseline_validation_ran")
or _BASELINE_CMD_RE.search(text)
)
if baseline_ran:
baseline_cwd = (session.get("baseline_worktree_path") or "").strip()
if not baseline_cwd:
match = _BASELINE_CWD_RE.search(text)
baseline_cwd = (match.group(1) if match else "").strip().rstrip(",.;")
if not baseline_cwd or not _path_under_branches(baseline_cwd, project_root):
reasons.append(
"baseline validation claimed without baseline worktree cwd "
"under branches/ (#398)"
)
baseline_sha = (session.get("baseline_target_sha") or "").strip()
if not baseline_sha:
match = _BASELINE_SHA_RE.search(text)
baseline_sha = (match.group(1) if match else "").strip()
if not baseline_sha:
reasons.append(
"baseline validation claimed without baseline target SHA (#398)"
)
baseline_cmd = (session.get("baseline_command") or "").strip()
if not baseline_cmd:
match = _BASELINE_CMD_RE.search(text)
baseline_cmd = (match.group(1) if match else "").strip()
if not baseline_cmd:
reasons.append(
"baseline validation claimed without exact baseline command (#398)"
)
proven = not reasons and not violations
return {
"proven": proven,
"block": bool(violations) or not proven,
"claims_validation": True,
"expected_head_sha": expected_head or None,
"observed_head_sha": observed_head or None,
"working_directory": cwd or None,
"reasons": reasons,
"violations": violations,
"safe_next_action": (
"before validation record pwd, git rev-parse HEAD, git status, "
"expected PR head SHA; run commands with git -C or cd in the same line"
if not proven
else "proceed"
),
}
+198
View File
@@ -0,0 +1,198 @@
"""Transient validation failure history verifier (#396).
Reviewer sessions may observe validation failures that later pass on rerun.
Final reports must document every failure observed during the session, not
only the last passing result.
"""
from __future__ import annotations
import re
from typing import Any
_SECTION_RE = re.compile(
r"validation failure history",
re.IGNORECASE,
)
_FAILURE_ENTRY_RE = re.compile(
r"(?:failure\s*(?:#|entry)?\s*\d+|validation failure)\s*:",
re.IGNORECASE,
)
_COMMAND_RE = re.compile(
r"(?:command|validation command)\s*:\s*(.+)",
re.IGNORECASE,
)
_FAILING_TEST_RE = re.compile(
r"(?:failing test|failure|error)\s*:\s*(.+)",
re.IGNORECASE,
)
_CAUSE_RE = re.compile(
r"(?:suspected cause|cause)\s*:\s*(.+)",
re.IGNORECASE,
)
_REPRODUCED_RE = re.compile(
r"(?:reproduced|reproduces)\s*:\s*(yes|no|unknown)",
re.IGNORECASE,
)
_BASELINE_RE = re.compile(
r"(?:on baseline master|baseline master|exists on baseline)\s*:\s*(yes|no|unknown|not checked)",
re.IGNORECASE,
)
_PR_CAUSED_RE = re.compile(
r"(?:pr[- ]caused|pr caused)\s*:\s*(yes|no|unknown|not proven)",
re.IGNORECASE,
)
_TRANSIENT_STATUS_RE = re.compile(
r"(?:passed after transient failure investigation|transient failure investigation)",
re.IGNORECASE,
)
_PLAIN_PASS_RE = re.compile(
r"validation\s*:\s*(?:pass|passed|strong|ok|green)\b",
re.IGNORECASE,
)
_ENV_CLEANUP_RE = re.compile(
r"(?:environmental cleanup|state cleaned|what changed between runs)\s*:",
re.IGNORECASE,
)
_UNKNOWN_CAUSE_RE = re.compile(
r"(?:suspected cause|cause)\s*:\s*(?:unknown|unexplained|not determined)",
re.IGNORECASE,
)
def _failure_documented_in_text(text: str, failure: dict[str, Any]) -> bool:
"""Return True when *failure* appears documented in free-form report text."""
command = (failure.get("command") or "").strip()
failing = (failure.get("failing_test") or failure.get("error") or "").strip()
if command and command not in text:
return False
if failing and failing not in text:
return False
return bool(command or failing)
def _section_has_structured_fields(text: str) -> bool:
if not _SECTION_RE.search(text):
return False
section_start = _SECTION_RE.search(text).start()
section = text[section_start:]
has_command = bool(_COMMAND_RE.search(section))
has_failure = bool(_FAILING_TEST_RE.search(section))
return has_command and has_failure
def assess_validation_failure_history_report(
report_text: str,
*,
validation_session: dict | None = None,
) -> dict[str, Any]:
"""Require final reports to account for transient validation failures (#396)."""
text = report_text or ""
session = dict(validation_session or {})
reasons: list[str] = []
violations: list[str] = []
observed = list(session.get("observed_failures") or [])
final_status = (session.get("final_validation_status") or "").strip().lower()
cause_unknown = any(
(f.get("suspected_cause") or "").strip().lower() in {
"unknown", "unexplained", "not determined", ""
}
and not (f.get("pr_caused") or "").strip().lower() in {"yes", "no"}
for f in observed
) or bool(session.get("cause_unknown"))
if not observed:
return {
"proven": True,
"block": False,
"reasons": [],
"violations": [],
"observed_failure_count": 0,
"safe_next_action": "proceed",
}
documented = _section_has_structured_fields(text)
if not documented:
for failure in observed:
if _failure_documented_in_text(text, failure):
documented = True
break
if not documented:
violations.append(
"session observed validation failure(s) but final report omits "
"Validation failure history"
)
reasons.append(
"every validation failure observed during the session must appear "
"in a Validation failure history section"
)
if documented and not _SECTION_RE.search(text):
reasons.append(
"failure details must appear under an explicit "
"'Validation failure history' heading"
)
for idx, failure in enumerate(observed, start=1):
prefix = f"failure #{idx}"
if not _failure_documented_in_text(text, failure) and documented:
reasons.append(
f"{prefix}: command and failing test/error not documented in report"
)
command = (failure.get("command") or "").strip()
failing = (failure.get("failing_test") or failure.get("error") or "").strip()
if not command:
reasons.append(f"{prefix}: missing command in session failure record")
if not failing:
reasons.append(
f"{prefix}: missing failing test or error in session failure record"
)
plain_pass = bool(_PLAIN_PASS_RE.search(text))
transient_wording = bool(_TRANSIENT_STATUS_RE.search(text))
final_passed = final_status in {
"passed",
"pass",
"passed_after_transient_failure_investigation",
}
if (final_passed or plain_pass) and observed:
if cause_unknown and not transient_wording:
violations.append(
"unknown transient failure cause cannot be erased as plain 'passed'"
)
reasons.append(
"when cause is unknown, use status "
"'passed after transient failure investigation'"
)
elif not transient_wording and final_status != "passed_after_transient_failure_investigation":
if not _ENV_CLEANUP_RE.search(text):
reasons.append(
"later passing rerun must document what changed between runs "
"or environmental cleanup performed"
)
if session.get("environmental_contamination") and not _ENV_CLEANUP_RE.search(text):
reasons.append(
"environmental /tmp state contamination must document cleanup or "
"what changed before the passing rerun"
)
proven = not reasons and not violations
return {
"proven": proven,
"block": bool(violations) or not proven,
"reasons": reasons,
"violations": violations,
"observed_failure_count": len(observed),
"documented": documented,
"safe_next_action": (
"add Validation failure history with command, failing test, cause, "
"reproduction, baseline comparison, and PR-caused evidence; use "
"'passed after transient failure investigation' when appropriate"
if not proven
else "proceed"
),
}
+2
View File
@@ -76,6 +76,7 @@ AUTHOR_TASKS = frozenset({
"delete_branch",
"work_issue",
"work-issue",
"reconcile_landed_pr",
})
RECONCILER_TASKS = frozenset({
@@ -104,6 +105,7 @@ TASK_REQUIRED_ROLE = {
"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",
+6
View File
@@ -0,0 +1,6 @@
#!/usr/bin/env bash
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/.." && pwd)"
cd "$repo_root"
exec python3 -m webui "$@"
+11 -5
View File
@@ -38,13 +38,21 @@ fi
branch="$1"
start_ref="${2:-prgs/master}"
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/.." && pwd)"
# Enforce issue-linked, traceable branch names (issue → branch → worktree → PR).
if [[ "$allow_unlinked" -eq 0 ]]; then
if [[ ! -f "/tmp/gitea_issue_lock.json" ]]; then
echo "Error: Issue lock file '/tmp/gitea_issue_lock.json' is missing. You must lock exactly one issue before branch creation (fail closed)." >&2
locked_branch=$(python3 -c "
import sys
sys.path.insert(0, '$repo_root')
import issue_lock_store
print(issue_lock_store.resolve_locked_branch_for_session('$branch'))
")
if [[ -z "$locked_branch" ]]; then
echo "Error: No session issue lock is bound. Call gitea_lock_issue before branch creation (fail closed)." >&2
exit 2
fi
locked_branch=$(python3 -c "import json; print(json.load(open('/tmp/gitea_issue_lock.json')).get('branch_name', ''))")
if [[ "$branch" != "$locked_branch" ]]; then
echo "Error: Requested branch '$branch' does not match locked branch '$locked_branch' (fail closed)." >&2
exit 2
@@ -68,8 +76,6 @@ EOF
fi
fi
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo_root="$(cd "$script_dir/.." && pwd)"
worktree_name="${branch//\//-}"
worktree_path="$repo_root/branches/$worktree_name"
@@ -91,4 +91,23 @@ Verifier: `review_proofs.assess_queue_status_report()`.
Narrative final report and controller handoff must agree on eligibility class,
candidate/reviewed head SHA, mutation state, worktree usage, review decision,
terminal review mutation, merge result, and linked issue status.
terminal review mutation, merge result, and linked issue status.
### Proof-backed claims (#395)
Proof-sensitive claims must cite explicit command/tool evidence in the report
or structured MCP metadata — not narrative alone:
- **Inventory complete:** `has_more=false`, `is_final_page=true`,
`inventory_complete=true`, and/or `total_count` from `gitea_list_prs`.
- **Skipped earlier PRs:** merge-simulation command output, conflict file list,
or live `gitea_get_pr_review_feedback` / mergeability fields.
- **Baseline validation:** baseline worktree path, baseline target SHA,
dirty-before/after, exact command, exact result.
- **Master integration:** exact `git merge` / `git rebase` command and exit status.
- **Cleanup:** final `git worktree list` (or remove commands) proving session
worktrees were removed.
When a claim relies on prior-session blocker state or MCP metadata only, label
the proof source explicitly (`command`, `MCP metadata`, `prior blocker`,
`not checked`). Do not use `live proof` without that classification.
@@ -538,6 +538,58 @@ Official validation integrity status must be one of:
Do not invent a softer status.
## 21A. Validation failure history rule
Every validation failure observed during the review session must appear in the
final report, even if a later rerun passes.
Before claiming `Validation: passed`, list every failed validation command from
the session under `Validation failure history`.
For each failure, report:
* command
* failing test or error
* suspected cause
* whether it reproduced
* whether it exists on baseline master
* evidence for whether it is or is not PR-caused
If a later rerun passes, also report:
* what changed between runs
* whether environmental state was cleaned (for example `/tmp` lock files)
* why the pass is trustworthy
If the cause is unknown, do not erase the earlier failure with plain
`Validation: passed`. Use a status such as
`passed after transient failure investigation`.
`gitea_validate_review_final_report` rejects reports that omit known earlier
validation failures when `validation_session.observed_failures` is supplied.
## 21B. Validation status taxonomy (#406)
When the final report summarizes how validation concluded, use one of these
**validation status** labels (distinct from per-command pass/fail entries):
* `passed` — raw PR-head validation passed on the unmodified head.
* `failed` — raw PR-head validation failed and no allowed resolution path
was proven.
* `baseline-equivalent failure accepted` — only when a clean baseline
worktree under `branches/` proves matching failure signatures on the target
branch (baseline path, target SHA, exact commands, failure lists, and
`failure signatures match: true`).
* `raw-head failure resolved by merge simulation` — raw PR-head validation
failed, but merge simulation into the current target passed cleanly; report
merge simulation under `Worktree/index mutations` with full #317 proof.
* `passed after transient failure investigation` — a later run passed after an
earlier failure in the same session; document the failure history (#396).
Do not use `baseline-equivalent failure accepted` when only merge simulation
resolved the failure. Do not use bare `passed` when raw PR-head validation
failed unless one of the resolution statuses above applies.
## 22. Baseline validation rule
Do not run tests in the main checkout.
@@ -574,6 +626,28 @@ Do not claim “full-suite failures are pre-existing” unless baseline proof is
## 23. Validation command proof rule
Before any diff, test, or compile validation, record in the same command
transcript or final report:
* `pwd` or explicit working directory
* `git rev-parse HEAD`
* `git status --short --branch`
* expected PR head SHA (candidate head SHA)
Validation commands must use one of:
* `git -C <review_worktree> ...`
* `cd <review_worktree> && ...` in the same command
* tool-provided explicit working-directory metadata
Do not rely on inferred shell cwd from a prior command in a different block.
`gitea_validate_review_final_report` rejects validation claims without
cwd/HEAD proof when `validation_session` is supplied.
Baseline validation must document baseline worktree path, baseline target SHA,
cwd proof, exact baseline command, and baseline result using the same rules.
Report the exact validation command as executed.
Report the working directory where validation ran.
@@ -702,6 +776,44 @@ The final report must identify:
* whether same-PR merge continuation was allowed
* whether the run stopped as required
## 26B. Per-PR reviewer lease (#407)
Parallel reviewer sessions are allowed only when each session holds a distinct,
live PR lease.
Before validation or review mutation on a selected PR:
1. Call `gitea_acquire_reviewer_pr_lease` with worktree path, candidate head SHA,
and target branch SHA.
2. Post heartbeats via `gitea_heartbeat_reviewer_pr_lease` before validation,
after validation, before review mutation, and before merge.
3. Do not approve, request changes, or merge unless the in-session lease
matches the selected PR.
If PR head or target branch advances during the lease, stop and refresh
inventory before continuing.
Final reports must include lease session id, acquisition proof, heartbeat
status, and release/blocked status.
## 26C. Conflict-fix lease and stale-head protection (#399)
Before validating, approving, or merging a PR:
1. Check for an active conflict-fix lease on the PR; stop if one is active.
2. Pin `expected_head_sha` before validation and pass it to
`gitea_mark_final_review_decision`, `gitea_submit_pr_review`, and
`gitea_merge_pr`.
3. Re-fetch live PR head immediately before approval and merge; refuse when
live head differs from the reviewed SHA.
Final reports must state:
* reviewed head SHA
* final live head SHA before approval
* final live head SHA before merge
* whether any push occurred during validation
## 27. Merge rules
Before merge, rerun fresh live checks:
@@ -712,6 +824,7 @@ Before merge, rerun fresh live checks:
* author safety
* PR re-fetch
* reviewed head SHA unchanged
* visible APPROVED review applies to the **current live PR head SHA** (`approval_at_current_head`); if the head moved after approval, re-review at the new head before merge (#471)
* target branch freshly fetched
* PR still open
* PR still mergeable
@@ -728,6 +841,7 @@ Do not merge if:
* capability state is stale
* worktree is dirty
* PR head changed
* approval is stale (approved SHA ≠ current live head SHA)
* validation failed
* inventory was incomplete
* PR is already landed
@@ -1096,6 +1210,8 @@ Controller Handoff:
* Baseline worktree path:
* Files reviewed:
* Validation:
* Validation failure history:
* Validation cwd/HEAD proof:
* Official validation integrity status:
* Terminal review mutation:
* Review decision:
@@ -83,6 +83,20 @@ Examples:
If capability cannot be proven, stop and produce a recovery handoff only.
### 2A. Pre-task role routing (#139)
Before issue selection or any mutation, resolve the composite author task:
* `gitea_route_task_session(task_type="work-issue", remote=…)` — must return
`route_result: allowed_current_session` and `downstream_allowed: true`
* `gitea_resolve_task_capability(task="work_issue", remote=…)` — must show
`required_role_kind: author` and `allowed_in_current_session: true`
Hyphen (`work-issue`) and underscore (`work_issue`) aliases are equivalent.
If routing returns `ambiguous_task_stop`, `wrong_role_stop`, or
`route_to_author_session`, stop and produce a recovery handoff only — do not
fall back to reviewer tools or guess a different profile.
## 3. Stop immediately on blocked infrastructure
If any of the following appears, stop immediately:
@@ -130,6 +144,14 @@ If the main checkout is dirty before selection, stop and produce a recovery hand
If the main checkout becomes dirty during the run, stop and produce a recovery handoff unless the change is explicitly allowed by the canonical workflow.
### Stacked PRs (explicit exception, #484)
Normal author work stays base-equivalent to `master`/`main`/`dev`. A **stacked PR** — deliberately based on another unmerged PR's branch — is the only sanctioned non-master base, and only when the operator/controller explicitly chooses it:
- Branch the `branches/` worktree from the dependency's branch, then lock with `gitea_lock_issue(..., stacked_base_branch=<dep-branch>, stacked_base_pr=<open-PR#>)`. The lock fails closed unless that open PR owns the branch; arbitrary or stale branches are rejected.
- Open the PR with `gitea_create_pr(base=<dep-branch>)`. The body must state: `Stacked on PR #<X> / issue #<Y>`, `Base branch: <dep-branch>`, `Head branch: <this-branch>`, `Do not merge before PR #<X>`, and note retarget/rebase to `master` after the dependency lands if required.
- This does not relax the main-checkout rule or bypass the issue lock — work still happens under `branches/`, and the approved base is recorded on the lock.
## 5. No raw MCP repair during normal issue work
Do not run `pkill`, kill MCP processes, edit MCP config, restart servers, or perform control-checkout repair during normal issue work.
@@ -283,6 +305,36 @@ Report:
Do not create another branch/PR for the same issue unless the project explicitly allows taking over or updating existing work and exact capability is proven.
### 10A. Duplicate-work gate phases (#400)
Before any file edits, prove duplicate-work clearance with
`gitea_assess_work_issue_duplicate` or `gitea_lock_issue` (which runs the same
gate). The gate checks live:
* open PRs linked to the issue (head branch or Closes/Fixes reference),
* remote branches matching `issue-<number>`,
* active claim leases from structured heartbeats.
Re-check immediately before:
* `gitea_commit_files` (commit),
* branch push,
* `gitea_create_pr` (PR creation).
If a concurrent open PR appears after work begins:
* before commit/push → stop and preserve local work without pushing,
* after commit but before push → stop without pushing,
* after push but before PR creation → stop and produce a reconciliation
handoff instead of opening a PR.
Final reports must state exactly one duplicate-work outcome:
* `duplicate PR prevented`
* `duplicate branch prevented`
* `duplicate commit prevented`
* `duplicate work not prevented`
## 11. Claim or lock the issue before implementation
Claim/lock the issue before implementation if the project provides a claim/lock mechanism.
@@ -563,6 +615,29 @@ After push, report:
If push fails, stop and produce a recovery handoff.
## 20A. Conflict-fix lease and push gate (#399)
When pushing to an existing PR branch to resolve merge conflicts:
1. Call `gitea_acquire_conflict_fix_lease` before any push.
2. Call `gitea_assess_conflict_fix_push` immediately before `git push` with:
* branch head before push
* branch head after push (local)
* session worktree path
* push cwd
* whether the push is fast-forward
3. Do not push when a reviewer holds an active lease on the same PR.
4. Do not force-push.
5. Do not push from the main checkout or wrong cwd.
Conflict-fix final reports must state:
* branch head before push
* branch head after push
* active reviewer lease status
* whether push was fast-forward
* whether any reviewer was active
## 21. PR creation rules
Create a PR only if implementation and validation pass, unless project policy explicitly allows draft PRs with documented validation failures.
@@ -703,6 +778,12 @@ Use only precise categories:
* External-state mutations:
* Read-only diagnostics:
Issue-lock file (`/tmp/gitea_issue_lock.json`) read/write/delete is always an
external-state mutation. Never claim `External-state mutations: none` after
seeding, restoring, or removing that file. Manual lock seeding is not a normal
recovery path (#447); use `gitea_lock_issue` or the #442 adoption recovery path
instead. Link broader redesign: #438.
`git fetch`, `git remote update`, and any command that updates refs must be listed under `Git ref mutations`, not read-only diagnostics.
If `git reset --hard`, checkout, clean, worktree add/remove, merge simulation, merge abort, or similar commands occurred, report them under `Worktree/index mutations`.
+211
View File
@@ -0,0 +1,211 @@
"""Stacked-PR support for author issue locks and PR creation (#484).
Normal author work locks a worktree that is base-equivalent to ``master``/
``main``/``dev`` and opens a PR against one of those base branches. A *stacked*
PR is deliberately based on another unmerged PR's branch, so its worktree is not
master-equivalent and its PR base is not a normal base branch.
This module holds the pure decision logic that lets:
* ``gitea_lock_issue`` approve a non-master base **only** when it is explicitly
declared and proven to correspond to an open pull request, and
* ``gitea_create_pr`` accept that approved base while still rejecting arbitrary,
mismatched, or stale (merged/closed) branches.
The normal master-based path is unchanged: when no stacked base is declared, and
when the PR base is a normal base branch, these helpers are inert. Nothing here
bypasses the issue lock a stacked base is recorded *on* the lock and re-checked
at PR time.
"""
from __future__ import annotations
BASE_BRANCHES = frozenset({"master", "main", "dev"})
# Phrases that satisfy the required merge-ordering statement in a stacked PR body.
MERGE_ORDER_PHRASES = ("do not merge before", "do not merge until")
def is_base_branch(base: str | None, base_branches: frozenset[str] | None = None) -> bool:
"""True when ``base`` is a normal base branch (master/main/dev)."""
bases = base_branches or BASE_BRANCHES
return (base or "").strip() in bases
def _pr_head_ref(pr: dict) -> str:
head = pr.get("head") or {}
if isinstance(head, dict):
return (head.get("ref") or "").strip()
return (str(head) if head else "").strip()
def find_open_pr_for_branch(open_prs: list[dict] | None, branch: str | None) -> dict | None:
"""Return the first OPEN PR whose head ref equals ``branch`` (else ``None``)."""
branch = (branch or "").strip()
if not branch:
return None
for pr in open_prs or []:
if (pr.get("state") or "").strip().lower() != "open":
continue
if _pr_head_ref(pr) == branch:
return pr
return None
def assess_stacked_base_declaration(
*,
stacked_base_branch: str | None,
stacked_base_pr: int | None,
open_prs: list[dict] | None,
) -> dict:
"""Validate an explicit stacked-base declaration at lock time.
Returns a dict with ``block`` (fail closed), ``reasons``, ``declared``
(whether a stacked base was requested), and ``approved`` (the metadata to
persist on the lock when valid, else ``None``).
"""
branch = (stacked_base_branch or "").strip()
if not branch:
# No stacked base requested — normal master-based lock path.
return {"block": False, "reasons": [], "approved": None, "declared": False}
if branch in BASE_BRANCHES:
return {
"block": True,
"declared": True,
"approved": None,
"reasons": [
f"stacked base '{branch}' is already a normal base branch; do not "
"declare a base branch as a stacked base"
],
}
if stacked_base_pr is None:
return {
"block": True,
"declared": True,
"approved": None,
"reasons": [
"stacked base branch declared without stacked_base_pr; a stacked PR "
"must cite the open PR that owns the base branch"
],
}
pr = find_open_pr_for_branch(open_prs, branch)
if pr is None:
return {
"block": True,
"declared": True,
"approved": None,
"reasons": [
f"stacked base branch '{branch}' does not correspond to any OPEN pull "
"request; arbitrary or stale branches are not allowed as stacked bases"
],
}
if int(pr.get("number")) != int(stacked_base_pr):
return {
"block": True,
"declared": True,
"approved": None,
"reasons": [
f"declared stacked_base_pr #{stacked_base_pr} does not match the open "
f"PR #{pr.get('number')} that owns base branch '{branch}'"
],
}
return {
"block": False,
"declared": True,
"reasons": [],
"approved": {
"branch": branch,
"pr_number": int(pr.get("number")),
"verified_open": True,
},
}
def assess_stacked_pr_body(
body: str | None, *, base_branch: str | None, pr_number: int | None
) -> list[str]:
"""Return the list of missing stacked-PR documentation fields (empty = ok)."""
text = body or ""
low = text.lower()
missing: list[str] = []
if base_branch and base_branch not in text:
missing.append(f"base branch '{base_branch}'")
if pr_number is not None and f"#{pr_number}" not in text:
missing.append(f"stacked-on PR reference '#{pr_number}'")
if not any(phrase in low for phrase in MERGE_ORDER_PHRASES):
missing.append("merge-ordering statement (e.g. 'Do not merge before PR #<n>')")
return missing
def assess_create_pr_base(
*,
base: str | None,
approved_stacked_base: dict | None,
body: str | None,
open_prs: list[dict] | None,
base_branches: frozenset[str] | None = None,
) -> dict:
"""Validate the PR base at create time.
Normal base branches pass through unchanged (``stacked`` False). A non-base
branch is allowed only when it matches the lock's approved stacked base, that
base still has an open PR, and the body documents the stack.
"""
bases = base_branches or BASE_BRANCHES
base = (base or "").strip()
if base in bases:
return {"block": False, "reasons": [], "stacked": False}
approved = approved_stacked_base or {}
approved_branch = (approved.get("branch") or "").strip()
if not approved_branch:
return {
"block": True,
"stacked": True,
"reasons": [
f"PR base '{base}' is not one of {'/'.join(sorted(bases))} and the "
"issue lock has no approved stacked base; re-lock with an explicit, "
"proof-backed stacked base to open a stacked PR"
],
}
if base != approved_branch:
return {
"block": True,
"stacked": True,
"reasons": [
f"PR base '{base}' does not match the issue lock's approved stacked "
f"base '{approved_branch}'"
],
}
pr = find_open_pr_for_branch(open_prs, base)
if pr is None:
return {
"block": True,
"stacked": True,
"reasons": [
f"approved stacked base '{base}' no longer corresponds to an OPEN pull "
"request (dependency merged, closed, or stale); retarget/rebase onto "
"master or re-lock against a live base"
],
}
pr_number = approved.get("pr_number") or pr.get("number")
missing = assess_stacked_pr_body(body, base_branch=base, pr_number=pr_number)
if missing:
return {
"block": True,
"stacked": True,
"reasons": [
"stacked PR body must document the stack; missing: "
+ ", ".join(missing)
],
}
return {"block": False, "reasons": [], "stacked": True, "stacked_base_pr": pr_number}
+16
View File
@@ -104,6 +104,22 @@ TASK_CAPABILITY_MAP: dict[str, dict[str, str]] = {
"permission": "gitea.read",
"role": "author",
},
"work_issue": {
"permission": "gitea.pr.create",
"role": "author",
},
"work-issue": {
"permission": "gitea.pr.create",
"role": "author",
},
"reconcile_landed_pr": {
"permission": "gitea.read",
"role": "author",
},
"reconcile-landed-pr": {
"permission": "gitea.read",
"role": "author",
},
"reconcile_already_landed_pr": {
"permission": "gitea.pr.close",
"role": "reconciler",
+9 -3
View File
@@ -74,18 +74,24 @@ ISSUE_WRITE_ENV = {
class TestIssueLockArtifactWarning(unittest.TestCase):
def setUp(self):
self._env_patcher = patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True)
self._lock_dir = tempfile.TemporaryDirectory()
env = {**ISSUE_WRITE_ENV, "GITEA_ISSUE_LOCK_DIR": self._lock_dir.name}
self._env_patcher = patch.dict(os.environ, env, clear=True)
self._env_patcher.start()
def tearDown(self):
self._env_patcher.stop()
self._lock_dir.cleanup()
@patch(
"mcp_server.issue_duplicate_context_fetcher",
return_value=([], [], {"status": "not_claimed"}),
)
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server._auth", return_value="token x")
@patch("mcp_server._resolve", return_value=("h", "o", "r"))
@patch("mcp_server.ISSUE_LOCK_FILE", new_callable=lambda: tempfile.mktemp())
@patch("issue_lock_worktree.read_worktree_git_state")
def test_lock_success_includes_artifact_warning(self, mock_state, _lock_file, *_mocks):
def test_lock_success_includes_artifact_warning(self, mock_state, *_mocks):
mock_state.return_value = {
"current_branch": "master",
"porcelain_status": "?? _emit_payload.py\n",
+46 -7
View File
@@ -284,8 +284,40 @@ class TestSimpleToolAudit(_AuditWiringBase):
self.assertEqual(result["number"], 9)
_NO_PR_WORK_LEASE_BLOCK = {"block": False, "reasons": [], "mutation_allowed": True}
class TestGatedToolAudit(_AuditWiringBase):
def setUp(self):
super().setUp()
from mcp_server import init_review_decision_lock
from tests.test_mcp_server import _install_owned_reviewer_lease
import reviewer_pr_lease
# init_review_decision_lock clears any prior session lease (#407).
init_review_decision_lock("prgs", "review_pr")
self._lease_patch = _install_owned_reviewer_lease(8)
self._lease_patch.start()
self._auth_identity_patch = patch(
"mcp_server._authenticated_username", return_value="reviewer-bot"
)
self._auth_identity_patch.start()
self._pr_lease_comments_patch = patch(
"mcp_server._list_pr_lease_comments", return_value=[]
)
self._pr_lease_comments_patch.start()
self._pr_work_lease_patch = patch(
"mcp_server._pr_work_lease_reviewer_block",
return_value=dict(_NO_PR_WORK_LEASE_BLOCK),
)
self._pr_work_lease_patch.start()
self.addCleanup(self._auth_identity_patch.stop)
self.addCleanup(self._lease_patch.stop)
self.addCleanup(self._pr_lease_comments_patch.stop)
self.addCleanup(self._pr_work_lease_patch.stop)
self.addCleanup(reviewer_pr_lease.clear_session_lease)
def _pr(self, author, state="open", sha="abc123", mergeable=True):
return {"user": {"login": author}, "state": state,
"head": {"sha": sha}, "mergeable": mergeable}
@@ -298,7 +330,8 @@ class TestGatedToolAudit(_AuditWiringBase):
{"login": "merger-bot"}, self._pr("author-bot"),
self._pr("author-bot"),
[{"id": 1, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
"submitted_at": "2026-07-06T10:00:00Z", "dismissed": False}],
"commit_id": "abc123", "submitted_at": "2026-07-06T10:00:00Z",
"dismissed": False}],
{}, {"merged_commit_sha": "c1"},
]
env = self._env(GITEA_PROFILE_NAME="gitea-merger",
@@ -334,7 +367,9 @@ class TestGatedToolAudit(_AuditWiringBase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_submit_review_success_audited(self, _auth, mock_api):
# mark_final_review_decision and submit each run eligibility (user + PR).
mock_api.side_effect = [
{"login": "reviewer-bot"}, self._pr("author-bot"),
{"login": "reviewer-bot"}, self._pr("author-bot"),
{"id": 7, "state": "APPROVED"},
[{"id": 7, "user": {"login": "reviewer-bot"}, "state": "APPROVED",
@@ -343,12 +378,16 @@ class TestGatedToolAudit(_AuditWiringBase):
env = self._env(GITEA_PROFILE_NAME="gitea-reviewer",
GITEA_ALLOWED_OPERATIONS="read,review,approve")
with patch.dict(os.environ, env, clear=True):
from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
init_review_decision_lock("prgs", "review_pr")
gitea_mark_final_review_decision(8, "approve", remote="prgs")
r = gitea_submit_pr_review(pr_number=8, action="approve",
body="LGTM", remote="prgs",
final_review_decision_ready=True)
from mcp_server import gitea_mark_final_review_decision
gitea_mark_final_review_decision(
8, "approve", expected_head_sha="abc123", remote="prgs",
)
r = gitea_submit_pr_review(
pr_number=8, action="approve",
body="LGTM", remote="prgs",
final_review_decision_ready=True,
)
self.assertTrue(r["performed"])
recs = self._records()
self.assertEqual(len(recs), 1)
+109
View File
@@ -0,0 +1,109 @@
"""Tests for branches-only author mutation worktree guard (#274)."""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import author_mutation_worktree as amw # noqa: E402
class TestPathUnderBranches(unittest.TestCase):
ROOT = "/repo/Gitea-Tools"
def test_branches_subdirectory_passes(self):
self.assertTrue(
amw.is_path_under_branches(f"{self.ROOT}/branches/issue-274", self.ROOT)
)
def test_control_checkout_fails(self):
self.assertFalse(amw.is_path_under_branches(self.ROOT, self.ROOT))
def test_sibling_directory_fails(self):
self.assertFalse(
amw.is_path_under_branches("/repo/other-checkout", self.ROOT)
)
class TestAssessAuthorMutationWorktree(unittest.TestCase):
ROOT = "/repo/Gitea-Tools"
def test_branches_worktree_passes(self):
result = amw.assess_author_mutation_worktree(
workspace_path=f"{self.ROOT}/branches/issue-274",
project_root=self.ROOT,
current_branch="feat/issue-274-branches-only-worktrees",
)
self.assertTrue(result["proven"])
self.assertFalse(result["block"])
def test_control_checkout_blocks(self):
result = amw.assess_author_mutation_worktree(
workspace_path=self.ROOT,
project_root=self.ROOT,
current_branch="master",
)
self.assertFalse(result["proven"])
self.assertTrue(result["block"])
self.assertIn("control checkout", result["reasons"][0])
def test_drifted_control_branch_blocks(self):
result = amw.assess_author_mutation_worktree(
workspace_path=self.ROOT,
project_root=self.ROOT,
current_branch="feat/some-task",
)
self.assertTrue(result["block"])
self.assertTrue(any("drift" in r for r in result["reasons"]))
def test_branches_worktree_as_project_root_passes(self):
"""MCP launched from a branches/ worktree uses that path as PROJECT_ROOT."""
worktree_root = f"{self.ROOT}/branches/issue-274"
result = amw.assess_author_mutation_worktree(
workspace_path=worktree_root,
project_root=worktree_root,
current_branch="feat/issue-274-branches-only-worktrees",
)
self.assertTrue(result["proven"])
self.assertFalse(result["block"])
class TestPreflightIntegration(unittest.TestCase):
def test_verify_preflight_blocks_control_checkout_with_test_porcelain(self):
import mcp_server
mcp_server._preflight_whoami_called = True
mcp_server._preflight_capability_called = True
mcp_server._preflight_resolved_role = "author"
control_root = "/repo/Gitea-Tools"
with mock.patch.object(mcp_server, "PROJECT_ROOT", control_root):
with mock.patch.dict(
"os.environ",
{"GITEA_TEST_PORCELAIN": ""},
clear=False,
):
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity()
self.assertIn("Branches-only mutation guard", str(ctx.exception))
def test_verify_preflight_allows_branches_worktree(self):
import mcp_server
mcp_server._preflight_whoami_called = True
mcp_server._preflight_capability_called = True
mcp_server._preflight_resolved_role = "author"
worktree = "/repo/Gitea-Tools/branches/issue-274"
with mock.patch.dict(
"os.environ",
{"GITEA_TEST_PORCELAIN": ""},
clear=False,
):
mcp_server.verify_preflight_purity(worktree_path=worktree)
if __name__ == "__main__":
unittest.main()
+7
View File
@@ -76,7 +76,14 @@ class CommitFilesCapabilityBase(unittest.TestCase):
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(CONFIG))
self._dup_fetcher_patcher = patch(
"mcp_server.issue_duplicate_context_fetcher",
return_value=([], [], {"status": "not_claimed"}),
)
self._dup_fetcher_patcher.start()
def tearDown(self):
self._dup_fetcher_patcher.stop()
self._remotes.stop()
mcp_server._IDENTITY_CACHE.clear()
mcp_server._preflight_whoami_called, mcp_server._preflight_capability_called = (
+38 -6
View File
@@ -55,10 +55,30 @@ class TestCommitPayloads(unittest.TestCase):
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(CONFIG))
self.locked_worktree_dir = tempfile.TemporaryDirectory()
repo_root = os.path.realpath(
os.path.join(os.path.dirname(__file__), os.pardir)
)
branches_root = os.path.join(repo_root, "branches")
os.makedirs(branches_root, exist_ok=True)
self.locked_worktree_dir = tempfile.TemporaryDirectory(
dir=branches_root,
prefix="test-commit-payloads-",
)
self.locked_worktree_path = os.path.realpath(self.locked_worktree_dir.name)
self.lock_file_path = "/tmp/gitea_issue_lock.json"
import issue_lock_store
import issue_lock_provenance
self._lock_dir = tempfile.TemporaryDirectory()
os.environ["GITEA_ISSUE_LOCK_DIR"] = self._lock_dir.name
work_lease = {
"operation_type": "author_issue_work",
"issue_number": 263,
"branch": "feat/issue-263-native-commit-payloads",
"claimant": {"username": "test-user", "profile": "test-author"},
"expires_at": "2999-01-01T00:00:00Z",
}
self.lock_data = {
"issue_number": 263,
"branch_name": "feat/issue-263-native-commit-payloads",
@@ -66,9 +86,13 @@ class TestCommitPayloads(unittest.TestCase):
"org": "Example-Org",
"repo": "Example-Repo",
"worktree_path": self.locked_worktree_path,
"work_lease": work_lease,
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
tool="gitea_lock_issue",
claimant=work_lease.get("claimant"),
),
}
with open(self.lock_file_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(self.lock_data))
self.lock_file_path = issue_lock_store.bind_session_lock(self.lock_data)
# Reset preflight status to bypass/pass verification in tests
self.orig_whoami_called = mcp_server._preflight_whoami_called
@@ -76,7 +100,14 @@ class TestCommitPayloads(unittest.TestCase):
mcp_server._preflight_whoami_called = True
mcp_server._preflight_capability_called = True
self._dup_fetcher_patcher = patch(
"mcp_server.issue_duplicate_context_fetcher",
return_value=([], [], {"status": "not_claimed"}),
)
self._dup_fetcher_patcher.start()
def tearDown(self):
self._dup_fetcher_patcher.stop()
self._remotes.stop()
mcp_server._IDENTITY_CACHE.clear()
@@ -85,8 +116,7 @@ class TestCommitPayloads(unittest.TestCase):
self._dir.cleanup()
self.locked_worktree_dir.cleanup()
if os.path.exists(self.lock_file_path):
os.remove(self.lock_file_path)
self._lock_dir.cleanup()
def _env(self, profile: str) -> dict:
return {
@@ -94,6 +124,8 @@ class TestCommitPayloads(unittest.TestCase):
"GITEA_MCP_PROFILE": profile,
"GITEA_TOKEN_AUTHOR": "author-pass",
"GITEA_TEST_PORCELAIN": "",
"GITEA_AUTHOR_WORKTREE": self.locked_worktree_path,
"GITEA_ISSUE_LOCK_DIR": self._lock_dir.name,
}
@patch("mcp_server.api_request")
+157
View File
@@ -0,0 +1,157 @@
import os
import sys
import unittest
from pathlib import Path
from unittest.mock import patch, MagicMock
# Ensure we import from the repo root
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import gitea_mcp_server as srv
FAKE_AUTH = {"Authorization": "token test-token"}
# Stable control checkout (parent of branches/), not the MCP server worktree root.
CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3])
PROJECT_ROOT = srv.PROJECT_ROOT
class TestCreateIssueWorkspaceGuard(unittest.TestCase):
def setUp(self):
# Reset preflight flags
srv._preflight_whoami_called = True
srv._preflight_capability_called = True
srv._preflight_resolved_role = "author"
srv._preflight_whoami_violation = False
srv._preflight_capability_violation = False
# Disable early return in verify_preflight_purity for testing
self._orig_in_test = srv._preflight_in_test_mode
srv._preflight_in_test_mode = lambda: False
def tearDown(self):
srv._preflight_in_test_mode = self._orig_in_test
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
@patch("gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, []))
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server.api_get_all", return_value=[])
@patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", return_value={"current_branch": "feat/issue-1"})
def test_create_issue_stable_checkout_rejected(self, _git, _get_all, mock_api, _role, _ns, _prof, _auth):
# Without worktree_path/env hints, workspace resolves to PROJECT_ROOT. When that
# path is the stable control checkout (not under branches/), mutation must fail.
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
with self.assertRaises(RuntimeError) as ctx:
srv.gitea_create_issue(title="Test issue", body="body text")
self.assertIn("stable control checkout", str(ctx.exception))
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
@patch("gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, []))
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server.api_get_all", return_value=[])
@patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", return_value={"current_branch": "feat/issue-1"})
@patch("os.path.exists", return_value=True)
@patch("os.path.isdir", return_value=True)
@patch("subprocess.run")
def test_create_issue_valid_worktree_succeeds(self, mock_run, mock_isdir, mock_exists, _git, _get_all, mock_api, _role, _ns, _prof, _auth):
# Mock subprocess.run for git --git-common-dir to return PROJECT_ROOT/.git
mock_res = MagicMock()
mock_res.stdout = f"{CONTROL_CHECKOUT_ROOT}/.git\n"
mock_run.return_value = mock_res
mock_api.return_value = {"number": 42, "html_url": "https://gitea.example.com/issues/42"}
# Provide a valid branches path under the control checkout root
valid_path = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "feat-issue-1")
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""):
res = srv.gitea_create_issue(
title="Test issue", body="body", worktree_path=valid_path
)
self.assertEqual(res["number"], 42)
mock_api.assert_called_once()
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
@patch("gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, []))
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server.api_get_all", return_value=[])
@patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", return_value={"current_branch": "feat/issue-1"})
def test_create_issue_missing_worktree_fails_closed(self, _git, _get_all, mock_api, _role, _ns, _prof, _auth):
# Path under branches/ but doesn't exist
missing_path = os.path.join(
CONTROL_CHECKOUT_ROOT, "branches", "nonexistent-worktree-path-999"
)
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
with self.assertRaises(RuntimeError) as ctx:
srv.gitea_create_issue(
title="Test issue", body="body", worktree_path=missing_path
)
self.assertIn("does not exist (fail closed)", str(ctx.exception))
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
@patch("gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop", return_value=(True, []))
@patch("gitea_mcp_server.api_request")
@patch("gitea_mcp_server.api_get_all", return_value=[])
@patch("gitea_mcp_server.issue_lock_worktree.read_worktree_git_state", return_value={"current_branch": "feat/issue-1"})
@patch("os.path.exists", return_value=True)
@patch("os.path.isdir", return_value=True)
@patch("author_mutation_worktree.subprocess.run")
@patch("subprocess.run")
def test_create_issue_wrong_repo_fails_closed(self, mock_run, mock_amw_run, mock_isdir, mock_exists, _git, _get_all, mock_api, _role, _ns, _prof, _auth):
wrong_repo_path = os.path.join(CONTROL_CHECKOUT_ROOT, "branches", "feat-issue-1")
def _subprocess_side_effect(cmd, *args, **kwargs):
mock_res = MagicMock(returncode=0)
if "--git-common-dir" in cmd:
cwd = cmd[cmd.index("-C") + 1] if "-C" in cmd else ""
if cwd == wrong_repo_path:
mock_res.stdout = "/Users/jasonwalker/Development/some-other-repo/.git\n"
else:
mock_res.stdout = f"{CONTROL_CHECKOUT_ROOT}/.git\n"
else:
mock_res.stdout = ""
return mock_res
mock_run.side_effect = _subprocess_side_effect
mock_amw_run.side_effect = _subprocess_side_effect
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
with patch("gitea_mcp_server._get_workspace_porcelain", return_value=""):
with self.assertRaises(RuntimeError) as ctx:
srv.gitea_create_issue(
title="Test issue", body="body", worktree_path=wrong_repo_path
)
self.assertIn("does not belong to the target repository", str(ctx.exception))
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
def test_create_issue_fails_without_whoami_preflight(self, _ns, _prof, _auth):
srv._preflight_whoami_called = False
with self.assertRaises(RuntimeError) as ctx:
srv.gitea_create_issue(title="Test issue", body="body")
self.assertIn("Identity (gitea_whoami) has not been verified", str(ctx.exception))
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
def test_create_issue_fails_without_capability_preflight(self, _ns, _prof, _auth):
srv._preflight_capability_called = False
with self.assertRaises(RuntimeError) as ctx:
srv.gitea_create_issue(title="Test issue", body="body")
self.assertIn("Task capability (gitea_resolve_task_capability) has not been resolved", str(ctx.exception))
if __name__ == "__main__":
unittest.main()
+217
View File
@@ -0,0 +1,217 @@
"""Tests for gitea_delete_branch capability gate (Issue #408).
``gitea_delete_branch`` requires the exact ``gitea.branch.delete`` operation:
without it the delete fails closed (no preflight, no auth lookup, no API call,
structured permission report). With it, deletion proceeds through existing
preflight and audit unchanged.
"""
import json
import os
import sys
import tempfile
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import mcp_server
from mcp_server import gitea_delete_branch
FAKE_AUTH = "token fake"
AUTHOR_NO_DELETE = {
"profile_name": "prgs-author",
"allowed_operations": [
"gitea.read", "gitea.pr.create", "gitea.pr.comment",
"gitea.branch.push", "gitea.issue.comment",
],
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
"audit_label": "prgs-author",
}
AUTHOR_WITH_DELETE = {
"profile_name": "prgs-author-deleter",
"allowed_operations": AUTHOR_NO_DELETE["allowed_operations"] + [
"gitea.branch.delete",
],
"forbidden_operations": ["gitea.pr.approve", "gitea.pr.merge"],
"audit_label": "prgs-author-deleter",
}
CONFIG = {
"version": 2,
"contexts": {
"ctx": {
"enabled": True,
"gitea": {"enabled": True, "base_url": "https://gitea.example.com"},
}
},
"profiles": {
"author-no-delete": {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "author-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": AUTHOR_NO_DELETE["allowed_operations"],
"forbidden_operations": [],
"execution_profile": "author-no-delete",
},
"author-with-delete": {
"enabled": True,
"context": "ctx",
"role": "author",
"username": "deleter-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_AUTHOR"},
"allowed_operations": AUTHOR_WITH_DELETE["allowed_operations"],
"forbidden_operations": [],
"execution_profile": "author-with-delete",
},
"reviewer-profile": {
"enabled": True,
"context": "ctx",
"role": "reviewer",
"username": "reviewer-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
"allowed_operations": [
"gitea.read", "gitea.pr.review", "gitea.pr.merge",
],
"forbidden_operations": [
"gitea.branch.delete", "gitea.branch.push", "gitea.pr.create",
],
"execution_profile": "reviewer-profile",
},
},
"rules": {"allow_runtime_switching": False},
}
class TestDeleteBranchToolGate(unittest.TestCase):
def setUp(self):
self._preflight_snapshot = (
mcp_server._preflight_whoami_called,
mcp_server._preflight_capability_called,
)
mcp_server._preflight_whoami_called = False
mcp_server._preflight_capability_called = False
self._remotes = patch.dict(mcp_server.REMOTES, {
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
"repo": "Example-Repo"},
})
self._remotes.start()
mcp_server._IDENTITY_CACHE.clear()
patch("gitea_config.load_config", return_value={}).start()
patch(
"gitea_config.is_runtime_switching_enabled", return_value=False
).start()
patch("gitea_audit.audit_enabled", return_value=False).start()
self.mock_api = patch("mcp_server.api_request").start()
self.mock_auth = patch(
"mcp_server.get_auth_header", return_value=FAKE_AUTH
).start()
def tearDown(self):
patch.stopall()
mcp_server._IDENTITY_CACHE.clear()
mcp_server._preflight_whoami_called, mcp_server._preflight_capability_called = (
self._preflight_snapshot
)
def _set_profile(self, profile):
patch("mcp_server.get_profile", return_value=profile).start()
def test_blocked_without_delete_capability(self):
self._set_profile(AUTHOR_NO_DELETE)
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
self.assertFalse(res["success"])
self.assertFalse(res["performed"])
self.assertEqual(res["required_permission"], "gitea.branch.delete")
self.assertTrue(res["reasons"])
self.assertEqual(
res["permission_report"]["missing_permission"],
"gitea.branch.delete",
)
self.mock_api.assert_not_called()
self.mock_auth.assert_not_called()
def test_allowed_delete_proceeds(self):
self._set_profile(AUTHOR_WITH_DELETE)
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="author")
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
self.assertTrue(res["success"])
self.assertIn("deleted", res["message"])
delete_calls = [
c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"
]
self.assertTrue(delete_calls)
def test_allowed_delete_audited_with_capability_proof(self):
self._set_profile(AUTHOR_WITH_DELETE)
patch("gitea_audit.audit_enabled", return_value=True).start()
mock_write = patch("gitea_audit.write_event").start()
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="author")
self.mock_api.return_value = {}
gitea_delete_branch(branch="feat/branch", remote="prgs")
mock_write.assert_called()
event = mock_write.call_args[0][0]
self.assertEqual(event["action"], "delete_branch")
self.assertEqual(
event["request_metadata"]["required_permission"],
"gitea.branch.delete",
)
class TestDeleteBranchResolverParity(unittest.TestCase):
def setUp(self):
self._remotes = patch.dict(mcp_server.REMOTES, {
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
"repo": "Example-Repo"},
})
self._remotes.start()
mcp_server._IDENTITY_CACHE.clear()
self._dir = tempfile.TemporaryDirectory()
self.config_path = os.path.join(self._dir.name, "profiles.json")
with open(self.config_path, "w", encoding="utf-8") as fh:
fh.write(json.dumps(CONFIG))
patch(
"gitea_config.is_runtime_switching_enabled", return_value=False
).start()
patch("gitea_audit.audit_enabled", return_value=False).start()
self.mock_api = patch("mcp_server.api_request").start()
patch("mcp_server.get_auth_header", return_value=FAKE_AUTH).start()
def tearDown(self):
patch.stopall()
mcp_server._IDENTITY_CACHE.clear()
self._dir.cleanup()
def _env(self, profile: str) -> dict:
return {
"GITEA_MCP_CONFIG": self.config_path,
"GITEA_MCP_PROFILE": profile,
"GITEA_TOKEN_AUTHOR": "author-pass",
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
}
def test_reviewer_resolver_denial_blocks_raw_tool(self):
with patch.dict(os.environ, self._env("reviewer-profile"), clear=True):
resolve = mcp_server.gitea_resolve_task_capability(
task="delete_branch", remote="prgs")
self.assertFalse(resolve["allowed_in_current_session"])
res = gitea_delete_branch(branch="feat/branch", remote="prgs")
self.assertFalse(res["success"])
self.assertEqual(
res["permission_report"]["missing_permission"],
resolve["required_operation_permission"],
)
delete_calls = [
c for c in self.mock_api.call_args_list if c.args[0] == "DELETE"
]
self.assertFalse(delete_calls)
if __name__ == "__main__":
unittest.main()
+198 -7
View File
@@ -41,6 +41,10 @@ def _review_handoff(**overrides):
"- Selected PR: #203",
"- Reviewer eligibility: eligible",
"- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Reviewed head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Final live head SHA before approval: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Final live head SHA before merge: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Push occurred during validation: no",
"- Worktree path: branches/review-203",
"- Worktree dirty: clean",
"- Scratch worktree used: yes (branches/review-203)",
@@ -57,26 +61,47 @@ def _review_handoff(**overrides):
return text
def _reconcile_handoff(**overrides):
def _reconcile_handoff(drop=(), **overrides):
lines = [
"## Controller Handoff",
"",
"- Task: reconcile already-landed PR #99",
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
"- Role: reconciler",
"- Identity: sysadmin / prgs-author",
"- Role/profile: reconciler / prgs-reconciler",
"- Identity: sysadmin",
"- Selected PR: #99",
"- Eligibility class: ALREADY_LANDED_RECONCILE_REQUIRED",
"- PR live state: open",
"- Candidate head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Target branch: master",
"- Target branch SHA: 5e023dc71b0e2b813a0b1eee6b0f42630c47a95c",
"- Ancestor proof: candidate head is ancestor of target branch SHA",
"- Linked issue: #98",
"- Linked issue live status: not verified in this session",
"- Eligibility class: ALREADY_LANDED_RECONCILE_REQUIRED",
"- Capabilities proven: gitea.read, gitea.pr.comment",
"- Missing capabilities: gitea.pr.close",
"- PR comments posted: 1 reconciliation comment on PR #99",
"- Issue comments posted: none",
"- PRs closed: none",
"- Issues closed: none",
"- File edits by reconciler: none",
"- Git ref mutations: git fetch prgs master",
"- Worktree mutations: none",
"- MCP/Gitea mutations: PR comment posted",
"- External-state mutations: none",
"- Read-only diagnostics: gitea_view_pr, gitea_view_issue",
"- Reconciliation mutations: PR comment posted",
"- Current status: reconciliation complete",
"- Safe next action: none",
"- Blocker: missing gitea.pr.close capability",
"- Safe next action: hand off to a profile with gitea.pr.close",
"- No review/merge confirmation: confirmed",
]
text = "\n".join(lines)
kept = [
line
for line in lines
if not any(line.startswith(f"- {name}:") for name in drop)
]
text = "\n".join(kept)
for key, value in overrides.items():
if f"- {key}:" in text:
text = text.replace(f"- {key}:", f"- {key}: {value}")
@@ -194,6 +219,20 @@ class TestReviewPrRules(unittest.TestCase):
)
)
def test_already_landed_oldest_eligible_pr_blocks(self):
report = (
_review_handoff()
+ "\nAlready landed.\nOldest eligible PR: PR #278."
)
result = assess_final_report_validator(report, "review_pr")
self.assertTrue(result["blocked"])
self.assertTrue(
any(
f["rule_id"] == "reviewer.already_landed_eligible_wording"
for f in result["findings"]
)
)
def test_already_landed_gate_blocks_review_terminal_states(self):
cases = {
"APPROVED": ("- Review decision: request_changes", "- Review decision: APPROVED"),
@@ -242,7 +281,6 @@ class TestReviewPrRules(unittest.TestCase):
)
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()
@@ -307,6 +345,159 @@ class TestReconciliationRules(unittest.TestCase):
)
class TestCanonicalReconcileSchema(unittest.TestCase):
"""Issue #307: reconciliation workflows emit only the canonical schema."""
def test_comment_only_reconciliation_passes(self):
result = assess_final_report_validator(
_reconcile_handoff(),
"reconcile_already_landed",
)
self.assertEqual(result["grade"], "A")
self.assertEqual(result["findings"], [])
def test_successful_close_reconciliation_passes(self):
report = _reconcile_handoff().replace(
"- Capabilities proven: gitea.read, gitea.pr.comment",
"- Capabilities proven: gitea.read, gitea.pr.comment, gitea.pr.close",
).replace(
"- Missing capabilities: gitea.pr.close",
"- Missing capabilities: none",
).replace(
"- PRs closed: none",
"- PRs closed: #99",
).replace(
"- Blocker: missing gitea.pr.close capability",
"- Blocker: none",
)
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertEqual(result["grade"], "A")
def test_blocked_reconciliation_passes(self):
report = _reconcile_handoff().replace(
"- Capabilities proven: gitea.read, gitea.pr.comment",
"- Capabilities proven: gitea.read",
).replace(
"- Missing capabilities: gitea.pr.close",
"- Missing capabilities: gitea.pr.close, gitea.pr.comment",
).replace(
"- PR comments posted: 1 reconciliation comment on PR #99",
"- PR comments posted: none",
).replace(
"- MCP/Gitea mutations: PR comment posted",
"- MCP/Gitea mutations: none",
).replace(
"- Reconciliation mutations: PR comment posted",
"- Reconciliation mutations: none",
)
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertEqual(result["grade"], "A")
def test_missing_capabilities_field_required(self):
report = _reconcile_handoff(drop=("Missing capabilities",))
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertEqual(result["grade"], "downgraded")
self.assertTrue(
any(
f["rule_id"] == "reconcile.controller_handoff"
and "Missing capabilities" in f["reason"]
for f in result["findings"]
)
)
def test_missing_ancestor_proof_and_candidate_sha_downgrade(self):
report = _reconcile_handoff(drop=("Ancestor proof", "Candidate head SHA"))
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertEqual(result["grade"], "downgraded")
reasons = " ".join(f["reason"] for f in result["findings"])
self.assertIn("Ancestor proof", reasons)
self.assertIn("Candidate head SHA", reasons)
def test_stale_issue_lock_proof_blocks(self):
report = _reconcile_handoff() + "\n- Issue lock proof: locked before diff"
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertTrue(result["blocked"])
self.assertTrue(
any(
f["rule_id"] == "reconcile.stale_author_reviewer_fields"
and f["field"] == "issue lock proof"
for f in result["findings"]
)
)
def test_stale_claim_comment_status_blocks(self):
report = _reconcile_handoff() + "\n- Claim/comment status: claimed"
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertTrue(result["blocked"])
self.assertTrue(
any(
f["rule_id"] == "reconcile.stale_author_reviewer_fields"
and f["field"] == "claim/comment status"
for f in result["findings"]
)
)
def test_stale_workspace_mutations_blocks_without_observed_mutations(self):
report = _reconcile_handoff() + "\n- Workspace mutations: None"
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertTrue(result["blocked"])
self.assertTrue(
any(
f["rule_id"] == "reconcile.stale_author_reviewer_fields"
and f["field"] == "workspace mutations"
for f in result["findings"]
)
)
def test_pr_number_opened_allowed_when_session_opened_pr(self):
report = _reconcile_handoff() + "\n- PR number opened: #12"
result = assess_final_report_validator(
report,
"reconcile_already_landed",
session_pr_opened=True,
)
self.assertFalse(
any(
f["rule_id"] == "reconcile.stale_author_reviewer_fields"
and f["field"] == "pr number opened"
for f in result["findings"]
)
)
def test_pr_number_opened_still_blocked_without_session_proof(self):
report = _reconcile_handoff() + "\n- PR number opened: #12"
result = assess_final_report_validator(
report,
"reconcile_already_landed",
session_pr_opened=False,
)
self.assertTrue(result["blocked"])
self.assertTrue(
any(
f["rule_id"] == "reconcile.stale_author_reviewer_fields"
and f["field"] == "pr number opened"
for f in result["findings"]
)
)
def test_action_log_fetch_requires_git_ref_mutation_entry(self):
report = _reconcile_handoff().replace(
"- Git ref mutations: git fetch prgs master",
"- Git ref mutations: none",
)
result = assess_final_report_validator(
report,
"reconcile_already_landed",
action_log=[{"command": "git fetch prgs master", "performed": True}],
)
self.assertTrue(
any(
f["rule_id"] == "reviewer.git_fetch_readonly"
for f in result["findings"]
)
)
class TestEntryPoint(unittest.TestCase):
def test_unknown_task_kind_blocks(self):
result = assess_final_report_validator("report", "unknown_mode")
+227
View File
@@ -0,0 +1,227 @@
"""Unit tests for own-branch lock adoption decision (#442 / #443)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from issue_lock_adoption import ( # noqa: E402
ADOPT,
BLOCK_COMPETING,
NO_MATCH,
assess_own_branch_adoption,
build_adoption_proof,
build_non_adoption_lock_proof,
)
REQ = "feat/issue-420-server-code-parity"
class TestAssessOwnBranchAdoption(unittest.TestCase):
def test_exact_own_branch_is_adopted(self):
result = assess_own_branch_adoption(
issue_number=420,
requested_branch=REQ,
existing_branches=[{"name": REQ, "commit_sha": "934688a"}],
)
self.assertEqual(result["outcome"], ADOPT)
self.assertTrue(result["adopt"])
self.assertFalse(result["block"])
self.assertEqual(result["matched_branch"], REQ)
self.assertEqual(result["matched_head_sha"], "934688a")
def test_exact_own_branch_adopted_when_sha_missing(self):
result = assess_own_branch_adoption(
issue_number=420, requested_branch=REQ, existing_branches=[REQ]
)
self.assertEqual(result["outcome"], ADOPT)
self.assertIsNone(result["matched_head_sha"])
def test_different_branch_same_issue_blocks(self):
result = assess_own_branch_adoption(
issue_number=420,
requested_branch=REQ,
existing_branches=[{"name": "feat/issue-420-other-work"}],
)
self.assertEqual(result["outcome"], BLOCK_COMPETING)
self.assertTrue(result["block"])
self.assertFalse(result["adopt"])
self.assertIn("feat/issue-420-other-work", result["competing_branches"])
self.assertIn("fail closed", result["reason"])
def test_own_branch_plus_competing_branch_blocks(self):
# Ambiguous ownership: fail closed even though the exact branch exists.
result = assess_own_branch_adoption(
issue_number=420,
requested_branch=REQ,
existing_branches=[{"name": REQ}, {"name": "feat/issue-420-rogue"}],
)
self.assertEqual(result["outcome"], BLOCK_COMPETING)
self.assertTrue(result["block"])
self.assertEqual(result["competing_branches"], ["feat/issue-420-rogue"])
def test_no_matching_branch_is_normal_path(self):
result = assess_own_branch_adoption(
issue_number=420,
requested_branch=REQ,
existing_branches=[{"name": "feat/issue-999-unrelated"}],
)
self.assertEqual(result["outcome"], NO_MATCH)
self.assertFalse(result["block"])
self.assertFalse(result["adopt"])
def test_empty_branch_list_is_normal_path(self):
result = assess_own_branch_adoption(
issue_number=420, requested_branch=REQ, existing_branches=[]
)
self.assertEqual(result["outcome"], NO_MATCH)
def test_higher_issue_number_branch_does_not_block_lower_issue_adoption(self):
# issue-420 must not be treated as competing work for issue #42.
own_branch = "feat/issue-42-widget"
result = assess_own_branch_adoption(
issue_number=42,
requested_branch=own_branch,
existing_branches=[
{"name": own_branch, "commit_sha": "abc1234"},
{"name": "feat/issue-420-server-code-parity"},
],
)
self.assertEqual(result["outcome"], ADOPT)
self.assertTrue(result["adopt"])
self.assertFalse(result["block"])
self.assertEqual(result["matched_branch"], own_branch)
def test_unrelated_higher_number_branch_is_ignored_without_own_branch(self):
result = assess_own_branch_adoption(
issue_number=42,
requested_branch="feat/issue-42-thing",
existing_branches=[{"name": "feat/issue-420-server-code-parity"}],
)
self.assertEqual(result["outcome"], NO_MATCH)
self.assertFalse(result["block"])
self.assertFalse(result["adopt"])
class TestBuildAdoptionProof(unittest.TestCase):
def test_proof_has_all_required_fields(self):
assessment = assess_own_branch_adoption(
issue_number=420,
requested_branch=REQ,
existing_branches=[{"name": REQ, "commit_sha": "934688a"}],
)
proof = build_adoption_proof(
issue_number=420,
branch_name=REQ,
assessment=assessment,
open_pr_checked=True,
competing_lock_checked=True,
lock_file_path="/tmp/example-lock.json",
lock_file_status="written",
)
for key in (
"issue_number",
"branch_name",
"branch_head_commit",
"adoption_reason",
"no_existing_pr_proof",
"no_competing_live_lock_proof",
"lock_file_path",
"lock_file_status",
):
self.assertIn(key, proof)
self.assertEqual(proof["branch_head_commit"], "934688a")
self.assertTrue(proof["no_existing_pr_proof"])
self.assertTrue(proof["no_competing_live_lock_proof"])
class TestExplicitAdoptionProofFields(unittest.TestCase):
"""#477: explicit, citable adoption-proof fields for all outcomes."""
def _proof(self, assessment, branch):
return build_adoption_proof(
issue_number=420,
branch_name=branch,
assessment=assessment,
open_pr_checked=True,
competing_lock_checked=True,
lock_file_path="/tmp/example-lock.json",
lock_file_status="written",
)
def test_adopt_proof_exposes_explicit_fields(self):
assessment = assess_own_branch_adoption(
issue_number=420,
requested_branch=REQ,
existing_branches=[{"name": REQ, "commit_sha": "934688a"}],
)
proof = self._proof(assessment, REQ)
self.assertEqual(proof["adoption_decision"], "ADOPT")
self.assertTrue(proof["adopted"])
self.assertEqual(proof["adopted_branch"], REQ)
self.assertEqual(proof["adopted_branch_head"], "934688a")
self.assertEqual(proof["competing_branch_check"]["result"], "clear")
self.assertEqual(proof["competing_branch_check"]["competing_branches"], [])
self.assertIn("gitea_create_pr", proof["safe_next_action"])
self.assertIn("exactly matches", proof["matcher_summary"])
def test_block_proof_reports_competing_and_does_not_claim_adoption(self):
assessment = assess_own_branch_adoption(
issue_number=420,
requested_branch=REQ,
existing_branches=[{"name": "feat/issue-420-rogue"}],
)
proof = self._proof(assessment, REQ)
self.assertEqual(proof["adoption_decision"], "BLOCK_COMPETING")
self.assertFalse(proof["adopted"])
self.assertIsNone(proof["adopted_branch"])
self.assertIsNone(proof["adopted_branch_head"])
self.assertEqual(proof["competing_branch_check"]["result"], "blocked")
self.assertIn(
"feat/issue-420-rogue",
proof["competing_branch_check"]["competing_branches"],
)
self.assertIn("fail closed", proof["safe_next_action"])
def test_no_match_proof_does_not_claim_adoption(self):
assessment = assess_own_branch_adoption(
issue_number=420,
requested_branch=REQ,
existing_branches=[{"name": "feat/issue-999-unrelated"}],
)
proof = self._proof(assessment, REQ)
self.assertEqual(proof["adoption_decision"], "NO_MATCH")
self.assertFalse(proof["adopted"])
self.assertIsNone(proof["adopted_branch"])
self.assertEqual(proof["competing_branch_check"]["result"], "clear")
def test_substring_collision_stays_boundary_safe(self):
# issue-42 must not adopt/claim against an issue-420 branch (#440/#477).
own = "feat/issue-42-widget"
assessment = assess_own_branch_adoption(
issue_number=42,
requested_branch=own,
existing_branches=[
{"name": own, "commit_sha": "abc1234"},
{"name": "feat/issue-420-server-code-parity"},
],
)
proof = self._proof(assessment, own)
self.assertEqual(proof["adoption_decision"], "ADOPT")
self.assertEqual(proof["adopted_branch"], own)
self.assertEqual(proof["competing_branch_check"]["competing_branches"], [])
def test_non_adoption_lock_proof_is_adoption_free(self):
proof = build_non_adoption_lock_proof(
issue_number=196, branch_name="feat/issue-196-mutations"
)
self.assertEqual(proof["adoption_decision"], "NO_MATCH")
self.assertFalse(proof["adopted"])
self.assertIsNone(proof["adopted_branch"])
self.assertIsNone(proof["adopted_branch_head"])
self.assertEqual(proof["competing_branch_check"]["result"], "clear")
self.assertIn("no adoption", proof["safe_next_action"].lower())
if __name__ == "__main__":
unittest.main()
+130
View File
@@ -0,0 +1,130 @@
"""Tests for issue-lock provenance and external-state disclosure (#447)."""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import issue_lock_provenance as ilp # noqa: E402
from final_report_validator import assess_final_report_validator # noqa: E402
def _sanctioned_lock(**overrides):
work_lease = {
"operation_type": "author_issue_work",
"issue_number": 447,
"branch": "feat/issue-447-lock-provenance",
"claimant": {"username": "jcwalker3", "profile": "prgs-author"},
"expires_at": "2999-01-01T00:00:00Z",
}
data = {
"issue_number": 447,
"branch_name": "feat/issue-447-lock-provenance",
"work_lease": work_lease,
"lock_provenance": ilp.build_sanctioned_lock_provenance(
tool="gitea_lock_issue",
claimant=work_lease["claimant"],
),
}
data.update(overrides)
return data
class TestLockProvenanceForCreatePr(unittest.TestCase):
def test_sanctioned_lock_passes(self):
result = ilp.assess_lock_file_for_create_pr(_sanctioned_lock())
self.assertTrue(result["proven"])
self.assertFalse(result["block"])
def test_manual_seed_without_provenance_blocked(self):
result = ilp.assess_lock_file_for_create_pr(
{"issue_number": 420, "branch_name": "feat/x", "work_lease": {}}
)
self.assertTrue(result["block"])
self.assertIn("lock_provenance", result["reasons"][0])
def test_operator_override_requires_reason(self):
result = ilp.assess_lock_file_for_create_pr(
_sanctioned_lock(
lock_provenance=ilp.build_sanctioned_lock_provenance(
tool="operator_override",
source=ilp.SOURCE_OPERATOR_OVERRIDE,
)
)
)
self.assertTrue(result["block"])
class TestExternalStateReportRules(unittest.TestCase):
def test_seed_with_external_none_blocked(self):
report = (
"Restored /tmp/gitea_issue_lock.json to unblock PR creation.\n"
"- External-state mutations: none\n"
)
result = ilp.assess_issue_lock_external_state_report(report)
self.assertTrue(result["block"])
def test_seed_with_disclosure_passes(self):
report = (
"Restored /tmp/gitea_issue_lock.json after MCP restart.\n"
"- External-state mutations: wrote /tmp/gitea_issue_lock.json\n"
)
result = ilp.assess_issue_lock_external_state_report(report)
self.assertTrue(result["proven"])
def test_remove_claimed_as_cleanup_only_blocked(self):
report = (
"rm /tmp/gitea_issue_lock.json after PR creation.\n"
"- Cleanup mutations: lock removed\n"
"- External-state mutations: none\n"
)
result = ilp.assess_issue_lock_external_state_report(report)
self.assertTrue(result["block"])
def test_manual_lock_pr_without_override_blocked(self):
report = (
"Programmatically seeded gitea_issue_lock.json then gitea_create_pr.\n"
"PR #444 created.\n"
)
result = ilp.assess_manual_lock_pr_without_override(report)
self.assertTrue(result["block"])
def test_author_reviewer_same_run_blocked(self):
report = (
"gitea_create_pr opened PR #444.\n"
"Submitted approve review on PR #444.\n"
)
result = ilp.assess_author_reviewer_same_run_report(report)
self.assertTrue(result["block"])
class TestFinalReportValidatorIntegration(unittest.TestCase):
def test_work_issue_blocks_hidden_lock_mutation(self):
report = (
"## Controller Handoff\n"
"- Task: work issue #420\n"
"- External-state mutations: none\n"
"Restored /tmp/gitea_issue_lock.json before PR creation.\n"
)
result = assess_final_report_validator(report, "work_issue")
rule_ids = {f["rule_id"] for f in result["findings"]}
self.assertIn("shared.issue_lock_external_state", rule_ids)
self.assertTrue(result["blocked"])
def test_review_pr_blocks_create_and_approve(self):
report = (
"## Controller Handoff\n"
"- Task: review PR #444\n"
"- Review decision: approve\n"
"Created PR #444 via gitea_create_pr earlier in this run.\n"
)
result = assess_final_report_validator(report, "review_pr")
rule_ids = {f["rule_id"] for f in result["findings"]}
self.assertIn("shared.author_reviewer_same_run", rule_ids)
if __name__ == "__main__":
unittest.main()
+280
View File
@@ -0,0 +1,280 @@
"""Unit tests for keyed issue-lock storage (#443) and flock hardening (#438)."""
import json
import os
import sys
import tempfile
import threading
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import issue_lock_store as ils # noqa: E402
def _lease(expires_at: str) -> dict:
return {
"operation_type": ils.AUTHOR_ISSUE_WORK_LEASE,
"expires_at": expires_at,
"created_at": "2026-01-01T00:00:00Z",
"last_heartbeat_at": "2026-01-01T00:00:00Z",
}
def _lock_record(**overrides) -> dict:
record = {
"issue_number": 420,
"branch_name": "feat/issue-420-server-code-parity",
"remote": "prgs",
"org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools",
"worktree_path": "/tmp/wt-420",
"work_lease": _lease("2999-01-01T00:00:00Z"),
}
record.update(overrides)
return record
class TestIssueLockStore(unittest.TestCase):
def setUp(self):
self._dir = tempfile.TemporaryDirectory()
self.lock_dir = self._dir.name
self._env = mock.patch.dict(os.environ, {"GITEA_ISSUE_LOCK_DIR": self.lock_dir})
self._env.start()
def tearDown(self):
self._env.stop()
self._dir.cleanup()
def test_concurrent_repo_locks_do_not_overwrite(self):
lock_a = _lock_record(
issue_number=108,
branch_name="feat/issue-108-root-menu",
repo="mcp-control-plane",
worktree_path="/tmp/wt-108",
)
lock_b = _lock_record(
issue_number=420,
branch_name="feat/issue-420-server-code-parity",
repo="Gitea-Tools",
worktree_path="/tmp/wt-420",
)
path_a = ils.bind_session_lock(lock_a)
with mock.patch("os.getpid", return_value=9999):
path_b = ils.bind_session_lock(lock_b)
self.assertNotEqual(path_a, path_b)
self.assertTrue(os.path.exists(path_a))
self.assertTrue(os.path.exists(path_b))
stored_a = ils.read_lock_file(path_a)
stored_b = ils.read_lock_file(path_b)
self.assertEqual(stored_a["issue_number"], 108)
self.assertEqual(stored_b["issue_number"], 420)
def test_concurrent_issue_locks_same_repo_do_not_overwrite(self):
lock_a = _lock_record(issue_number=427, branch_name="feat/issue-427-a")
lock_b = _lock_record(issue_number=428, branch_name="feat/issue-428-b")
path_a = ils.bind_session_lock(lock_a)
with mock.patch("os.getpid", return_value=4242):
path_b = ils.bind_session_lock(lock_b)
self.assertNotEqual(path_a, path_b)
self.assertEqual(ils.read_lock_file(path_a)["issue_number"], 427)
self.assertEqual(ils.read_lock_file(path_b)["issue_number"], 428)
def test_foreign_live_lease_blocks_overwrite(self):
existing = _lock_record(
branch_name="feat/issue-420-other",
worktree_path="/tmp/other",
work_lease=_lease("2999-01-01T00:00:00Z"),
)
path = ils.lock_file_path(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
issue_number=420,
)
ils.save_lock_file(path, existing)
incoming = _lock_record(worktree_path="/tmp/mine")
block = ils.assess_foreign_lock_overwrite(existing, incoming)
self.assertIn("live foreign issue lock", block or "")
def test_expired_lease_allows_takeover_with_conflict_check(self):
existing = _lock_record(
branch_name="feat/issue-420-other",
worktree_path="/tmp/other",
work_lease=_lease("2000-01-01T00:00:00Z"),
)
incoming = _lock_record(worktree_path="/tmp/mine")
self.assertIsNone(ils.assess_foreign_lock_overwrite(existing, incoming))
block = ils.assess_same_issue_lease_conflict(
existing,
issue_number=420,
branch_name="feat/issue-420-server-code-parity",
worktree_path="/tmp/mine",
)
self.assertIn("Recovery review is required", block or "")
def test_same_owner_lease_conflict_allows_refresh(self):
worktree = "/tmp/wt-420"
existing = _lock_record(worktree_path=worktree)
block = ils.assess_same_issue_lease_conflict(
existing,
issue_number=420,
branch_name="feat/issue-420-server-code-parity",
worktree_path=worktree,
)
self.assertIsNone(block)
def test_find_lock_for_branch_after_restart(self):
record = _lock_record()
path = ils.lock_file_path(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
issue_number=420,
)
ils.save_lock_file(path, record)
with mock.patch("os.getpid", return_value=5555):
self.assertIsNone(ils.read_session_issue_lock())
found = ils.find_lock_for_branch(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
branch_name="feat/issue-420-server-code-parity",
)
self.assertEqual(found["issue_number"], 420)
def test_has_active_issue_lock_scans_keyed_store(self):
ils.bind_session_lock(_lock_record())
self.assertTrue(
ils.has_active_issue_lock("feat/issue-420-server-code-parity")
)
self.assertFalse(ils.has_active_issue_lock("feat/issue-999-other"))
def test_approved_stacked_base_survives_round_trip(self):
# #484: the approved stacked base recorded on the lock must persist so
# gitea_create_pr can validate the non-master base at PR time.
record = _lock_record(
issue_number=482,
branch_name="feat/issue-482-skip-stale-request-changes-pr",
approved_stacked_base={
"branch": "feat/issue-478-mcp-menu-shell",
"pr_number": 479,
"verified_open": True,
},
)
path = ils.lock_file_path(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
issue_number=482,
)
ils.save_lock_file(path, record)
stored = ils.read_lock_file(path)
self.assertEqual(stored["approved_stacked_base"]["branch"], "feat/issue-478-mcp-menu-shell")
self.assertEqual(stored["approved_stacked_base"]["pr_number"], 479)
self.assertTrue(stored["approved_stacked_base"]["verified_open"])
def test_atomic_write_preserves_unrelated_lock(self):
path_a = ils.lock_file_path(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
issue_number=108,
)
ils.save_lock_file(path_a, _lock_record(issue_number=108, repo="mcp-control-plane"))
path_b = ils.lock_file_path(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
issue_number=420,
)
ils.save_lock_file(path_b, _lock_record())
self.assertTrue(os.path.exists(path_a))
self.assertTrue(os.path.exists(path_b))
self.assertEqual(ils.read_lock_file(path_a)["issue_number"], 108)
def test_concurrent_bind_same_issue_only_one_wins(self):
barrier = threading.Barrier(2)
results: list[str | Exception] = []
def worker():
barrier.wait()
try:
ils.bind_session_lock(
_lock_record(worktree_path=f"/tmp/wt-{threading.get_ident()}")
)
results.append("ok")
except Exception as exc: # noqa: BLE001
results.append(exc)
threads = [threading.Thread(target=worker) for _ in range(2)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
successes = [item for item in results if item == "ok"]
failures = [item for item in results if isinstance(item, Exception)]
self.assertEqual(len(successes), 1)
self.assertEqual(len(failures), 1)
failure_text = str(failures[0]).lower()
self.assertTrue(
"active" in failure_text or "lock contention" in failure_text,
failures[0],
)
def test_verify_lock_for_mutation_blocks_stale_lock(self):
record = _lock_record(
work_lease=_lease("2000-01-01T00:00:00Z"),
)
record["pid"] = 999999
record["session_pid"] = 999999
result = ils.verify_lock_for_mutation(
record,
issue_number=420,
branch_name="feat/issue-420-server-code-parity",
worktree_path="/tmp/wt-420",
)
self.assertTrue(result["block"])
self.assertIn("not live", result["reasons"][0])
def test_list_live_locks_excludes_stale_records(self):
live_path = ils.lock_file_path(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
issue_number=420,
)
ils.save_lock_file(
live_path,
_lock_record(worktree_path="/tmp/wt-420"),
)
stale_path = ils.lock_file_path(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
issue_number=440,
)
ils.save_lock_file(
stale_path,
_lock_record(
issue_number=440,
branch_name="feat/issue-440-recovery",
work_lease=_lease("2000-01-01T00:00:00Z"),
worktree_path="/tmp/wt-440",
),
)
live = ils.list_live_locks(lock_dir=self.lock_dir)
self.assertEqual([entry["issue_number"] for entry in live], [420])
if __name__ == "__main__":
unittest.main()
+53
View File
@@ -72,6 +72,59 @@ class TestIssueLockWorktreeAssessment(unittest.TestCase):
self.assertTrue(result["proven"])
class TestStackedBaseEquivalence(unittest.TestCase):
"""extra_bases (an approved stacked base) can anchor base-equivalence (#484)."""
def _git(self, *args):
import subprocess
subprocess.run(
["git", "-C", self.repo, *args],
check=True,
capture_output=True,
text=True,
)
def setUp(self):
import subprocess
import tempfile
self.tmp = tempfile.TemporaryDirectory()
self.repo = self.tmp.name
subprocess.run(["git", "init", "-q", self.repo], check=True, capture_output=True)
self._git("config", "user.email", "t@t")
self._git("config", "user.name", "t")
self._git("commit", "--allow-empty", "-q", "-m", "base")
# Rename the default branch away from master/main/dev so no *base* branch
# exists at HEAD — otherwise HEAD would be base-equivalent for free.
self._git("branch", "-m", "trunk")
# Create a non-master "dependency" branch at the same commit, then a
# feature branch off it — mirrors a stacked worktree.
self._git("branch", "feat/issue-100-dep")
self._git("checkout", "-q", "-b", "feat/issue-101-stacked")
def tearDown(self):
self.tmp.cleanup()
def test_stacked_base_not_equivalent_without_extra_bases(self):
state = issue_lock_worktree.read_worktree_git_state(self.repo)
# HEAD does not match master/main/dev, so base-equivalence is False.
self.assertFalse(state["base_equivalent"])
def test_stacked_base_equivalent_with_extra_bases(self):
state = issue_lock_worktree.read_worktree_git_state(
self.repo, extra_bases=("feat/issue-100-dep",)
)
self.assertTrue(state["base_equivalent"])
self.assertEqual(state["base_branch"], "feat/issue-100-dep")
def test_unrelated_extra_base_does_not_anchor(self):
state = issue_lock_worktree.read_worktree_git_state(
self.repo, extra_bases=("feat/does-not-exist",)
)
self.assertFalse(state["base_equivalent"])
class TestIssueLockWorktreeResolution(unittest.TestCase):
def test_explicit_path_wins(self):
resolved = issue_lock_worktree.resolve_author_worktree_path(
+272
View File
@@ -0,0 +1,272 @@
"""Tests for early duplicate-work detection (#400)."""
import os
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import issue_lock_provenance
import issue_lock_store
import issue_work_duplicate_gate as dup_gate
import mcp_server
from issue_work_duplicate_gate import (
OUTCOME_DUPLICATE_BRANCH_PREVENTED,
OUTCOME_DUPLICATE_COMMIT_PREVENTED,
OUTCOME_DUPLICATE_PR_PREVENTED,
OUTCOME_DUPLICATE_WORK_NOT_PREVENTED,
PHASE_COMMIT,
PHASE_CREATE_PR,
PHASE_LOCK,
assess_work_issue_duplicate_gate,
assess_work_issue_duplicate_report,
)
class TestDuplicateGateAssessment(unittest.TestCase):
def test_clear_issue_passes(self):
result = assess_work_issue_duplicate_gate(
400,
open_prs=[],
branch_names=["feat/other-issue-99"],
claim_entry={"status": "not_claimed"},
locked_branch="feat/issue-400-duplicate-work-preflight",
phase=PHASE_LOCK,
)
self.assertFalse(result["block"])
self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_WORK_NOT_PREVENTED)
def test_open_pr_blocks(self):
prs = [{
"number": 397,
"title": "feat: handoff",
"body": "Closes #395",
"head": {"ref": "feat/issue-395-proof-backed-review-handoff"},
}]
result = assess_work_issue_duplicate_gate(
395,
open_prs=prs,
branch_names=[],
phase=PHASE_LOCK,
)
self.assertTrue(result["block"])
self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_PR_PREVENTED)
def test_conflicting_remote_branch_blocks(self):
result = assess_work_issue_duplicate_gate(
395,
open_prs=[],
branch_names=["feat/issue-395-proof-backed-handoff-claims"],
locked_branch="feat/issue-395-new-attempt",
phase=PHASE_LOCK,
)
self.assertTrue(result["block"])
self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_BRANCH_PREVENTED)
def test_active_claim_on_other_branch_blocks(self):
result = assess_work_issue_duplicate_gate(
398,
open_prs=[],
branch_names=[],
claim_entry={
"status": "active",
"latest_heartbeat": {
"branch": "feat/issue-398-validation-cwd-proof",
},
},
locked_branch="feat/issue-398-other-branch",
phase=PHASE_LOCK,
)
self.assertTrue(result["block"])
def test_commit_phase_maps_to_commit_outcome(self):
prs = [{
"number": 411,
"title": "x",
"body": "Closes #398",
"head": {"ref": "feat/issue-398-validation-cwd-proof"},
}]
result = assess_work_issue_duplicate_gate(
398,
open_prs=prs,
branch_names=[],
locked_branch="feat/issue-398-alt",
phase=PHASE_COMMIT,
)
self.assertTrue(result["block"])
self.assertEqual(result["outcome"], OUTCOME_DUPLICATE_COMMIT_PREVENTED)
def test_stale_claim_does_not_block_by_status_alone(self):
result = assess_work_issue_duplicate_gate(
400,
open_prs=[],
branch_names=[],
claim_entry={"status": "reclaimable", "reasons": ["stale"]},
locked_branch="feat/issue-400-duplicate-work-preflight",
phase=PHASE_LOCK,
)
self.assertFalse(result["block"])
class TestDuplicateReportOutcome(unittest.TestCase):
def test_requires_exactly_one_outcome(self):
bad = assess_work_issue_duplicate_report("work finished")
self.assertFalse(bad["complete"])
good = assess_work_issue_duplicate_report(
"Duplicate work not prevented for issue #400."
)
self.assertTrue(good["complete"])
self.assertEqual(good["outcome"], OUTCOME_DUPLICATE_WORK_NOT_PREVENTED)
class TestInjectableDuplicateFetcher(unittest.TestCase):
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.get_auth_header", return_value="token x")
def test_lock_issue_uses_injected_fetcher(self, _auth, _api):
seen = {}
def fetcher(h, o, r, auth, issue_number):
seen["issue_number"] = issue_number
return [], [], {"status": "not_claimed"}
with patch(
"mcp_server.issue_duplicate_context_fetcher",
side_effect=fetcher,
), patch(
"mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value={
"current_branch": "master",
"porcelain_status": "",
"base_equivalent": True,
},
):
with tempfile.TemporaryDirectory() as lock_dir:
with patch.dict(os.environ, {
"GITEA_ALLOWED_OPERATIONS": "gitea.issue.comment",
"GITEA_ISSUE_LOCK_DIR": lock_dir,
}, clear=True):
mcp_server.gitea_lock_issue(
issue_number=400,
branch_name="feat/issue-400-duplicate-work-preflight",
remote="prgs",
)
self.assertEqual(seen["issue_number"], 400)
class TestMcpDuplicateRecheck(unittest.TestCase):
def setUp(self):
self._dir = tempfile.TemporaryDirectory()
self._env_patch = patch.dict(
os.environ,
{"GITEA_ISSUE_LOCK_DIR": self._dir.name},
clear=False,
)
self._env_patch.start()
self._remotes = patch.dict(mcp_server.REMOTES, {
"prgs": {"host": "gitea.example.com", "org": "Example-Org",
"repo": "Example-Repo"},
})
self._remotes.start()
mcp_server._IDENTITY_CACHE.clear()
def tearDown(self):
patch.stopall()
self._dir.cleanup()
def _write_lock(self, issue_number=400, branch="feat/issue-400-x"):
worktree_path = os.path.realpath(os.getcwd())
work_lease = {
"operation_type": "author_issue_work",
"issue_number": issue_number,
"branch": branch,
"claimant": {"username": "test-user", "profile": "test-author"},
"expires_at": "2999-01-01T00:00:00Z",
}
issue_lock_store.bind_session_lock({
"issue_number": issue_number,
"branch_name": branch,
"remote": "prgs",
"org": "Example-Org",
"repo": "Example-Repo",
"worktree_path": worktree_path,
"work_lease": work_lease,
"lock_provenance": issue_lock_provenance.build_sanctioned_lock_provenance(
tool="gitea_lock_issue",
claimant=work_lease.get("claimant"),
),
})
@patch("mcp_server._assess_issue_duplicate_gate")
@patch("mcp_server.get_profile", return_value={
"profile_name": "test-author",
"allowed_operations": ["gitea.read", "gitea.repo.commit"],
"forbidden_operations": [],
"audit_label": "test-author",
})
@patch("mcp_server.get_auth_header", return_value="token x")
def test_commit_files_blocked_on_recheck(self, _auth, _profile, mock_gate):
self._write_lock()
mock_gate.return_value = {
"block": True,
"reasons": ["open PR #412 already covers issue #400"],
"outcome": OUTCOME_DUPLICATE_COMMIT_PREVENTED,
"safe_next_action": "stop",
}
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="author")
with patch(
"mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []),
):
result = mcp_server.gitea_commit_files(
files=[{
"operation": "create",
"path": "a.txt",
"content_plain": "hi",
}],
message="test",
remote="prgs",
)
self.assertFalse(result["success"])
self.assertIn("duplicate_gate", result)
@patch("mcp_server._assess_issue_duplicate_gate")
@patch("mcp_server.get_profile", return_value={
"profile_name": "test-author",
"allowed_operations": ["gitea.read", "gitea.pr.create"],
"forbidden_operations": [],
"audit_label": "test-author",
})
@patch("mcp_server.get_auth_header", return_value="token x")
def test_create_pr_returns_handoff_on_duplicate(self, _auth, _profile, mock_gate):
self._write_lock()
mock_gate.return_value = {
"block": True,
"reasons": ["open PR #412 already covers issue #400"],
"outcome": OUTCOME_DUPLICATE_PR_PREVENTED,
"safe_next_action": "reconciliation handoff",
}
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", resolved_role="author")
with patch(
"mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []),
):
result = mcp_server.gitea_create_pr(
title="feat: x (Closes #400)",
head="feat/issue-400-x",
base="master",
body="Closes #400",
remote="prgs",
worktree_path=os.path.realpath(os.getcwd()),
)
self.assertFalse(result["success"])
self.assertIsNone(result.get("number"))
self.assertIn("duplicate_gate", result)
if __name__ == "__main__":
unittest.main()
+8 -4
View File
@@ -122,15 +122,19 @@ class TestShaCannotBypassSelfReview(unittest.TestCase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_review_tool_refuses_self_approval_despite_sha(self, _auth, mock_api, mock_get_all):
mock_get_all.return_value = [{"number": 9, "title": "PR 9", "state": "open", "head": {"ref": "branch9", "sha": "abc1234"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "jcwalker3"}}]
from mcp_server import init_review_decision_lock
from tests.test_mcp_server import FULL_HEAD_SHA, _seed_ready_review_decision
head_sha = FULL_HEAD_SHA
mock_get_all.return_value = [{"number": 9, "title": "PR 9", "state": "open", "head": {"ref": "branch9", "sha": head_sha}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "jcwalker3"}}]
mock_api.side_effect = [
{"login": "jcwalker3"}, # /user (inventory)
{"login": "jcwalker3"}, # /user (submit eligibility)
{"user": {"login": "jcwalker3"}, "state": "open", "head": {"sha": "abc1234"}, "mergeable": True}, # /pulls/9
{"user": {"login": "jcwalker3"}, "state": "open", "head": {"sha": head_sha}, "mergeable": True}, # /pulls/9
]
from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
init_review_decision_lock("prgs", "review_pr")
gitea_mark_final_review_decision(9, "approve", remote="prgs")
with patch("mcp_server._list_pr_lease_comments", return_value=[]):
_seed_ready_review_decision(9, "approve", sha=head_sha, remote="prgs")
env = self._env(SHA_WOULD_BE_REVIEWER, "reviewer")
with patch.dict(os.environ, env, clear=True):
r = gitea_review_pr(
+54
View File
@@ -100,6 +100,8 @@ def test_work_issue_workflow_contract():
assert "canonical: true" in text
assert "Do not merge your own PR" in text
assert "## 21. PR creation rules" in text
assert "gitea_route_task_session" in text
assert "work-issue" in text
def test_pr_queue_cleanup_workflow_contract():
@@ -139,6 +141,16 @@ def test_start_issue_template_references_work_issue_workflow():
assert "workflows/work-issue.md" in text
def test_reconcile_skill_registered_in_mcp_server():
from mcp_server import _PROJECT_SKILLS
assert "gitea-reconcile-landed-pr" in _PROJECT_SKILLS
steps = _PROJECT_SKILLS["gitea-reconcile-landed-pr"]["steps"]
joined = " ".join(steps).lower()
assert "gitea_scan_already_landed_open_prs" in joined
assert "gitea_assess_already_landed_reconciliation" in joined
def test_pr_queue_cleanup_template_references_workflow():
text = (SKILL_DIR / "templates" / "pr-queue-cleanup.md").read_text(
encoding="utf-8"
@@ -165,12 +177,24 @@ def test_final_report_validator_exported():
assert callable(assess_final_report_validator)
def test_review_final_report_schema_verifier_exported():
from review_proofs import assess_review_final_report_schema
assert callable(assess_review_final_report_schema)
def test_create_issue_final_report_verifier_exported():
from review_proofs import assess_create_issue_final_report
assert callable(assess_create_issue_final_report)
def test_work_issue_final_report_verifier_exported():
from review_proofs import assess_work_issue_final_report
assert callable(assess_work_issue_final_report)
def test_merge_simulation_verifier_exported():
from review_proofs import assess_merge_simulation_report
@@ -183,6 +207,18 @@ def test_validation_integrity_verifier_exported():
assert callable(assess_validation_integrity_report)
def test_validation_failure_history_verifier_exported():
from review_proofs import assess_validation_failure_history_report
assert callable(assess_validation_failure_history_report)
def test_validation_cwd_proof_verifier_exported():
from review_proofs import assess_validation_cwd_proof_report
assert callable(assess_validation_cwd_proof_report)
def test_prior_blocker_skip_verifier_exported():
from review_proofs import assess_prior_blocker_skip_proof
@@ -201,6 +237,12 @@ def test_validation_worktree_edit_verifier_exported():
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
@@ -219,6 +261,12 @@ def test_inventory_worktree_verifier_exported():
assert callable(assess_inventory_worktree_report)
def test_proof_backed_handoff_verifier_exported():
from review_proofs import assess_proof_backed_handoff_report
assert callable(assess_proof_backed_handoff_report)
def test_infra_stop_handoff_verifier_exported():
from review_proofs import assess_infra_stop_handoff_report
@@ -237,6 +285,12 @@ def test_worktree_ownership_verifier_exported():
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
+672 -143
View File
File diff suppressed because it is too large Load Diff
+59
View File
@@ -0,0 +1,59 @@
"""Hermetic tests for merge approval head pinning (#471)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from merge_approval_gate import assess_merge_approval_head # noqa: E402
HEAD_OLD = "8b61c4b41f1b49b271ed3b99657431cf06eeda3e"
HEAD_NEW = "3e4b721d60e97147ba0704773cf57cd0d42cbe31"
class TestMergeApprovalGate(unittest.TestCase):
def test_fresh_approval_at_current_head(self):
result = assess_merge_approval_head(
current_head_sha=HEAD_NEW,
latest_by_reviewer={
"reviewer1": {
"verdict": "APPROVED",
"dismissed": False,
"reviewed_head_sha": HEAD_NEW,
"submitted_at": "2026-07-06T12:00:00Z",
}
},
)
self.assertTrue(result["approval_at_current_head"])
self.assertIsNone(result["stale_approval_block_reason"])
def test_stale_approval_after_rebase(self):
result = assess_merge_approval_head(
current_head_sha=HEAD_NEW,
latest_by_reviewer={
"reviewer1": {
"verdict": "APPROVED",
"dismissed": False,
"reviewed_head_sha": HEAD_OLD,
"submitted_at": "2026-07-06T10:00:00Z",
}
},
)
self.assertFalse(result["approval_at_current_head"])
self.assertEqual(result["latest_approved_head_sha"], HEAD_OLD)
self.assertIn("stale approval", result["stale_approval_block_reason"])
self.assertIn(HEAD_OLD, result["stale_approval_block_reason"])
self.assertIn(HEAD_NEW, result["stale_approval_block_reason"])
self.assertIn("re-review PR at current head", result["stale_approval_block_reason"])
def test_no_approval_entries(self):
result = assess_merge_approval_head(
current_head_sha=HEAD_NEW,
latest_by_reviewer={},
)
self.assertFalse(result["approval_at_current_head"])
self.assertIsNone(result["latest_approved_head_sha"])
if __name__ == "__main__":
unittest.main()
+6 -2
View File
@@ -230,7 +230,9 @@ class TestEligibilityDenialReport(PermissionReportBase):
return PR_PAYLOAD
mock_api.side_effect = fake_api
mcp_server.init_review_decision_lock("prgs", "review_pr")
mcp_server.gitea_mark_final_review_decision(42, "approve", remote="prgs")
from tests.test_mcp_server import _seed_ready_review_decision
_seed_ready_review_decision(42, "approve", remote="prgs")
with patch.dict(os.environ, self._env("author-profile")):
res = mcp_server.gitea_submit_pr_review(
pr_number=42, action="approve", body="lgtm", remote="prgs",
@@ -272,7 +274,9 @@ class TestReviewCommentPathUsesCanonicalOp(PermissionReportBase):
return PR_PAYLOAD
mock_api.side_effect = fake_api
mcp_server.init_review_decision_lock("prgs", "review_pr")
mcp_server.gitea_mark_final_review_decision(42, "comment", remote="prgs")
from tests.test_mcp_server import _seed_ready_review_decision
_seed_ready_review_decision(42, "comment", remote="prgs")
with patch.dict(os.environ, self._env("author-profile")):
res = mcp_server.gitea_submit_pr_review(
pr_number=42, action="comment", body="finding", remote="prgs",
@@ -0,0 +1,76 @@
"""Regression tests for non-list API payloads on PR/issue comment listing (#485)."""
import os
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
from mcp_server import ( # noqa: E402
_list_pr_lease_comments,
gitea_list_issue_comments,
)
FAKE_AUTH = "Basic dGVzdDp0ZXN0"
AUTHOR_ENV = {
"GITEA_PROFILE_NAME": "gitea-author",
"GITEA_ALLOWED_OPERATIONS": "gitea.read,gitea.issue.comment",
}
class TestPrLeaseCommentsNonListGuard(unittest.TestCase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_list_pr_lease_comments_non_list_payload_returns_empty(self, _auth, mock_api):
mock_api.return_value = {"message": "Unauthorized"}
result = _list_pr_lease_comments(
12, remote="prgs", host=None, org=None, repo=None)
self.assertEqual(result, [])
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_list_pr_lease_comments_none_returns_empty(self, _auth, mock_api):
mock_api.return_value = None
result = _list_pr_lease_comments(
12, remote="prgs", host=None, org=None, repo=None)
self.assertEqual(result, [])
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_list_pr_lease_comments_list_payload_unchanged(self, _auth, mock_api):
comment = {"id": 7, "body": "<!-- mcp-review-lease:v1 -->"}
mock_api.return_value = [comment]
result = _list_pr_lease_comments(
12, remote="prgs", host=None, org=None, repo=None, limit=5)
self.assertEqual(result, [comment])
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_list_issue_comments_non_list_payload_returns_empty(self, _auth, mock_api):
mock_api.return_value = {"message": "Unauthorized"}
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
result = gitea_list_issue_comments(issue_number=9, remote="prgs")
self.assertTrue(result["success"])
self.assertEqual(result["comments"], [])
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_list_issue_comments_list_payload_unchanged(self, _auth, mock_api):
mock_api.return_value = [
{
"id": 101,
"user": {"login": "alice"},
"body": "hello",
"created_at": "2026-07-03T00:00:00Z",
"updated_at": "2026-07-03T01:00:00Z",
}
]
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
result = gitea_list_issue_comments(issue_number=9, remote="prgs")
self.assertTrue(result["success"])
self.assertEqual(len(result["comments"]), 1)
self.assertEqual(result["comments"][0]["author"], "alice")
if __name__ == "__main__":
unittest.main()
+30 -9
View File
@@ -128,18 +128,29 @@ class TestPRQueueInventory(unittest.TestCase):
"forbidden_operations": [],
"base_url": None,
}
from mcp_server import init_review_decision_lock
from tests.test_mcp_server import (
FULL_HEAD_SHA,
_install_owned_reviewer_lease,
_seed_ready_review_decision,
)
import reviewer_pr_lease
head_sha = FULL_HEAD_SHA
mock_fetch.return_value = _final_page_fetch([
{"number": 1, "title": "PR 1", "state": "open", "head": {"ref": "branch1", "sha": "abc1"}, "base": {"ref": "master"}, "mergeable": True, "user": {"login": "other_user"}}
{"number": 1, "title": "PR 1", "state": "open",
"head": {"ref": "branch1", "sha": head_sha},
"base": {"ref": "master"}, "mergeable": True,
"user": {"login": "other_user"}}
])
# mock_api: inventory whoami, eligibility whoami, eligibility PR,
# POST review (#244: state + visible-verdict GET reviews).
mock_api.side_effect = [
{"login": "reviewer1"},
{"login": "reviewer1"},
{
"user": {"login": "other_user"},
"state": "open",
"head": {"sha": "abc1"},
"head": {"sha": head_sha},
"mergeable": True,
},
{"id": 100, "state": "APPROVED"},
@@ -148,17 +159,27 @@ class TestPRQueueInventory(unittest.TestCase):
"id": 100,
"user": {"login": "reviewer1"},
"state": "APPROVED",
"commit_id": "abc1",
"commit_id": head_sha,
"submitted_at": "2026-07-06T10:00:00Z",
"dismissed": False,
}
],
]
from mcp_server import init_review_decision_lock, gitea_mark_final_review_decision
init_review_decision_lock("prgs", "review_pr")
gitea_mark_final_review_decision(1, "approve", remote="prgs")
result = gitea_review_pr(pr_number=1, event="APPROVE", remote="prgs", final_review_decision_ready=True)
with patch("mcp_server._authenticated_username", return_value="reviewer1"), \
patch("mcp_server._list_pr_lease_comments", return_value=[]):
init_review_decision_lock("prgs", "review_pr")
_seed_ready_review_decision(1, "approve", sha=head_sha, remote="prgs")
lease_patch = _install_owned_reviewer_lease(
1, head_sha=head_sha, session_id="inventory-review-lease",
)
lease_patch.start()
self.addCleanup(lease_patch.stop)
self.addCleanup(reviewer_pr_lease.clear_session_lease)
result = gitea_review_pr(
pr_number=1, event="APPROVE", remote="prgs",
final_review_decision_ready=True,
)
self.assertTrue(result["success"])
self.assertIn("=== PR Queue Inventory ===", result["message"])
self.assertIn("Repository:", result["message"])
+207
View File
@@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""Regression tests for conflict-fix and reviewer PR work leases (#399)."""
from __future__ import annotations
import os
import sys
import unittest
from datetime import datetime, timedelta, timezone
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from pr_work_lease import ( # noqa: E402
CONFLICT_FIX_LEASE_MARKER,
REVIEWER_LEASE_MARKER,
assess_conflict_fix_final_report,
assess_conflict_fix_push,
assess_head_sha_equality,
assess_reviewer_mutation_blocked,
assess_reviewer_stale_head_final_report,
format_conflict_fix_lease_body,
parse_conflict_fix_lease_comment,
parse_reviewer_lease_comment,
)
HEAD_A = "a" * 40
HEAD_B = "b" * 40
NOW = datetime(2026, 7, 7, 15, 0, tzinfo=timezone.utc)
def _reviewer_lease_body(*, phase: str = "validating", expires_minutes: int = 60) -> str:
expires = (NOW + timedelta(minutes=expires_minutes)).isoformat().replace("+00:00", "Z")
return "\n".join([
REVIEWER_LEASE_MARKER,
"pr: #376",
"phase: " + phase,
f"candidate_head: {HEAD_A}",
f"expires_at: {expires}",
"profile: prgs-reviewer",
])
def _conflict_fix_body(*, phase: str = "claimed", worktree: str = "branches/fix-376") -> str:
expires = (NOW + timedelta(minutes=60)).isoformat().replace("+00:00", "Z")
return "\n".join([
CONFLICT_FIX_LEASE_MARKER,
"pr: #376",
f"phase: {phase}",
f"worktree: {worktree}",
f"head_before: {HEAD_A}",
f"expires_at: {expires}",
"profile: prgs-author",
])
class TestLeaseParsing(unittest.TestCase):
def test_parse_reviewer_lease(self):
parsed = parse_reviewer_lease_comment(_reviewer_lease_body())
self.assertEqual(parsed["pr_number"], 376)
self.assertEqual(parsed["phase"], "validating")
self.assertEqual(parsed["candidate_head"], HEAD_A)
def test_parse_conflict_fix_lease(self):
parsed = parse_conflict_fix_lease_comment(_conflict_fix_body())
self.assertEqual(parsed["pr_number"], 376)
self.assertEqual(parsed["phase"], "claimed")
class TestConflictFixPushGate(unittest.TestCase):
def test_blocks_push_during_active_reviewer_lease(self):
comments = [{"body": _reviewer_lease_body()}]
result = assess_conflict_fix_push(
pr_number=376,
comments=comments,
branch_head_before=HEAD_A,
branch_head_after=HEAD_B,
worktree_path="branches/fix-376",
push_cwd="/proj/branches/fix-376",
is_fast_forward=True,
now=NOW,
)
self.assertFalse(result["push_allowed"])
self.assertTrue(any("reviewer lease" in r for r in result["reasons"]))
def test_rejects_non_fast_forward(self):
result = assess_conflict_fix_push(
pr_number=376,
comments=[],
branch_head_before=HEAD_A,
branch_head_after=HEAD_B,
worktree_path="branches/fix-376",
push_cwd="/proj/branches/fix-376",
is_fast_forward=False,
now=NOW,
)
self.assertFalse(result["push_allowed"])
self.assertTrue(any("non-fast-forward" in r for r in result["reasons"]))
def test_wrong_cwd_push_attempt(self):
result = assess_conflict_fix_push(
pr_number=376,
comments=[],
branch_head_before=HEAD_A,
branch_head_after=HEAD_B,
worktree_path="branches/fix-376",
push_cwd="/proj/master",
is_fast_forward=True,
now=NOW,
)
self.assertFalse(result["push_allowed"])
self.assertTrue(any("cwd" in r.lower() for r in result["reasons"]))
def test_sibling_conflict_fix_collision(self):
comments = [{"body": _conflict_fix_body(phase="pushing", worktree="branches/other")}]
result = assess_conflict_fix_push(
pr_number=376,
comments=comments,
branch_head_before=HEAD_A,
branch_head_after=HEAD_B,
worktree_path="branches/fix-376",
push_cwd="/proj/branches/fix-376",
is_fast_forward=True,
now=NOW,
)
self.assertFalse(result["push_allowed"])
self.assertTrue(any("sibling conflict-fix" in r for r in result["reasons"]))
class TestReviewerMutationGate(unittest.TestCase):
def test_blocks_review_during_conflict_fix(self):
comments = [{"body": _conflict_fix_body(phase="pushing")}]
result = assess_reviewer_mutation_blocked(
pr_number=376,
comments=comments,
reviewed_head_sha=HEAD_A,
live_head_sha=HEAD_A,
mutation="approve",
now=NOW,
)
self.assertFalse(result["mutation_allowed"])
self.assertTrue(any("conflict-fix lease" in r for r in result["reasons"]))
def test_stale_head_blocks_approval(self):
result = assess_reviewer_mutation_blocked(
pr_number=376,
comments=[],
reviewed_head_sha=HEAD_A,
live_head_sha=HEAD_B,
mutation="merge",
now=NOW,
)
self.assertFalse(result["mutation_allowed"])
self.assertTrue(result["head_check"]["head_changed"])
def test_head_equality_required_fields(self):
result = assess_head_sha_equality(HEAD_A, HEAD_B)
self.assertFalse(result["proven"])
self.assertTrue(result["head_changed"])
class TestFinalReportProof(unittest.TestCase):
def test_reviewer_stale_head_report_requires_fields(self):
result = assess_reviewer_stale_head_final_report("no head proof here")
self.assertFalse(result["proven"])
def test_reviewer_stale_head_report_passes(self):
report = "\n".join([
f"Reviewed head SHA: {HEAD_A}",
f"Final live head SHA before approval: {HEAD_A}",
f"Final live head SHA before merge: {HEAD_A}",
"Push occurred during validation: no",
])
result = assess_reviewer_stale_head_final_report(report)
self.assertTrue(result["proven"])
def test_conflict_fix_report_requires_fields(self):
result = assess_conflict_fix_final_report("incomplete")
self.assertFalse(result["proven"])
def test_conflict_fix_report_passes(self):
report = "\n".join([
f"Branch head before push: {HEAD_A}",
f"Branch head after push: {HEAD_B}",
"Active reviewer lease status: none",
"Whether push was fast-forward: yes",
"Whether any reviewer was active: no",
])
result = assess_conflict_fix_final_report(report)
self.assertTrue(result["proven"])
class TestFormatLease(unittest.TestCase):
def test_format_conflict_fix_lease_includes_marker(self):
body = format_conflict_fix_lease_body(
pr_number=376,
branch="feat/x",
worktree="branches/fix-376",
profile="prgs-author",
head_before=HEAD_A,
)
self.assertIn(CONFLICT_FIX_LEASE_MARKER, body)
parsed = parse_conflict_fix_lease_comment(body)
self.assertEqual(parsed["pr_number"], 376)
if __name__ == "__main__":
unittest.main()
+159
View File
@@ -0,0 +1,159 @@
"""#469/#470: capability preflight lifetime across safe read-only calls."""
import os
import unittest
import gitea_mcp_server as mcp_server
class TestPreflightReadSurvival(unittest.TestCase):
def setUp(self):
self.orig_whoami = mcp_server._preflight_whoami_called
self.orig_capability = mcp_server._preflight_capability_called
self.orig_whoami_violation = mcp_server._preflight_whoami_violation
self.orig_capability_violation = mcp_server._preflight_capability_violation
self.orig_resolved_role = mcp_server._preflight_resolved_role
self.orig_resolved_task = mcp_server._preflight_resolved_task
self.orig_process_start = mcp_server._process_start_porcelain
self.orig_whoami_baseline = mcp_server._preflight_whoami_baseline_porcelain
self.orig_capability_baseline = mcp_server._preflight_capability_baseline_porcelain
for key in ("GITEA_TEST_FORCE_DIRTY", "GITEA_TEST_PORCELAIN"):
if key in os.environ:
del os.environ[key]
os.environ["GITEA_TEST_PORCELAIN"] = ""
mcp_server._preflight_whoami_called = False
mcp_server._preflight_capability_called = False
mcp_server._preflight_whoami_violation = False
mcp_server._preflight_capability_violation = False
mcp_server._preflight_resolved_role = None
mcp_server._preflight_resolved_task = None
mcp_server._process_start_porcelain = ""
mcp_server._preflight_whoami_baseline_porcelain = None
mcp_server._preflight_capability_baseline_porcelain = None
def tearDown(self):
mcp_server._preflight_whoami_called = self.orig_whoami
mcp_server._preflight_capability_called = self.orig_capability
mcp_server._preflight_whoami_violation = self.orig_whoami_violation
mcp_server._preflight_capability_violation = self.orig_capability_violation
mcp_server._preflight_resolved_role = self.orig_resolved_role
mcp_server._preflight_resolved_task = self.orig_resolved_task
mcp_server._process_start_porcelain = self.orig_process_start
mcp_server._preflight_whoami_baseline_porcelain = self.orig_whoami_baseline
mcp_server._preflight_capability_baseline_porcelain = self.orig_capability_baseline
for key in ("GITEA_TEST_FORCE_DIRTY", "GITEA_TEST_PORCELAIN"):
if key in os.environ:
del os.environ[key]
def test_interleaved_whoami_preserves_capability(self):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability", resolved_role="reconciler", resolved_task="close_pr"
)
self.assertTrue(mcp_server._preflight_capability_called)
self.assertEqual(mcp_server._preflight_resolved_task, "close_pr")
mcp_server.record_preflight_check("whoami")
self.assertTrue(mcp_server._preflight_capability_called)
self.assertEqual(mcp_server._preflight_resolved_task, "close_pr")
mcp_server.verify_preflight_purity(task="close_pr")
self.assertFalse(mcp_server._preflight_capability_called)
def test_missing_capability_still_fails_closed(self):
mcp_server.record_preflight_check("whoami")
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity(task="close_pr")
self.assertIn("has not been resolved", str(ctx.exception))
def test_task_mismatch_fails_closed(self):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability", resolved_role="author", resolved_task="create_issue"
)
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity(task="close_pr")
self.assertIn("task mismatch", str(ctx.exception))
def test_capability_consumed_after_mutation_gate(self):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability", resolved_role="author", resolved_task="create_issue"
)
mcp_server.verify_preflight_purity(task="create_issue")
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity(task="create_issue")
self.assertIn("has not been resolved", str(ctx.exception))
def test_whoami_recovery_after_violation_clears_capability(self):
os.environ["GITEA_TEST_FORCE_DIRTY"] = "1"
mcp_server.record_preflight_check("whoami")
self.assertTrue(mcp_server._preflight_whoami_violation)
del os.environ["GITEA_TEST_FORCE_DIRTY"]
os.environ["GITEA_TEST_PORCELAIN"] = ""
mcp_server.record_preflight_check("whoami")
self.assertFalse(mcp_server._preflight_whoami_violation)
self.assertFalse(mcp_server._preflight_capability_called)
mcp_server.record_preflight_check(
"capability", resolved_role="reviewer", resolved_task="review_pr"
)
mcp_server.verify_preflight_purity(task="review_pr")
def test_close_pr_sequence_with_interleaved_reads(self):
"""resolve(close_pr) → whoami/view reads → close_pr gate (#470)."""
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability", resolved_role="reconciler", resolved_task="close_pr"
)
# Simulate live-state revalidation between resolve and mutation.
mcp_server.record_preflight_check("whoami")
self.assertTrue(mcp_server._preflight_capability_called)
self.assertEqual(mcp_server._preflight_resolved_task, "close_pr")
mcp_server.verify_preflight_purity(task="close_pr")
self.assertFalse(mcp_server._preflight_capability_called)
def test_fresh_capability_resolve_replaces_prior_task(self):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability", resolved_role="author", resolved_task="create_issue"
)
mcp_server.record_preflight_check(
"capability", resolved_role="reconciler", resolved_task="close_pr"
)
self.assertEqual(mcp_server._preflight_resolved_task, "close_pr")
mcp_server.verify_preflight_purity(task="close_pr")
def test_missing_capability_error_names_re_resolve(self):
mcp_server.record_preflight_check("whoami")
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity(task="close_pr")
msg = str(ctx.exception)
self.assertIn("gitea_resolve_task_capability", msg)
self.assertIn('task="close_pr"', msg)
def test_task_mismatch_error_names_re_resolve(self):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability", resolved_role="author", resolved_task="create_issue"
)
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity(task="close_pr")
msg = str(ctx.exception)
self.assertIn("task mismatch", msg)
self.assertIn('task="close_pr"', msg)
def test_consumed_capability_error_names_re_resolve(self):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability", resolved_role="reconciler", resolved_task="close_pr"
)
mcp_server.verify_preflight_purity(task="close_pr")
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity(task="close_pr")
self.assertIn('task="close_pr"', str(ctx.exception))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,89 @@
"""Reconciler close_pr must not require author branches/ worktree (#468)."""
import os
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import gitea_mcp_server as srv
FAKE_AUTH = "token test"
CONTROL_CHECKOUT_ROOT = str(Path(__file__).resolve().parents[3])
RECONCILER_PROFILE = {
"profile_name": "prgs-reconciler",
"allowed_operations": ["gitea.read", "gitea.pr.close", "gitea.pr.comment"],
"forbidden_operations": [
"gitea.pr.approve",
"gitea.pr.merge",
"gitea.pr.review",
"gitea.pr.create",
"gitea.branch.push",
"gitea.repo.commit",
],
"audit_label": "prgs-reconciler",
}
class TestReconcilerCloseWorkspaceGuard(unittest.TestCase):
def setUp(self):
srv._preflight_whoami_called = True
srv._preflight_capability_called = True
srv._preflight_whoami_violation = False
srv._preflight_capability_violation = False
self._orig_in_test = srv._preflight_in_test_mode
srv._preflight_in_test_mode = lambda: False
def tearDown(self):
srv._preflight_in_test_mode = self._orig_in_test
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
@patch("gitea_mcp_server.get_profile", return_value=RECONCILER_PROFILE)
@patch("gitea_mcp_server.api_request")
def test_reconciler_close_pr_from_control_checkout_succeeds(
self, mock_api, _profile, _ns, _auth
):
srv._preflight_resolved_role = "reconciler"
mock_api.return_value = {
"number": 414,
"title": "old",
"body": "",
"state": "closed",
"html_url": "https://gitea.example.com/pulls/414",
}
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
with patch.dict(os.environ, {}, clear=False):
os.environ.pop("GITEA_AUTHOR_WORKTREE", None)
os.environ.pop("GITEA_ACTIVE_WORKTREE", None)
result = srv.gitea_edit_pr(414, state="closed", remote="prgs")
self.assertTrue(result["success"])
self.assertEqual(result["state"], "closed")
mock_api.assert_called_once()
@patch("gitea_mcp_server._auth", return_value=FAKE_AUTH)
@patch("gitea_mcp_server._profile_permission_block", return_value=None)
@patch("gitea_mcp_server._namespace_mutation_block", return_value=None)
@patch(
"gitea_mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []),
)
@patch("gitea_mcp_server.api_get_all", return_value=[])
@patch(
"gitea_mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value={"current_branch": "master"},
)
def test_author_create_issue_still_blocked_on_control_checkout(
self, _git, _get_all, _role, _ns, _prof, _auth
):
srv._preflight_resolved_role = "author"
with patch.object(srv, "PROJECT_ROOT", CONTROL_CHECKOUT_ROOT):
with self.assertRaises(RuntimeError) as ctx:
srv.gitea_create_issue(title="Test", body="body")
self.assertIn("stable control checkout", str(ctx.exception))
if __name__ == "__main__":
unittest.main()
+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()
+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()
+26
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):
+16
View File
@@ -115,6 +115,22 @@ class TestPRReviewFeedbackDiscovery(unittest.TestCase):
result["latest_review_state_by_reviewer"], {"reviewer1": "APPROVED"})
self.assertFalse(result["has_blocking_change_requests"])
self.assertTrue(result["approval_visible"])
self.assertTrue(result["approval_at_current_head"])
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@patch("mcp_server.get_profile")
def test_stale_approval_not_at_current_head(self, mock_get_profile, _auth, mock_api):
mock_get_profile.return_value = self._profile()
mock_api.side_effect = [
_pr_details(head_sha="newhead3"),
[_review("reviewer1", "APPROVED", commit_id="oldhead1")],
]
result = gitea_get_pr_review_feedback(pr_number=5, remote="prgs")
self.assertTrue(result["approval_visible"])
self.assertFalse(result["approval_at_current_head"])
self.assertEqual(result["latest_approved_head_sha"], "oldhead1")
self.assertIn("stale approval", result["stale_approval_block_reason"])
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
+162
View File
@@ -0,0 +1,162 @@
"""Tests for reviewer final-report schema verification (#391)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from review_final_report_schema import assess_review_final_report_schema # noqa: E402
def _minimal_review_report(**overrides):
lines = [
"## Controller Handoff",
"",
"- Task: review PR #203",
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
"- Role: reviewer",
"- Identity: sysadmin / prgs-reviewer",
"- Issue/PR: #182 / PR #203",
"- Branch/SHA: feat/x @ 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Files changed: review_proofs.py",
"- Validation: pass: pytest tests/test_review_proofs.py -q in branches/review-203",
"- Reviewed head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Final live head SHA before approval: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Final live head SHA before merge: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Push occurred during validation: no",
"- Mutations: review only",
"- File edits by reviewer: none",
"- Worktree/index mutations: none",
"- Git ref mutations: git fetch prgs master",
"- MCP/Gitea mutations: review submitted",
"- Review mutations: request_changes submitted",
"- Merge mutations: none",
"- Cleanup mutations: none",
"- External-state mutations: none",
"- Read-only diagnostics: gitea_view_pr, gitea_view_issue",
"- Current status: PR open",
"- Blockers: none",
"- Next: await author",
"- Safety: no self-review; no self-merge",
"- Selected PR: #203",
"- Reviewer eligibility: eligible",
"- Candidate head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Worktree path: branches/review-203",
"- Worktree dirty: clean",
"- Unrelated local mutations: none",
"- Review decision: request_changes",
"- Merge result: not attempted",
"- Linked issue: #182",
"- Linked issue live status: open (gitea_view_issue)",
"- Cleanup status: none",
]
text = "\n".join(lines)
for key, value in overrides.items():
prefix = f"- {key}:"
replaced = False
new_lines = []
for line in text.splitlines():
if line.strip().startswith(prefix):
new_lines.append(f"{prefix} {value}")
replaced = True
else:
new_lines.append(line)
text = "\n".join(new_lines)
if not replaced:
text += f"\n{prefix} {value}"
return text
class TestReviewFinalReportSchema(unittest.TestCase):
def test_clean_report_passes(self):
result = assess_review_final_report_schema(
_minimal_review_report(),
linked_issue_lock={"issue_number": 182, "pr_number": 203},
)
self.assertFalse(result["blocked"])
self.assertIn(result["grade"], ("A", "downgraded"))
def test_legacy_pinned_reviewed_head_blocks(self):
report = _minimal_review_report(**{"Pinned reviewed head": "abc123"})
result = assess_review_final_report_schema(report)
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertIn("reviewer.legacy_stale_field", rule_ids)
def test_reviewed_head_without_validation_blocks(self):
report = _minimal_review_report().replace(
"- Validation: pass: pytest tests/test_review_proofs.py -q in branches/review-203",
"- Validation: not run",
)
report += "\n- Pinned reviewed head: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9"
result = assess_review_final_report_schema(report)
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertIn("reviewer.reviewed_head_without_validation", rule_ids)
def test_merged_without_proof_blocks(self):
report = _minimal_review_report() + "\n\nPR #203 merged successfully."
result = assess_review_final_report_schema(report)
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertIn("reviewer.merged_without_proof", rule_ids)
def test_issue_closed_without_live_proof_blocks(self):
report = _minimal_review_report(
**{
"Linked issue live status": "closed",
"Read-only diagnostics": "gitea_view_pr only",
}
)
result = assess_review_final_report_schema(report)
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertIn("reviewer.issue_closed_without_live_proof", rule_ids)
def test_full_suite_pass_with_ignored_tests_blocks(self):
report = (
_minimal_review_report()
+ "\nFull test suite passed with 0 failed; some tests ignored."
)
result = assess_review_final_report_schema(report)
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertIn("reviewer.full_suite_pass_ignored_tests", rule_ids)
def test_blocked_handoff_replay_blocks(self):
report = (
_minimal_review_report(**{"Blockers": "capability stop"})
+ "\nSafe next action: run gitea_merge_pr to finish."
)
result = assess_review_final_report_schema(report)
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertIn("reviewer.blocked_handoff_replay", rule_ids)
def test_narrative_handoff_drift_blocks(self):
report = (
_minimal_review_report()
+ "\n## Summary\nVerdict: APPROVE this PR."
)
result = assess_review_final_report_schema(report)
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertIn("reviewer.narrative_handoff_drift", rule_ids)
def test_pending_vs_approved_blocks(self):
report = _minimal_review_report(
**{"Review decision": "PENDING official review submitted approve"}
)
result = assess_review_final_report_schema(report)
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertIn("reviewer.pending_vs_approved", rule_ids)
def test_exported_from_review_proofs(self):
from review_proofs import assess_review_final_report_schema as exported
self.assertTrue(callable(exported))
class TestMcpValidateReviewFinalReport(unittest.TestCase):
def test_mcp_tool_callable(self):
import gitea_mcp_server
result = gitea_mcp_server.gitea_validate_review_final_report(
_minimal_review_report()
)
self.assertIn("grade", result)
self.assertIn("findings", result)
self.assertFalse(result["blocked"])
+50
View File
@@ -40,6 +40,9 @@ from review_proofs import ( # noqa: E402
assess_create_issue_final_report,
assess_create_issue_mode_isolation,
assess_create_issue_workflow_source,
assess_work_issue_final_report,
assess_work_issue_mode_isolation,
assess_work_issue_workflow_source,
assess_edited_pr_inventory_coverage,
assess_empty_queue_report,
assess_fresh_issue_selection,
@@ -2320,6 +2323,53 @@ class TestCreateIssueFinalReport(unittest.TestCase):
self.assertTrue(result["downgraded"])
class TestWorkIssueFinalReport(unittest.TestCase):
"""#139: work-issue final-report verifier coverage."""
def _good_report(self):
return "\n".join([
"Task mode: work-issue",
"Workflow source: skills/llm-project-workflow/workflows/work-issue.md",
"## Controller Handoff",
"- Task: work-issue",
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
"- Role: author",
"- Identity: jcwalker3 / prgs-author",
"- Active profile: prgs-author",
"- Runtime context: prgs-author on prgs",
"- Selected issue: #139",
"- Branch name: feat/issue-139-role-aware-work-issue-routing",
"- PR number: not created in this session",
"- File edits by author: role_session_router.py",
"- Blockers: none",
"- Current status: implementing routing",
"- Safe next action: open PR",
"- Next: open PR",
"- Safety statement: no review/merge",
"- Duplicate work outcome: duplicate work not prevented",
])
def test_complete_work_issue_report_earns_a(self):
result = assess_work_issue_final_report(self._good_report())
self.assertEqual(result["grade"], "A")
self.assertFalse(result["downgraded"])
def test_missing_workflow_source_downgrades(self):
report = self._good_report().replace(
"workflows/work-issue.md", "SKILL.md only"
)
result = assess_work_issue_workflow_source(report)
self.assertTrue(result["downgraded"])
def test_review_field_in_handoff_downgrades(self):
report = self._good_report().replace(
"- Safety statement:",
"- Review decision: APPROVE\n- Safety statement:",
)
result = assess_work_issue_mode_isolation(report)
self.assertTrue(result["downgraded"])
class TestValidationEnvironmentProof(unittest.TestCase):
"""Issue #311: exact validation command and execution environment."""
@@ -0,0 +1,81 @@
import unittest
from reviewer_already_landed_classification import ( # noqa: E402
ALREADY_LANDED_ELIGIBILITY_CLASS,
assess_already_landed_classification_report,
)
class TestAlreadyLandedClassification(unittest.TestCase):
def test_non_landed_report_passes(self):
result = assess_already_landed_classification_report(
"Selected the next eligible PR: PR #286."
)
self.assertTrue(result["proven"])
self.assertFalse(result["landed_context"])
def test_oldest_eligible_pr_on_landed_blocks(self):
result = assess_already_landed_classification_report(
"PR is already landed.\n"
"Oldest eligible PR: PR #278."
)
self.assertFalse(result["proven"])
self.assertTrue(result["block"])
def test_next_eligible_pr_on_landed_blocks(self):
result = assess_already_landed_classification_report(
"Ancestor proof: passed\n"
"Selected the next eligible PR: PR #278."
)
self.assertFalse(result["proven"])
def test_eligible_for_review_on_landed_blocks(self):
result = assess_already_landed_classification_report(
"Already landed on master and eligible for review next."
)
self.assertFalse(result["proven"])
def test_proper_landed_wording_passes(self):
result = assess_already_landed_classification_report(
"Oldest open PR requiring action: PR #278\n"
f"Eligibility class: {ALREADY_LANDED_ELIGIBILITY_CLASS}\n"
"Candidate head SHA: abc123\n"
"Reviewed head SHA: none\n"
"Queue blocked by already-landed PR requiring reconciliation",
selected_pr_already_landed=True,
)
self.assertTrue(result["proven"])
def test_pinned_reviewed_head_without_validation_blocks(self):
result = assess_already_landed_classification_report(
"Already landed.\n"
"Pinned reviewed head: abc123def456",
selected_pr_already_landed=True,
)
self.assertFalse(result["proven"])
def test_wrong_eligibility_class_blocks(self):
result = assess_already_landed_classification_report(
"Already landed.\n"
"Eligibility class: REVIEW_ELIGIBLE",
selected_pr_already_landed=True,
)
self.assertFalse(result["proven"])
def test_missing_queue_blocker_blocks(self):
result = assess_already_landed_classification_report(
f"Eligibility class: {ALREADY_LANDED_ELIGIBILITY_CLASS}\n"
"Candidate head SHA: abc123\n"
"Reviewed head SHA: none",
selected_pr_already_landed=True,
)
self.assertFalse(result["proven"])
def test_exported_from_review_proofs(self):
from review_proofs import assess_already_landed_classification_report as exported
self.assertTrue(callable(exported))
if __name__ == "__main__":
unittest.main()
+193
View File
@@ -0,0 +1,193 @@
"""Tests for per-PR reviewer leases (#407)."""
import sys
import unittest
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import reviewer_pr_lease as leases
def _lease_comment(
pr_number: int,
session_id: str,
*,
phase: str = "claimed",
minutes_ago: int = 0,
candidate_head: str = "a" * 40,
) -> dict:
now = datetime.now(timezone.utc) - timedelta(minutes=minutes_ago)
body = leases.format_lease_body(
repo="Scaled-Tech-Consulting/Gitea-Tools",
pr_number=pr_number,
issue_number=295,
reviewer_identity="rev1",
profile="prgs-reviewer",
session_id=session_id,
worktree="branches/review-pr382",
phase=phase,
candidate_head=candidate_head,
target_branch="master",
target_branch_sha="b" * 40,
last_activity=now,
)
return {"id": 1, "body": body, "user": {"login": "rev1"}}
class TestReviewerLeaseAcquire(unittest.TestCase):
def setUp(self):
leases.clear_session_lease()
def test_two_reviewers_cannot_lease_same_pr(self):
comments = [_lease_comment(382, "session-a")]
result = leases.assess_acquire_lease(
comments,
pr_number=382,
reviewer_identity="rev2",
profile="prgs-reviewer",
session_id="session-b",
repo="Scaled-Tech-Consulting/Gitea-Tools",
issue_number=295,
worktree="branches/review-pr382-b",
candidate_head="c" * 40,
target_branch="master",
target_branch_sha="d" * 40,
)
self.assertFalse(result["acquire_allowed"])
self.assertTrue(any("already has active" in r for r in result["reasons"]))
def test_two_reviewers_can_lease_different_prs(self):
comments = [_lease_comment(382, "session-a")]
result = leases.assess_acquire_lease(
comments,
pr_number=383,
reviewer_identity="rev2",
profile="prgs-reviewer",
session_id="session-b",
repo="Scaled-Tech-Consulting/Gitea-Tools",
issue_number=296,
worktree="branches/review-pr383",
candidate_head="c" * 40,
target_branch="master",
target_branch_sha="d" * 40,
)
self.assertTrue(result["acquire_allowed"])
self.assertIsNotNone(result["lease_body"])
class TestReviewerLeaseFreshness(unittest.TestCase):
def test_stale_warning_after_30_minutes(self):
lease = leases.parse_lease_comment(
_lease_comment(382, "session-a", minutes_ago=35)["body"]
)
self.assertEqual(
leases.classify_lease_freshness(lease),
"stale_warning",
)
def test_reclaimable_after_60_minutes(self):
lease = leases.parse_lease_comment(
_lease_comment(382, "session-a", minutes_ago=65)["body"]
)
self.assertEqual(
leases.classify_lease_freshness(lease),
"reclaimable",
)
class TestReviewerLeaseMutationGate(unittest.TestCase):
def setUp(self):
leases.clear_session_lease()
def test_reviewer_without_lease_cannot_mutate(self):
head = "f" * 40
comments = [_lease_comment(382, "other-session", candidate_head=head)]
result = leases.assess_mutation_lease_gate(
pr_number=382,
comments=comments,
reviewer_identity="rev1",
session_id="my-session",
mutation="approve",
live_head_sha=head,
pinned_head_sha=head,
)
self.assertTrue(result["block"])
def test_owned_lease_allows_mutation(self):
head = "f" * 40
comments = [_lease_comment(382, "my-session", candidate_head=head)]
leases.record_session_lease({
"pr_number": 382,
"session_id": "my-session",
"candidate_head": head,
"target_branch": "master",
})
result = leases.assess_mutation_lease_gate(
pr_number=382,
comments=comments,
reviewer_identity="rev1",
session_id="my-session",
mutation="approve",
live_head_sha=head,
pinned_head_sha=head,
)
self.assertFalse(result["block"])
def test_head_change_invalidates_lease(self):
reviewed = "f" * 40
live = "e" * 40
comments = [_lease_comment(382, "my-session", candidate_head=reviewed)]
leases.record_session_lease({
"pr_number": 382,
"session_id": "my-session",
"candidate_head": reviewed,
})
result = leases.assess_mutation_lease_gate(
pr_number=382,
comments=comments,
reviewer_identity="rev1",
session_id="my-session",
mutation="merge",
live_head_sha=live,
pinned_head_sha=reviewed,
)
self.assertTrue(result["block"])
self.assertTrue(any("head" in r.lower() for r in result["reasons"]))
class TestReviewerLeaseMcpGate(unittest.TestCase):
def setUp(self):
leases.clear_session_lease()
patch("mcp_server.verify_preflight_purity").start()
patch("gitea_audit.audit_enabled", return_value=False).start()
mcp_server = __import__("mcp_server")
mcp_server._IDENTITY_CACHE.clear()
mcp_server.init_review_decision_lock("prgs", "review_pr")
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check("capability", "reviewer")
def tearDown(self):
patch.stopall()
leases.clear_session_lease()
def test_reviewer_pr_lease_gate_helper_blocks_without_session(self):
import mcp_server
head = "a" * 40
with patch("mcp_server._fetch_pr_comments", return_value=[]):
reasons = mcp_server._reviewer_pr_lease_gate(
pr_number=382,
remote="prgs",
host=None,
org=None,
repo=None,
mutation="approve",
live_head_sha=head,
pinned_head_sha=head,
)
self.assertTrue(any("lease" in r.lower() for r in reasons))
if __name__ == "__main__":
unittest.main()
+117
View File
@@ -0,0 +1,117 @@
"""Tests for proof-backed reviewer handoff verifier (#395)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from reviewer_proof_backed_handoff import ( # noqa: E402
assess_proof_backed_handoff_report,
)
def _good_report() -> str:
return "\n".join([
"Inventory pagination proof: is_final_page: true, has_more: false, total_count: 11",
"Earlier PRs skipped: #372 non-mergeable — merge simulation conflicts in review_proofs.py",
"Baseline worktree path: branches/baseline-master-pr383",
"Baseline target SHA: 4243b60ca32d79f1ee5e6b0f10d7570e9dea2937",
"Baseline dirty before validation: false",
"Baseline validation command: venv/bin/python -m pytest tests/ -q",
"Baseline validation result: 1 failed (test_clean_reviewer_report_passes)",
"Baseline dirty after validation: false",
"Cleanup mutations: git worktree list showed session worktrees removed",
"## Controller Handoff",
"- Inventory pagination proof: inventory_complete: true, total_count: 11",
"- Earlier PRs skipped: #372 conflict proof via merge simulation",
"- Baseline worktree used: true",
"- Baseline worktree path: branches/baseline-master-pr383",
"- Cleanup mutations: git worktree remove branches/review-pr383",
])
class TestProofBackedHandoff(unittest.TestCase):
def test_fully_proof_backed_report_passes(self):
result = assess_proof_backed_handoff_report(
_good_report(),
action_log=[{"command": "gitea_list_prs", "result": "inventory_complete=true"}],
baseline_session={
"complete": True,
"clean_before": True,
"clean_after": True,
"command": "pytest",
"result": "passed",
},
)
self.assertTrue(result["proven"], result["reasons"])
def test_inventory_page_size_assumption_blocks(self):
report = (
"Inventory is complete because 13 results are less than page size 50."
)
result = assess_proof_backed_handoff_report(report)
self.assertFalse(result["proven"])
self.assertTrue(any("page-size" in r.lower() for r in result["reasons"]))
def test_skip_without_conflict_proof_blocks(self):
report = "\n".join([
"Earlier PRs skipped: #372, #374",
"## Controller Handoff",
"- Earlier PRs skipped: #372, #374",
])
result = assess_proof_backed_handoff_report(report)
self.assertFalse(result["proven"])
self.assertTrue(any("skip" in r.lower() for r in result["reasons"]))
def test_baseline_without_command_blocks(self):
report = "Baseline validation passed; same as master failures."
result = assess_proof_backed_handoff_report(report)
self.assertFalse(result["proven"])
self.assertTrue(any("baseline" in r.lower() for r in result["reasons"]))
def test_cleanup_without_worktree_list_blocks(self):
report = "Worktrees were cleaned after merge."
result = assess_proof_backed_handoff_report(report)
self.assertFalse(result["proven"])
self.assertTrue(any("cleanup" in r.lower() for r in result["reasons"]))
def test_cleanup_with_worktree_list_passes(self):
report = "Cleanup: git worktree list confirmed removal."
result = assess_proof_backed_handoff_report(
report,
action_log=[{"command": "git worktree list"}],
)
self.assertTrue(result["proven"], result["reasons"])
def test_master_integration_requires_command(self):
report = "Merged master into the review worktree before validation."
result = assess_proof_backed_handoff_report(report)
self.assertFalse(result["proven"])
self.assertTrue(any("master integration" in r.lower() for r in result["reasons"]))
def test_master_integration_with_action_log_passes(self):
report = "Merged master into review worktree; merge_exit=0"
result = assess_proof_backed_handoff_report(
report,
action_log=[{"command": "git merge --no-commit prgs/master"}],
)
self.assertTrue(result["proven"], result["reasons"])
def test_live_proof_requires_source_classification(self):
report = "Live proof shows inventory complete."
result = assess_proof_backed_handoff_report(report)
self.assertFalse(result["proven"])
self.assertTrue(any("proof source" in r.lower() for r in result["reasons"]))
class TestExport(unittest.TestCase):
def test_review_proofs_reexport(self):
from review_proofs import assess_proof_backed_handoff_report as exported
self.assertTrue(callable(exported))
result = exported(_good_report(), inventory_session={"inventory_complete": True})
self.assertTrue(result["proven"], result["reasons"])
if __name__ == "__main__":
unittest.main()
+102
View File
@@ -0,0 +1,102 @@
"""Tests for reconciliation inventory pagination verifier (#308)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from reviewer_reconcile_inventory import assess_reconcile_inventory_report # noqa: E402
def _good_report() -> str:
return "\n".join([
"## Already-landed reconciliation",
"Open PR inventory:",
"- Requested page size: 50",
"- Pages fetched: 2",
"- Returned PR count per page: page1=50, page2=6",
"- Inventory pagination proof: is_final_page: true, has_more: false",
"All already-landed PRs in the scanned open queue were found.",
"",
"## Controller Handoff",
"- Selected PR: #278",
"- Eligibility class: ALREADY_LANDED_RECONCILE_REQUIRED",
])
class TestReconcileInventoryPagination(unittest.TestCase):
def test_complete_metadata_passes(self):
result = assess_reconcile_inventory_report(_good_report())
self.assertTrue(result["proven"], result["reasons"])
self.assertTrue(result["pagination_proven"])
def test_no_inventory_claim_skips_checks(self):
report = "## Controller Handoff\n- Selected PR: #278"
result = assess_reconcile_inventory_report(report)
self.assertTrue(result["proven"])
self.assertFalse(result["inventory_claimed"])
def test_first_page_below_limit_with_finality_passes(self):
report = "\n".join([
"Open PRs listed for reconciliation.",
"- Requested page size: 50",
"- Pages fetched: 1",
"- Returned PR count per page: page1=6",
"- Inventory pagination proof: is_final_page: true",
])
result = assess_reconcile_inventory_report(report)
self.assertTrue(result["proven"], result["reasons"])
def test_exactly_full_first_page_without_finality_blocks(self):
report = "\n".join([
"Complete queue scan of open PRs.",
"- Requested page size: 50",
"- Pages fetched: 1",
"- Returned PR count per page: page1=50",
"Listed 50 open PRs on the first page.",
])
result = assess_reconcile_inventory_report(
report,
inventory_session={"requested_page_size": 50, "returned_page_size": 50},
)
self.assertFalse(result["proven"])
self.assertTrue(any("full first" in r.lower() for r in result["reasons"]))
def test_missing_pagination_metadata_blocks(self):
report = "All open PRs were inventoried. Inventory is complete."
result = assess_reconcile_inventory_report(report)
self.assertFalse(result["proven"])
self.assertTrue(any("metadata" in r.lower() for r in result["reasons"]))
def test_default_page_size_assumption_blocks(self):
report = (
"Exhaustive PR inventory: fewer than the default Gitea page size returned."
)
result = assess_reconcile_inventory_report(report)
self.assertFalse(result["proven"])
self.assertTrue(any("page-size" in r.lower() or "page size" in r.lower()
for r in result["reasons"]))
def test_session_pagination_complete_passes(self):
report = "All already-landed PRs were found after scanning open PRs."
result = assess_reconcile_inventory_report(
report,
inventory_session={
"pagination_complete": True,
"requested_page_size": 50,
"pages_fetched": 2,
"returned_per_page": [50, 3],
},
)
self.assertTrue(result["proven"], result["reasons"])
class TestExport(unittest.TestCase):
def test_review_proofs_reexport(self):
from review_proofs import assess_reconcile_inventory_report as exported
self.assertTrue(callable(exported))
if __name__ == "__main__":
unittest.main()
+135
View File
@@ -0,0 +1,135 @@
"""Tests for validation cwd/HEAD proof verifier (#398)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from final_report_validator import assess_final_report_validator # noqa: E402
from reviewer_validation_cwd_proof import assess_validation_cwd_proof_report # noqa: E402
ROOT = "/Users/jasonwalker/Development/Gitea-Tools"
WORKTREE = f"{ROOT}/branches/review-feat-issue-398"
HEAD = "f5953549aad5e822f14f52d3ea3c6d7990109384"
def _proof_backed_report() -> str:
return "\n".join([
f"Candidate head SHA: {HEAD}",
f"pwd: {WORKTREE}",
f"git rev-parse HEAD: {HEAD}",
"git status --short --branch: ## feat/issue-398...prgs/master",
f"Validation command: cd {WORKTREE} && venv/bin/python -m pytest tests/ -q",
"Result: 1497 passed, 6 skipped",
])
class TestValidationCwdProof(unittest.TestCase):
def test_no_validation_claim_passes(self):
result = assess_validation_cwd_proof_report("Review decision: approve")
self.assertTrue(result["proven"])
def test_missing_cwd_proof_fails(self):
result = assess_validation_cwd_proof_report(
f"Validation command: pytest tests/\nCandidate head SHA: {HEAD}",
validation_session={"validation_ran": True, "expected_head_sha": HEAD},
)
self.assertFalse(result["proven"])
self.assertTrue(result["block"])
def test_main_checkout_cwd_blocks(self):
result = assess_validation_cwd_proof_report(
"\n".join([
f"Candidate head SHA: {HEAD}",
f"pwd: {ROOT}",
f"git rev-parse HEAD: {HEAD}",
"git status --short --branch: ## master",
"Validation command: pytest tests/ -q",
]),
validation_session={"validation_ran": True, "expected_head_sha": HEAD},
project_root=ROOT,
)
self.assertFalse(result["proven"])
self.assertTrue(result["violations"])
def test_wrong_head_blocks(self):
wrong = "a" * 40
result = assess_validation_cwd_proof_report(
"\n".join([
f"Candidate head SHA: {HEAD}",
f"pwd: {WORKTREE}",
f"git rev-parse HEAD: {wrong}",
"git status --short --branch: clean",
f"Validation command: cd {WORKTREE} && pytest -q",
]),
validation_session={"validation_ran": True, "expected_head_sha": HEAD},
project_root=ROOT,
)
self.assertFalse(result["proven"])
self.assertTrue(result["violations"])
def test_fully_proof_backed_passes(self):
result = assess_validation_cwd_proof_report(
_proof_backed_report(),
validation_session={"validation_ran": True, "expected_head_sha": HEAD},
project_root=ROOT,
)
self.assertTrue(result["proven"], result["reasons"])
def test_baseline_without_cwd_fails(self):
result = assess_validation_cwd_proof_report(
"\n".join([
_proof_backed_report(),
"Baseline validation command: pytest tests/ -q",
]),
validation_session={
"validation_ran": True,
"expected_head_sha": HEAD,
"baseline_validation_ran": True,
},
project_root=ROOT,
)
self.assertFalse(result["proven"])
self.assertTrue(
any("baseline" in r.lower() for r in result["reasons"])
)
def test_baseline_with_full_proof_passes(self):
result = assess_validation_cwd_proof_report(
"\n".join([
_proof_backed_report(),
f"Baseline worktree: {ROOT}/branches/baseline-master-pr376",
f"Baseline target SHA: {HEAD}",
f"Baseline validation command: cd {ROOT}/branches/baseline-master-pr376 && pytest -q",
]),
validation_session={
"validation_ran": True,
"expected_head_sha": HEAD,
"baseline_validation_ran": True,
},
project_root=ROOT,
)
self.assertTrue(result["proven"], result["reasons"])
def test_final_report_validator_integration(self):
result = assess_final_report_validator(
"Validation command: pytest tests/ -q",
"review_pr",
validation_session={"validation_ran": True},
)
self.assertTrue(result["blocked"] or result["downgraded"])
self.assertTrue(
any(
f.get("rule_id") == "reviewer.validation_cwd_proof"
for f in result.get("findings") or []
)
)
def test_exported_from_review_proofs(self):
from review_proofs import assess_validation_cwd_proof_report as exported
self.assertTrue(callable(exported))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,147 @@
"""Tests for transient validation failure history verifier (#396)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from final_report_validator import assess_final_report_validator # noqa: E402
from reviewer_validation_failure_history import ( # noqa: E402
assess_validation_failure_history_report,
)
def _full_history_report() -> str:
return "\n".join([
"Validation failure history",
"Failure #1:",
"Command: venv/bin/python -m pytest tests/test_worktrees.py -q",
"Failing test: tests/test_worktrees.py::TestWorktreeStart::test_rejects_untraceable_branches",
"Suspected cause: stale /tmp/gitea_issue_lock.json from prior session",
"Reproduced: no",
"On baseline master: no",
"PR-caused: no",
"What changed between runs: removed /tmp/gitea_issue_lock.json",
"Environmental cleanup: lock file removed before rerun",
"Validation: passed after transient failure investigation",
"Final validation command: venv/bin/python -m pytest tests/ -q",
"Final result: 1497 passed, 6 skipped",
])
class TestValidationFailureHistory(unittest.TestCase):
def test_no_failures_observed_passes(self):
result = assess_validation_failure_history_report(
"Validation: passed",
validation_session={"observed_failures": []},
)
self.assertTrue(result["proven"])
def test_omitted_failure_blocks(self):
result = assess_validation_failure_history_report(
"Validation: passed\n1497 passed, 6 skipped",
validation_session={
"observed_failures": [{
"command": "pytest tests/test_worktrees.py -q",
"failing_test": (
"tests/test_worktrees.py::TestWorktreeStart::"
"test_rejects_untraceable_branches"
),
}],
"final_validation_status": "passed",
},
)
self.assertFalse(result["proven"])
self.assertTrue(result["block"])
def test_full_history_with_transient_investigation_passes(self):
result = assess_validation_failure_history_report(
_full_history_report(),
validation_session={
"observed_failures": [{
"command": (
"venv/bin/python -m pytest tests/test_worktrees.py -q"
),
"failing_test": (
"tests/test_worktrees.py::TestWorktreeStart::"
"test_rejects_untraceable_branches"
),
"suspected_cause": "stale /tmp/gitea_issue_lock.json",
"reproduced": "no",
"on_baseline_master": "no",
"pr_caused": "no",
}],
"final_validation_status": "passed_after_transient_failure_investigation",
"environmental_contamination": True,
},
)
self.assertTrue(result["proven"], result["reasons"])
def test_baseline_equivalent_failure_documented(self):
report = "\n".join([
"Validation failure history",
"Command: pytest tests/test_example.py -q",
"Failing test: tests/test_example.py::test_flaky",
"Suspected cause: pre-existing master failure",
"On baseline master: yes",
"PR-caused: no",
"What changed between runs: none; failure matches baseline",
"Validation: passed after transient failure investigation",
])
result = assess_validation_failure_history_report(
report,
validation_session={
"observed_failures": [{
"command": "pytest tests/test_example.py -q",
"failing_test": "tests/test_example.py::test_flaky",
"on_baseline_master": "yes",
"pr_caused": "no",
}],
"final_validation_status": "passed_after_transient_failure_investigation",
},
)
self.assertTrue(result["proven"], result["reasons"])
def test_unknown_cause_plain_pass_blocks(self):
result = assess_validation_failure_history_report(
"Validation: passed",
validation_session={
"observed_failures": [{
"command": "pytest tests/ -q",
"failing_test": "tests/test_foo.py::test_bar",
"suspected_cause": "unknown",
}],
"final_validation_status": "passed",
"cause_unknown": True,
},
)
self.assertFalse(result["proven"])
self.assertTrue(any("transient" in r.lower() for r in result["reasons"]))
def test_final_report_validator_rejects_omitted_failure(self):
result = assess_final_report_validator(
"Validation: passed",
"review_pr",
validation_session={
"observed_failures": [{
"command": "pytest tests/ -q",
"failing_test": "tests/test_foo.py::test_bar",
}],
},
)
self.assertTrue(result["blocked"] or result["downgraded"])
self.assertTrue(
any(
f.get("rule_id") == "reviewer.validation_failure_history"
for f in result.get("findings") or []
)
)
def test_exported_from_review_proofs(self):
from review_proofs import assess_validation_failure_history_report as exported
self.assertTrue(callable(exported))
if __name__ == "__main__":
unittest.main()
+25
View File
@@ -192,6 +192,31 @@ class TestRoleSessionRouter(unittest.TestCase):
)
self.assertEqual(result.get("number"), 999)
@patch("mcp_server.api_request", return_value={"login": "jcwalker3"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_work_issue_task_under_author_profile_allowed(self, _auth, _api):
with patch.dict(os.environ, self._env("prgs-author")):
for task_type in ("work_issue", "work-issue"):
route = mcp_server.gitea_route_task_session(
task_type=task_type, remote="prgs"
)
self.assertEqual(
route["route_result"], role_session_router.ROUTE_ALLOWED
)
self.assertTrue(route["downstream_allowed"])
@patch("mcp_server.api_request", return_value={"login": "sysadmin"})
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
def test_work_issue_task_under_reviewer_profile_routes_to_author(self, _auth, _api):
with patch.dict(os.environ, self._env("prgs-reviewer")):
route = mcp_server.gitea_route_task_session(
task_type="work-issue", remote="prgs"
)
self.assertEqual(
route["route_result"], role_session_router.ROUTE_TO_AUTHOR
)
self.assertFalse(route["downstream_allowed"])
@patch("mcp_server.api_request", return_value={"login": "sysadmin"})
@patch("mcp_server.get_auth_header", return_value="token reviewer-pass")
def test_reviewer_task_under_reviewer_profile_allowed(self, _auth, _api):
+185
View File
@@ -0,0 +1,185 @@
"""Unit tests for stacked-PR base policy (#484)."""
import unittest
import stacked_pr_support as sps
def _pr(number, branch, state="open"):
return {"number": number, "state": state, "head": {"ref": branch}}
OPEN_PRS = [
_pr(479, "feat/issue-478-mcp-menu-shell"),
_pr(481, "feat/issue-477-lock-adoption-proof"),
]
# Motivating case (#482 stacked on #479 / #478).
STACKED_BODY = (
"Closes #482.\n\n"
"Stacked on PR #479 / issue #478.\n"
"Base branch: feat/issue-478-mcp-menu-shell\n"
"Head branch: feat/issue-482-skip-stale-request-changes-pr\n"
"Do not merge before PR #479 lands."
)
class TestIsBaseBranch(unittest.TestCase):
def test_master_main_dev_are_base(self):
for b in ("master", "main", "dev"):
self.assertTrue(sps.is_base_branch(b))
def test_feature_branch_is_not_base(self):
self.assertFalse(sps.is_base_branch("feat/issue-478-mcp-menu-shell"))
class TestStackedBaseDeclaration(unittest.TestCase):
def test_no_declaration_is_normal_path(self):
out = sps.assess_stacked_base_declaration(
stacked_base_branch=None, stacked_base_pr=None, open_prs=OPEN_PRS
)
self.assertFalse(out["block"])
self.assertFalse(out["declared"])
self.assertIsNone(out["approved"])
def test_valid_open_pr_base_is_approved(self):
out = sps.assess_stacked_base_declaration(
stacked_base_branch="feat/issue-478-mcp-menu-shell",
stacked_base_pr=479,
open_prs=OPEN_PRS,
)
self.assertFalse(out["block"])
self.assertEqual(out["approved"]["branch"], "feat/issue-478-mcp-menu-shell")
self.assertEqual(out["approved"]["pr_number"], 479)
self.assertTrue(out["approved"]["verified_open"])
def test_missing_pr_number_blocks(self):
out = sps.assess_stacked_base_declaration(
stacked_base_branch="feat/issue-478-mcp-menu-shell",
stacked_base_pr=None,
open_prs=OPEN_PRS,
)
self.assertTrue(out["block"])
self.assertIn("without stacked_base_pr", out["reasons"][0])
def test_arbitrary_branch_with_no_open_pr_blocks(self):
out = sps.assess_stacked_base_declaration(
stacked_base_branch="feat/random-unrelated-branch",
stacked_base_pr=999,
open_prs=OPEN_PRS,
)
self.assertTrue(out["block"])
self.assertIn("does not correspond to any OPEN pull request", out["reasons"][0])
def test_stale_merged_base_blocks(self):
merged = [_pr(479, "feat/issue-478-mcp-menu-shell", state="closed")]
out = sps.assess_stacked_base_declaration(
stacked_base_branch="feat/issue-478-mcp-menu-shell",
stacked_base_pr=479,
open_prs=merged,
)
self.assertTrue(out["block"])
self.assertIsNone(out["approved"])
def test_pr_number_mismatch_blocks(self):
out = sps.assess_stacked_base_declaration(
stacked_base_branch="feat/issue-478-mcp-menu-shell",
stacked_base_pr=481, # wrong PR for this branch
open_prs=OPEN_PRS,
)
self.assertTrue(out["block"])
self.assertIn("does not match the open", out["reasons"][0])
def test_declaring_a_base_branch_blocks(self):
out = sps.assess_stacked_base_declaration(
stacked_base_branch="master", stacked_base_pr=1, open_prs=OPEN_PRS
)
self.assertTrue(out["block"])
self.assertIn("already a normal base branch", out["reasons"][0])
class TestStackedPrBody(unittest.TestCase):
def test_complete_body_has_no_missing_fields(self):
missing = sps.assess_stacked_pr_body(
STACKED_BODY, base_branch="feat/issue-478-mcp-menu-shell", pr_number=479
)
self.assertEqual(missing, [])
def test_missing_all_fields(self):
missing = sps.assess_stacked_pr_body(
"just some text", base_branch="feat/issue-478-mcp-menu-shell", pr_number=479
)
self.assertEqual(len(missing), 3)
def test_missing_merge_ordering_only(self):
body = "Base branch feat/issue-478-mcp-menu-shell for PR #479"
missing = sps.assess_stacked_pr_body(
body, base_branch="feat/issue-478-mcp-menu-shell", pr_number=479
)
self.assertEqual(len(missing), 1)
self.assertIn("merge-ordering", missing[0])
class TestCreatePrBase(unittest.TestCase):
APPROVED = {"branch": "feat/issue-478-mcp-menu-shell", "pr_number": 479, "verified_open": True}
def test_master_base_passes_without_stacked_metadata(self):
out = sps.assess_create_pr_base(
base="master", approved_stacked_base=None, body="Closes #1", open_prs=[]
)
self.assertFalse(out["block"])
self.assertFalse(out["stacked"])
def test_non_base_without_approval_blocks(self):
out = sps.assess_create_pr_base(
base="feat/issue-478-mcp-menu-shell",
approved_stacked_base=None,
body=STACKED_BODY,
open_prs=OPEN_PRS,
)
self.assertTrue(out["block"])
self.assertIn("no approved stacked base", out["reasons"][0])
def test_non_base_mismatched_approval_blocks(self):
out = sps.assess_create_pr_base(
base="feat/some-other-branch",
approved_stacked_base=self.APPROVED,
body=STACKED_BODY,
open_prs=OPEN_PRS,
)
self.assertTrue(out["block"])
self.assertIn("does not match the issue lock's approved stacked base", out["reasons"][0])
def test_approved_base_with_good_body_passes(self):
out = sps.assess_create_pr_base(
base="feat/issue-478-mcp-menu-shell",
approved_stacked_base=self.APPROVED,
body=STACKED_BODY,
open_prs=OPEN_PRS,
)
self.assertFalse(out["block"])
self.assertTrue(out["stacked"])
self.assertEqual(out["stacked_base_pr"], 479)
def test_approved_base_now_stale_blocks(self):
out = sps.assess_create_pr_base(
base="feat/issue-478-mcp-menu-shell",
approved_stacked_base=self.APPROVED,
body=STACKED_BODY,
open_prs=[_pr(479, "feat/issue-478-mcp-menu-shell", state="merged")],
)
self.assertTrue(out["block"])
self.assertIn("no longer corresponds to an OPEN", out["reasons"][0])
def test_approved_base_with_incomplete_body_blocks(self):
out = sps.assess_create_pr_base(
base="feat/issue-478-mcp-menu-shell",
approved_stacked_base=self.APPROVED,
body="Closes #482 only",
open_prs=OPEN_PRS,
)
self.assertTrue(out["block"])
self.assertIn("must document the stack", out["reasons"][0])
if __name__ == "__main__":
unittest.main()
+18 -11
View File
@@ -12,6 +12,8 @@ from unittest.mock import patch
import mcp_server
HEAD_SHA = "a" * 40
def _lock(mutations=None, correction=False):
return {
@@ -127,10 +129,20 @@ def _feedback(blocking, stale=False, success=True):
"success": success,
"has_blocking_change_requests": blocking,
"review_feedback_stale": stale,
"current_head_sha": "abc123",
"current_head_sha": HEAD_SHA,
}
def _mark(action, pr_number=6, **kwargs):
kwargs.setdefault("expected_head_sha", HEAD_SHA)
no_lease_block = {"block": False, "reasons": [], "mutation_allowed": True}
with patch("mcp_server._list_pr_lease_comments", return_value=[]), \
patch("mcp_server._pr_work_lease_reviewer_block", return_value=no_lease_block):
return mcp_server.gitea_mark_final_review_decision(
pr_number=pr_number, action=action, remote="prgs", **kwargs
)
class TestDuplicateRequestChangesSuppression(unittest.TestCase):
def tearDown(self):
mcp_server._save_review_decision_lock(None)
@@ -139,8 +151,7 @@ class TestDuplicateRequestChangesSuppression(unittest.TestCase):
_seed()
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
return_value=_feedback(blocking=True, stale=False)):
result = mcp_server.gitea_mark_final_review_decision(
pr_number=6, action="request_changes", remote="prgs")
result = _mark("request_changes")
self.assertFalse(result["marked_ready"])
self.assertTrue(
any("duplicate" in r for r in result["reasons"]),
@@ -150,16 +161,14 @@ class TestDuplicateRequestChangesSuppression(unittest.TestCase):
_seed()
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
return_value=_feedback(blocking=True, stale=True)):
result = mcp_server.gitea_mark_final_review_decision(
pr_number=6, action="request_changes", remote="prgs")
result = _mark("request_changes")
self.assertTrue(result["marked_ready"], result.get("reasons"))
def test_request_changes_allowed_when_no_blocker(self):
_seed()
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
return_value=_feedback(blocking=False)):
result = mcp_server.gitea_mark_final_review_decision(
pr_number=6, action="request_changes", remote="prgs")
result = _mark("request_changes")
self.assertTrue(result["marked_ready"], result.get("reasons"))
def test_request_changes_fails_closed_when_feedback_unavailable(self):
@@ -167,8 +176,7 @@ class TestDuplicateRequestChangesSuppression(unittest.TestCase):
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
return_value=_feedback(blocking=False,
success=False)):
result = mcp_server.gitea_mark_final_review_decision(
pr_number=6, action="request_changes", remote="prgs")
result = _mark("request_changes")
self.assertFalse(result["marked_ready"])
self.assertTrue(
any("could not verify" in r for r in result["reasons"]),
@@ -178,8 +186,7 @@ class TestDuplicateRequestChangesSuppression(unittest.TestCase):
_seed()
with patch.object(mcp_server, "gitea_get_pr_review_feedback",
side_effect=AssertionError("must not be called")):
result = mcp_server.gitea_mark_final_review_decision(
pr_number=6, action="approve", remote="prgs")
result = _mark("approve")
self.assertTrue(result["marked_ready"], result.get("reasons"))
+198
View File
@@ -0,0 +1,198 @@
"""Tests for validation status vocabulary (#406)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from final_report_validator import assess_final_report_validator # noqa: E402
from validation_status_vocabulary import ( # noqa: E402
STATUS_BASELINE_EQUIVALENT,
STATUS_FAILED,
STATUS_MERGE_SIM_RESOLVED,
STATUS_PASSED,
STATUS_TRANSIENT_PASS,
assess_validation_status_vocabulary,
)
def _handoff(**extra):
fields = {
"Task": "review PR #386",
"Validation status": STATUS_PASSED,
"Raw PR-head validation result": "passed",
"Merge simulation result": "not run",
"Baseline worktree used": "none",
}
fields.update(extra)
lines = ["## Controller Handoff", ""]
lines.extend(f"- {key}: {value}" for key, value in fields.items())
return "\n".join(lines)
class TestValidationStatusVocabulary(unittest.TestCase):
def test_raw_head_pass_status(self):
report = _handoff()
result = assess_validation_status_vocabulary(report)
self.assertFalse(result["block"])
self.assertEqual(result["status_claimed"], STATUS_PASSED)
def test_raw_head_failure_with_baseline_match(self):
report = _handoff(
**{
"Validation status": STATUS_BASELINE_EQUIVALENT,
"Raw PR-head validation result": "failed",
"Baseline worktree used": "branches/baseline-master-pr386",
"Baseline target SHA": "a" * 40,
"Baseline failures": "test_foo failed",
"PR failures": "test_foo failed",
"Failure signatures match": "true",
}
)
result = assess_validation_status_vocabulary(report)
self.assertFalse(result["block"])
self.assertTrue(result["baseline_proof_complete"])
def test_baseline_equivalent_without_baseline_proof_blocked(self):
report = _handoff(
**{
"Validation status": STATUS_BASELINE_EQUIVALENT,
"Raw PR-head validation result": "failed",
}
)
result = assess_validation_status_vocabulary(report)
self.assertTrue(result["block"])
self.assertIn("baseline-equivalent", result["reasons"][0])
def test_merge_simulation_resolution_passes(self):
report = "\n".join([
_handoff(
**{
"Validation status": STATUS_MERGE_SIM_RESOLVED,
"Raw PR-head validation result": "failed",
"Merge simulation result": "passed",
}
),
"Worktree/index mutations: merge simulation in branches/review-pr386",
"Worktree path: branches/review-pr386",
"Pre-simulation clean status: clean",
"Merge result: clean merge",
"Abort command: git merge --abort",
"Post-abort clean status: clean",
])
command_log = [
{"command": "git merge --no-commit prgs/master"},
{"command": "git merge --abort"},
]
result = assess_validation_status_vocabulary(
report, command_log=command_log
)
self.assertFalse(result["block"])
self.assertTrue(result["merge_simulation_passed"])
def test_merge_simulation_failure_stays_failed(self):
report = _handoff(
**{
"Validation status": STATUS_FAILED,
"Raw PR-head validation result": "failed",
"Merge simulation result": "failed",
}
)
result = assess_validation_status_vocabulary(report)
self.assertFalse(result["block"])
def test_failed_status_with_passing_merge_sim_blocked(self):
report = "\n".join([
_handoff(
**{
"Validation status": STATUS_FAILED,
"Raw PR-head validation result": "failed",
"Merge simulation result": "passed",
}
),
"Worktree/index mutations: merge simulation",
"Worktree path: branches/review-pr386",
"Pre-simulation clean status: clean",
"Merge result: clean",
"Abort command: git merge --abort",
"Post-abort clean status: clean",
])
result = assess_validation_status_vocabulary(
report,
command_log=[{"command": "git merge --no-commit prgs/master"}],
)
self.assertTrue(result["block"])
def test_transient_failure_then_pass(self):
report = _handoff(
**{
"Validation status": STATUS_TRANSIENT_PASS,
"Raw PR-head validation result": "passed",
"Transient validation failure history": (
"first run failed with infra flake; rerun passed"
),
}
)
result = assess_validation_status_vocabulary(report)
self.assertFalse(result["block"])
def test_transient_pass_without_history_blocked(self):
report = _handoff(
**{
"Validation status": STATUS_TRANSIENT_PASS,
"Raw PR-head validation result": "passed",
}
)
result = assess_validation_status_vocabulary(report)
self.assertTrue(result["block"])
def test_bare_passed_after_raw_failure_blocked(self):
report = _handoff(
**{
"Validation status": STATUS_PASSED,
"Raw PR-head validation result": "failed",
}
)
result = assess_validation_status_vocabulary(report)
self.assertTrue(result["block"])
def test_wrong_baseline_label_when_merge_sim_used_blocked(self):
report = "\n".join([
_handoff(
**{
"Validation status": STATUS_BASELINE_EQUIVALENT,
"Raw PR-head validation result": "failed",
"Merge simulation result": "passed",
}
),
"Worktree/index mutations: merge simulation",
"Worktree path: branches/review-pr386",
"Pre-simulation clean status: clean",
"Merge result: clean",
"Abort command: git merge --abort",
"Post-abort clean status: clean",
])
result = assess_validation_status_vocabulary(
report,
command_log=[{"command": "git merge --no-commit prgs/master"}],
)
self.assertTrue(result["block"])
joined = " ".join(result["reasons"]).lower()
self.assertTrue(
"misleading" in joined or "baseline-equivalent" in joined
)
def test_final_report_validator_integration_blocks_misleading_label(self):
report = _handoff(
**{
"Validation status": STATUS_BASELINE_EQUIVALENT,
"Raw PR-head validation result": "failed",
}
)
result = assess_final_report_validator(report, "review_pr")
blocked_ids = {f["rule_id"] for f in result["findings"]}
self.assertIn("reviewer.validation_status_vocabulary", blocked_ids)
if __name__ == "__main__":
unittest.main()
+111
View File
@@ -0,0 +1,111 @@
"""Tests for web UI project registry (#427)."""
import json
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from starlette.testclient import TestClient
from webui.app import create_app
from webui.project_registry import (
default_registry_path,
load_registry,
project_to_dict,
)
class TestProjectRegistryLoader(unittest.TestCase):
def test_default_registry_loads_gitea_tools(self):
registry = load_registry()
self.assertEqual(registry.version, 1)
self.assertEqual(len(registry.projects), 1)
project = registry.projects[0]
self.assertEqual(project.id, "gitea-tools")
self.assertEqual(project.repo_name, "Gitea-Tools")
self.assertEqual(project.gitea_owner, "Scaled-Tech-Consulting")
self.assertEqual(project.remote_host, "https://gitea.prgs.cc")
self.assertEqual(project.profiles["author"], "prgs-author")
self.assertEqual(project.profiles["reviewer"], "prgs-reviewer")
self.assertEqual(project.profiles["reconciler"], "prgs-reconciler")
self.assertIn("skill", project.workflow_paths)
self.assertGreaterEqual(len(project.onboarding_checklist), 4)
def test_registry_rejects_credential_keys(self):
payload = {
"version": 1,
"projects": [
{
"id": "bad",
"repo_name": "Bad",
"gitea_owner": "Org",
"remote_host": "https://gitea.example.invalid",
"default_branch": "main",
"local_checkout_path": ".",
"profiles": {
"author": "a",
"reviewer": "r",
"reconciler": "c",
},
"workflow_paths": {"skill": "skills/x.md"},
"api_token": "secret",
}
],
}
with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False) as handle:
json.dump(payload, handle)
path = Path(handle.name)
try:
with self.assertRaises(ValueError):
load_registry(path)
finally:
path.unlink(missing_ok=True)
def test_default_registry_path_points_at_packaged_data(self):
path = default_registry_path()
self.assertTrue(path.name == "projects.registry.json")
self.assertTrue(path.parent.name == "data")
class TestProjectRegistryRoutes(unittest.TestCase):
def setUp(self):
self.client = TestClient(create_app())
def test_projects_page_lists_gitea_tools(self):
response = self.client.get("/projects")
self.assertEqual(response.status_code, 200)
self.assertIn("Gitea-Tools", response.text)
self.assertIn("Scaled-Tech-Consulting", response.text)
self.assertIn("prgs-author", response.text)
self.assertNotIn("child issue", response.text.lower())
def test_project_detail_renders_checklist(self):
response = self.client.get("/projects/gitea-tools")
self.assertEqual(response.status_code, 200)
self.assertIn("Onboarding checklist", response.text)
self.assertIn("Configure execution profiles", response.text)
self.assertIn("branches/", response.text)
def test_project_detail_404(self):
response = self.client.get("/projects/unknown-repo")
self.assertEqual(response.status_code, 404)
def test_api_projects_json(self):
response = self.client.get("/api/projects")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["version"], 1)
self.assertEqual(len(data["projects"]), 1)
self.assertEqual(data["projects"][0]["id"], "gitea-tools")
self.assertIn("onboarding_checklist", data["projects"][0])
def test_project_to_dict_is_json_safe(self):
registry = load_registry()
encoded = json.dumps(project_to_dict(registry.projects[0]))
self.assertIn("gitea-tools", encoded)
if __name__ == "__main__":
unittest.main()
+90
View File
@@ -0,0 +1,90 @@
"""Tests for web UI prompt library (#428)."""
import json
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from starlette.testclient import TestClient
from webui.app import create_app
from webui.prompt_library import find_prompt, library_to_dict, load_prompt_library, prompt_to_dict
REQUIRED_PROMPT_SLUGS = frozenset({
"review-pr",
"work-issue",
"create-issue",
"comment-issue",
"cleanup",
"audit",
"onboarding",
})
class TestPromptLibraryLoader(unittest.TestCase):
def test_library_loads_required_prompts(self):
entries = load_prompt_library()
slugs = {entry.slug for entry in entries}
self.assertEqual(slugs, REQUIRED_PROMPT_SLUGS)
def test_workflow_hashes_present(self):
review = find_prompt("review-pr")
self.assertIsNotNone(review)
assert review is not None
self.assertTrue(review.workflow_hash)
self.assertEqual(len(review.workflow_hash), 64)
def test_prompt_text_is_short(self):
for entry in load_prompt_library():
self.assertLess(len(entry.prompt_text), 400)
self.assertNotIn("Do not improvise around the gates", entry.prompt_text)
class TestPromptLibraryRoutes(unittest.TestCase):
def setUp(self):
self.client = TestClient(create_app())
def test_prompts_page_lists_all_entries(self):
response = self.client.get("/prompts")
self.assertEqual(response.status_code, 200)
for label in (
"Review PR",
"Work issue",
"Create issue",
"Comment on issue",
"Post-merge cleanup",
"Reconciliation audit",
"Project onboarding",
):
self.assertIn(label, response.text)
self.assertIn("Copy prompt", response.text)
self.assertIn("sha256:", response.text)
self.assertNotIn("child issue", response.text.lower())
def test_prompt_detail_route(self):
response = self.client.get("/prompts/work-issue")
self.assertEqual(response.status_code, 200)
self.assertIn("work-issue.md", response.text)
self.assertIn("Copy prompt", response.text)
def test_prompt_detail_404(self):
self.assertEqual(self.client.get("/prompts/missing").status_code, 404)
def test_api_prompts_json(self):
response = self.client.get("/api/prompts")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["count"], 7)
review = next(item for item in data["prompts"] if item["slug"] == "review-pr")
self.assertIn("workflow_hash", review)
self.assertIn("review-merge-pr.md", review["workflow_path"])
def test_prompt_to_dict_roundtrip(self):
entry = load_prompt_library()[0]
encoded = json.dumps(prompt_to_dict(entry))
self.assertIn("workflow_path", encoded)
if __name__ == "__main__":
unittest.main()
+288
View File
@@ -0,0 +1,288 @@
"""Tests for web UI live queue dashboard (#429)."""
import sys
import unittest
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from starlette.testclient import TestClient
from webui.app import create_app
from webui.queue_loader import (
PaginationMeta,
QueueSnapshot,
_classify_issue,
_classify_pr,
_extract_linked_issue,
load_queue_snapshot,
snapshot_to_dict,
)
from webui.queue_views import render_queue_page
_RECENT = datetime.now(timezone.utc).isoformat()
_STALE = (datetime.now(timezone.utc) - timedelta(days=30)).isoformat()
_SAMPLE_PRS = [
{
"number": 100,
"title": "feat: queue dashboard (Closes #429)",
"body": "",
"mergeable": True,
"updated_at": _RECENT,
"head": {"ref": "feat/issue-429", "sha": "abc123def456"},
"base": {"ref": "master"},
},
{
"number": 99,
"title": "fix: conflict",
"body": "Closes #50",
"mergeable": False,
"updated_at": _STALE,
"head": {"ref": "fix/issue-50", "sha": "deadbeef0001"},
"base": {"ref": "master"},
},
{
"number": 98,
"title": "feat: duplicate A (Closes #60)",
"body": "",
"mergeable": True,
"updated_at": _RECENT,
"head": {"ref": "feat/issue-60-a", "sha": "111111111111"},
"base": {"ref": "master"},
},
{
"number": 97,
"title": "feat: duplicate B (Closes #60)",
"body": "",
"mergeable": True,
"updated_at": _RECENT,
"head": {"ref": "feat/issue-60-b", "sha": "222222222222"},
"base": {"ref": "master"},
},
]
_SAMPLE_ISSUES = [
{
"number": 429,
"title": "Web UI queue dashboard",
"state": "open",
"labels": [{"name": "status:in-progress"}],
"assignee": None,
"updated_at": _RECENT,
},
{
"number": 60,
"title": "Duplicate PR target",
"state": "open",
"labels": [],
"assignee": None,
"updated_at": _RECENT,
},
{
"number": 50,
"title": "Blocked PR target",
"state": "open",
"labels": [],
"assignee": None,
"updated_at": _STALE,
},
]
def _mock_pagination(count: int) -> PaginationMeta:
return PaginationMeta(
page=1,
per_page=50,
returned_count=count,
has_more=False,
is_final_page=True,
inventory_complete=True,
pages_fetched=1,
)
def _mock_fetch_prs(*_args, **_kwargs):
return list(_SAMPLE_PRS), _mock_pagination(len(_SAMPLE_PRS))
def _mock_fetch_issues(*_args, **_kwargs):
return list(_SAMPLE_ISSUES), _mock_pagination(len(_SAMPLE_ISSUES))
class TestQueueClassification(unittest.TestCase):
def test_extract_linked_issue_from_title(self):
self.assertEqual(
_extract_linked_issue("feat: X (Closes #429)", ""),
429,
)
def test_classify_pr_blocked_and_stale(self):
pr = _SAMPLE_PRS[1]
badges = _classify_pr(
pr,
issue_claimed=set(),
issue_to_prs={50: [99]},
)
self.assertIn("blocked", badges)
self.assertIn("stale", badges)
def test_classify_pr_duplicate(self):
badges = _classify_pr(
_SAMPLE_PRS[2],
issue_claimed=set(),
issue_to_prs={60: [98, 97]},
)
self.assertIn("duplicate", badges)
self.assertIn("in-review", badges)
def test_classify_issue_claimed_and_duplicate(self):
claimed = _classify_issue(_SAMPLE_ISSUES[0], linked_prs=[])
self.assertIn("claimed", claimed)
duplicate = _classify_issue(_SAMPLE_ISSUES[1], linked_prs=[98, 97])
self.assertIn("duplicate", duplicate)
class TestQueueLoader(unittest.TestCase):
def test_snapshot_with_mock_fetch(self):
snapshot = load_queue_snapshot(
fetch_prs=_mock_fetch_prs,
fetch_issues=_mock_fetch_issues,
)
self.assertEqual(snapshot.project_id, "gitea-tools")
self.assertIsNone(snapshot.fetch_error)
self.assertEqual(len(snapshot.prs), 4)
self.assertEqual(len(snapshot.issues), 3)
self.assertTrue(snapshot.pr_pagination.inventory_complete)
self.assertTrue(snapshot.issue_pagination.inventory_complete)
pr100 = next(p for p in snapshot.prs if p.number == 100)
self.assertEqual(pr100.extra["linked_issue"], "429")
self.assertIn("claimed", pr100.badges)
def test_snapshot_dict_export(self):
snapshot = load_queue_snapshot(
fetch_prs=_mock_fetch_prs,
fetch_issues=_mock_fetch_issues,
)
data = snapshot_to_dict(snapshot)
self.assertEqual(data["project_id"], "gitea-tools")
self.assertIsNone(data["fetch_error"])
self.assertEqual(len(data["prs"]), 4)
self.assertTrue(data["pagination"]["prs"]["inventory_complete"])
class TestQueueRoutes(unittest.TestCase):
def setUp(self):
self.client = TestClient(create_app())
self._patch = mock.patch(
"webui.app.load_queue_snapshot",
return_value=load_queue_snapshot(
fetch_prs=_mock_fetch_prs,
fetch_issues=_mock_fetch_issues,
),
)
self._patch.start()
def tearDown(self):
self._patch.stop()
def test_queue_page_renders_tables_and_pagination(self):
response = self.client.get("/queue")
self.assertEqual(response.status_code, 200)
self.assertIn("Live queue", response.text)
self.assertIn("Open pull requests", response.text)
self.assertIn("Open issues", response.text)
self.assertIn("pages_fetched", response.text)
self.assertIn("Gitea-Tools", response.text)
self.assertNotIn("child issue", response.text.lower())
def test_api_queue_json(self):
response = self.client.get("/api/queue")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["repo"], "Scaled-Tech-Consulting/Gitea-Tools")
self.assertEqual(data["pagination"]["issues"]["returned_count"], 3)
def test_queue_fail_closed_without_credentials(self):
with mock.patch("webui.queue_loader.get_auth_header", return_value=None):
snapshot = load_queue_snapshot()
self.assertIsNotNone(snapshot.fetch_error)
self.assertEqual(len(snapshot.prs), 0)
def _empty_pagination() -> PaginationMeta:
return PaginationMeta(
page=1,
per_page=50,
returned_count=0,
has_more=False,
is_final_page=True,
inventory_complete=True,
pages_fetched=1,
)
def _empty_fetch(*_args, **_kwargs):
return [], _empty_pagination()
class TestQueueFailClosedUx(unittest.TestCase):
"""Regression tests for #458 fail-closed empty-state copy."""
def test_fail_closed_view_suppresses_empty_queue_copy(self):
snapshot = QueueSnapshot(
project_id="gitea-tools",
repo_label="Scaled-Tech-Consulting/Gitea-Tools",
prs=(),
issues=(),
pr_pagination=None,
issue_pagination=None,
fetch_error="Gitea credentials unavailable for gitea.prgs.cc",
)
html = render_queue_page(snapshot)
self.assertIn("Queue unavailable", html)
self.assertIn("Not loaded", html)
self.assertNotIn("No open items.", html)
self.assertIn("pagination:</strong> unavailable", html)
def test_fail_closed_route_does_not_show_empty_queue_copy(self):
client = TestClient(create_app())
snapshot = load_queue_snapshot(
fetch_prs=_empty_fetch,
fetch_issues=_empty_fetch,
)
snapshot = QueueSnapshot(
project_id=snapshot.project_id,
repo_label=snapshot.repo_label,
prs=(),
issues=(),
pr_pagination=None,
issue_pagination=None,
fetch_error="Gitea credentials unavailable for gitea.prgs.cc",
)
with mock.patch("webui.app.load_queue_snapshot", return_value=snapshot):
response = client.get("/queue")
self.assertEqual(response.status_code, 200)
self.assertIn("Queue unavailable", response.text)
self.assertNotIn("No open items.", response.text)
def test_successful_empty_inventory_shows_empty_copy(self):
snapshot = load_queue_snapshot(
fetch_prs=_empty_fetch,
fetch_issues=_empty_fetch,
)
self.assertIsNone(snapshot.fetch_error)
self.assertEqual(len(snapshot.prs), 0)
self.assertEqual(len(snapshot.issues), 0)
self.assertTrue(snapshot.pr_pagination.inventory_complete)
html = render_queue_page(snapshot)
self.assertNotIn("Queue unavailable", html)
self.assertEqual(html.count("No open items."), 2)
self.assertIn("pagination (complete)", html)
if __name__ == "__main__":
unittest.main()
+73
View File
@@ -0,0 +1,73 @@
"""Tests for internal web UI skeleton (#426)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from starlette.testclient import TestClient
from webui.app import create_app
class TestWebuiSkeleton(unittest.TestCase):
def setUp(self):
self.client = TestClient(create_app())
def test_health_returns_json(self):
response = self.client.get("/health")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["status"], "ok")
self.assertEqual(data["service"], "mcp-control-plane-webui")
self.assertEqual(data["mode"], "read-only-mvp")
self.assertIn("timestamp", data)
def test_home_renders(self):
response = self.client.get("/")
self.assertEqual(response.status_code, 200)
self.assertIn("Operator console", response.text)
self.assertIn("Read-only MVP", response.text)
def test_route_stubs_render(self):
for path in ("/runtime", "/audit"):
with self.subTest(path=path):
response = self.client.get(path)
self.assertEqual(response.status_code, 200)
self.assertIn("child issue", response.text.lower())
def test_prompts_is_implemented(self):
response = self.client.get("/prompts")
self.assertEqual(response.status_code, 200)
self.assertIn("Prompt library", response.text)
def test_projects_is_implemented(self):
response = self.client.get("/projects")
self.assertEqual(response.status_code, 200)
self.assertIn("Gitea-Tools", response.text)
def test_extra_stub_routes(self):
for path in ("/worktrees", "/leases"):
with self.subTest(path=path):
self.assertEqual(self.client.get(path).status_code, 200)
def test_post_is_rejected(self):
response = self.client.post("/health")
self.assertEqual(response.status_code, 405)
self.assertEqual(response.json()["error"], "read-only-mvp")
def test_queue_route_renders(self):
response = self.client.get("/queue")
self.assertEqual(response.status_code, 200)
self.assertIn("Live queue", response.text)
def test_nav_links_on_all_pages(self):
for path in ("/", "/queue", "/projects", "/prompts", "/runtime", "/audit"):
with self.subTest(path=path):
text = self.client.get(path).text
for href in ("/queue", "/projects", "/prompts", "/runtime", "/audit"):
self.assertIn(f'href="{href}"', text)
if __name__ == "__main__":
unittest.main()
+153
View File
@@ -0,0 +1,153 @@
"""Tests for runtime_context / mutation-guard workspace alignment (#460)."""
from __future__ import annotations
import os
import sys
import unittest
from pathlib import Path
from unittest import mock
from unittest.mock import MagicMock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import author_mutation_worktree as amw # noqa: E402
import gitea_mcp_server as srv # noqa: E402
CONTROL_ROOT = str(Path(__file__).resolve().parents[3])
BRANCHES_WORKTREE = str(Path(__file__).resolve().parents[1])
MCP_PROCESS_ROOT = BRANCHES_WORKTREE
class TestCanonicalRepoRoot(unittest.TestCase):
@mock.patch("subprocess.run")
def test_resolves_main_repo_from_branches_worktree(self, mock_run):
mock_run.return_value = MagicMock(
returncode=0,
stdout=f"{CONTROL_ROOT}/.git\n",
)
root = amw.resolve_canonical_repo_root(BRANCHES_WORKTREE, MCP_PROCESS_ROOT)
self.assertEqual(root, CONTROL_ROOT)
def test_falls_back_when_git_unavailable(self):
root = amw.resolve_canonical_repo_root("/missing/path", MCP_PROCESS_ROOT)
self.assertEqual(root, os.path.realpath(MCP_PROCESS_ROOT))
class TestWorkspaceRepoMembership(unittest.TestCase):
@mock.patch("os.path.isdir", return_value=True)
@mock.patch("os.path.exists", return_value=True)
@mock.patch("subprocess.run")
def test_valid_branches_worktree_accepted(self, mock_run, *_exists):
mock_run.return_value = MagicMock(
returncode=0,
stdout=f"{CONTROL_ROOT}/.git\n",
)
result = amw.assess_workspace_repo_membership(
workspace_path=BRANCHES_WORKTREE,
canonical_repo_root=CONTROL_ROOT,
)
self.assertTrue(result["proven"])
self.assertFalse(result["block"])
@mock.patch("os.path.isdir", return_value=True)
@mock.patch("os.path.exists", return_value=True)
@mock.patch("subprocess.run")
def test_wrong_repo_rejected(self, mock_run, *_exists):
mock_run.return_value = MagicMock(
returncode=0,
stdout="/other/repo/.git\n",
)
result = amw.assess_workspace_repo_membership(
workspace_path=BRANCHES_WORKTREE,
canonical_repo_root=CONTROL_ROOT,
)
self.assertTrue(result["block"])
self.assertIn("does not belong", result["reasons"][0])
@mock.patch("os.path.exists", return_value=False)
def test_missing_worktree_rejected(self, *_exists):
result = amw.assess_workspace_repo_membership(
workspace_path=f"{CONTROL_ROOT}/branches/missing-worktree",
canonical_repo_root=CONTROL_ROOT,
)
self.assertTrue(result["block"])
self.assertIn("does not exist", result["reasons"][0])
class TestRuntimeContextGuardAlignment(unittest.TestCase):
def setUp(self):
srv._preflight_whoami_called = True
srv._preflight_capability_called = True
srv._preflight_resolved_role = "author"
self._orig_in_test = srv._preflight_in_test_mode
srv._preflight_in_test_mode = lambda: False
self._env_patch = mock.patch.dict(
os.environ,
{},
clear=False,
)
self._env_patch.start()
os.environ.pop("GITEA_ACTIVE_WORKTREE", None)
os.environ.pop("GITEA_AUTHOR_WORKTREE", None)
def tearDown(self):
srv._preflight_in_test_mode = self._orig_in_test
self._env_patch.stop()
def test_runtime_context_and_guard_share_resolved_workspace(self):
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
ctx = srv._resolve_author_mutation_context(BRANCHES_WORKTREE)
status = srv.assess_preflight_status(worktree_path=BRANCHES_WORKTREE)
self.assertEqual(ctx["workspace_path"], os.path.realpath(BRANCHES_WORKTREE))
self.assertEqual(ctx["canonical_repo_root"], CONTROL_ROOT)
self.assertFalse(ctx["roots_aligned"])
self.assertEqual(
status["preflight_workspace"]["active_task_workspace_root"],
os.path.realpath(BRANCHES_WORKTREE),
)
self.assertEqual(
status["preflight_workspace"]["canonical_repository_root"],
CONTROL_ROOT,
)
self.assertIn("workspace_root_mismatch", status["preflight_workspace"])
@mock.patch("subprocess.run")
@mock.patch("os.path.isdir", return_value=True)
@mock.patch("os.path.exists", return_value=True)
def test_declared_branches_worktree_passes_when_mcp_root_differs(
self, _exists, _isdir, mock_run
):
mock_run.return_value = MagicMock(
returncode=0,
stdout=f"{CONTROL_ROOT}/.git\n",
)
with mock.patch.object(srv, "PROJECT_ROOT", MCP_PROCESS_ROOT):
with mock.patch.dict("os.environ", {"GITEA_TEST_PORCELAIN": ""}, clear=False):
srv.verify_preflight_purity(worktree_path=BRANCHES_WORKTREE)
def test_stable_checkout_still_rejected(self):
with mock.patch.object(srv, "PROJECT_ROOT", CONTROL_ROOT):
with mock.patch.dict("os.environ", {"GITEA_TEST_PORCELAIN": ""}, clear=False):
with self.assertRaises(RuntimeError) as ctx:
srv.verify_preflight_purity()
self.assertIn("stable control checkout", str(ctx.exception))
@mock.patch("os.path.isdir", return_value=True)
@mock.patch("os.path.exists", return_value=True)
@mock.patch("subprocess.run")
def test_non_branches_worktree_rejected(self, mock_run, *_exists):
outside = "/tmp/outside-repo-checkout"
mock_run.return_value = MagicMock(
returncode=0,
stdout=f"{CONTROL_ROOT}/.git\n",
)
with mock.patch.object(srv, "PROJECT_ROOT", CONTROL_ROOT):
with mock.patch.dict("os.environ", {"GITEA_TEST_PORCELAIN": ""}, clear=False):
with self.assertRaises(RuntimeError) as ctx:
srv.verify_preflight_purity(worktree_path=outside)
self.assertIn("not under", str(ctx.exception))
if __name__ == "__main__":
unittest.main()
+26 -9
View File
@@ -20,33 +20,50 @@ def run(script, *args):
branch = arg
break
lock_file = Path("/tmp/gitea_issue_lock.json")
created_lock = False
lock_dir_ctx = None
extra_env = os.environ.copy()
if script == "worktree-start" and branch:
import re
import json
import tempfile
import issue_lock_store
m = re.search(r"issue-(\d+)", branch)
if not m:
m = re.search(r"pr-(\d+)", branch)
issue_num = int(m.group(1)) if m else 999
lock_file.write_text(json.dumps({
lock_dir_ctx = tempfile.TemporaryDirectory()
extra_env["GITEA_ISSUE_LOCK_DIR"] = lock_dir_ctx.name
record = {
"issue_number": issue_num,
"branch_name": branch,
"remote": "prgs",
"org": "Scaled-Tech-Consulting",
"repo": "Gitea-Tools"
}), encoding="utf-8")
created_lock = True
"repo": "Gitea-Tools",
"worktree_path": "/tmp/test-worktree",
"work_lease": {
"operation_type": "author_issue_work",
"expires_at": "2999-01-01T00:00:00Z",
},
}
path = issue_lock_store.lock_file_path(
remote="prgs",
org="Scaled-Tech-Consulting",
repo="Gitea-Tools",
issue_number=issue_num,
lock_dir=lock_dir_ctx.name,
)
issue_lock_store.save_lock_file(path, record)
try:
proc = subprocess.run(
["bash", str(SCRIPTS / script), *args],
capture_output=True, text=True, cwd=str(REPO),
env=extra_env,
)
return proc.returncode, proc.stdout, proc.stderr
finally:
if created_lock and lock_file.exists():
lock_file.unlink()
if lock_dir_ctx is not None:
lock_dir_ctx.cleanup()
class TestWorktreeStart(unittest.TestCase):
+205
View File
@@ -0,0 +1,205 @@
"""Precise validation-status vocabulary for reviewer final reports (#406)."""
from __future__ import annotations
import re
from typing import Any
from reviewer_merge_simulation import assess_merge_simulation_report
STATUS_PASSED = "passed"
STATUS_FAILED = "failed"
STATUS_BASELINE_EQUIVALENT = "baseline-equivalent failure accepted"
STATUS_MERGE_SIM_RESOLVED = "raw-head failure resolved by merge simulation"
STATUS_TRANSIENT_PASS = "passed after transient failure investigation"
ALLOWED_VALIDATION_STATUSES = frozenset({
STATUS_PASSED,
STATUS_FAILED,
STATUS_BASELINE_EQUIVALENT,
STATUS_MERGE_SIM_RESOLVED,
STATUS_TRANSIENT_PASS,
})
_STATUS_FIELD_RE = re.compile(
r"^\s*[-*]?\s*(?:validation status|pr-head validation status|"
r"official validation status)\s*:\s*(.+?)\s*$",
re.IGNORECASE | re.MULTILINE,
)
_RAW_HEAD_RESULT_RE = re.compile(
r"^\s*[-*]?\s*raw pr-head validation result\s*:\s*(.+?)\s*$",
re.IGNORECASE | re.MULTILINE,
)
_MERGE_SIM_RESULT_RE = re.compile(
r"^\s*[-*]?\s*merge simulation result\s*:\s*(.+?)\s*$",
re.IGNORECASE | re.MULTILINE,
)
_BASELINE_WORKTREE_USED_RE = re.compile(
r"^\s*[-*]?\s*baseline (?:validation )?worktree(?: used)?\s*:\s*(.+?)\s*$",
re.IGNORECASE | re.MULTILINE,
)
_BASELINE_TARGET_SHA_RE = re.compile(
r"^\s*[-*]?\s*baseline target sha\s*:\s*([0-9a-f]{7,40})\s*$",
re.IGNORECASE | re.MULTILINE,
)
_FAILURE_SIGNATURE_RE = re.compile(
r"failure signatures match\s*:\s*(true|yes)",
re.IGNORECASE,
)
_BASELINE_FAILURES_RE = re.compile(
r"baseline failures\s*:",
re.IGNORECASE,
)
_TRANSIENT_HISTORY_RE = re.compile(
r"(?:transient validation failure|earlier validation failure|"
r"prior failure|failure history|failed then passed)",
re.IGNORECASE,
)
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$", re.IGNORECASE)
def _first_match(pattern: re.Pattern[str], text: str) -> str:
match = pattern.search(text or "")
return (match.group(1).strip() if match else "")
def _normalize_status_label(raw: str) -> str:
text = (raw or "").strip().lower()
for status in ALLOWED_VALIDATION_STATUSES:
if text == status.lower():
return status
return raw.strip()
def _baseline_proof_complete(text: str, baseline_proof: dict | None) -> bool:
proof = baseline_proof or {}
worktree = (
(proof.get("worktree_path") or "").strip()
or _first_match(_BASELINE_WORKTREE_USED_RE, text)
).lower()
if not worktree or worktree in {"none", "n/a", "not used", "not applicable"}:
return False
if "branches/" not in worktree and not worktree.startswith("branches/"):
return False
target_sha = (proof.get("baseline_target_sha") or "").strip()
if not target_sha:
target_sha = _first_match(_BASELINE_TARGET_SHA_RE, text)
if not _FULL_SHA.match(target_sha or ""):
return False
if proof.get("failure_signatures_match") is True:
return True
if _FAILURE_SIGNATURE_RE.search(text) and _BASELINE_FAILURES_RE.search(text):
return True
return False
def _merge_simulation_passed(text: str, command_log: list | None) -> bool:
merge_result = _first_match(_MERGE_SIM_RESULT_RE, text).lower()
if merge_result in {"passed", "pass", "clean", "succeeded", "success"}:
sim = assess_merge_simulation_report(text, command_log=command_log)
return sim.get("proven") and not sim.get("block")
if "pass" in merge_result and "fail" not in merge_result:
sim = assess_merge_simulation_report(text, command_log=command_log)
return sim.get("proven") and not sim.get("block")
return False
def _raw_head_failed(text: str) -> bool:
raw = _first_match(_RAW_HEAD_RESULT_RE, text).lower()
if raw in {"failed", "fail", "failure"}:
return True
if "fail" in raw and "pass" not in raw:
return True
return bool(re.search(r"\bfailed\b.*pr-head validation", text, re.IGNORECASE))
def _raw_head_passed(text: str) -> bool:
raw = _first_match(_RAW_HEAD_RESULT_RE, text).lower()
return raw in {"passed", "pass", "success"}
def assess_validation_status_vocabulary(
report_text: str,
*,
command_log: list | None = None,
baseline_proof: dict | None = None,
) -> dict[str, Any]:
"""Bind validation-status labels to the proof path that actually ran (#406)."""
text = report_text or ""
reasons: list[str] = []
status_raw = _first_match(_STATUS_FIELD_RE, text)
status = _normalize_status_label(status_raw) if status_raw else ""
if status_raw and status not in ALLOWED_VALIDATION_STATUSES:
reasons.append(
f"unknown validation status {status_raw!r}; use one of "
f"{sorted(ALLOWED_VALIDATION_STATUSES)}"
)
if status == STATUS_BASELINE_EQUIVALENT:
if not _baseline_proof_complete(text, baseline_proof):
reasons.append(
"baseline-equivalent failure accepted requires baseline "
"worktree path, baseline target SHA, and matching failure "
"signatures (#406)"
)
if status == STATUS_MERGE_SIM_RESOLVED:
if not _raw_head_failed(text):
reasons.append(
"raw-head failure resolved by merge simulation requires "
"raw PR-head validation result: failed (#406)"
)
if not _merge_simulation_passed(text, command_log):
reasons.append(
"raw-head failure resolved by merge simulation requires "
"passing merge simulation with worktree/index mutation proof "
"(#317/#406)"
)
if status == STATUS_TRANSIENT_PASS:
if not _TRANSIENT_HISTORY_RE.search(text):
reasons.append(
"passed after transient failure investigation requires "
"documented earlier validation failure history (#396/#406)"
)
if status == STATUS_PASSED and _raw_head_failed(text):
reasons.append(
"validation status passed contradicts raw PR-head validation "
"failure; use a precise status (#406)"
)
if status == STATUS_BASELINE_EQUIVALENT and _merge_simulation_passed(
text, command_log
) and not _baseline_proof_complete(text, baseline_proof):
reasons.append(
"baseline-equivalent failure accepted is misleading when only "
"merge simulation resolved the failure; use "
"'raw-head failure resolved by merge simulation' (#406)"
)
if status == STATUS_FAILED and _merge_simulation_passed(text, command_log):
reasons.append(
"validation status failed contradicts passing merge simulation; "
"report the precise resolution status (#406)"
)
block = bool(reasons)
return {
"block": block,
"proven": not block,
"status_claimed": status or None,
"raw_status_label": status_raw or None,
"raw_head_failed": _raw_head_failed(text),
"raw_head_passed": _raw_head_passed(text),
"merge_simulation_passed": _merge_simulation_passed(text, command_log),
"baseline_proof_complete": _baseline_proof_complete(text, baseline_proof),
"reasons": reasons,
"safe_next_action": (
"use a validation status that matches the proof path executed "
"(baseline worktree, merge simulation, or transient history)"
if reasons
else "proceed"
),
}
+5
View File
@@ -0,0 +1,5 @@
"""Internal MCP Control Plane web UI (read-only MVP skeleton, #426)."""
from webui.app import create_app
__all__ = ["create_app"]
+19
View File
@@ -0,0 +1,19 @@
"""Run the internal web UI: ``python -m webui``."""
from __future__ import annotations
import os
import uvicorn
from webui.app import create_app
def main() -> None:
host = os.environ.get("WEBUI_HOST", "127.0.0.1")
port = int(os.environ.get("WEBUI_PORT", "8765"))
uvicorn.run(create_app(), host=host, port=port, log_level="info")
if __name__ == "__main__":
main()
+180
View File
@@ -0,0 +1,180 @@
"""Starlette application for the internal read-only web UI (#426)."""
from __future__ import annotations
from datetime import datetime, timezone
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import HTMLResponse, JSONResponse, Response
from starlette.routing import Route
from webui.layout import render_page
from webui.project_registry import find_project, load_registry, registry_to_dict
from webui.project_views import render_project_detail, render_projects_list
from webui.prompt_library import find_prompt, library_to_dict
from webui.prompt_views import render_prompt_detail, render_prompts_page
from webui.queue_loader import load_queue_snapshot, snapshot_to_dict
from webui.queue_views import render_queue_page
_READ_ONLY_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
def _stub_page(title: str, description: str) -> HTMLResponse:
body = (
f"<h2>{title}</h2>"
f'<div class="stub"><p>{description}</p>'
"<p>Implementation tracked in a child issue of #425.</p></div>"
)
return HTMLResponse(render_page(title=title, body_html=body))
async def home(_request: Request) -> HTMLResponse:
body = (
"<h2>Operator console</h2>"
"<p>Local entry point for MCP Control Plane operational views.</p>"
"<ul>"
"<li><strong>Queue</strong> — live PR and issue dashboard (#429)</li>"
"<li><strong>Projects</strong> — registry and onboarding (#427)</li>"
"<li><strong>Prompts</strong> — canonical workflow prompt library (#428)</li>"
"<li><strong>Runtime</strong> — MCP health and stale-runtime detection (#430)</li>"
"<li><strong>Audit</strong> — final-report paste and validator preview (#431)</li>"
"<li><strong>Worktrees</strong> — branch hygiene dashboard (#432)</li>"
"<li><strong>Leases</strong> — collision and lease visibility (#433)</li>"
"</ul>"
)
return HTMLResponse(render_page(title="Home", body_html=body))
async def health(_request: Request) -> JSONResponse:
return JSONResponse({
"status": "ok",
"service": "mcp-control-plane-webui",
"mode": "read-only-mvp",
"timestamp": datetime.now(timezone.utc).isoformat(),
})
async def queue(_request: Request) -> HTMLResponse:
snapshot = load_queue_snapshot()
return HTMLResponse(render_page(title="Queue", body_html=render_queue_page(snapshot)))
async def api_queue(_request: Request) -> JSONResponse:
return JSONResponse(snapshot_to_dict(load_queue_snapshot()))
async def projects(_request: Request) -> HTMLResponse:
registry = load_registry()
return HTMLResponse(render_projects_list(registry))
async def project_detail(request: Request) -> HTMLResponse:
project_id = request.path_params["project_id"]
registry = load_registry()
project = find_project(registry, project_id)
if project is None:
return HTMLResponse(
render_page(
title="Project not found",
body_html=(
"<h2>Project not found</h2>"
f"<p>No registry entry for <code>{project_id}</code>.</p>"
'<p><a href="/projects">← All projects</a></p>'
),
),
status_code=404,
)
return HTMLResponse(render_project_detail(project))
async def api_projects(_request: Request) -> JSONResponse:
registry = load_registry()
return JSONResponse(registry_to_dict(registry))
async def prompts(_request: Request) -> HTMLResponse:
return HTMLResponse(render_prompts_page())
async def prompt_detail(request: Request) -> HTMLResponse:
prompt_id = request.path_params["prompt_id"]
prompt = find_prompt(prompt_id)
if prompt is None:
return HTMLResponse(
render_page(
title="Prompt not found",
body_html=(
"<h2>Prompt not found</h2>"
f"<p>No library entry for <code>{prompt_id}</code>.</p>"
'<p><a href="/prompts">← All prompts</a></p>'
),
),
status_code=404,
)
return HTMLResponse(render_prompt_detail(prompt))
async def api_prompts(_request: Request) -> JSONResponse:
return JSONResponse(library_to_dict())
async def runtime(_request: Request) -> HTMLResponse:
return _stub_page(
"Runtime",
"Runtime health will report MCP profile, preflight, and stale-server signals.",
)
async def audit(_request: Request) -> HTMLResponse:
return _stub_page(
"Audit",
"Report audit will accept pasted final reports and run validator previews.",
)
async def worktrees(_request: Request) -> HTMLResponse:
return _stub_page(
"Worktrees",
"Worktree hygiene will summarize branches/ session folders and cleanup risk.",
)
async def leases(_request: Request) -> HTMLResponse:
return _stub_page(
"Leases",
"Lease visibility will show active issue and reviewer PR leases.",
)
async def method_not_allowed(request: Request, _exc: Exception) -> Response:
if request.method not in _READ_ONLY_METHODS:
return JSONResponse(
{"error": "read-only-mvp", "detail": f"{request.method} not permitted"},
status_code=405,
)
return JSONResponse({"error": "not_found"}, status_code=404)
def create_app() -> Starlette:
"""Build the read-only MVP Starlette app."""
return Starlette(
debug=False,
routes=[
Route("/", home, methods=["GET"]),
Route("/health", health, methods=["GET"]),
Route("/queue", queue, methods=["GET"]),
Route("/api/queue", api_queue, methods=["GET"]),
Route("/projects", projects, methods=["GET"]),
Route("/projects/{project_id}", project_detail, methods=["GET"]),
Route("/api/projects", api_projects, methods=["GET"]),
Route("/prompts", prompts, methods=["GET"]),
Route("/prompts/{prompt_id}", prompt_detail, methods=["GET"]),
Route("/api/prompts", api_prompts, methods=["GET"]),
Route("/runtime", runtime, methods=["GET"]),
Route("/audit", audit, methods=["GET"]),
Route("/worktrees", worktrees, methods=["GET"]),
Route("/leases", leases, methods=["GET"]),
],
exception_handlers={405: method_not_allowed},
)
+49
View File
@@ -0,0 +1,49 @@
{
"version": 1,
"projects": [
{
"id": "gitea-tools",
"repo_name": "Gitea-Tools",
"gitea_owner": "Scaled-Tech-Consulting",
"remote_host": "https://gitea.prgs.cc",
"default_branch": "master",
"local_checkout_path": ".",
"profiles": {
"author": "prgs-author",
"reviewer": "prgs-reviewer",
"reconciler": "prgs-reconciler"
},
"workflow_paths": {
"skill": "skills/llm-project-workflow/SKILL.md",
"work_issue": "skills/llm-project-workflow/workflows/work-issue.md",
"review_merge": "skills/llm-project-workflow/workflows/review-merge-pr.md"
},
"schema_paths": {
"mcp_config_v2": "gitea-mcp.v2-contexts.example.json",
"mcp_config_v1": "gitea-mcp.example.json"
},
"onboarding_checklist": [
{
"id": "profiles",
"title": "Configure execution profiles",
"description": "Install author, reviewer, and reconciler MCP profiles (prgs-author, prgs-reviewer, prgs-reconciler) in separate namespaces. Tokens stay in keychain — never in this registry."
},
{
"id": "mcp_config",
"title": "Wire MCP v2 contexts",
"description": "Copy and customize gitea-mcp.v2-contexts.example.json for your machine. Map this repo path under projects with default_owner Scaled-Tech-Consulting and default_repo Gitea-Tools."
},
{
"id": "wiki_gate",
"title": "Wiki publication readiness",
"description": "For wiki-tracked work, satisfy the live Gitea Wiki proof gate (#224) before closing issues. See docs/wiki/Safety-and-Gates.md."
},
{
"id": "branches_layout",
"title": "Isolate work under branches/",
"description": "All LLM task edits happen in worktrees under branches/. Main checkout stays clean; use skills/llm-project-workflow templates for start-issue and review flows."
}
]
}
]
}
+176
View File
@@ -0,0 +1,176 @@
"""Shared HTML layout for the internal web UI."""
from __future__ import annotations
NAV_ITEMS = (
("/", "Home"),
("/queue", "Queue"),
("/projects", "Projects"),
("/prompts", "Prompts"),
("/runtime", "Runtime"),
("/audit", "Audit"),
("/worktrees", "Worktrees"),
("/leases", "Leases"),
)
MVP_NOTICE = (
"Read-only MVP — Gitea, MCP tools, and canonical workflows remain the "
"source of truth. No mutation endpoints."
)
def render_page(*, title: str, body_html: str, extra_head: str = "") -> str:
nav_links = "".join(
f'<a href="{href}">{label}</a>' for href, label in NAV_ITEMS
)
return f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title} · MCP Control Plane</title>
<style>
:root {{
--bg: #0f1419;
--surface: #1a2332;
--text: #e7ecf3;
--muted: #8b9cb3;
--accent: #5b9fd4;
--border: #2a3648;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}}
header {{
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0.75rem 1.25rem;
}}
header h1 {{
margin: 0 0 0.5rem;
font-size: 1.1rem;
font-weight: 600;
}}
nav {{
display: flex;
flex-wrap: wrap;
gap: 0.75rem 1rem;
}}
nav a {{
color: var(--accent);
text-decoration: none;
font-size: 0.9rem;
}}
nav a:hover {{ text-decoration: underline; }}
main {{
max-width: 52rem;
margin: 0 auto;
padding: 1.5rem 1.25rem 2.5rem;
}}
.notice {{
color: var(--muted);
font-size: 0.85rem;
margin-bottom: 1.25rem;
padding: 0.65rem 0.85rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
}}
h2 {{ margin-top: 0; font-size: 1.35rem; }}
p {{ color: var(--muted); }}
.stub {{
border-left: 3px solid var(--accent);
padding-left: 0.85rem;
margin: 1rem 0;
}}
.meta {{ font-size: 0.85rem; }}
table.registry, table.detail {{
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
font-size: 0.9rem;
}}
table.registry th, table.registry td,
table.detail th, table.detail td {{
text-align: left;
padding: 0.45rem 0.6rem;
border-bottom: 1px solid var(--border);
}}
table.registry th, table.detail th {{
color: var(--muted);
font-weight: 500;
}}
code {{
font-family: ui-monospace, monospace;
font-size: 0.85em;
color: var(--text);
}}
ol.checklist {{
padding-left: 1.25rem;
margin: 0.5rem 0 1.5rem;
}}
ol.checklist li {{ margin-bottom: 0.85rem; }}
ol.checklist p {{ margin: 0.25rem 0 0; font-size: 0.9rem; }}
.prompt-card {{
margin: 1.25rem 0 1.75rem;
padding: 1rem 1.1rem;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
}}
.prompt-card h3 {{ margin: 0 0 0.5rem; font-size: 1.05rem; }}
pre.prompt-text {{
white-space: pre-wrap;
word-break: break-word;
margin: 0.75rem 0;
padding: 0.75rem 0.85rem;
border-radius: 6px;
background: var(--bg);
border: 1px solid var(--border);
font-size: 0.9rem;
color: var(--text);
}}
.copy-btn {{
background: var(--accent);
color: #0b1219;
border: none;
border-radius: 6px;
padding: 0.4rem 0.85rem;
font-size: 0.85rem;
cursor: pointer;
}}
.copy-btn:hover {{ filter: brightness(1.08); }}
.muted {{ color: var(--muted); }}
.badges {{ display: inline-flex; flex-wrap: wrap; gap: 0.35rem; margin-left: 0.5rem; }}
.badge {{
font-size: 0.72rem;
padding: 0.1rem 0.45rem;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--muted);
text-transform: lowercase;
}}
.badge-claimed {{ color: #8fd19e; border-color: #3d6b4a; }}
.badge-blocked {{ color: #f0a8a8; border-color: #7a3b3b; }}
.badge-in-review {{ color: #9ec8f0; border-color: #3d5f7a; }}
.badge-duplicate {{ color: #e0c27a; border-color: #6b5730; }}
.badge-stale {{ color: #c9b8e8; border-color: #5a4a78; }}
</style>
{extra_head}
</head>
<body>
<header>
<h1>MCP Control Plane</h1>
<nav>{nav_links}</nav>
</header>
<main>
<p class="notice">{MVP_NOTICE}</p>
{body_html}
</main>
</body>
</html>"""
+196
View File
@@ -0,0 +1,196 @@
"""Load and validate the web UI project registry (#427)."""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Any
_FORBIDDEN_EXACT_KEYS = frozenset({
"token",
"password",
"secret",
"credential",
"auth",
"api_key",
"api-key",
})
_FORBIDDEN_KEY_PREFIXES = ("auth_", "api_key_", "api-key_")
_FORBIDDEN_KEY_SUFFIXES = ("_token", "_secret", "_password", "_credential", "_auth")
def _is_forbidden_key(key: str) -> bool:
lowered = key.lower()
if lowered in _FORBIDDEN_EXACT_KEYS:
return True
return (
lowered.startswith(_FORBIDDEN_KEY_PREFIXES)
or lowered.endswith(_FORBIDDEN_KEY_SUFFIXES)
)
_REQUIRED_PROJECT_FIELDS = (
"id",
"repo_name",
"gitea_owner",
"remote_host",
"default_branch",
"local_checkout_path",
"profiles",
"workflow_paths",
)
_REQUIRED_PROFILE_ROLES = ("author", "reviewer", "reconciler")
@dataclass(frozen=True)
class OnboardingStep:
id: str
title: str
description: str
@dataclass(frozen=True)
class ProjectRecord:
id: str
repo_name: str
gitea_owner: str
remote_host: str
default_branch: str
local_checkout_path: str
profiles: dict[str, str]
workflow_paths: dict[str, str]
schema_paths: dict[str, str]
onboarding_checklist: tuple[OnboardingStep, ...]
@dataclass(frozen=True)
class ProjectRegistry:
version: int
projects: tuple[ProjectRecord, ...]
source_path: Path
def default_registry_path() -> Path:
override = os.environ.get("WEBUI_PROJECT_REGISTRY", "").strip()
if override:
return Path(override).expanduser().resolve()
return (Path(__file__).resolve().parent / "data" / "projects.registry.json").resolve()
def _reject_credential_keys(obj: Any, *, path: str = "") -> None:
if isinstance(obj, dict):
for key, value in obj.items():
key_path = f"{path}.{key}" if path else key
if _is_forbidden_key(key):
raise ValueError(f"registry must not store credentials ({key_path})")
_reject_credential_keys(value, path=key_path)
elif isinstance(obj, list):
for index, item in enumerate(obj):
_reject_credential_keys(item, path=f"{path}[{index}]")
def _parse_onboarding(raw: list[dict[str, Any]] | None) -> tuple[OnboardingStep, ...]:
if not raw:
return ()
steps: list[OnboardingStep] = []
for item in raw:
steps.append(
OnboardingStep(
id=str(item["id"]),
title=str(item["title"]),
description=str(item["description"]),
)
)
return tuple(steps)
def _parse_project(raw: dict[str, Any]) -> ProjectRecord:
missing = [field for field in _REQUIRED_PROJECT_FIELDS if field not in raw]
if missing:
raise ValueError(f"project missing required fields: {', '.join(missing)}")
profiles = raw["profiles"]
if not isinstance(profiles, dict):
raise ValueError("profiles must be an object")
for role in _REQUIRED_PROFILE_ROLES:
if role not in profiles or not profiles[role]:
raise ValueError(f"profiles.{role} is required")
workflow_paths = raw["workflow_paths"]
if not isinstance(workflow_paths, dict) or not workflow_paths:
raise ValueError("workflow_paths must be a non-empty object")
schema_paths = raw.get("schema_paths") or {}
if not isinstance(schema_paths, dict):
raise ValueError("schema_paths must be an object when present")
return ProjectRecord(
id=str(raw["id"]),
repo_name=str(raw["repo_name"]),
gitea_owner=str(raw["gitea_owner"]),
remote_host=str(raw["remote_host"]),
default_branch=str(raw["default_branch"]),
local_checkout_path=str(raw["local_checkout_path"]),
profiles={role: str(profiles[role]) for role in _REQUIRED_PROFILE_ROLES},
workflow_paths={key: str(value) for key, value in workflow_paths.items()},
schema_paths={key: str(value) for key, value in schema_paths.items()},
onboarding_checklist=_parse_onboarding(raw.get("onboarding_checklist")),
)
def load_registry(path: Path | None = None) -> ProjectRegistry:
"""Load the versioned project registry from disk."""
source = (path or default_registry_path()).resolve()
raw_text = source.read_text(encoding="utf-8")
payload = json.loads(raw_text)
if not isinstance(payload, dict):
raise ValueError("registry root must be an object")
version = payload.get("version")
if version != 1:
raise ValueError(f"unsupported registry version: {version!r}")
_reject_credential_keys(payload)
projects_raw = payload.get("projects")
if not isinstance(projects_raw, list) or not projects_raw:
raise ValueError("projects must be a non-empty array")
projects = tuple(_parse_project(item) for item in projects_raw)
return ProjectRegistry(version=version, projects=projects, source_path=source)
def project_to_dict(project: ProjectRecord) -> dict[str, Any]:
"""Serialize a project for JSON API responses."""
return {
"id": project.id,
"repo_name": project.repo_name,
"gitea_owner": project.gitea_owner,
"remote_host": project.remote_host,
"default_branch": project.default_branch,
"local_checkout_path": project.local_checkout_path,
"profiles": dict(project.profiles),
"workflow_paths": dict(project.workflow_paths),
"schema_paths": dict(project.schema_paths),
"onboarding_checklist": [
{"id": step.id, "title": step.title, "description": step.description}
for step in project.onboarding_checklist
],
}
def registry_to_dict(registry: ProjectRegistry) -> dict[str, Any]:
return {
"version": registry.version,
"source_path": str(registry.source_path),
"projects": [project_to_dict(project) for project in registry.projects],
}
def find_project(registry: ProjectRegistry, project_id: str) -> ProjectRecord | None:
for project in registry.projects:
if project.id == project_id:
return project
return None
+93
View File
@@ -0,0 +1,93 @@
"""HTML views for project registry pages (#427)."""
from __future__ import annotations
import html
from webui.layout import render_page
from webui.project_registry import ProjectRecord, ProjectRegistry
def _escape(text: str) -> str:
return html.escape(text, quote=True)
def render_projects_list(registry: ProjectRegistry) -> str:
rows = []
for project in registry.projects:
rows.append(
"<tr>"
f"<td><a href=\"/projects/{_escape(project.id)}\">{_escape(project.repo_name)}</a></td>"
f"<td>{_escape(project.gitea_owner)}</td>"
f"<td>{_escape(project.remote_host)}</td>"
f"<td>{_escape(project.default_branch)}</td>"
f"<td><code>{_escape(project.profiles['author'])}</code></td>"
"</tr>"
)
table = (
"<table class=\"registry\">"
"<thead><tr>"
"<th>Repository</th><th>Owner</th><th>Remote</th>"
"<th>Branch</th><th>Author profile</th>"
"</tr></thead>"
f"<tbody>{''.join(rows)}</tbody></table>"
)
body = (
"<h2>Projects</h2>"
"<p>Configured repositories managed by the MCP Control Plane.</p>"
f"<p class=\"meta\">Registry: <code>{_escape(str(registry.source_path))}</code> "
f"(version {registry.version})</p>"
f"{table}"
"<p><a href=\"/api/projects\">JSON API</a></p>"
)
return render_page(title="Projects", body_html=body)
def render_project_detail(project: ProjectRecord) -> str:
profile_rows = "".join(
f"<tr><th>{_escape(role)}</th><td><code>{_escape(name)}</code></td></tr>"
for role, name in project.profiles.items()
)
workflow_rows = "".join(
f"<tr><th>{_escape(key)}</th><td><code>{_escape(path)}</code></td></tr>"
for key, path in project.workflow_paths.items()
)
schema_rows = "".join(
f"<tr><th>{_escape(key)}</th><td><code>{_escape(path)}</code></td></tr>"
for key, path in project.schema_paths.items()
)
checklist_items = []
for index, step in enumerate(project.onboarding_checklist, start=1):
checklist_items.append(
"<li>"
f"<strong>{index}. {_escape(step.title)}</strong>"
f"<p>{_escape(step.description)}</p>"
"</li>"
)
checklist_html = (
"<ol class=\"checklist\">" + "".join(checklist_items) + "</ol>"
if checklist_items
else "<p>No onboarding steps defined.</p>"
)
body = (
f"<h2>{_escape(project.repo_name)}</h2>"
"<p><a href=\"/projects\">← All projects</a></p>"
"<h3>Identity</h3>"
"<table class=\"detail\">"
f"<tr><th>Registry id</th><td><code>{_escape(project.id)}</code></td></tr>"
f"<tr><th>Gitea owner</th><td>{_escape(project.gitea_owner)}</td></tr>"
f"<tr><th>Remote host</th><td>{_escape(project.remote_host)}</td></tr>"
f"<tr><th>Default branch</th><td><code>{_escape(project.default_branch)}</code></td></tr>"
f"<tr><th>Local checkout</th><td><code>{_escape(project.local_checkout_path)}</code></td></tr>"
"</table>"
"<h3>Profiles</h3>"
f"<table class=\"detail\">{profile_rows}</table>"
"<h3>Workflow paths</h3>"
f"<table class=\"detail\">{workflow_rows}</table>"
"<h3>Schema paths</h3>"
f"<table class=\"detail\">{schema_rows}</table>"
"<h3>Onboarding checklist</h3>"
"<p class=\"meta\">Read-only MVP — complete these steps outside the UI.</p>"
f"{checklist_html}"
)
return render_page(title=project.repo_name, body_html=body)
+197
View File
@@ -0,0 +1,197 @@
"""Canonical workflow prompt library for the internal web UI (#428)."""
from __future__ import annotations
import hashlib
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
_WORKFLOW_ROOT = Path("skills/llm-project-workflow/workflows")
_DEFAULT_PROMPT_RE = re.compile(
r"\*\*Default task prompt:\*\*\s*\n+>\s*(.+?)(?=\n\n|\nDo not improvise)",
re.DOTALL,
)
_FRONTMATTER_TASK_MODE_RE = re.compile(r"^task_mode:\s*(\S+)", re.MULTILINE)
@dataclass(frozen=True)
class PromptEntry:
slug: str
label: str
prompt_text: str
workflow_path: str
task_mode: str | None
workflow_hash: str | None
source_note: str
def _repo_root() -> Path:
override = (os.environ.get("WEBUI_REPO_ROOT") or "").strip()
if override:
return Path(override).resolve()
return Path(__file__).resolve().parent.parent
def _workflow_file(path: str) -> Path:
return _repo_root() / path
def _sha256_hex(content: str) -> str:
return hashlib.sha256(content.encode("utf-8")).hexdigest()
def _read_workflow(path: str) -> tuple[str, str]:
file_path = _workflow_file(path)
text = file_path.read_text(encoding="utf-8")
return text, _sha256_hex(text)
def _extract_default_prompt(markdown: str) -> str | None:
match = _DEFAULT_PROMPT_RE.search(markdown)
if not match:
return None
lines = [line.strip() for line in match.group(1).splitlines()]
return " ".join(line for line in lines if line)
def _extract_task_mode(markdown: str) -> str | None:
match = _FRONTMATTER_TASK_MODE_RE.search(markdown)
return match.group(1) if match else None
def _entry_from_workflow(
*,
slug: str,
label: str,
workflow_path: str,
prompt_override: str | None = None,
source_note: str = "",
) -> PromptEntry:
markdown, digest = _read_workflow(workflow_path)
prompt_text = prompt_override or _extract_default_prompt(markdown)
if not prompt_text:
raise ValueError(f"No default task prompt found in {workflow_path}")
return PromptEntry(
slug=slug,
label=label,
prompt_text=prompt_text,
workflow_path=workflow_path,
task_mode=_extract_task_mode(markdown),
workflow_hash=digest,
source_note=source_note,
)
def _static_entry(
*,
slug: str,
label: str,
prompt_text: str,
workflow_path: str,
source_note: str,
) -> PromptEntry:
path = _workflow_file(workflow_path)
digest = _sha256_hex(path.read_text(encoding="utf-8")) if path.is_file() else None
markdown = path.read_text(encoding="utf-8") if path.is_file() else ""
return PromptEntry(
slug=slug,
label=label,
prompt_text=prompt_text,
workflow_path=workflow_path,
task_mode=_extract_task_mode(markdown) if markdown else None,
workflow_hash=digest,
source_note=source_note,
)
def load_prompt_library() -> tuple[PromptEntry, ...]:
"""Load operator prompts derived from canonical workflows."""
entries = (
_entry_from_workflow(
slug="review-pr",
label="Review PR",
workflow_path=str(_WORKFLOW_ROOT / "review-merge-pr.md"),
),
_entry_from_workflow(
slug="work-issue",
label="Work issue",
workflow_path=str(_WORKFLOW_ROOT / "work-issue.md"),
),
_entry_from_workflow(
slug="create-issue",
label="Create issue",
workflow_path=str(_WORKFLOW_ROOT / "create-issue.md"),
),
_static_entry(
slug="comment-issue",
label="Comment on issue",
workflow_path=str(_WORKFLOW_ROOT / "create-issue.md"),
prompt_text=(
"Comment on the target Gitea issue only if exact comment_issue "
"capability is proven. Load the canonical create-issue workflow "
"first and follow §16 (comment-on-existing issue rule). Include "
"specific evidence; do not duplicate existing comments."
),
source_note="Derived from create-issue.md §16; full policy remains in the workflow file.",
),
_static_entry(
slug="cleanup",
label="Post-merge cleanup",
workflow_path="skills/llm-project-workflow/templates/worktree-cleanup.md",
prompt_text=(
"Task: clean up branch/worktree for PR #<pr> / issue #<n> after merge. "
"Confirm the merge on remote master before any deletion; never "
"force-remove a dirty worktree."
),
source_note="Full cleanup steps live in templates/worktree-cleanup.md.",
),
_entry_from_workflow(
slug="audit",
label="Reconciliation audit",
workflow_path=str(_WORKFLOW_ROOT / "reconcile-landed-pr.md"),
),
_static_entry(
slug="onboarding",
label="Project onboarding",
workflow_path="skills/llm-project-workflow/SKILL.md",
prompt_text=(
"Onboard this repository into the MCP Control Plane: prove identity "
"and task capability, configure author/reviewer/reconciler profiles "
"in separate namespaces, then complete the checklist at /projects. "
"Canonical router: skills/llm-project-workflow/SKILL.md."
),
source_note="Checklist details live in webui/data/projects.registry.json and /projects.",
),
)
return entries
def find_prompt(slug: str) -> PromptEntry | None:
for entry in load_prompt_library():
if entry.slug == slug:
return entry
return None
def prompt_to_dict(entry: PromptEntry) -> dict[str, Any]:
return {
"slug": entry.slug,
"label": entry.label,
"prompt_text": entry.prompt_text,
"workflow_path": entry.workflow_path,
"task_mode": entry.task_mode,
"workflow_hash": entry.workflow_hash,
"source_note": entry.source_note,
}
def library_to_dict() -> dict[str, Any]:
entries = load_prompt_library()
return {
"count": len(entries),
"prompts": [prompt_to_dict(entry) for entry in entries],
}
+87
View File
@@ -0,0 +1,87 @@
"""HTML views for the prompt library (#428)."""
from __future__ import annotations
import html
from webui.layout import render_page
from webui.prompt_library import PromptEntry, load_prompt_library
def _escape(text: str) -> str:
return html.escape(text, quote=True)
def _render_prompt_card(entry: PromptEntry) -> str:
hash_short = (
f"<code>{_escape(entry.workflow_hash[:12])}</code>"
if entry.workflow_hash
else "<span class=\"muted\">n/a</span>"
)
task_mode = (
f"<code>{_escape(entry.task_mode)}</code>"
if entry.task_mode
else "<span class=\"muted\">n/a</span>"
)
note = (
f'<p class="meta">{_escape(entry.source_note)}</p>'
if entry.source_note
else ""
)
prompt_id = f"prompt-{entry.slug}"
return (
f'<section class="prompt-card" id="{_escape(entry.slug)}">'
f"<h3>{_escape(entry.label)}</h3>"
f'<p class="meta">Workflow: <code>{_escape(entry.workflow_path)}</code> · '
f"task_mode: {task_mode} · sha256: {hash_short}</p>"
f'<pre class="prompt-text" id="{prompt_id}">{_escape(entry.prompt_text)}</pre>'
f'<button type="button" class="copy-btn" data-copy-target="{prompt_id}">'
"Copy prompt</button>"
f"{note}"
"</section>"
)
PROMPT_PAGE_SCRIPT = """
<script>
document.querySelectorAll('.copy-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
const targetId = btn.getAttribute('data-copy-target');
const node = document.getElementById(targetId);
if (!node) return;
const text = node.textContent || '';
try {
await navigator.clipboard.writeText(text);
const prior = btn.textContent;
btn.textContent = 'Copied';
setTimeout(() => { btn.textContent = prior; }, 1200);
} catch (_err) {
btn.textContent = 'Copy failed';
}
});
});
</script>
"""
def render_prompts_page() -> str:
entries = load_prompt_library()
cards = "".join(_render_prompt_card(entry) for entry in entries)
body = (
"<h2>Prompt library</h2>"
"<p>Short copy/paste task prompts derived from canonical workflows. "
"Full policy remains in the cited workflow files — not duplicated here.</p>"
f"{cards}"
"<p><a href=\"/api/prompts\">JSON API</a></p>"
f"{PROMPT_PAGE_SCRIPT}"
)
return render_page(title="Prompts", body_html=body)
def render_prompt_detail(entry: PromptEntry) -> str:
body = (
f"<p><a href=\"/prompts\">← All prompts</a></p>"
f"{_render_prompt_card(entry)}"
f"{PROMPT_PAGE_SCRIPT}"
)
return render_page(title=entry.label, body_html=body)
+385
View File
@@ -0,0 +1,385 @@
"""Load live Gitea PR/issue queue state for the web UI dashboard (#429)."""
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Callable
from urllib.parse import urlparse
from gitea_auth import api_fetch_page, get_auth_header, repo_api_url
from webui.project_registry import ProjectRecord, load_registry
_CLOSES_RE = re.compile(r"(?:closes|fixes|resolves)\s+#(\d+)", re.I)
_ISSUE_REF_RE = re.compile(r"#(\d+)")
_STALE_DAYS = 14
@dataclass(frozen=True)
class PaginationMeta:
page: int
per_page: int
returned_count: int
has_more: bool
is_final_page: bool
inventory_complete: bool
pages_fetched: int
@dataclass(frozen=True)
class QueueItem:
number: int
title: str
badges: tuple[str, ...]
extra: dict[str, str]
@dataclass(frozen=True)
class QueueSnapshot:
project_id: str
repo_label: str
prs: tuple[QueueItem, ...]
issues: tuple[QueueItem, ...]
pr_pagination: PaginationMeta | None
issue_pagination: PaginationMeta | None
fetch_error: str | None = None
def _host_from_url(remote_host: str) -> str:
parsed = urlparse(remote_host.strip())
return parsed.netloc or remote_host.strip().rstrip("/")
def _extract_linked_issue(title: str | None, body: str | None = None) -> int | None:
for text in (title, body):
if not text:
continue
match = _CLOSES_RE.search(text)
if match:
return int(match.group(1))
if body:
refs = _ISSUE_REF_RE.findall(body)
if refs:
return int(refs[0])
return None
def _is_stale(updated_at: str | None) -> bool:
if not updated_at:
return False
try:
normalized = updated_at.replace("Z", "+00:00")
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
age = datetime.now(timezone.utc) - parsed.astimezone(timezone.utc)
return age.days >= _STALE_DAYS
except ValueError:
return False
def _classify_pr(
pr: dict,
*,
issue_claimed: set[int],
issue_to_prs: dict[int, list[int]],
) -> tuple[str, ...]:
badges: list[str] = []
mergeable = pr.get("mergeable")
if mergeable is False:
badges.append("blocked")
linked = _extract_linked_issue(pr.get("title"), pr.get("body"))
if linked is not None:
if linked in issue_claimed:
badges.append("claimed")
if len(issue_to_prs.get(linked, [])) > 1:
badges.append("duplicate")
if _is_stale(pr.get("updated_at")):
badges.append("stale")
if mergeable is True and "blocked" not in badges:
badges.append("in-review")
if not badges:
badges.append("open")
return tuple(dict.fromkeys(badges))
def _classify_issue(
issue: dict,
*,
linked_prs: list[int],
) -> tuple[str, ...]:
badges: list[str] = []
labels = [lb.get("name", "") for lb in issue.get("labels", [])]
if "status:in-progress" in labels:
badges.append("claimed")
if len(linked_prs) > 1:
badges.append("duplicate")
elif linked_prs and "claimed" not in badges:
badges.append("in-review")
if _is_stale(issue.get("updated_at")):
badges.append("stale")
if not badges:
badges.append("open")
return tuple(dict.fromkeys(badges))
def _format_pr_item(pr: dict, badges: tuple[str, ...]) -> QueueItem:
head = pr.get("head") or {}
base = pr.get("base") or {}
mergeable = pr.get("mergeable")
merge_label = (
"mergeable" if mergeable is True else "conflicted" if mergeable is False else "unknown"
)
linked = _extract_linked_issue(pr.get("title"), pr.get("body"))
return QueueItem(
number=int(pr["number"]),
title=str(pr.get("title") or ""),
badges=badges,
extra={
"branch": f"{head.get('ref', '?')}{base.get('ref', '?')}",
"head_sha": str(head.get("sha") or "")[:12],
"mergeable": merge_label,
"linked_issue": str(linked) if linked is not None else "",
},
)
def _format_issue_item(issue: dict, badges: tuple[str, ...]) -> QueueItem:
labels = ", ".join(lb.get("name", "") for lb in issue.get("labels", []))
assignee = (issue.get("assignee") or {}).get("login", "")
return QueueItem(
number=int(issue["number"]),
title=str(issue.get("title") or ""),
badges=badges,
extra={
"labels": labels or "",
"assignee": assignee or "unassigned",
"state": str(issue.get("state") or ""),
},
)
def _pagination_from_pages(
*,
per_page: int,
pages_fetched: int,
returned_count: int,
is_final_page: bool,
) -> PaginationMeta:
return PaginationMeta(
page=1,
per_page=per_page,
returned_count=returned_count,
has_more=not is_final_page,
is_final_page=is_final_page,
inventory_complete=is_final_page,
pages_fetched=pages_fetched,
)
def _fetch_prs(
host: str,
org: str,
repo: str,
auth: str,
*,
per_page: int = 50,
) -> tuple[list[dict], PaginationMeta]:
url = f"{repo_api_url(host, org, repo)}/pulls?state=open"
all_raw: list[dict] = []
pages_fetched = 0
is_final = False
page = 1
while pages_fetched < 20:
raw_page, meta = api_fetch_page(url, auth, page=page, limit=per_page)
pages_fetched += 1
all_raw.extend(raw_page)
is_final = bool(meta["is_final_page"])
if is_final:
break
page += 1
pagination = _pagination_from_pages(
per_page=per_page,
pages_fetched=pages_fetched,
returned_count=len(all_raw),
is_final_page=is_final,
)
return all_raw, pagination
def _fetch_issues(
host: str,
org: str,
repo: str,
auth: str,
*,
per_page: int = 50,
) -> tuple[list[dict], PaginationMeta]:
url = f"{repo_api_url(host, org, repo)}/issues?state=open&type=issues"
all_raw: list[dict] = []
page = 1
pages_fetched = 0
is_final = False
while pages_fetched < 20:
raw_page, meta = api_fetch_page(url, auth, page=page, limit=per_page)
pages_fetched += 1
all_raw.extend(raw_page)
is_final = bool(meta["is_final_page"])
if is_final:
break
page += 1
pagination = _pagination_from_pages(
per_page=per_page,
pages_fetched=pages_fetched,
returned_count=len(all_raw),
is_final_page=is_final,
)
return all_raw, pagination
def load_queue_snapshot(
project_id: str | None = None,
*,
fetch_prs: Callable[..., tuple[list[dict], PaginationMeta]] | None = None,
fetch_issues: Callable[..., tuple[list[dict], PaginationMeta]] | None = None,
) -> QueueSnapshot:
"""Load open PR and issue queues for a registry project (default: first entry)."""
registry = load_registry()
project: ProjectRecord | None = None
if project_id:
for entry in registry.projects:
if entry.id == project_id:
project = entry
break
else:
project = registry.projects[0] if registry.projects else None
if project is None:
return QueueSnapshot(
project_id=project_id or "",
repo_label="",
prs=(),
issues=(),
pr_pagination=None,
issue_pagination=None,
fetch_error="project not found in registry",
)
host = _host_from_url(project.remote_host)
pr_fetch = fetch_prs or _fetch_prs
issue_fetch = fetch_issues or _fetch_issues
using_live_fetch = fetch_prs is None or fetch_issues is None
auth = get_auth_header(host) if using_live_fetch else "test-auth"
if using_live_fetch and not auth:
return QueueSnapshot(
project_id=project.id,
repo_label=f"{project.gitea_owner}/{project.repo_name}",
prs=(),
issues=(),
pr_pagination=None,
issue_pagination=None,
fetch_error=(
f"Gitea credentials unavailable for {host}; "
"queue cannot be loaded (fail closed — not showing empty queue)"
),
)
try:
raw_prs, pr_pagination = pr_fetch(
host, project.gitea_owner, project.repo_name, auth
)
raw_issues, issue_pagination = issue_fetch(
host, project.gitea_owner, project.repo_name, auth
)
except Exception as exc: # noqa: BLE001 — surface operator-visible fetch errors
return QueueSnapshot(
project_id=project.id,
repo_label=f"{project.gitea_owner}/{project.repo_name}",
prs=(),
issues=(),
pr_pagination=None,
issue_pagination=None,
fetch_error=f"Gitea fetch failed: {exc}",
)
issue_to_prs: dict[int, list[int]] = {}
for pr in raw_prs:
linked = _extract_linked_issue(pr.get("title"), pr.get("body"))
if linked is not None:
issue_to_prs.setdefault(linked, []).append(int(pr["number"]))
issue_claimed = {
int(i["number"])
for i in raw_issues
if any(lb.get("name") == "status:in-progress" for lb in i.get("labels", []))
}
pr_items = tuple(
_format_pr_item(
pr,
_classify_pr(
pr,
issue_claimed=issue_claimed,
issue_to_prs=issue_to_prs,
),
)
for pr in sorted(raw_prs, key=lambda p: int(p["number"]), reverse=True)
)
issue_items = tuple(
_format_issue_item(
issue,
_classify_issue(
issue,
linked_prs=issue_to_prs.get(int(issue["number"]), []),
),
)
for issue in sorted(raw_issues, key=lambda i: int(i["number"]), reverse=True)
)
return QueueSnapshot(
project_id=project.id,
repo_label=f"{project.gitea_owner}/{project.repo_name}",
prs=pr_items,
issues=issue_items,
pr_pagination=pr_pagination,
issue_pagination=issue_pagination,
)
def snapshot_to_dict(snapshot: QueueSnapshot) -> dict[str, Any]:
"""JSON-serializable export for /api/queue."""
def _page(meta: PaginationMeta | None) -> dict[str, Any] | None:
if meta is None:
return None
return {
"page": meta.page,
"per_page": meta.per_page,
"returned_count": meta.returned_count,
"has_more": meta.has_more,
"is_final_page": meta.is_final_page,
"inventory_complete": meta.inventory_complete,
"pages_fetched": meta.pages_fetched,
}
def _item(item: QueueItem) -> dict[str, Any]:
return {
"number": item.number,
"title": item.title,
"badges": list(item.badges),
**item.extra,
}
return {
"project_id": snapshot.project_id,
"repo": snapshot.repo_label,
"fetch_error": snapshot.fetch_error,
"prs": [_item(p) for p in snapshot.prs],
"issues": [_item(i) for i in snapshot.issues],
"pagination": {
"prs": _page(snapshot.pr_pagination),
"issues": _page(snapshot.issue_pagination),
},
}
+119
View File
@@ -0,0 +1,119 @@
"""HTML views for the live Gitea queue dashboard (#429)."""
from __future__ import annotations
import html
from webui.queue_loader import PaginationMeta, QueueItem, QueueSnapshot
def _badge_html(badges: tuple[str, ...]) -> str:
if not badges:
return ""
chips = "".join(
f'<span class="badge badge-{html.escape(b)}">{html.escape(b)}</span>'
for b in badges
)
return f'<span class="badges">{chips}</span>'
def _pagination_html(label: str, meta: PaginationMeta | None) -> str:
if meta is None:
return (
f"<p class='muted'><strong>{html.escape(label)} pagination:</strong> "
"unavailable</p>"
)
status = "complete" if meta.inventory_complete else "partial"
more = "yes" if meta.has_more else "no"
return (
f"<p class='meta'><strong>{html.escape(label)} pagination ({status}):</strong> "
f"returned {meta.returned_count} · per_page {meta.per_page} · "
f"pages_fetched {meta.pages_fetched} · has_more {more} · "
f"final_page {'yes' if meta.is_final_page else 'no'}</p>"
)
def _queue_table(
*,
title: str,
items: tuple[QueueItem, ...],
columns: tuple[tuple[str, str], ...],
fetch_failed: bool = False,
) -> str:
if fetch_failed:
return (
f"<h3>{html.escape(title)}</h3>"
"<p class='muted'>Not loaded — queue fetch did not complete.</p>"
)
if not items:
return f"<h3>{html.escape(title)}</h3><p class='muted'>No open items.</p>"
headers = "".join(f"<th>{html.escape(label)}</th>" for _, label in columns)
rows = []
for item in items:
cells = []
for key, _ in columns:
if key == "number":
cells.append(f"<td>#{item.number}</td>")
elif key == "title":
cells.append(
f"<td>{html.escape(item.title)}{_badge_html(item.badges)}</td>"
)
else:
cells.append(f"<td>{html.escape(item.extra.get(key, ''))}</td>")
rows.append("<tr>" + "".join(cells) + "</tr>")
body = (
f"<h3>{html.escape(title)}</h3>"
f"<table class='registry'><thead><tr>{headers}</tr></thead>"
f"<tbody>{''.join(rows)}</tbody></table>"
)
return body
def render_queue_page(snapshot: QueueSnapshot) -> str:
error_block = ""
if snapshot.fetch_error:
error_block = (
f'<div class="stub"><p><strong>Queue unavailable:</strong> '
f"{html.escape(snapshot.fetch_error)}</p></div>"
)
fetch_failed = bool(snapshot.fetch_error)
pr_section = _queue_table(
title="Open pull requests",
items=snapshot.prs,
fetch_failed=fetch_failed,
columns=(
("number", "#"),
("title", "Title"),
("branch", "Branch"),
("head_sha", "Head"),
("mergeable", "Mergeable"),
("linked_issue", "Linked issue"),
),
)
issue_section = _queue_table(
title="Open issues",
items=snapshot.issues,
fetch_failed=fetch_failed,
columns=(
("number", "#"),
("title", "Title"),
("labels", "Labels"),
("assignee", "Assignee"),
("state", "State"),
),
)
return (
"<h2>Live queue</h2>"
f"<p class='meta'>Repository: <code>{html.escape(snapshot.repo_label)}</code> "
f"· project <code>{html.escape(snapshot.project_id)}</code></p>"
f"{error_block}"
f"{_pagination_html('PR', snapshot.pr_pagination)}"
f"{pr_section}"
f"{_pagination_html('Issue', snapshot.issue_pagination)}"
f"{issue_section}"
"<p class='muted'>Read-only MVP — claims, reviews, and merges stay in Gitea/MCP tools.</p>"
)