merge master for review validation only
This commit is contained in:
@@ -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__":
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
@@ -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"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user