merge master for review validation only

This commit is contained in:
2026-07-09 09:13:11 -04:00
76 changed files with 7842 additions and 167 deletions
+1 -1
View File
@@ -284,7 +284,7 @@ class TestTaskCapabilityMap(unittest.TestCase):
required_permission("reconciliation_cleanup"),
"gitea.branch.delete",
)
self.assertEqual(required_role("reconciliation_cleanup"), "author")
self.assertEqual(required_role("reconciliation_cleanup"), "reconciler")
if __name__ == "__main__":
+217
View File
@@ -0,0 +1,217 @@
"""Unit tests for canonical_comment_validator (#496)."""
import unittest
import canonical_comment_validator as ccv
FULL_SHA = "a" * 40
_VALID_ISSUE = f"""## Canonical Issue State
STATE: ready-for-author
WHO_IS_NEXT: author
NEXT_ACTION: Implement canonical comment validator and wire MCP gates
NEXT_PROMPT:
```text
You are the author LLM for issue #496. Add canonical_comment_validator.py
and wire fail-closed gates into gitea_create_issue_comment.
```
WHAT_HAPPENED: Issue filed for MCP validation wall
WHY: Workflow comments must carry durable next-action state
RELATED_PRS: none
BLOCKERS: none
VALIDATION: unit tests not yet run
LAST_UPDATED_BY: prgs-author
"""
_VALID_PR = f"""## Canonical PR State
STATE: ready-for-review
WHO_IS_NEXT: reviewer
NEXT_ACTION: Run focused pytest and review the validator wiring diff
NEXT_PROMPT:
```text
Review PR for issue #496. Confirm casual comments still post and workflow
comments without canonical fields are rejected before the Gitea API call.
```
WHAT_HAPPENED: Author opened PR with validator module
WHY: Fail-closed enforcement belongs at the MCP boundary
ISSUE: #496
HEAD_SHA: {FULL_SHA}
REVIEW_STATUS: pending
MERGE_READY: false
BLOCKERS: none
VALIDATION: not yet run
LAST_UPDATED_BY: prgs-author
"""
_VALID_SUPERSEDED = """## Canonical PR State
STATE: superseded
WHO_IS_NEXT: reconciler
NEXT_ACTION: Close superseded issue after canonical issue #495 lands
NEXT_PROMPT:
```text
Close issue #494 as superseded by #495 once templates and docs are merged.
```
WHY: Duplicate tracking issue replaced by durable canonical template work
CANONICAL_ITEM: issue #495
SUPERSEDED_ITEM: issue #494
CLOSE_OR_KEEP_OPEN: close superseded issue #494 after #495 merges
"""
_VALID_BLOCKED = """## Canonical Issue State
STATE: blocked
WHO_IS_NEXT: author
NEXT_ACTION: Fix failing validator unit tests before requesting re-review
NEXT_PROMPT:
```text
Run pytest tests/test_canonical_comment_validator.py and repair failures.
```
WHAT_HAPPENED: Validator tests failed in CI
WHY: Blocked until test suite is green
RELATED_PRS: PR #500
BLOCKERS: pytest failures; unblock after all validator tests pass
VALIDATION: pytest failed
LAST_UPDATED_BY: prgs-author
"""
_VALID_READY_TO_MERGE = f"""## Canonical PR State
STATE: ready-to-merge
WHO_IS_NEXT: merger
NEXT_ACTION: Merge PR after confirming approval_at_current_head
NEXT_PROMPT:
```text
Merge PR #500 for issue #496 after live mergeable check passes.
```
WHAT_HAPPENED: Reviewer approved at current head
WHY: All gates passed and head SHA is current
ISSUE: #496
HEAD_SHA: {FULL_SHA}
REVIEW_STATUS: approved / approval_at_current_head
MERGE_READY: true
BLOCKERS: none
VALIDATION: pytest passed; reviewer approved at head {FULL_SHA}
LAST_UPDATED_BY: prgs-reviewer
"""
class TestCanonicalCommentDetection(unittest.TestCase):
def test_casual_comment_not_workflow(self):
self.assertFalse(ccv.is_workflow_changing_comment("Thanks, I will check this."))
def test_workflow_trigger_detected(self):
self.assertTrue(ccv.is_workflow_changing_comment("Blocked, author should fix."))
def test_machine_marker_exempt(self):
body = "<!-- mcp-review-lease:v1 -->\nphase: claimed"
self.assertFalse(ccv.is_workflow_changing_comment(body))
class TestCanonicalCommentValidation(unittest.TestCase):
def test_casual_comment_allowed(self):
result = ccv.assess_canonical_comment("Thanks, I will check this.")
self.assertTrue(result["allowed"])
self.assertFalse(result["is_workflow_comment"])
def test_workflow_comment_missing_fields_rejected(self):
result = ccv.assess_canonical_comment("Blocked, author should fix.")
self.assertFalse(result["allowed"])
self.assertTrue(result["is_workflow_comment"])
self.assertIn("WHO_IS_NEXT", result["missing_fields"])
self.assertIn("correction_message", result)
self.assertIn("Suggested template", result["correction_message"])
def test_missing_next_prompt_rejected(self):
body = """STATE: blocked
WHO_IS_NEXT: author
NEXT_ACTION: Repair the failing validator unit tests in CI
WHY: Tests must pass before merge
BLOCKERS: pytest failed; unblock after tests pass
VALIDATION: failed
"""
result = ccv.assess_canonical_comment(body)
self.assertFalse(result["allowed"])
self.assertIn("NEXT_PROMPT", result["missing_fields"] + result["vague_fields"])
def test_vague_next_action_rejected(self):
body = _VALID_ISSUE.replace(
"Implement canonical comment validator and wire MCP gates",
"continue",
)
result = ccv.assess_canonical_comment(body)
self.assertFalse(result["allowed"])
self.assertIn("NEXT_ACTION", result["vague_fields"])
def test_invalid_who_is_next_rejected(self):
body = _VALID_ISSUE.replace("WHO_IS_NEXT: author", "WHO_IS_NEXT: llm")
result = ccv.assess_canonical_comment(body)
self.assertFalse(result["allowed"])
self.assertIn("WHO_IS_NEXT", result["vague_fields"])
def test_valid_issue_comment_allowed(self):
result = ccv.assess_canonical_comment(_VALID_ISSUE)
self.assertTrue(result["allowed"])
def test_issue_comment_mentioning_pr_requires_related_prs(self):
body = _VALID_ISSUE.replace("RELATED_PRS: none", "RELATED_PRS:")
body = body.replace("Issue filed", "Issue filed; see PR #500")
result = ccv.assess_canonical_comment(body)
self.assertFalse(result["allowed"])
self.assertIn("RELATED_PRS", result["missing_fields"])
def test_pr_review_requires_head_sha(self):
body = _VALID_PR.replace(f"HEAD_SHA: {FULL_SHA}", "HEAD_SHA:")
result = ccv.assess_canonical_comment(body, context="pr_review")
self.assertFalse(result["allowed"])
self.assertIn("HEAD_SHA", result["missing_fields"])
def test_ready_to_merge_requires_approval_and_sha_proof(self):
body = _VALID_READY_TO_MERGE.replace("approval_at_current_head", "pending")
body = body.replace(f"HEAD_SHA: {FULL_SHA}", "HEAD_SHA: pending")
body = body.replace("reviewer approved at head", "reviewer pending at head")
body = body.replace("approved /", "pending /")
result = ccv.assess_canonical_comment(body, context="pr_comment")
self.assertFalse(result["allowed"])
self.assertTrue(result["extra_reasons"])
def test_ready_to_merge_valid_allowed(self):
result = ccv.assess_canonical_comment(_VALID_READY_TO_MERGE, context="pr_comment")
self.assertTrue(result["allowed"])
def test_superseded_requires_canonical_and_superseded_items(self):
body = _VALID_SUPERSEDED.replace("CANONICAL_ITEM: issue #495", "CANONICAL_ITEM:")
result = ccv.assess_canonical_comment(body, context="supersession")
self.assertFalse(result["allowed"])
self.assertIn("CANONICAL_ITEM", result["missing_fields"])
def test_superseded_valid_allowed(self):
result = ccv.assess_canonical_comment(_VALID_SUPERSEDED, context="supersession")
self.assertTrue(result["allowed"])
def test_blocked_requires_unblock_condition(self):
body = _VALID_BLOCKED.replace(
"BLOCKERS: pytest failures; unblock after all validator tests pass",
"BLOCKERS: pytest failures",
)
result = ccv.assess_canonical_comment(body)
self.assertFalse(result["allowed"])
self.assertIn("BLOCKERS", result["vague_fields"])
def test_blocked_valid_allowed(self):
result = ccv.assess_canonical_comment(_VALID_BLOCKED)
self.assertTrue(result["allowed"])
def test_correction_lists_missing_fields(self):
result = ccv.assess_canonical_comment("ready for merge")
self.assertFalse(result["allowed"])
message = result["correction_message"]
self.assertIn("Missing fields", message)
self.assertIn("WHO_IS_NEXT", message)
self.assertIn("Suggested template", message)
if __name__ == "__main__":
unittest.main()
+143
View File
@@ -0,0 +1,143 @@
"""Tests for canonical state comment validation (#495)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from canonical_state_comments import ( # noqa: E402
validate_canonical_state_comment,
validate_final_report_state_update,
)
GOOD_ISSUE_STATE = """## Canonical Issue State
STATE:
ready-for-author
WHO_IS_NEXT:
author
NEXT_ACTION:
Implement the focused validator and open a PR.
NEXT_PROMPT:
Find issue #495, load the work-issue workflow, implement the validator, and open a PR.
WHAT_HAPPENED:
Controller selected the canonical tracking issue.
WHY:
The duplicate issue points here as canonical.
RELATED_PRS:
none
BRANCH:
none
HEAD_SHA:
none
VALIDATION:
none
BLOCKERS:
none
"""
class TestCanonicalStateComments(unittest.TestCase):
def test_good_issue_state_validates(self):
result = validate_canonical_state_comment(GOOD_ISSUE_STATE)
self.assertTrue(result["valid"])
self.assertEqual(result["reasons"], [])
def test_missing_next_actor_is_rejected(self):
result = validate_canonical_state_comment(
GOOD_ISSUE_STATE.replace("WHO_IS_NEXT:\nauthor", "WHO_IS_NEXT:\n")
)
self.assertFalse(result["valid"])
self.assertIn("WHO_IS_NEXT", " ".join(result["reasons"]))
def test_missing_next_prompt_is_rejected(self):
result = validate_canonical_state_comment(
GOOD_ISSUE_STATE.replace(
"NEXT_PROMPT:\nFind issue #495, load the work-issue workflow, implement the validator, and open a PR.",
"NEXT_PROMPT:\n",
)
)
self.assertFalse(result["valid"])
self.assertIn("NEXT_PROMPT", " ".join(result["reasons"]))
def test_vague_next_action_is_rejected(self):
result = validate_canonical_state_comment(
GOOD_ISSUE_STATE.replace(
"NEXT_ACTION:\nImplement the focused validator and open a PR.",
"NEXT_ACTION:\ncontinue",
)
)
self.assertFalse(result["valid"])
self.assertIn("too vague", " ".join(result["reasons"]))
def test_ready_to_merge_requires_approval_and_head_sha(self):
text = """## Canonical PR State
STATE:
ready-to-merge
WHO_IS_NEXT:
merger
NEXT_ACTION:
Merge PR #12 after checking the current head.
NEXT_PROMPT:
Load the merge workflow and merge PR #12 if gates pass.
ISSUE:
#11
HEAD_SHA:
abc123
REVIEW_STATUS:
none
MERGE_READY:
yes
"""
result = validate_canonical_state_comment(text)
self.assertFalse(result["valid"])
reasons = " ".join(result["reasons"])
self.assertIn("approved REVIEW_STATUS", reasons)
self.assertIn("full HEAD_SHA", reasons)
def test_superseded_requires_canonical_item(self):
text = GOOD_ISSUE_STATE.replace("STATE:\nready-for-author", "STATE:\nsuperseded")
result = validate_canonical_state_comment(text)
self.assertFalse(result["valid"])
self.assertIn("canonical item", " ".join(result["reasons"]))
def test_blocked_requires_unblock_condition(self):
text = GOOD_ISSUE_STATE.replace("STATE:\nready-for-author", "STATE:\nblocked")
text = text.replace("BLOCKERS:\nnone", "BLOCKERS:\n")
result = validate_canonical_state_comment(text)
self.assertFalse(result["valid"])
self.assertIn("BLOCKERS", " ".join(result["reasons"]))
def test_final_report_claim_without_block_is_rejected(self):
report = "Posted canonical state comment on issue #495."
result = validate_final_report_state_update(report)
self.assertTrue(result["applicable"])
self.assertFalse(result["valid"])
def test_final_report_without_state_claim_not_applicable(self):
result = validate_final_report_state_update("ordinary final report")
self.assertFalse(result["applicable"])
self.assertTrue(result["valid"])
if __name__ == "__main__":
unittest.main()
+139
View File
@@ -0,0 +1,139 @@
"""Tests for Canonical Thread Handoff (CTH) protocol (#505)."""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import canonical_thread_handoff as cth # noqa: E402
REPO_ROOT = Path(__file__).resolve().parent.parent
RUNBOOK = REPO_ROOT / "docs" / "llm-workflow-runbooks.md"
PROTOCOL_DOC = REPO_ROOT / "docs" / "canonical-thread-handoff.md"
TEMPLATE = REPO_ROOT / "skills" / "llm-project-workflow/templates" / "canonical-thread-handoff.md"
REVIEW_WORKFLOW = REPO_ROOT / "skills" / "llm-project-workflow/workflows" / "review-merge-pr.md"
WORK_WORKFLOW = REPO_ROOT / "skills" / "llm-project-workflow/workflows" / "work-issue.md"
REVIEW_TEMPLATE = REPO_ROOT / "skills" / "llm-project-workflow/templates" / "review-pr.md"
START_TEMPLATE = REPO_ROOT / "skills" / "llm-project-workflow/templates" / "start-issue.md"
MERGE_TEMPLATE = REPO_ROOT / "skills" / "llm-project-workflow/templates" / "merge-pr.md"
class TestCthModule(unittest.TestCase):
def test_defines_cth_term_and_types(self):
self.assertEqual(cth.CTH_ABBREV, "CTH")
self.assertIn("State Handoff", cth.CTH_TYPES)
self.assertIn("Reviewer Handoff", cth.CTH_TYPES)
self.assertIn("Blocker", cth.CTH_TYPES)
def test_format_and_parse_round_trip(self):
body = cth.format_cth_body(
cth_type="Author Handoff",
status="implementation_complete",
next_owner="reviewer",
current_blocker="none",
decision="PR opened",
proof="pytest passed",
next_action="review PR #9",
ready_to_paste_prompt="Review PR #9 for issue #5",
)
parsed = cth.parse_cth_comment(body)
self.assertIsNotNone(parsed)
self.assertEqual(parsed["cth_type"], "Author Handoff")
self.assertEqual(parsed["fields"]["status"], "implementation_complete")
self.assertEqual(parsed["fields"]["next owner"], "reviewer")
def test_valid_cth_passes_assessment(self):
body = cth.format_cth_body(
cth_type="Reviewer Handoff",
status="approved",
next_owner="merger",
current_blocker="none",
decision="APPROVE",
proof="review id 42",
next_action="merge if gates pass",
ready_to_paste_prompt="Merge PR #12 with pinned head abcdef0123456789abcdef0123456789abcdef01",
)
result = cth.assess_cth_comment(body)
self.assertFalse(result["block"])
self.assertTrue(result["valid"])
def test_missing_fields_fail_closed(self):
body = "## CTH: Blocker\n\nStatus: blocked\n"
result = cth.assess_cth_comment(body)
self.assertTrue(result["block"])
self.assertIn("missing required fields", result["reasons"][0])
def test_find_latest_cth_prefers_newer_comment(self):
older = cth.format_cth_body(
cth_type="State Handoff",
status="old",
next_owner="author",
current_blocker="none",
decision="old",
proof="old",
next_action="old",
ready_to_paste_prompt="old prompt text here",
)
newer = cth.format_cth_body(
cth_type="Reviewer Handoff",
status="new",
next_owner="merger",
current_blocker="none",
decision="approve",
proof="new",
next_action="merge",
ready_to_paste_prompt="merge PR #3 now with pinned head",
)
comments = [
{"id": 1, "created_at": "2026-07-01T10:00:00Z", "body": older},
{"id": 2, "created_at": "2026-07-02T10:00:00Z", "body": newer},
]
latest = cth.find_latest_cth(comments)
self.assertEqual(latest["comment_id"], 2)
self.assertEqual(latest["cth_type"], "Reviewer Handoff")
def test_cth_does_not_replace_formal_reviews_documented_in_protocol(self):
text = PROTOCOL_DOC.read_text(encoding="utf-8")
self.assertIn("do not replace", text.lower())
self.assertIn("formal Gitea review", text)
def test_examples_cover_required_scenarios(self):
text = PROTOCOL_DOC.read_text(encoding="utf-8")
expected_phrases = (
"PR approved and ready for merger",
"request-changes back to author",
"superseded PR closure",
"dirty root/worktree",
"Stale head requiring fresh review",
"Issue implementation handoff",
)
for phrase in expected_phrases:
self.assertIn(phrase, text)
class TestCthDocumentationChecks(unittest.TestCase):
def test_runbook_references_cth_discovery_and_posting(self):
text = RUNBOOK.read_text(encoding="utf-8")
self.assertIn("Canonical Thread Handoff", text)
self.assertIn("Find the latest CTH comment", text)
self.assertIn("Post a new CTH", text)
self.assertIn("canonical-thread-handoff.md", text)
def test_workflows_reference_cth_rules(self):
for path in (REVIEW_WORKFLOW, WORK_WORKFLOW):
text = path.read_text(encoding="utf-8")
self.assertIn("latest CTH comment", text)
self.assertIn("canonical-thread-handoff.md", text)
def test_prompt_templates_reference_cth_rules(self):
for path in (REVIEW_TEMPLATE, START_TEMPLATE, MERGE_TEMPLATE, TEMPLATE):
text = path.read_text(encoding="utf-8")
self.assertIn("CTH", text)
self.assertIn("canonical-thread-handoff", text)
if __name__ == "__main__":
unittest.main()
+34
View File
@@ -435,6 +435,40 @@ class ValidateConfigTests(unittest.TestCase):
with open(example, encoding="utf-8") as fh:
self.assertEqual(gitea_config.validate_config(json.load(fh)), [])
def test_repo_example_file_reviewer_vs_merger_boundaries(self):
example_path = __import__("pathlib").Path(__file__).resolve().parent.parent \
/ "gitea-mcp.v2-contexts.example.json"
with open(example_path, encoding="utf-8") as fh:
cfg = json.load(fh)
# 1. Config includes separate reviewer and merger examples
self.assertIn("example-reviewer", cfg["profiles"])
self.assertIn("example-merger", cfg["profiles"])
reviewer = cfg["profiles"]["example-reviewer"]
merger = cfg["profiles"]["example-merger"]
# 2. Example reviewer config does not include merge capability
reviewer_allowed = reviewer.get("allowed_operations") or []
reviewer_forbidden = reviewer.get("forbidden_operations") or []
self.assertNotIn("merge", reviewer_allowed)
self.assertNotIn("gitea.pr.merge", reviewer_allowed)
self.assertIn("merge", reviewer_forbidden)
# 3. Example reviewer can resolve review task, cannot resolve merge task
# 4. Example merger can resolve merge task
rev_review_ok, _ = gitea_config.check_operation("gitea.pr.review", reviewer_allowed, reviewer_forbidden)
rev_merge_ok, _ = gitea_config.check_operation("gitea.pr.merge", reviewer_allowed, reviewer_forbidden)
merg_allowed = merger.get("allowed_operations") or []
merg_forbidden = merger.get("forbidden_operations") or []
merg_merge_ok, _ = gitea_config.check_operation("gitea.pr.merge", merg_allowed, merg_forbidden)
self.assertTrue(rev_review_ok, "example-reviewer must allow review")
self.assertFalse(rev_merge_ok, "example-reviewer must not allow merge")
self.assertTrue(merg_merge_ok, "example-merger must allow merge")
def test_broken_contexts_config_reports_problems(self):
data = contexts_config()
data["profiles"]["prgs-author"]["context"] = "nope"
+142
View File
@@ -0,0 +1,142 @@
"""Tests for live PR head re-pin before conflict-fix classification (#522)."""
from __future__ import annotations
import unittest
from conflict_fix_classification import (
CLASSIFICATION_CONFLICT_FIX_NEEDED,
CLASSIFICATION_INCOMPLETE,
CLASSIFICATION_LIVE_MERGEABLE,
CLASSIFICATION_STALE_INVENTORY_SKIP,
assess_conflict_fix_classification,
assess_conflict_fix_classification_final_report,
)
def _sha(prefix: str) -> str:
return (prefix + "0" * 40)[:40]
class TestConflictFixClassification(unittest.TestCase):
def test_stale_inventory_mergeable_skip(self):
"""PR #508 style: inventory mergeable:false/stale head, live mergeable:true."""
result = assess_conflict_fix_classification(
pr_number=508,
inventory_head_sha=_sha("dad1dc8"),
inventory_mergeable=False,
live_head_sha=_sha("3f3d6cb"),
live_mergeable=True,
)
self.assertEqual(result["classification"], CLASSIFICATION_STALE_INVENTORY_SKIP)
self.assertTrue(result["skip_author_mutation"])
self.assertFalse(result["worktree_allowed"])
self.assertTrue(result["inventory_stale_head"])
self.assertTrue(result["inventory_stale_mergeable"])
self.assertEqual(result["pinned_head_sha"], _sha("3f3d6cb"))
def test_stale_inventory_head_only_live_mergeable_true(self):
"""PR #493 style: head moved; live still mergeable."""
result = assess_conflict_fix_classification(
pr_number=493,
inventory_head_sha=_sha("685e627"),
inventory_mergeable=True,
live_head_sha=_sha("4561d7a"),
live_mergeable=True,
)
self.assertEqual(result["classification"], CLASSIFICATION_STALE_INVENTORY_SKIP)
self.assertTrue(result["skip_author_mutation"])
self.assertFalse(result["worktree_allowed"])
self.assertTrue(result["inventory_stale_head"])
self.assertEqual(result["pinned_head_sha"], _sha("4561d7a"))
def test_live_conflict_allows_worktree_on_pinned_head(self):
result = assess_conflict_fix_classification(
pr_number=99,
inventory_head_sha=_sha("aaaaaaa"),
inventory_mergeable=False,
live_head_sha=_sha("bbbbbbb"),
live_mergeable=False,
)
self.assertEqual(result["classification"], CLASSIFICATION_CONFLICT_FIX_NEEDED)
self.assertTrue(result["worktree_allowed"])
self.assertFalse(result["skip_author_mutation"])
self.assertTrue(result["inventory_stale_head"])
self.assertEqual(result["pinned_head_sha"], _sha("bbbbbbb"))
def test_matching_inventory_live_mergeable_skip(self):
head = _sha("cccccccc")
result = assess_conflict_fix_classification(
pr_number=10,
inventory_head_sha=head,
inventory_mergeable=True,
live_head_sha=head,
live_mergeable=True,
)
self.assertEqual(result["classification"], CLASSIFICATION_LIVE_MERGEABLE)
self.assertFalse(result["worktree_allowed"])
self.assertTrue(result["skip_author_mutation"])
self.assertFalse(result["inventory_stale_head"])
def test_missing_live_head_incomplete(self):
result = assess_conflict_fix_classification(
pr_number=1,
inventory_head_sha=_sha("ddddddd"),
inventory_mergeable=False,
live_head_sha=None,
live_mergeable=False,
)
self.assertEqual(result["classification"], CLASSIFICATION_INCOMPLETE)
self.assertFalse(result["worktree_allowed"])
self.assertTrue(result["skip_author_mutation"])
self.assertTrue(any("live PR head" in r for r in result["reasons"]))
def test_short_sha_rejected(self):
result = assess_conflict_fix_classification(
pr_number=1,
inventory_head_sha="abc1234",
inventory_mergeable=False,
live_head_sha="def5678",
live_mergeable=False,
)
self.assertEqual(result["classification"], CLASSIFICATION_INCOMPLETE)
self.assertFalse(result["worktree_allowed"])
class TestConflictFixClassificationFinalReport(unittest.TestCase):
def test_non_conflict_report_not_applicable(self):
result = assess_conflict_fix_classification_final_report(
"Implemented feature without merge issues."
)
self.assertTrue(result["proven"])
self.assertFalse(result["applicable"])
def test_conflict_report_requires_tool_live_head_classification(self):
result = assess_conflict_fix_classification_final_report(
"Did conflict-fix work on PR #99."
)
self.assertFalse(result["proven"])
self.assertTrue(result["applicable"])
joined = " ".join(result["reasons"])
self.assertIn("gitea_assess_conflict_fix_classification", joined)
self.assertIn("Live head SHA", joined)
self.assertIn("classification", joined.lower())
def test_good_conflict_report_passes(self):
live = _sha("3f3d6cb")
inv = _sha("dad1dc8")
text = f"""
Conflict-fix classification: stale_inventory_skip
Called gitea_assess_conflict_fix_classification before worktree creation.
Inventory head SHA: {inv}
Live head SHA: {live}
Use live head only; skip author mutation.
"""
result = assess_conflict_fix_classification_final_report(text)
self.assertTrue(result["proven"], msg=result.get("reasons"))
self.assertEqual(result["live_head_sha"], live)
self.assertEqual(result["inventory_head_sha"], inv)
if __name__ == "__main__":
unittest.main()
+31
View File
@@ -103,6 +103,37 @@ class TestAPIPayload(unittest.TestCase):
self.assertEqual(payload["title"], "My Title")
self.assertEqual(payload["body"], "My Body")
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
def test_applies_workflow_labels_by_name(self, _cred):
def api_side_effect(method, url, auth, payload=None):
if method == "GET" and url.endswith("/labels"):
return [
{"id": 1, "name": "type:feature"},
{"id": 2, "name": "status:ready"},
]
if method == "POST" and url.endswith("/issues"):
return {"number": 7, "html_url": "http://x/7"}
if method == "PUT" and url.endswith("/issues/7/labels"):
return [{"name": "type:feature"}, {"name": "status:ready"}]
return {}
with patch("create_issue.api_request", side_effect=api_side_effect) as mock_api:
rc = create_issue.main([
"--title", "My Title",
"--type-label", "feature",
"--status-label", "ready",
])
self.assertEqual(rc, 0)
put_call = next(c for c in mock_api.call_args_list if c[0][0] == "PUT")
self.assertEqual(put_call[0][3], {"labels": [1, 2]})
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
def test_require_workflow_labels_blocks_missing_labels(self, _cred):
with patch("create_issue.api_request") as mock_api:
rc = create_issue.main(["--title", "My Title", "--require-workflow-labels"])
self.assertEqual(rc, 1)
mock_api.assert_not_called()
@patch("create_issue.get_credentials", return_value=FAKE_CREDS)
def test_url_construction(self, _cred):
with patch("create_issue.api_request",
+209
View File
@@ -35,6 +35,9 @@ def _review_handoff(**overrides):
"- External-state mutations: none",
"- Read-only diagnostics: issue and PR metadata inspected",
"- Current status: PR open",
"- Next actor: author",
"- Next action: address review feedback",
"- Next prompt: Fix PR #203 per reviewer request_changes and push updated head",
"- Blockers: none",
"- Next: await author",
"- Safety: no self-review; no self-merge",
@@ -92,6 +95,9 @@ def _reconcile_handoff(drop=(), **overrides):
"- Read-only diagnostics: gitea_view_pr, gitea_view_issue",
"- Reconciliation mutations: PR comment posted",
"- Current status: reconciliation complete",
"- Next actor: reconciler",
"- Next action: close PR #99 after capability proof",
"- Next prompt: Reconcile already-landed PR #99 with prgs-reconciler profile",
"- Blocker: missing gitea.pr.close capability",
"- Safe next action: hand off to a profile with gitea.pr.close",
"- No review/merge confirmation: confirmed",
@@ -345,6 +351,92 @@ class TestReconciliationRules(unittest.TestCase):
)
class TestCanonicalStateUpdateRules(unittest.TestCase):
def test_claimed_state_comment_without_block_blocks(self):
report = _reconcile_handoff() + "\nPosted canonical state comment on PR #99."
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertTrue(result["blocked"])
self.assertTrue(
any(f["rule_id"] == "shared.canonical_state_update" for f in result["findings"])
)
def test_state_comment_missing_next_prompt_blocks(self):
report = (
_reconcile_handoff()
+ """
## Canonical PR State
STATE:
needs-review
WHO_IS_NEXT:
reviewer
NEXT_ACTION:
Review PR #99.
NEXT_PROMPT:
ISSUE:
#98
HEAD_SHA:
0fdc8f582026b72a229d59a172c0a63ac4aaeaf9
REVIEW_STATUS:
none
MERGE_READY:
no
"""
)
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertTrue(result["blocked"])
reasons = " ".join(f["reason"] for f in result["findings"])
self.assertIn("NEXT_PROMPT", reasons)
def test_valid_state_comment_claim_passes_shared_rule(self):
report = (
_reconcile_handoff()
+ """
## Canonical PR State
STATE:
needs-review
WHO_IS_NEXT:
reviewer
NEXT_ACTION:
Review PR #99 against the pinned head.
NEXT_PROMPT:
Load the review workflow and review PR #99.
ISSUE:
#98
HEAD_SHA:
0fdc8f582026b72a229d59a172c0a63ac4aaeaf9
REVIEW_STATUS:
none
MERGE_READY:
no
BLOCKERS:
none
"""
)
result = assess_final_report_validator(report, "reconcile_already_landed")
self.assertFalse(
any(f["rule_id"] == "shared.canonical_state_update" for f in result["findings"])
)
class TestCanonicalReconcileSchema(unittest.TestCase):
"""Issue #307: reconciliation workflows emit only the canonical schema."""
@@ -573,6 +665,31 @@ class TestReconcilerCloseProof(unittest.TestCase):
)
class TestCanonicalCommentPostClaim(unittest.TestCase):
def test_blocks_post_claim_when_validator_rejected_in_report(self):
report = "\n".join([
"## Controller Handoff",
"- MCP/Gitea mutations: issue comment posted",
"- Issue comments posted: 1 canonical state comment",
"canonical comment validation failed (fail closed before posting).",
'- canonical_comment_validation: {"allowed": false}',
])
result = assess_final_report_validator(report, "work_issue")
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertIn("shared.canonical_comment_post_claim", rule_ids)
self.assertTrue(result["blocked"])
def test_allows_none_when_validator_rejected_without_post_claim(self):
report = "\n".join([
"## Controller Handoff",
"- Issue comments posted: none",
"canonical comment validation failed (fail closed before posting).",
])
result = assess_final_report_validator(report, "work_issue")
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertNotIn("shared.canonical_comment_post_claim", rule_ids)
class TestEntryPoint(unittest.TestCase):
def test_unknown_task_kind_blocks(self):
result = assess_final_report_validator("report", "unknown_mode")
@@ -588,8 +705,100 @@ class TestEntryPoint(unittest.TestCase):
def test_supported_task_kinds_include_review_and_reconcile(self):
self.assertIn("review_pr", FINAL_REPORT_TASK_KINDS)
self.assertIn("merge_pr", FINAL_REPORT_TASK_KINDS)
self.assertIn("reconcile_already_landed", FINAL_REPORT_TASK_KINDS)
class TestMergePrRules(unittest.TestCase):
def test_clean_merger_report_passes(self):
lines = [
"## Controller Handoff",
"",
"- Task: merge PR #203",
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
"- Role: merger",
"- Identity: sysadmin / prgs-merger",
"- Issue/PR: #182 / PR #203",
"- Branch/SHA: feat/x @ 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Files changed: review_proofs.py",
"- Validation: pytest in branches/review-203",
"- Mutations: merge completed",
"- File edits by reviewer: none",
"- Worktree/index mutations: none",
"- Git ref mutations: git fetch prgs master",
"- MCP/Gitea mutations: merge completed",
"- Review mutations: none",
"- Merge mutations: merged PR #203",
"- Cleanup mutations: none",
"- External-state mutations: none",
"- Read-only diagnostics: metadata check",
"- Current status: PR merged",
"- Blockers: none",
"- Next: none",
"- Safety: review and merge are separate roles",
"- Selected PR: #203",
"- 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",
"- Active profile: prgs-merger",
"- Role kind: merger",
"- Merge capability source: profile",
"- Explicit operator authorization: MERGE PR 203",
"- Expected head SHA: 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Approval at current head: yes",
"- Merge result: PR merged successfully",
"- Cleanup status: complete",
]
report = "\n".join(lines)
result = assess_final_report_validator(
report,
"merge_pr",
)
self.assertEqual(result["grade"], "A")
self.assertFalse(result["blocked"])
def test_missing_merger_fields_blocks(self):
lines = [
"## Controller Handoff",
"",
"- Task: merge PR #203",
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
"- Role: merger",
"- Identity: sysadmin / prgs-merger",
"- Issue/PR: #182 / PR #203",
"- Branch/SHA: feat/x @ 0fdc8f582026b72a229d59a172c0a63ac4aaeaf9",
"- Files changed: review_proofs.py",
"- Validation: pytest in branches/review-203",
"- Mutations: merge completed",
"- File edits by reviewer: none",
"- Worktree/index mutations: none",
"- Git ref mutations: git fetch prgs master",
"- MCP/Gitea mutations: merge completed",
"- Review mutations: none",
"- Merge mutations: merged PR #203",
"- Cleanup mutations: none",
"- External-state mutations: none",
"- Read-only diagnostics: metadata check",
"- Current status: PR merged",
"- Blockers: none",
"- Next: none",
"- Safety: review and merge are separate roles",
"- Selected PR: #203",
"- 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",
]
report = "\n".join(lines)
result = assess_final_report_validator(
report,
"merge_pr",
)
self.assertTrue(result["downgraded"])
if __name__ == "__main__":
unittest.main()
+73
View File
@@ -0,0 +1,73 @@
"""Tests for canonical issue workflow label policy (#513)."""
from __future__ import annotations
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import issue_workflow_labels as labels # noqa: E402
class TestIssueWorkflowLabelValidation(unittest.TestCase):
def test_valid_labeled_issue(self):
result = labels.assess_issue_labels(["type:feature", "status:ready"])
self.assertTrue(result["valid"])
self.assertEqual(result["type_labels"], ["type:feature"])
self.assertEqual(result["status_labels"], ["status:ready"])
def test_issue_missing_type_label(self):
result = labels.assess_issue_labels(["status:ready"])
self.assertFalse(result["valid"])
self.assertIn("issue is missing a type:* label", result["errors"])
def test_issue_missing_status_label(self):
result = labels.assess_issue_labels(["type:bug"])
self.assertFalse(result["valid"])
self.assertIn("issue is missing a status:* label", result["errors"])
def test_discussion_issue_missing_type_discussion(self):
result = labels.assess_issue_labels(
["type:feature", "status:triage"],
discussion=True,
)
self.assertFalse(result["valid"])
self.assertIn("discussion issue is missing type:discussion", result["errors"])
def test_multiple_conflicting_status_labels(self):
result = labels.assess_issue_labels(
["type:feature", "status:ready", "status:blocked"]
)
self.assertFalse(result["valid"])
self.assertTrue(
any("multiple active status:* labels" in err for err in result["errors"])
)
class TestIssueWorkflowStatusTransitions(unittest.TestCase):
def test_status_transition_removes_old_status_label(self):
result = labels.transition_status_labels(
["type:feature", "status:ready", "workflow"],
"in-progress",
)
self.assertEqual(result, ["type:feature", "workflow", "status:in-progress"])
def test_pr_open_transition_applies_status_pr_open(self):
result = labels.transition_status_labels(
["type:feature", "status:in-progress"],
"pr-open",
)
self.assertEqual(result, ["type:feature", "status:pr-open"])
def test_duplicate_transition_applies_status_duplicate(self):
result = labels.transition_status_labels(
["type:process", "status:triage"],
"duplicate",
)
self.assertEqual(result, ["type:process", "status:duplicate"])
if __name__ == "__main__":
unittest.main()
+6 -5
View File
@@ -164,13 +164,14 @@ class TestAllowedIssueWritesProceed(IssueWriteGateBase):
result = mcp_server.gitea_close_issue(issue_number=9, remote="prgs")
self.assertTrue(result.get("success"))
@patch("mcp_server.api_get_all", return_value=[{"id": 10, "name": "status:in-progress"}])
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_resolver_allowed_mark_issue_proceeds(self, _auth, mock_api):
def test_resolver_allowed_mark_issue_proceeds(self, _auth, mock_api, _labels):
def api_side_effect(method, url, auth, payload=None):
if method == "GET" and url.endswith("/labels?limit=100"):
return [{"id": 10, "name": "status:in-progress"}]
if method == "POST" and "/issues/9/labels" in url:
if method == "GET" and "/issues/9" in url:
return {"labels": []}
if method == "PUT" and "/issues/9/labels" in url:
return [{"name": "status:in-progress"}]
if method == "POST" and "/issues/9/comments" in url:
return {"id": 501}
@@ -209,4 +210,4 @@ class TestCreateIssueMissingPermissionReport(IssueWriteGateBase):
if __name__ == "__main__":
unittest.main()
unittest.main()
+6
View File
@@ -11,6 +11,7 @@ from unittest.mock import MagicMock, call, patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import manage_labels # noqa: E402
import issue_workflow_labels # noqa: E402
FAKE_AUTH = "Basic dGVzdDp0ZXN0" # base64("test:test")
@@ -129,6 +130,11 @@ class TestConstants(unittest.TestCase):
names = [l["name"] for l in manage_labels.LABELS]
self.assertIn("status:in-progress", names)
def test_canonical_workflow_labels_are_defined(self):
names = {l["name"] for l in manage_labels.LABELS}
for spec in issue_workflow_labels.CANONICAL_LABEL_SPECS:
self.assertIn(spec.name, names)
def test_all_mapped_labels_are_defined(self):
defined = {l["name"] for l in manage_labels.LABELS}
for issue, names in manage_labels.MAPPING.items():
+152
View File
@@ -0,0 +1,152 @@
"""Tests for the master-parity staleness gate (#420).
Covers the pure assessment logic, the mutation-block helper, the env escape
hatches, and the server-side wiring: reads are never blocked by staleness, a
mutation is refused when the on-disk master has advanced, and the read-only
gitea_assess_master_parity tool reports the stale state.
"""
import os
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import master_parity_gate as mp # noqa: E402
SHA_A = "a" * 40
SHA_B = "b" * 40
class TestAssessMasterParity(unittest.TestCase):
def test_in_parity_when_heads_match(self):
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
self.assertTrue(res["in_parity"])
self.assertFalse(res["stale"])
self.assertFalse(res["restart_required"])
self.assertTrue(res["determinable"])
def test_stale_when_master_advanced(self):
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B)
self.assertFalse(res["in_parity"])
self.assertTrue(res["stale"])
self.assertTrue(res["restart_required"])
self.assertTrue(res["determinable"])
self.assertTrue(any("restart" in r for r in res["reasons"]))
def test_missing_startup_head_not_determinable(self):
res = mp.assess_master_parity({"startup_head": None}, SHA_B)
self.assertFalse(res["determinable"])
self.assertFalse(res["stale"])
self.assertTrue(res["in_parity"])
def test_missing_current_head_not_determinable(self):
res = mp.assess_master_parity({"startup_head": SHA_A}, None)
self.assertFalse(res["determinable"])
self.assertFalse(res["stale"])
self.assertTrue(res["in_parity"])
def test_none_startup_baseline(self):
res = mp.assess_master_parity(None, SHA_A)
self.assertFalse(res["determinable"])
self.assertFalse(res["stale"])
class TestBlockReasonsAndReport(unittest.TestCase):
def test_stale_produces_block_reasons(self):
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B)
self.assertTrue(mp.parity_block_reasons(res))
def test_in_parity_no_block_reasons(self):
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_A)
self.assertEqual(mp.parity_block_reasons(res), [])
def test_disable_env_suppresses_block(self):
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B)
with patch.dict(os.environ, {mp.ENV_DISABLE: "1"}):
self.assertTrue(mp.gate_disabled())
self.assertEqual(mp.parity_block_reasons(res), [])
def test_report_shape_when_stale(self):
res = mp.assess_master_parity({"startup_head": SHA_A}, SHA_B)
report = mp.parity_report(res)
self.assertEqual(report["kind"], "server_stale")
self.assertTrue(report["restart_required"])
self.assertEqual(report["startup_head"], SHA_A)
self.assertEqual(report["current_head"], SHA_B)
self.assertTrue(report["recovery"])
class TestReadGitHead(unittest.TestCase):
def test_test_override_takes_precedence(self):
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
self.assertEqual(mp.read_git_head("/nonexistent"), SHA_B)
def test_blank_override_is_none(self):
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: " "}):
self.assertIsNone(mp.read_git_head("/nonexistent"))
def test_empty_root_is_none(self):
# No override set; empty root cannot resolve a HEAD.
env = {k: v for k, v in os.environ.items()
if k != mp.ENV_TEST_CURRENT_HEAD}
with patch.dict(os.environ, env, clear=True):
self.assertIsNone(mp.read_git_head(""))
class TestServerWiring(unittest.TestCase):
"""Integration with the gate choke point in the server namespace."""
def setUp(self):
import mcp_server
self.srv = mcp_server
# Pin a known startup baseline so the current-HEAD override can diverge.
self._saved = self.srv._STARTUP_PARITY
self.srv._STARTUP_PARITY = {"root": self.srv.PROJECT_ROOT,
"startup_head": SHA_A}
def tearDown(self):
self.srv._STARTUP_PARITY = self._saved
def test_reads_not_blocked_when_stale(self):
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
self.assertEqual(self.srv._master_parity_block("gitea.read"), [])
def test_mutation_blocked_when_stale(self):
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
reasons = self.srv._master_parity_block("gitea.pr.create")
self.assertTrue(reasons)
# The profile-operation choke point surfaces the same block.
self.assertTrue(self.srv._profile_operation_gate("gitea.pr.create"))
def test_mutation_allowed_when_in_parity(self):
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A}):
self.assertEqual(
self.srv._master_parity_block("gitea.pr.create"), [])
def test_disable_env_lets_mutation_through(self):
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B,
mp.ENV_DISABLE: "1"}):
self.assertEqual(
self.srv._master_parity_block("gitea.pr.create"), [])
def test_assess_tool_reports_stale(self):
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_B}):
out = self.srv.gitea_assess_master_parity(remote="prgs")
self.assertTrue(out["stale"])
self.assertTrue(out["restart_required"])
self.assertEqual(out["startup_head"], SHA_A)
self.assertEqual(out["current_head"], SHA_B)
self.assertIn("report", out)
def test_assess_tool_reports_in_parity(self):
with patch.dict(os.environ, {mp.ENV_TEST_CURRENT_HEAD: SHA_A}):
out = self.srv.gitea_assess_master_parity(remote="prgs")
self.assertFalse(out["stale"])
self.assertTrue(out["in_parity"])
self.assertNotIn("report", out)
if __name__ == "__main__":
unittest.main()
+67
View File
@@ -0,0 +1,67 @@
"""Tests for sanctioned MCP daemon guards (#558)."""
from __future__ import annotations
import os
import unittest
from unittest.mock import patch
import mcp_daemon_guard
import gitea_auth
class TestMcpDaemonGuard(unittest.TestCase):
def test_unsanctioned_blocks_mutation_runtime(self):
env = {k: v for k, v in os.environ.items() if k not in {
mcp_daemon_guard.SANCTIONED_DAEMON_ENV,
mcp_daemon_guard.ALLOW_DIRECT_IMPORT_ENV,
"PYTEST_CURRENT_TEST",
}}
with patch.dict(os.environ, env, clear=True):
with self.assertRaises(mcp_daemon_guard.UnsanctionedRuntimeError):
mcp_daemon_guard.assert_sanctioned_mutation_runtime("test")
def test_pytest_allows_runtime(self):
# Running under pytest already sets PYTEST_CURRENT_TEST.
mcp_daemon_guard.assert_sanctioned_mutation_runtime("pytest")
def test_mark_sanctioned_allows(self):
env = {k: v for k, v in os.environ.items() if k != "PYTEST_CURRENT_TEST"}
env[mcp_daemon_guard.SANCTIONED_DAEMON_ENV] = "1"
with patch.dict(os.environ, env, clear=True):
mcp_daemon_guard.assert_sanctioned_mutation_runtime("daemon")
def test_keychain_blocked_without_sanction(self):
env = {
k: v
for k, v in os.environ.items()
if k
not in {
mcp_daemon_guard.SANCTIONED_DAEMON_ENV,
mcp_daemon_guard.ALLOW_DIRECT_IMPORT_ENV,
mcp_daemon_guard.ALLOW_KEYCHAIN_CLI_ENV,
"PYTEST_CURRENT_TEST",
}
}
with patch.dict(os.environ, env, clear=True):
with self.assertRaises(mcp_daemon_guard.UnsanctionedRuntimeError):
mcp_daemon_guard.assert_keychain_access_allowed()
def test_get_auth_header_blocked_when_unsanctioned(self):
env = {
k: v
for k, v in os.environ.items()
if k
not in {
mcp_daemon_guard.SANCTIONED_DAEMON_ENV,
mcp_daemon_guard.ALLOW_DIRECT_IMPORT_ENV,
"PYTEST_CURRENT_TEST",
}
}
with patch.dict(os.environ, env, clear=True):
with self.assertRaises(mcp_daemon_guard.UnsanctionedRuntimeError):
gitea_auth.get_auth_header("gitea.prgs.cc")
if __name__ == "__main__":
unittest.main()
+13
View File
@@ -105,6 +105,19 @@ class TestMcpMenuScript(unittest.TestCase):
self.assertIn("./mcp-menu.sh", docs_text)
self.assertIn("placeholder", docs_text.lower())
def test_reviewer_skip_stale_request_changes_prompt_discoverable(self):
# #482: the skip-already-reviewed-stale-REQUEST_CHANGES reviewer prompt
# must be reachable from the reviewer menu and documented.
label = "Skip already-reviewed stale REQUEST_CHANGES PR and hand off to author"
reviewer_fn = self._extract_function("show_reviewer_prompts")
self.assertIn(label, reviewer_fn, "new reviewer prompt label must appear in the reviewer menu")
# The prompt must instruct: no duplicate terminal review mutation for a
# non-stale REQUEST_CHANGES head, and must carry the author handoff.
self.assertIn("do NOT submit another terminal review mutation", reviewer_fn)
self.assertIn("author-ready fix prompt", reviewer_fn)
docs_text = DOCS.read_text(encoding="utf-8")
self.assertIn("REQUEST_CHANGES", docs_text)
def _extract_function(self, name: str) -> str:
marker = f"{name}() {{"
start = self.content.index(marker)
+168 -23
View File
@@ -91,6 +91,16 @@ def _visible_approval_reviews(reviewer="reviewer-bot", sha="abc123"):
_DEFAULT_LEASE_SESSION = "mcp-test-reviewer-lease"
_NO_PR_WORK_LEASE_BLOCK = {"block": False, "reasons": [], "mutation_allowed": True}
WORKFLOW_REPO_LABELS = [
{"id": 1, "name": "type:feature"},
{"id": 2, "name": "status:in-progress"},
{"id": 3, "name": "status:ready"},
{"id": 4, "name": "status:pr-open"},
]
def _issue_with_labels(*names):
return {"labels": [{"name": name} for name in names]}
def _reviewer_lease_comment(
@@ -305,6 +315,22 @@ class TestCreateIssue(unittest.TestCase):
self.assertIn("gitea.prgs.cc", url)
self.assertIn("Scaled-Tech-Consulting", url)
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_issue_required_workflow_labels_blocks(self, _auth, _get_all, mock_api, _role):
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
result = gitea_create_issue(
title="Test issue",
require_workflow_labels=True,
)
self.assertFalse(result["success"])
self.assertFalse(result["performed"])
self.assertIn("issue is missing a type:* label", result["reasons"])
mock_api.assert_not_called()
# ---------------------------------------------------------------------------
# Create PR
@@ -315,13 +341,24 @@ class TestCreatePR(unittest.TestCase):
"mcp_server.issue_duplicate_context_fetcher",
return_value=([], [], {"status": "not_claimed"}),
)
@patch("mcp_server.api_get_all", return_value=WORKFLOW_REPO_LABELS)
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_creates_pr(self, _auth, mock_api, _role, _dup_fetcher):
def test_creates_pr(self, _auth, mock_api, _role, _labels, _dup_fetcher):
worktree = os.path.realpath(os.getcwd())
mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"}
def api_side_effect(method, url, auth, payload=None):
if method == "GET" and "/issues/123" in url:
return _issue_with_labels("type:feature", "status:in-progress")
if method == "POST" and url.endswith("/pulls"):
return {"number": 3, "html_url": "https://example.com/pulls/3"}
if method == "PUT" and url.endswith("/issues/123/labels"):
return [{"name": "type:feature"}, {"name": "status:pr-open"}]
return {}
mock_api.side_effect = api_side_effect
with tempfile.TemporaryDirectory() as lock_dir:
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
with patch.dict(os.environ, env, clear=True):
@@ -334,22 +371,37 @@ class TestCreatePR(unittest.TestCase):
)
self.assertEqual(result["number"], 3)
self.assertNotIn("url", result)
payload = mock_api.call_args[0][3]
post_call = next(c for c in mock_api.call_args_list if c[0][0] == "POST")
payload = post_call[0][3]
self.assertEqual(payload["head"], "feat/x")
self.assertEqual(payload["base"], "main")
self.assertIn("Closes #123", payload["title"])
put_call = next(c for c in mock_api.call_args_list if c[0][0] == "PUT")
self.assertEqual(put_call[0][3], {"labels": [1, 4]})
self.assertTrue(result["issue_status_transition"]["performed"])
@patch(
"mcp_server.issue_duplicate_context_fetcher",
return_value=([], [], {"status": "not_claimed"}),
)
@patch("mcp_server.api_get_all", return_value=WORKFLOW_REPO_LABELS)
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_reveal_opt_in_includes_url(self, _auth, mock_api, _role, _dup_fetcher):
def test_create_pr_reveal_opt_in_includes_url(self, _auth, mock_api, _role, _labels, _dup_fetcher):
worktree = os.path.realpath(os.getcwd())
mock_api.return_value = {"number": 3, "html_url": "https://example.com/pulls/3"}
def api_side_effect(method, url, auth, payload=None):
if method == "GET" and "/issues/123" in url:
return _issue_with_labels("type:feature", "status:in-progress")
if method == "POST" and url.endswith("/pulls"):
return {"number": 3, "html_url": "https://example.com/pulls/3"}
if method == "PUT" and url.endswith("/issues/123/labels"):
return [{"name": "type:feature"}, {"name": "status:pr-open"}]
return {}
mock_api.side_effect = api_side_effect
with tempfile.TemporaryDirectory() as lock_dir:
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir, "GITEA_MCP_REVEAL_ENDPOINTS": "1"}
with patch.dict(os.environ, env, clear=True):
@@ -362,6 +414,38 @@ class TestCreatePR(unittest.TestCase):
)
self.assertIn("pulls/3", result["url"])
@patch(
"mcp_server.issue_duplicate_context_fetcher",
return_value=([], [], {"status": "not_claimed"}),
)
@patch("mcp_server.api_get_all", return_value=[{"id": 1, "name": "type:feature"}])
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_blocks_when_pr_open_status_label_missing(
self, _auth, mock_api, _role, _labels, _dup_fetcher
):
worktree = os.path.realpath(os.getcwd())
mock_api.return_value = _issue_with_labels("type:feature", "status:in-progress")
with tempfile.TemporaryDirectory() as lock_dir:
env = {**CREATE_PR_ENV, "GITEA_ISSUE_LOCK_DIR": lock_dir}
with patch.dict(os.environ, env, clear=True):
_bind_test_lock(issue_number=123, branch_name="feat/x", worktree_path=worktree)
result = gitea_create_pr(
title="feat: X Closes #123",
head="feat/x",
base="main",
worktree_path=worktree,
)
self.assertFalse(result["success"])
self.assertFalse(result["performed"])
self.assertIn("status:pr-open", result["reasons"][0])
self.assertFalse(
any(c[0][0] == "POST" and c[0][1].endswith("/pulls")
for c in mock_api.call_args_list)
)
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
@@ -486,26 +570,33 @@ class TestViewIssue(unittest.TestCase):
# ---------------------------------------------------------------------------
class TestMarkIssue(unittest.TestCase):
@patch("mcp_server.api_get_all", return_value=WORKFLOW_REPO_LABELS)
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_start_adds_label(self, _auth, mock_api):
# labels, add label, post claim heartbeat comment
mock_api.side_effect = [
[{"id": 10, "name": "status:in-progress"}],
[{"name": "status:in-progress"}],
{"id": 99},
]
def test_start_adds_label(self, _auth, mock_api, _labels):
def api_side_effect(method, url, auth, payload=None):
if method == "GET" and "/issues/5" in url:
return _issue_with_labels("type:feature", "status:ready")
if method == "PUT" and url.endswith("/issues/5/labels"):
return [{"name": "type:feature"}, {"name": "status:in-progress"}]
if method == "POST" and url.endswith("/issues/5/comments"):
return {"id": 99}
return {}
mock_api.side_effect = api_side_effect
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
result = gitea_mark_issue(issue_number=5, action="start")
self.assertTrue(result["success"])
self.assertIn("claimed", result["message"])
self.assertTrue(result.get("heartbeat_posted"))
put_call = next(c for c in mock_api.call_args_list if c[0][0] == "PUT")
self.assertEqual(put_call[0][3], {"labels": [1, 2]})
@patch("mcp_server.api_get_all", return_value=WORKFLOW_REPO_LABELS)
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_done_removes_label(self, _auth, mock_api):
def test_done_removes_label(self, _auth, mock_api, _labels):
mock_api.side_effect = [
[{"id": 10, "name": "status:in-progress"}],
None,
]
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
@@ -517,10 +608,10 @@ class TestMarkIssue(unittest.TestCase):
with self.assertRaises(ValueError):
gitea_mark_issue(issue_number=5, action="pause")
@patch("mcp_server.api_get_all", return_value=[])
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_missing_label_raises(self, _auth, mock_api):
mock_api.return_value = [] # no labels exist
def test_missing_label_raises(self, _auth, mock_api, _labels):
with patch.dict(os.environ, ISSUE_WRITE_ENV, clear=True):
with self.assertRaises(RuntimeError):
gitea_mark_issue(issue_number=5, action="start")
@@ -861,6 +952,19 @@ class TestMergePR(unittest.TestCase):
# -- identity / profile / eligibility fail-closed -------------------------
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_reviewer_profile_cannot_merge(self, _auth, mock_api):
env = {"GITEA_PROFILE_NAME": "prgs-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,approve"}
with patch.dict(os.environ, env, clear=True):
with self.assertRaises(RuntimeError) as ctx:
gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), remote="prgs"
)
self.assertIn("Reviewer profile cannot merge. Use a merger profile/session after formal approval at current head.", str(ctx.exception))
mock_api.assert_not_called()
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_self_author_cannot_merge(self, _auth, mock_api):
@@ -902,14 +1006,13 @@ class TestMergePR(unittest.TestCase):
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_profile_without_merge_permission_blocks(self, _auth, mock_api):
mock_api.side_effect = [{"login": "merger-bot"}, self._pr("author-bot")]
env = {"GITEA_PROFILE_NAME": "gitea-reviewer",
"GITEA_ALLOWED_OPERATIONS": "read,review,approve"}
with patch.dict(os.environ, env, clear=True):
r = gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), remote="prgs")
self.assertFalse(r["performed"])
self.assertIn("profile is not allowed to merge", r["reasons"])
with self.assertRaises(RuntimeError) as ctx:
gitea_merge_pr(
pr_number=8, confirmation=self._confirm(8), remote="prgs")
self.assertIn("Reviewer profile cannot merge. Use a merger profile/session after formal approval at current head.", str(ctx.exception))
self._assert_no_merge_call(mock_api)
# -- PR state / mergeability ----------------------------------------------
@@ -3102,6 +3205,36 @@ class TestIssueCommentTools(unittest.TestCase):
self.assertFalse(result["success"])
mock_api.assert_not_called()
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_casual_comment_allowed(self, _auth, mock_api):
mock_api.return_value = self._comment(556, "gitea-author", "casual")
with patch.dict(os.environ, self.AUTHOR_ENV, clear=True):
result = gitea_create_issue_comment(
issue_number=9,
body="Thanks, I will check this.",
remote="prgs",
)
self.assertTrue(result["success"])
self.assertTrue(result["performed"])
mock_api.assert_called_once()
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_workflow_comment_missing_fields_blocked(self, _auth, mock_api):
with patch.dict(os.environ, self.AUTHOR_ENV, clear=True):
result = gitea_create_issue_comment(
issue_number=9,
body="Blocked, author should fix.",
remote="prgs",
)
self.assertFalse(result["success"])
self.assertFalse(result["performed"])
self.assertIn("canonical_comment_validation", result)
self.assertFalse(result["canonical_comment_validation"]["allowed"])
self.assertTrue(result["canonical_comment_validation"]["is_workflow_comment"])
mock_api.assert_not_called()
@patch("mcp_server.api_request")
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_missing_issue_error_is_redacted(self, _auth, mock_api):
@@ -3765,13 +3898,24 @@ class TestIssueLocking(unittest.TestCase):
)
self.assertIn("lock provenance", str(ctx.exception).lower())
@patch("mcp_server.api_get_all", return_value=WORKFLOW_REPO_LABELS)
@patch("mcp_server.api_request")
@patch("mcp_server.role_session_router.check_author_mutation_after_reviewer_stop",
return_value=(True, []))
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_create_pr_honors_scratch_worktree_lock(self, _auth, _role, mock_api):
def test_create_pr_honors_scratch_worktree_lock(self, _auth, _role, mock_api, _labels):
scratch = os.path.realpath("/tmp/gitea-tools-author-scratch/issue-249-e2e")
mock_api.return_value = {"number": 250, "html_url": "https://example/pr/250"}
def api_side_effect(method, url, auth, payload=None):
if method == "GET" and "/issues/249" in url:
return _issue_with_labels("type:feature", "status:in-progress")
if method == "POST" and url.endswith("/pulls"):
return {"number": 250, "html_url": "https://example/pr/250"}
if method == "PUT" and url.endswith("/issues/249/labels"):
return [{"name": "type:feature"}, {"name": "status:pr-open"}]
return {}
mock_api.side_effect = api_side_effect
_bind_test_lock(
issue_number=249,
branch_name="feat/issue-249-issue-lock-scratch-worktree",
@@ -3786,6 +3930,7 @@ class TestIssueLocking(unittest.TestCase):
worktree_path=scratch,
)
self.assertEqual(res["number"], 250)
self.assertTrue(res["issue_status_transition"]["performed"])
# ---------------------------------------------------------------------------
+456 -2
View File
@@ -4,7 +4,7 @@ import os
import sys
import tempfile
import unittest
from unittest.mock import patch
from unittest.mock import MagicMock, Mock, patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
@@ -154,6 +154,460 @@ class TestMergedCleanupReport(unittest.TestCase):
finally:
os.remove(lock_path)
@patch("subprocess.run")
def test_discover_local_worktrees(self, mock_run):
mock_output = (
"worktree /repo/Gitea-Tools\n"
"bare\n\n"
"worktree /repo/Gitea-Tools/branches/custom-name\n"
"branch refs/heads/feat/issue-11-x\n"
"HEAD cafebabe\n\n"
)
mock_run.return_value = Mock(returncode=0, stdout=mock_output)
res = mcr.discover_local_worktrees("/repo/Gitea-Tools")
self.assertEqual(res, {"feat/issue-11-x": "/repo/Gitea-Tools/branches/custom-name"})
@patch("subprocess.run")
@patch("merged_cleanup_reconcile.read_local_worktree_state")
def test_build_report_discovers_non_canonical_worktree_by_branch(self, mock_state, mock_run):
mock_output = (
"worktree /repo/Gitea-Tools\n"
"bare\n\n"
"worktree /repo/Gitea-Tools/branches/custom-name\n"
"branch refs/heads/feat/issue-11-x\n"
"HEAD cafebabe\n\n"
)
mock_run.return_value = Mock(returncode=0, stdout=mock_output)
mock_state.side_effect = [
{
"exists": False,
"clean": None,
"current_branch": None,
"head_sha": None,
"dirty_files": [],
},
{
"exists": True,
"clean": True,
"current_branch": "feat/issue-11-x",
"head_sha": "cafebabe",
"dirty_files": [],
},
]
report = mcr.build_reconciliation_report(
project_root="/repo/Gitea-Tools",
closed_prs=[
{
"number": 11,
"title": "merged",
"body": "Closes #11",
"head": {"ref": "feat/issue-11-x"},
"merged_at": "2026-07-06T12:00:00Z",
"merge_commit_sha": "abc123",
}
],
open_prs=[],
remote_branch_exists={"feat/issue-11-x": True},
head_on_master={11: True},
delete_capability_allowed=True,
)
self.assertEqual(report["merged_pr_count"], 1)
entry = report["entries"][0]
local = entry["local_worktree"]
self.assertEqual(local["worktree_path"], "/repo/Gitea-Tools/branches/custom-name")
self.assertEqual(local["match_type"], "branch")
@patch("merged_cleanup_reconcile.read_local_worktree_state")
@patch("subprocess.run")
def test_build_report_discovers_issue_alias_worktree(self, mock_run, mock_state):
mock_run.return_value = Mock(
returncode=0,
stdout=(
"worktree /repo/Gitea-Tools\n"
"HEAD aaaa\n"
"branch refs/heads/master\n\n"
"worktree /repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation\n"
"HEAD cafebabe\n"
"branch refs/heads/feat/issue-510-workspace-binding-isolation\n\n"
),
)
mock_state.side_effect = [
{
"exists": False,
"clean": None,
"current_branch": None,
"head_sha": None,
"dirty_files": [],
},
{
"exists": True,
"clean": True,
"current_branch": "feat/issue-510-workspace-binding-isolation",
"head_sha": "cafebabe",
"dirty_files": [],
},
]
report = mcr.build_reconciliation_report(
project_root="/repo/Gitea-Tools",
closed_prs=[
{
"number": 512,
"title": "merged cleanup",
"body": "Closes #510",
"head": {
"ref": "feat/issue-510-workspace-binding-isolation",
"sha": "cafebabe",
},
"merged_at": "2026-07-06T12:00:00Z",
"merge_commit_sha": "abc123",
}
],
open_prs=[],
remote_branch_exists={"feat/issue-510-workspace-binding-isolation": True},
head_on_master={512: True},
delete_capability_allowed=True,
target_ref="prgs/master",
)
local = report["entries"][0]["local_worktree"]
self.assertTrue(local["safe_to_remove_worktree"])
self.assertEqual(local["match_type"], "branch")
self.assertEqual(
local["expected_worktree_path"],
"/repo/Gitea-Tools/branches/feat-issue-510-workspace-binding-isolation",
)
self.assertEqual(
local["discovered_worktree_path"],
"/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation",
)
@patch("merged_cleanup_reconcile.read_local_worktree_state")
@patch("subprocess.run")
def test_ambiguous_issue_alias_worktrees_fail_closed(self, mock_run, mock_state):
mock_run.return_value = Mock(
returncode=0,
stdout=(
"worktree /repo/Gitea-Tools/branches/issue-510-one\n"
"HEAD cafebabe\n"
"branch refs/heads/feat/issue-510-workspace-binding-isolation\n\n"
"worktree /repo/Gitea-Tools/branches/issue-510-two\n"
"HEAD cafebabe\n"
"branch refs/heads/feat/issue-510-workspace-binding-isolation\n\n"
),
)
mock_state.return_value = {
"exists": False,
"clean": None,
"current_branch": None,
"head_sha": None,
"dirty_files": [],
}
report = mcr.build_reconciliation_report(
project_root="/repo/Gitea-Tools",
closed_prs=[
{
"number": 512,
"title": "merged cleanup",
"body": "Closes #510",
"head": {
"ref": "feat/issue-510-workspace-binding-isolation",
"sha": "cafebabe",
},
"merged_at": "2026-07-06T12:00:00Z",
"merge_commit_sha": "abc123",
}
],
open_prs=[],
remote_branch_exists={"feat/issue-510-workspace-binding-isolation": True},
head_on_master={512: True},
delete_capability_allowed=True,
target_ref="prgs/master",
)
local = report["entries"][0]["local_worktree"]
self.assertFalse(local["safe_to_remove_worktree"])
self.assertEqual(local["match_type"], "ambiguous_alias")
self.assertIn("ambiguous local worktree aliases", local["block_reasons"][0])
@patch("merged_cleanup_reconcile.is_head_ancestor_of_ref", return_value=True)
@patch("merged_cleanup_reconcile.read_local_worktree_state")
@patch("subprocess.run")
def test_alias_head_on_target_branch_is_safe_cleanup_commit(
self, mock_run, mock_state, _ancestor
):
mock_run.return_value = Mock(
returncode=0,
stdout=(
"worktree /repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation\n"
"HEAD deadbeef\n"
"branch refs/heads/feat/issue-510-workspace-binding-isolation\n\n"
),
)
mock_state.side_effect = [
{
"exists": False,
"clean": None,
"current_branch": None,
"head_sha": None,
"dirty_files": [],
},
{
"exists": True,
"clean": True,
"current_branch": "feat/issue-510-workspace-binding-isolation",
"head_sha": "deadbeef",
"dirty_files": [],
},
]
state = mcr.resolve_cleanup_worktree_state(
project_root="/repo/Gitea-Tools",
head_branch="feat/issue-510-workspace-binding-isolation",
issue_number=510,
pr_head_sha="cafebabe",
target_ref="prgs/master",
)
self.assertTrue(state["exists"])
self.assertEqual(state["match_type"], "branch")
self.assertEqual(
state["discovered_worktree_path"],
"/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation",
)
@patch("os.path.isdir", return_value=True)
@patch("subprocess.run")
def test_remove_local_worktree_uses_assessed_path(self, mock_run, _isdir):
mock_run.return_value = Mock(returncode=0, stdout="", stderr="")
result = mcr.remove_local_worktree(
"/repo/Gitea-Tools",
"feat/issue-510-workspace-binding-isolation",
worktree_path="/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation",
)
self.assertTrue(result["success"])
self.assertEqual(
result["worktree_path"],
"/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation",
)
mock_run.assert_called_once_with(
[
"git",
"-C",
"/repo/Gitea-Tools",
"worktree",
"remove",
"/repo/Gitea-Tools/branches/issue-510-workspace-binding-isolation",
],
capture_output=True,
text=True,
check=False,
)
class TestReviewerScratchCleanup(unittest.TestCase):
"""#534: authorized cleanup path for reviewer scratch worktrees."""
def test_parse_reviewer_scratch_folder(self):
self.assertEqual(mcr.parse_reviewer_scratch_folder("review-pr512-submit"), 512)
self.assertEqual(mcr.parse_reviewer_scratch_folder("review-pr99"), 99)
self.assertIsNone(mcr.parse_reviewer_scratch_folder("feat-issue-11-x"))
self.assertIsNone(mcr.parse_reviewer_scratch_folder("review-feat-issue-11"))
self.assertIsNone(mcr.parse_reviewer_scratch_folder("review-pr512-extra-tail"))
def test_assess_safe_clean_detached_after_merge(self):
assessment = mcr.assess_reviewer_scratch_cleanup(
pr_number=512,
worktree_path="/repo/Gitea-Tools/branches/review-pr512-submit",
folder_name="review-pr512-submit",
pr_merged=True,
pr_closed=True,
worktree_state={
"exists": True,
"clean": True,
"current_branch": None,
"head_sha": "abc",
"dirty_files": [],
},
active_reviewer_lease=False,
)
self.assertTrue(assessment["safe_to_remove_worktree"])
self.assertFalse(assessment["author_worktree_cleanup"])
self.assertEqual(
assessment["recommended_action"], "remove_reviewer_scratch_worktree"
)
self.assertEqual(assessment["worktree_kind"], "reviewer_scratch")
def test_assess_blocks_dirty_scratch(self):
assessment = mcr.assess_reviewer_scratch_cleanup(
pr_number=512,
worktree_path="/repo/Gitea-Tools/branches/review-pr512-submit",
folder_name="review-pr512-submit",
pr_merged=True,
pr_closed=True,
worktree_state={
"exists": True,
"clean": False,
"current_branch": None,
"head_sha": "abc",
"dirty_files": ["foo.py"],
},
active_reviewer_lease=False,
)
self.assertFalse(assessment["safe_to_remove_worktree"])
self.assertTrue(
any("tracked edits" in r for r in assessment["block_reasons"])
)
def test_assess_blocks_active_lease(self):
assessment = mcr.assess_reviewer_scratch_cleanup(
pr_number=512,
worktree_path="/repo/Gitea-Tools/branches/review-pr512-submit",
folder_name="review-pr512-submit",
pr_merged=True,
pr_closed=True,
worktree_state={
"exists": True,
"clean": True,
"current_branch": None,
"head_sha": "abc",
"dirty_files": [],
},
active_reviewer_lease=True,
)
self.assertFalse(assessment["safe_to_remove_worktree"])
self.assertTrue(
any("active reviewer lease" in r for r in assessment["block_reasons"])
)
def test_assess_blocks_open_pr(self):
assessment = mcr.assess_reviewer_scratch_cleanup(
pr_number=512,
worktree_path="/repo/Gitea-Tools/branches/review-pr512-submit",
folder_name="review-pr512-submit",
pr_merged=False,
pr_closed=False,
worktree_state={
"exists": True,
"clean": True,
"current_branch": None,
"head_sha": "abc",
"dirty_files": [],
},
active_reviewer_lease=False,
)
self.assertFalse(assessment["safe_to_remove_worktree"])
self.assertTrue(any("still open" in r for r in assessment["block_reasons"]))
@patch("subprocess.run")
@patch("merged_cleanup_reconcile.read_local_worktree_state")
def test_report_lists_scratch_separately_from_author(self, mock_state, mock_run):
mock_output = (
"worktree /repo/Gitea-Tools\n"
"bare\n\n"
"worktree /repo/Gitea-Tools/branches/feat-issue-11-x\n"
"branch refs/heads/feat/issue-11-x\n"
"HEAD deadbeef\n\n"
"worktree /repo/Gitea-Tools/branches/review-pr512-submit\n"
"HEAD abcdef0\n"
"detached\n\n"
)
mock_run.return_value = MagicMock(returncode=0, stdout=mock_output)
def _state(path):
if "review-pr512" in path:
return {
"exists": True,
"clean": True,
"current_branch": None,
"head_sha": "abcdef0",
"dirty_files": [],
}
return {
"exists": True,
"clean": True,
"current_branch": "feat/issue-11-x",
"head_sha": "deadbeef",
"dirty_files": [],
}
mock_state.side_effect = _state
report = mcr.build_reconciliation_report(
project_root="/repo/Gitea-Tools",
closed_prs=[
{
"number": 11,
"title": "merged author",
"body": "Closes #11",
"head": {"ref": "feat/issue-11-x"},
"merged_at": "2026-07-06T12:00:00Z",
},
{
"number": 512,
"title": "merged reviewed",
"body": "Closes #512",
"head": {"ref": "feat/issue-512-x"},
"merged_at": "2026-07-06T12:00:00Z",
},
],
open_prs=[],
remote_branch_exists={
"feat/issue-11-x": False,
"feat/issue-512-x": False,
},
head_on_master={11: True, 512: True},
delete_capability_allowed=True,
active_reviewer_leases={512: False},
)
self.assertEqual(report["merged_pr_count"], 2)
self.assertEqual(report["reviewer_scratch_count"], 1)
scratch = report["reviewer_scratch_entries"][0]
self.assertEqual(scratch["pr_number"], 512)
self.assertEqual(scratch["worktree_kind"], "reviewer_scratch")
self.assertFalse(scratch["author_worktree_cleanup"])
self.assertTrue(scratch["safe_to_remove_worktree"])
# Author entries must not swallow the scratch path as local_worktree.
author_paths = {
(e.get("local_worktree") or {}).get("worktree_path")
for e in report["entries"]
}
self.assertNotIn(
"/repo/Gitea-Tools/branches/review-pr512-submit", author_paths
)
@patch("subprocess.run")
def test_remove_reviewer_scratch_worktree_path_guard(self, mock_run):
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
with patch("os.path.isdir", return_value=True):
bad = mcr.remove_reviewer_scratch_worktree(
"/repo/Gitea-Tools",
"/repo/Gitea-Tools/branches/feat-issue-11-x",
)
self.assertFalse(bad["performed"])
good = mcr.remove_reviewer_scratch_worktree(
"/repo/Gitea-Tools",
"/repo/Gitea-Tools/branches/review-pr512-submit",
)
self.assertTrue(good["success"])
self.assertTrue(good["performed"])
self.assertFalse(good["author_worktree_cleanup"])
mock_run.assert_any_call(
[
"git",
"-C",
"/repo/Gitea-Tools",
"worktree",
"remove",
"/repo/Gitea-Tools/branches/review-pr512-submit",
],
capture_output=True,
text=True,
check=False,
)
if __name__ == "__main__":
unittest.main()
unittest.main()
+18 -2
View File
@@ -33,8 +33,15 @@ REVIEWER_ENV = {
"GITEA_PROFILE_NAME": "reviewer-test",
"GITEA_ALLOWED_OPERATIONS":
"gitea.read,gitea.pr.review,gitea.pr.comment,gitea.pr.approve,"
"gitea.pr.request_changes,gitea.pr.merge",
"GITEA_FORBIDDEN_OPERATIONS": "gitea.pr.create,gitea.branch.push",
"gitea.pr.request_changes",
"GITEA_FORBIDDEN_OPERATIONS": "gitea.pr.create,gitea.branch.push,gitea.pr.merge",
}
MERGER_ENV = {
"GITEA_PROFILE_NAME": "merger-test",
"GITEA_ALLOWED_OPERATIONS":
"gitea.read,gitea.pr.comment,gitea.pr.merge",
"GITEA_FORBIDDEN_OPERATIONS": "gitea.pr.create,gitea.branch.push,gitea.pr.approve",
}
EXPECTED_SKILLS = [
@@ -88,6 +95,15 @@ class TestControlPlaneGuide(GuideTestBase):
self.assertIn("eligibility", blob)
self.assertIn("pinned", blob)
@patch("mcp_server.api_request", return_value={"login": "merger-bot"})
@patch("mcp_server.get_auth_header", return_value=FAKE_AUTH)
def test_merger_profile_guidance(self, _auth, _api):
with patch.dict(os.environ, MERGER_ENV, clear=True):
g = mcp_get_control_plane_guide(remote="prgs")
self.assertEqual(g["profile"]["role_kind"], "merger")
blob = " ".join(g["guidance"]).lower()
self.assertIn("merge", blob)
@patch("mcp_server.get_auth_header", return_value=None)
def test_unresolved_identity_instructs_stop(self, _auth):
with patch.dict(os.environ, AUTHOR_ENV, clear=True):
+33
View File
@@ -265,5 +265,38 @@ class TestCleanupReportVerifier(unittest.TestCase):
self.assertEqual(direct["proven"], wrapped["proven"])
class TestCleanupEmptyQueue(unittest.TestCase):
def test_empty_queue_report_passes(self):
report = _clean_report(
selected="Selected PR: none",
decision="Review decision: comment",
next="Next suggested PR: approvals (queue empty)",
pagination="PR inventory pagination proof: inventory_complete=true, total_count 0",
)
report += "\npr_inventory_trust_gate.status: trusted_empty"
result = assess_pr_queue_cleanup_report(report)
self.assertTrue(result["proven"], result["reasons"])
def test_empty_queue_without_evidence_fails(self):
report = _clean_report(
selected="Selected PR: none",
)
result = assess_pr_queue_cleanup_report(report)
self.assertFalse(result["proven"])
self.assertTrue(
any("no valid empty-queue claim" in r for r in result["reasons"]),
result["reasons"]
)
def test_contradictory_selected_pr_fails(self):
report = _clean_report() + "\nSelected PR: none"
result = assess_pr_queue_cleanup_report(report)
self.assertFalse(result["proven"])
self.assertTrue(
any("contains both a selected PR and a claim of none selected" in r for r in result["reasons"]),
result["reasons"]
)
if __name__ == "__main__":
unittest.main()
+112
View File
@@ -0,0 +1,112 @@
import unittest
from premerge_baseline_proof import (
CLEAN_PASS,
PREMERGE_BASELINE_PROVEN_FAILURE,
UNRESOLVED_REGRESSION_RISK,
assess_premerge_baseline_proof,
)
from final_report_validator import assess_final_report_validator
_FIELDS = (
"Pre-merge base commit: 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b\n"
"Tested commit: 1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b\n"
"Command: venv/bin/python -m pytest tests/test_foo.py -q\n"
"Exit status: 1\n"
"Failure signature: AssertionError: None is not true\n"
)
# Current-master-only reproduction carries no pre-merge base proof (that is the
# whole defect #533 guards against): a command/exit/signature but no base commit.
_CURRENT_ONLY_FIELDS = (
"Command: venv/bin/python -m pytest tests/test_foo.py -q\n"
"Exit status: 1\n"
"Failure signature: AssertionError: None is not true\n"
)
class TestPremergeBaselineProof(unittest.TestCase):
def test_clean_pass_not_blocked(self):
result = assess_premerge_baseline_proof(
"Validation: full suite passed, exit status 0. Clean pass."
)
self.assertFalse(result["block"])
self.assertTrue(result["skipped"])
self.assertEqual(result["label"], CLEAN_PASS)
def test_nonzero_exit_not_claimed_baseline_not_blocked(self):
result = assess_premerge_baseline_proof(
"Full suite: 1 failed. Investigating the regression; not yet classified."
)
self.assertFalse(result["block"])
self.assertEqual(result["label"], UNRESOLVED_REGRESSION_RISK)
def test_current_master_only_reproduction_rejected(self):
report = (
"Full suite: 1 failed. This is a pre-existing baseline failure — "
"reproduced on current master after the PR merged.\n" + _CURRENT_ONLY_FIELDS
)
result = assess_premerge_baseline_proof(report)
self.assertTrue(result["block"])
self.assertTrue(
any("current-master reproduction only" in r for r in result["reasons"])
)
def test_baseline_claim_missing_fields_blocked(self):
report = (
"Full suite: 1 failed. This failure is baseline / pre-existing. "
"Verified against the pre-merge base commit."
)
result = assess_premerge_baseline_proof(report)
self.assertTrue(result["block"])
self.assertTrue(
any("missing required proof field" in r for r in result["reasons"])
)
def test_premerge_base_proof_accepted(self):
report = (
"Full suite: 1 failed. This is a pre-existing baseline failure, proven "
"on the PR pre-merge base commit.\n" + _FIELDS
)
result = assess_premerge_baseline_proof(report)
self.assertFalse(result["block"])
self.assertTrue(result["proven"])
self.assertEqual(result["label"], PREMERGE_BASELINE_PROVEN_FAILURE)
def test_documented_known_failure_record_accepted(self):
report = (
"Full suite: 1 failed. Baseline failure: cited known-failure record #529 "
"which predates the PR.\n" + _FIELDS
)
result = assess_premerge_baseline_proof(report)
self.assertFalse(result["block"])
self.assertEqual(result["label"], PREMERGE_BASELINE_PROVEN_FAILURE)
class TestPremergeBaselineProofValidatorIntegration(unittest.TestCase):
def _has_rule(self, report):
result = assess_final_report_validator(report, "review_pr")
return any(
f["rule_id"] == "reviewer.premerge_baseline_proof"
for f in result["findings"]
)
def test_review_report_current_master_only_flagged(self):
report = (
"## Reviewer final report\n"
"Full suite: 1 failed. Pre-existing baseline failure — reproduced on "
"post-merge master.\n" + _CURRENT_ONLY_FIELDS
)
self.assertTrue(self._has_rule(report))
def test_review_report_premerge_proof_clean(self):
report = (
"## Reviewer final report\n"
"Full suite: 1 failed. Pre-existing baseline failure proven on the "
"pre-merge base commit.\n" + _FIELDS
)
self.assertFalse(self._has_rule(report))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,263 @@
"""Integration tests for reconciler merged cleanup (#523)."""
import os
import sys
import unittest
from unittest.mock import patch
sys.path.insert(0, str(__import__("pathlib").Path(__file__).resolve().parent.parent))
import mcp_server
import task_capability_map
from audit_reconciliation_mode import clear_phase
from task_capability_map import required_role
RECONCILER_PROFILE = {
"profile_name": "prgs-reconciler",
"context": "prgs",
"role": "reconciler",
"username": "sysadmin",
"base_url": "https://gitea.prgs.cc",
"allowed_operations": [
"gitea.read",
"gitea.pr.close",
"gitea.pr.comment",
"gitea.issue.comment",
],
"forbidden_operations": [],
}
AUTHOR_PROFILE = {
"profile_name": "prgs-author",
"context": "prgs",
"role": "author",
"username": "jcwalker3",
"base_url": "https://gitea.prgs.cc",
"allowed_operations": [
"gitea.read",
"gitea.pr.create",
"gitea.branch.create",
"gitea.branch.push",
"gitea.repo.commit",
],
"forbidden_operations": [],
}
CONTROL_ROOT = "/Users/jasonwalker/Development/Gitea-Tools"
class TestReconcilerCleanupIntegration(unittest.TestCase):
def setUp(self):
clear_phase()
mcp_server._preflight_whoami_called = False
mcp_server._preflight_capability_called = False
mcp_server._preflight_resolved_role = None
mcp_server._preflight_resolved_task = None
mcp_server._preflight_whoami_violation = False
mcp_server._preflight_capability_violation = False
mcp_server._preflight_whoami_violation_files = []
mcp_server._preflight_capability_violation_files = []
mcp_server._preflight_capability_baseline_porcelain = ""
self.mock_api = patch("gitea_auth.api_request").start()
self.mock_auth = patch(
"mcp_server.get_auth_header", return_value="token test"
).start()
def tearDown(self):
patch.stopall()
clear_phase()
mcp_server._IDENTITY_CACHE.clear()
def test_capability_map_routes_merged_cleanup_to_reconciler(self):
self.assertEqual(required_role("reconcile_merged_cleanups"), "reconciler")
self.assertEqual(required_role("reconciliation_cleanup"), "reconciler")
self.assertEqual(
task_capability_map.required_permission("reconcile_merged_cleanups"),
"gitea.read",
)
@patch.dict(os.environ, {"GITEA_PROFILE_NAME": "prgs-reconciler"}, clear=True)
@patch("mcp_server.get_profile", return_value=RECONCILER_PROFILE)
def test_reconciler_can_run_reconcile_merged_cleanups_directly(self, _profile):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability",
resolved_role="reconciler",
resolved_task="reconcile_merged_cleanups",
)
self.mock_api.side_effect = [
[ # closed pulls page
{
"number": 100,
"title": "merged feature",
"body": "Closes #100",
"merged": True,
"merged_at": "2026-07-06T12:00:00Z",
"merge_commit_sha": "deadbeef",
"head": {"ref": "feat/issue-100", "sha": "cafebabe"},
},
{
# closed but NOT merged — must not become a cleanup entry
"number": 101,
"title": "abandoned",
"body": "Closes #101",
"merged": False,
"merged_at": None,
"head": {"ref": "feat/issue-101", "sha": "badcafe"},
},
],
[], # open pulls
]
with patch("mcp_server._remote_branch_exists", return_value=True):
with patch(
"mcp_server.merged_cleanup_reconcile.is_head_ancestor_of_ref",
return_value=True,
):
result = mcp_server.gitea_reconcile_merged_cleanups(
dry_run=True,
remote="prgs",
)
self.assertTrue(result["success"])
self.assertTrue(result["dry_run"])
entry_numbers = [e["issue_number"] for e in result["entries"]]
self.assertEqual(entry_numbers, [100])
self.assertNotIn(101, entry_numbers)
@patch.dict(
os.environ,
{
"GITEA_PROFILE_NAME": "prgs-reconciler",
# Force preflight purity to evaluate (disable test short-circuit).
"GITEA_TEST_PORCELAIN": "",
},
clear=True,
)
@patch("mcp_server.get_profile", return_value=RECONCILER_PROFILE)
def test_reconciler_role_bypasses_branches_only_mutation_guard(self, _profile):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability",
resolved_role="reconciler",
resolved_task="reconcile_merged_cleanups",
)
with patch("mcp_server.PROJECT_ROOT", CONTROL_ROOT):
with patch(
"mcp_server.root_checkout_guard.resolve_remote_master_sha",
return_value="abc123",
):
with patch(
"mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value={
"current_branch": "master",
"head_sha": "abc123",
"porcelain_status": "",
},
):
try:
mcp_server.verify_preflight_purity(
remote="prgs",
task="reconcile_merged_cleanups",
)
except RuntimeError as e:
self.fail(
"verify_preflight_purity raised RuntimeError "
f"unexpectedly for reconciler: {e}"
)
@patch.dict(
os.environ,
{"GITEA_PROFILE_NAME": "prgs-author", "GITEA_TEST_PORCELAIN": ""},
clear=True,
)
@patch("mcp_server.get_profile", return_value=AUTHOR_PROFILE)
def test_author_role_remains_blocked_on_root_checkout(self, _profile):
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability",
resolved_role="author",
resolved_task="reconcile_merged_cleanups",
)
with patch("mcp_server.PROJECT_ROOT", CONTROL_ROOT):
with patch(
"mcp_server.root_checkout_guard.resolve_remote_master_sha",
return_value="abc123",
):
with patch(
"mcp_server.issue_lock_worktree.read_worktree_git_state",
return_value={
"current_branch": "master",
"head_sha": "abc123",
"porcelain_status": "",
},
):
with self.assertRaises(RuntimeError) as ctx:
mcp_server.verify_preflight_purity(
remote="prgs",
task="reconcile_merged_cleanups",
)
message = str(ctx.exception)
self.assertTrue(
"stable control checkout" in message
or "author mutation blocked" in message
or "branches/" in message,
msg=message,
)
@patch.dict(os.environ, {"GITEA_PROFILE_NAME": "prgs-reconciler"}, clear=True)
@patch("mcp_server.get_profile", return_value=RECONCILER_PROFILE)
def test_open_unmerged_pr_heads_are_not_safe_cleanup_targets(self, _profile):
"""AC5: active/unmerged author work must remain blocked from cleanup."""
mcp_server.record_preflight_check("whoami")
mcp_server.record_preflight_check(
"capability",
resolved_role="reconciler",
resolved_task="reconcile_merged_cleanups",
)
open_pr = {
"number": 200,
"title": "active work",
"body": "Closes #200",
"merged": False,
"state": "open",
"head": {"ref": "feat/issue-200", "sha": "livehead1"},
}
# Same head branch still open on another PR while a closed/merged PR
# used the same branch name historically — open head must block delete.
closed_merged = {
"number": 199,
"title": "older merge same branch name",
"body": "Closes #199",
"merged": True,
"merged_at": "2026-07-01T12:00:00Z",
"merge_commit_sha": "deadbeef",
"head": {"ref": "feat/issue-200", "sha": "oldhead99"},
}
self.mock_api.side_effect = [
[closed_merged], # closed
[open_pr], # open
]
with patch("mcp_server._remote_branch_exists", return_value=True):
with patch(
"mcp_server.merged_cleanup_reconcile.is_head_ancestor_of_ref",
return_value=True,
):
result = mcp_server.gitea_reconcile_merged_cleanups(
dry_run=True,
remote="prgs",
)
self.assertTrue(result["success"])
self.assertEqual(len(result["entries"]), 1)
remote_assessment = result["entries"][0].get("remote_branch") or {}
self.assertFalse(
remote_assessment.get("safe_to_delete_remote"),
msg=f"open head must block remote delete: {remote_assessment}",
)
if __name__ == "__main__":
unittest.main()
+15 -4
View File
@@ -44,9 +44,19 @@ CONFIG_RESOLVER = {
"role": "reviewer",
"username": "reviewer-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve", "gitea.pr.merge", "gitea.issue.comment"],
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve", "gitea.issue.comment"],
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"],
"execution_profile": "reviewer-profile"
},
"merger-profile": {
"enabled": True,
"context": "ctx",
"role": "merger",
"username": "merger-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_MERGER"},
"allowed_operations": ["gitea.read", "gitea.pr.merge", "gitea.issue.comment"],
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.approve"],
"execution_profile": "merger-profile"
}
},
"rules": {
@@ -83,6 +93,7 @@ class TestResolveTaskCapability(unittest.TestCase):
"GITEA_MCP_PROFILE": profile,
"GITEA_TOKEN_AUTHOR": "author-pass",
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
"GITEA_TOKEN_MERGER": "merger-pass",
}
@patch("mcp_server.api_request", return_value={"login": "author-user"})
@@ -231,9 +242,9 @@ class TestResolveTaskCapability(unittest.TestCase):
with patch.dict(os.environ, self._env("author-profile")):
res = mcp_server.gitea_resolve_task_capability(task="merge_pr", remote="prgs")
self.assertFalse(res["allowed_in_current_session"])
self.assertIn("reviewer", res["exact_safe_next_action"].lower())
self.assertIn("merger", res["exact_safe_next_action"].lower())
self.assertEqual(res["required_operation_permission"], "gitea.pr.merge")
self.assertEqual(res["required_role_kind"], "reviewer")
self.assertEqual(res["required_role_kind"], "merger")
@patch("mcp_server.api_request")
def test_self_review_blocked_structured(self, mock_api):
+120
View File
@@ -0,0 +1,120 @@
"""Tests for reviewer handoff consistency validation (#501)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import reviewer_handoff_consistency # noqa: E402
from final_report_validator import assess_final_report_validator # noqa: E402
def _base_handoff(**overrides):
lines = [
"## Controller Handoff",
"",
"- Task: review PR #472",
"- Review decision: request_changes",
"- Review mutations: submitted request_changes review",
"- Merge mutations: none",
"- MCP/Gitea mutations: reviewer lease acquired",
]
text = "\n".join(lines)
for key, value in overrides.items():
token = f"- {key}:"
if token in text:
before, _, after = text.partition(token)
rest = after.split("\n", 1)
suffix = rest[1] if len(rest) > 1 else ""
text = f"{before}{token} {value}" + (f"\n{suffix}" if suffix else "")
else:
text += f"\n- {key}: {value}"
return text
class TestReviewerHandoffConsistency(unittest.TestCase):
def test_consistent_handoff_passes(self):
result = reviewer_handoff_consistency.assess_reviewer_handoff_consistency(
_base_handoff()
)
self.assertTrue(result["proven"], result["reasons"])
def test_claimed_merge_missing_from_ledger(self):
report = _base_handoff(
**{
"Current status": "Merged PR #411 earlier; reviewing PR #472",
"Merge mutations": "none",
"MCP/Gitea mutations": "reviewer lease acquired",
}
)
result = reviewer_handoff_consistency.assess_reviewer_handoff_consistency(report)
self.assertFalse(result["proven"])
self.assertTrue(any("merge" in r.lower() for r in result["reasons"]))
def test_terminal_budget_consumed_without_merge_in_ledger(self):
report = _base_handoff(
**{
"Current status": "Terminal mutation budget already consumed by merge",
"Merge mutations": "none",
"MCP/Gitea mutations": "reviewer lease acquired",
}
)
result = reviewer_handoff_consistency.assess_reviewer_handoff_consistency(report)
self.assertFalse(result["proven"])
self.assertTrue(any("terminal mutation budget" in r for r in result["reasons"]))
def test_review_blocked_but_final_decision_marked(self):
report = _base_handoff(
**{
"Current status": "gitea_submit_pr_review blocked",
"Review mutations": "none",
}
)
report += "\nServer-side final decision marked via gitea_mark_final_review_decision."
result = reviewer_handoff_consistency.assess_reviewer_handoff_consistency(report)
self.assertFalse(result["proven"])
self.assertTrue(any("blocked" in r and "final decision" in r for r in result["reasons"]))
def test_lease_without_review_decision(self):
report = _base_handoff(**{"Review decision": "none"})
report += "\nReviewer lease acquired for PR #472."
result = reviewer_handoff_consistency.assess_reviewer_handoff_consistency(report)
self.assertFalse(result["proven"])
self.assertTrue(any("Review decision" in r for r in result["reasons"]))
def test_rejected_mutation_without_proof(self):
report = _base_handoff(
**{
"Review mutations": "none",
"MCP/Gitea mutations": "none",
}
)
report += "\nReview submission blocked before mutation."
result = reviewer_handoff_consistency.assess_reviewer_handoff_consistency(report)
self.assertFalse(result["proven"])
self.assertTrue(any("proof fields" in r for r in result["reasons"]))
def test_blocked_template_includes_required_fields(self):
template = reviewer_handoff_consistency.render_blocked_review_handoff_template()
for field in reviewer_handoff_consistency._BLOCKED_REVIEW_PROOF_FIELDS:
self.assertIn(field, template.lower())
def test_final_report_validator_integration(self):
report = _base_handoff(
**{
"Current status": "Merged PR #411; terminal mutation budget consumed",
"Merge mutations": "none",
}
)
result = assess_final_report_validator(report, "review_pr")
self.assertTrue(
any(
f["rule_id"] == "reviewer.handoff_consistency"
for f in result["findings"]
),
result["findings"],
)
if __name__ == "__main__":
unittest.main()
+46 -4
View File
@@ -43,9 +43,19 @@ CONFIG_SWITCHING_DISABLED = {
"role": "reviewer",
"username": "reviewer-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_REVIEWER"},
"allowed_operations": ["gitea.read", "gitea.pr.approve", "gitea.pr.merge"],
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push"],
"allowed_operations": ["gitea.read", "gitea.pr.review", "gitea.pr.approve"],
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.merge"],
"execution_profile": "reviewer-profile"
},
"merger-profile": {
"enabled": True,
"context": "ctx",
"role": "merger",
"username": "merger-user",
"auth": {"type": "env", "name": "GITEA_TOKEN_MERGER"},
"allowed_operations": ["gitea.read", "gitea.pr.merge"],
"forbidden_operations": ["gitea.pr.create", "gitea.branch.push", "gitea.pr.approve"],
"execution_profile": "merger-profile"
}
},
"rules": {
@@ -90,6 +100,7 @@ class TestRuntimeClarity(unittest.TestCase):
"GITEA_MCP_REVEAL_ENDPOINTS": reveal,
"GITEA_TOKEN_AUTHOR": "author-pass",
"GITEA_TOKEN_REVIEWER": "reviewer-pass",
"GITEA_TOKEN_MERGER": "merger-pass",
}
# -------------------------------------------------------------------------
@@ -125,7 +136,7 @@ class TestRuntimeClarity(unittest.TestCase):
t for t in caps["task_capabilities"] if t["task"] == "merge_pr"
)
self.assertFalse(merge_entry["allowed_in_current_session"])
self.assertIn("reviewer-profile", merge_entry["matching_configured_profiles"])
self.assertIn("merger-profile", merge_entry["matching_configured_profiles"])
@patch(
"mcp_server.assess_preflight_status",
@@ -144,6 +155,30 @@ class TestRuntimeClarity(unittest.TestCase):
caps = ctx["session_capabilities"]
self.assertFalse(caps["can_author_prs"])
self.assertFalse(caps["can_create_issues"])
self.assertTrue(caps["can_review_prs"])
self.assertFalse(caps["can_merge_prs"])
merge_entry = next(
t for t in caps["task_capabilities"] if t["task"] == "merge_pr"
)
self.assertFalse(merge_entry["allowed_in_current_session"])
@patch(
"mcp_server.assess_preflight_status",
return_value={"preflight_ready": True, "preflight_block_reasons": []},
)
@patch("mcp_server.api_request", return_value={"login": "merger-user"})
@patch("mcp_server.get_auth_header", return_value="token merger-pass")
def test_get_runtime_context_merger(self, _auth, _api, _preflight):
with patch.dict(os.environ, self._env("merger-profile"), clear=True):
ctx = mcp_server.gitea_get_runtime_context(remote="dadeschools")
self.assertEqual(ctx["active_profile"], "merger-profile")
self.assertEqual(ctx["authenticated_username"], "merger-user")
self.assertTrue(ctx["review_merge_allowed"])
self.assertEqual(ctx["suggested_fix"], "none")
self.assertEqual(ctx["safe_next_action"], "None; ready for operations.")
caps = ctx["session_capabilities"]
self.assertFalse(caps["can_author_prs"])
self.assertFalse(caps["can_create_issues"])
self.assertFalse(caps["can_review_prs"])
self.assertTrue(caps["can_merge_prs"])
merge_entry = next(
@@ -160,7 +195,7 @@ class TestRuntimeClarity(unittest.TestCase):
with patch.dict(os.environ, self._env("author-profile", reveal="0"), clear=True):
res = mcp_server.gitea_list_profiles()
profiles = res["profiles"]
self.assertEqual(len(profiles), 2)
self.assertEqual(len(profiles), 3)
author_prof = next(p for p in profiles if p["name"] == "author-profile")
self.assertTrue(author_prof["is_active"])
@@ -176,6 +211,13 @@ class TestRuntimeClarity(unittest.TestCase):
self.assertEqual(reviewer_prof["base_url"], "<redacted>")
self.assertEqual(reviewer_prof["identity_status"], "credentials present")
merger_prof = next(p for p in profiles if p["name"] == "merger-profile")
self.assertFalse(merger_prof["is_active"])
self.assertEqual(merger_prof["role_kind"], "merger")
self.assertEqual(merger_prof["auth"]["name"], "<redacted>")
self.assertEqual(merger_prof["base_url"], "<redacted>")
self.assertEqual(merger_prof["identity_status"], "credentials present")
@patch("mcp_server.api_request", return_value={"login": "author-user"})
@patch("mcp_server.get_auth_header", return_value="token author-pass")
def test_list_profiles_revealed_under_opt_in(self, _auth, _api):
+243
View File
@@ -0,0 +1,243 @@
"""Tests for canonical state handoff ledger (#494)."""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from final_report_validator import assess_final_report_validator # noqa: E402
from state_handoff_ledger import ( # noqa: E402
ISSUE_STATE_FIELDS,
assess_contradictory_state_handoff,
assess_final_report_next_action_handoff,
assess_state_comment,
assess_state_handoff_ledger_report,
parse_state_comment,
render_issue_state_comment,
render_pr_state_comment,
render_queue_controller_report,
)
def _work_issue_handoff(**overrides):
lines = [
"## Controller Handoff",
"",
"- Task: work-issue",
"- Repo: Scaled-Tech-Consulting/Gitea-Tools",
"- Role: author",
"- Identity: jcwalker3 / prgs-author",
"- Selected issue: #494",
"- Current status: implementation complete; PR opened awaiting review",
"- Next actor: reviewer",
"- Next action: review PR at pinned head",
"- Next prompt: Review PR #500 for issue #494 at current head in reviewer worktree",
"- Safe next action: switch to reviewer session",
"- External-state mutations: none",
]
text = "\n".join(lines)
for key, value in overrides.items():
needle = f"- {key}:"
if needle in text:
prefix = text.split(needle)[0]
rest = text.split(needle, 1)[1]
suffix = rest.split("\n", 1)[1] if "\n" in rest else ""
text = f"{prefix}{needle} {value}\n{suffix}".rstrip()
else:
text += f"\n- {key}: {value}"
return text
class TestStateCommentTemplates(unittest.TestCase):
def test_render_issue_state_includes_all_fields(self):
body = render_issue_state_comment(
STATE="issue_ready_for_author",
NEXT_ACTOR="author",
NEXT_ACTION="lock and implement",
NEXT_PROMPT="Find issue #494 and open a PR",
LAST_UPDATED_BY="jcwalker3",
)
parsed = parse_state_comment(body)
for field in ISSUE_STATE_FIELDS:
self.assertIn(field, parsed)
def test_render_pr_state_parses(self):
body = render_pr_state_comment(
STATE="needs_review",
NEXT_ACTOR="reviewer",
NEXT_ACTION="review at head",
NEXT_PROMPT="Review PR #500",
ISSUE="#494",
HEAD_SHA="a" * 40,
LAST_UPDATED_BY="sysadmin",
)
parsed = parse_state_comment(body)
self.assertEqual(parsed["STATE"], "needs_review")
self.assertEqual(parsed["NEXT_ACTOR"], "reviewer")
def test_queue_controller_template(self):
body = render_queue_controller_report(
QUEUE_STATE="open_prs_need_review",
SELECTED_OBJECT="PR #500",
SELECTED_OBJECT_TYPE="pr",
NEXT_ACTOR="reviewer",
NEXT_ACTION="start review session",
NEXT_PROMPT="Review PR #500",
LAST_UPDATED_BY="controller",
)
result = assess_state_comment(body, kind="queue_controller")
self.assertTrue(result["complete"])
class TestStateCommentValidation(unittest.TestCase):
def test_complete_issue_state_comment(self):
body = render_issue_state_comment(
STATE="in_progress",
NEXT_ACTOR="author",
NEXT_ACTION="push branch",
NEXT_PROMPT="Continue issue #494",
STATUS="open",
RELATED_PRS="none",
BRANCH="feat/issue-494-state-handoff-ledger",
HEAD_SHA="b" * 40,
VALIDATION="pytest passed",
BLOCKERS="none",
DUPLICATES_OR_SUPERSEDES="none",
LAST_UPDATED_BY="jcwalker3",
)
result = assess_state_comment(body, kind="issue_state")
self.assertTrue(result["complete"])
self.assertFalse(result["block"])
def test_missing_fields_blocked(self):
body = "STATE: open\nNEXT_ACTOR: author"
result = assess_state_comment(body, kind="issue_state")
self.assertFalse(result["complete"])
self.assertIn("NEXT_ACTION", result["missing_fields"])
def test_discussion_requires_five_comments_by_default(self):
body = render_issue_state_comment(
STATE="collecting_feedback",
NEXT_ACTOR="controller",
NEXT_ACTION="facilitate discussion",
NEXT_PROMPT="Add acceptance criteria",
STATUS="open",
RELATED_PRS="none",
BRANCH="none",
HEAD_SHA="none",
VALIDATION="none",
BLOCKERS="none",
DUPLICATES_OR_SUPERSEDES="none",
LAST_UPDATED_BY="controller",
)
# Re-map to discussion fields manually for comment-count test
from state_handoff_ledger import render_discussion_state_comment
disc = render_discussion_state_comment(
STATE="collecting_feedback",
NEXT_ACTOR="controller",
NEXT_ACTION="wait for comments",
NEXT_PROMPT="Continue discussion",
COMMENT_COUNT="2",
SUBSTANTIVE_COMMENTS="yes",
URGENCY="normal",
BLOCKERS="none",
LAST_UPDATED_BY="controller",
)
result = assess_state_comment(disc, kind="discussion_state")
self.assertFalse(result["complete"])
self.assertTrue(any("5" in reason for reason in result["reasons"]))
def test_discussion_urgent_exception(self):
from state_handoff_ledger import render_discussion_state_comment
disc = render_discussion_state_comment(
STATE="ready_for_summary",
NEXT_ACTOR="controller",
NEXT_ACTION="summarize",
NEXT_PROMPT="Post summary",
COMMENT_COUNT="2",
SUBSTANTIVE_COMMENTS="yes",
URGENCY="urgent",
BLOCKERS="none",
LAST_UPDATED_BY="controller",
)
result = assess_state_comment(disc, kind="discussion_state")
self.assertTrue(result["complete"])
class TestFinalReportNextAction(unittest.TestCase):
def test_complete_next_action_handoff(self):
report = _work_issue_handoff()
result = assess_final_report_next_action_handoff(report)
self.assertTrue(result["complete"])
self.assertFalse(result["block"])
def test_missing_next_prompt_blocked(self):
report = _work_issue_handoff(**{"Next prompt": "none"})
result = assess_final_report_next_action_handoff(report)
self.assertFalse(result["complete"])
self.assertIn("Next prompt", result["missing_fields"])
def test_missing_handoff_section_blocked(self):
result = assess_final_report_next_action_handoff("no handoff here")
self.assertTrue(result["block"])
class TestContradictoryState(unittest.TestCase):
def test_ready_to_merge_without_approval_blocked(self):
report = _work_issue_handoff(
**{
"Current status": "PR ready to merge",
"Review decision": "not attempted",
}
)
result = assess_contradictory_state_handoff(report)
self.assertTrue(result["block"])
def test_ready_to_merge_with_approval_allowed(self):
report = _work_issue_handoff(
**{
"Current status": "PR ready to merge",
"Review decision": "approve",
}
)
result = assess_contradictory_state_handoff(report)
self.assertFalse(result["block"])
def test_issue_done_without_pr_proof_blocked(self):
report = _work_issue_handoff(**{"Current status": "issue complete"})
result = assess_contradictory_state_handoff(report)
self.assertTrue(result["block"])
def test_external_none_with_lock_activity_blocked(self):
report = _work_issue_handoff(
**{
"External-state mutations": "none",
}
)
report += "\n- Issue mutations: gitea_lock_issue adopted branch"
result = assess_contradictory_state_handoff(report)
self.assertTrue(result["block"])
def test_discussion_complete_without_summary_blocked(self):
report = _work_issue_handoff(**{"Current status": "discussion complete"})
result = assess_contradictory_state_handoff(report)
self.assertTrue(result["block"])
class TestFinalReportValidatorIntegration(unittest.TestCase):
def test_work_issue_validator_flags_missing_next_prompt(self):
report = _work_issue_handoff(**{"Next prompt": ""})
result = assess_final_report_validator(report, "work_issue")
rule_ids = [f["rule_id"] for f in result["findings"]]
self.assertIn("shared.state_handoff_next_action", rule_ids)
def test_work_issue_validator_accepts_complete_handoff(self):
report = _work_issue_handoff()
result = assess_state_handoff_ledger_report(report)
self.assertTrue(result["complete"])
if __name__ == "__main__":
unittest.main()
+88
View File
@@ -0,0 +1,88 @@
"""Tests for web UI runtime health dashboard (#430)."""
import sys
import unittest
from pathlib import Path
from unittest import mock
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from starlette.testclient import TestClient
from webui.app import create_app
from webui.runtime_health import _role_kind, load_runtime_snapshot, snapshot_to_dict
def _mock_identity(_host: str):
return "jcwalker3", None
def _mock_git_sync(_repo: Path, _remote: str, _branch: str):
return "localsha123456", "remoteshaabcdef", 2
class TestRuntimeHealthLoader(unittest.TestCase):
def test_role_kind_author(self):
allowed = ["gitea.pr.create", "gitea.branch.push", "gitea.read"]
forbidden = ["gitea.pr.merge", "gitea.pr.approve"]
self.assertEqual(_role_kind(allowed, forbidden), "author")
def test_snapshot_with_mocks(self):
snapshot = load_runtime_snapshot(
resolve_username=_mock_identity,
git_sync=_mock_git_sync,
)
self.assertEqual(snapshot.project_id, "gitea-tools")
self.assertEqual(snapshot.authenticated_username, "jcwalker3")
self.assertEqual(snapshot.commits_behind_master, 2)
self.assertIsNotNone(snapshot.stale_runtime_warning)
self.assertGreaterEqual(len(snapshot.workflow_hashes), 3)
self.assertGreaterEqual(len(snapshot.schema_hashes), 2)
self.assertIn("shell_use_allowed", snapshot.shell_health)
def test_snapshot_dict_export(self):
snapshot = load_runtime_snapshot(
resolve_username=_mock_identity,
git_sync=_mock_git_sync,
)
data = snapshot_to_dict(snapshot)
self.assertEqual(data["authenticated_username"], "jcwalker3")
self.assertEqual(data["commits_behind_master"], 2)
self.assertTrue(data["stale_runtime_warning"])
class TestRuntimeRoutes(unittest.TestCase):
def setUp(self):
self.client = TestClient(create_app())
self.snapshot = load_runtime_snapshot(
resolve_username=_mock_identity,
git_sync=_mock_git_sync,
)
self._patch = mock.patch(
"webui.app.load_runtime_snapshot",
return_value=self.snapshot,
)
self._patch.start()
def tearDown(self):
self._patch.stop()
def test_runtime_page_renders_profile_and_warning(self):
response = self.client.get("/runtime")
self.assertEqual(response.status_code, 200)
self.assertIn("Runtime health", response.text)
self.assertIn("Active profile", response.text)
self.assertIn("Stale runtime warning", response.text)
self.assertIn("Workflow hashes", response.text)
self.assertNotIn("child issue", response.text.lower())
def test_api_runtime_json(self):
response = self.client.get("/api/runtime")
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["project_id"], "gitea-tools")
self.assertEqual(data["role_kind"], self.snapshot.role_kind)
self.assertEqual(data["commits_behind_master"], 2)
if __name__ == "__main__":
unittest.main()
+6 -1
View File
@@ -29,8 +29,13 @@ class TestWebuiSkeleton(unittest.TestCase):
self.assertIn("Operator console", response.text)
self.assertIn("Read-only MVP", response.text)
def test_runtime_is_implemented(self):
response = self.client.get("/runtime")
self.assertEqual(response.status_code, 200)
self.assertIn("Runtime health", response.text)
def test_route_stubs_render(self):
for path in ("/runtime", "/audit"):
for path in ("/audit",):
with self.subTest(path=path):
response = self.client.get(path)
self.assertEqual(response.status_code, 200)