Add orthogonal role:* (single-active owner) and hazard:* (additive warning) lifecycle labels plus status:changes-requested, folding the #603 state:* vocabulary into the canonical status:* set rather than a conflicting parallel prefix. - issue_workflow_labels.py: ROLE_/HAZARD_ specs + frozensets; role/hazard transition maps and helpers (transition_role_labels, add/clear_hazard_label, canonical_role_label, canonical_hazard_label, is_discussion, is_implementation_candidate, requires_blocking_reason); state:* -> status:* synonym transitions; assess_issue_labels surfaces role/hazard dims and flags multiple active role labels - docs/label-taxonomy.md: role, hazard, state->canonical migration, and allocator cross-check sections - tests: role/hazard/discussion/blocking-reason/state-synonym coverage - manage_labels.py seeds the new labels automatically via CANONICAL_LABEL_SPECS Labels remain advisory; control-plane leases (#601) and live PR state stay the source of truth for mutations (#603 AC2). Closes #603 Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
208 lines
7.9 KiB
Python
208 lines
7.9 KiB
Python
"""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"])
|
|
|
|
|
|
class TestLifecycleRoleLabels(unittest.TestCase):
|
|
def test_role_transition_author_to_reviewer_to_merger_single_active(self):
|
|
after_author = labels.transition_role_labels(
|
|
["type:feature", "status:pr-open"], "author"
|
|
)
|
|
self.assertEqual(after_author, ["type:feature", "status:pr-open", "role:author"])
|
|
|
|
after_reviewer = labels.transition_role_labels(after_author, "reviewer")
|
|
self.assertEqual(
|
|
after_reviewer, ["type:feature", "status:pr-open", "role:reviewer"]
|
|
)
|
|
self.assertNotIn("role:author", after_reviewer)
|
|
|
|
after_merger = labels.transition_role_labels(after_reviewer, "merger")
|
|
self.assertEqual(labels.role_labels(after_merger), ["role:merger"])
|
|
|
|
def test_role_transition_accepts_canonical_label(self):
|
|
result = labels.transition_role_labels(["type:bug"], "role:reviewer")
|
|
self.assertEqual(result, ["type:bug", "role:reviewer"])
|
|
|
|
def test_unknown_role_raises(self):
|
|
with self.assertRaises(ValueError):
|
|
labels.canonical_role_label("wizard")
|
|
|
|
def test_assess_reports_multiple_active_role_labels(self):
|
|
result = labels.assess_issue_labels(
|
|
["type:feature", "status:pr-open", "role:author", "role:reviewer"]
|
|
)
|
|
self.assertFalse(result["valid"])
|
|
self.assertTrue(
|
|
any("multiple active role:* labels" in err for err in result["errors"])
|
|
)
|
|
self.assertEqual(
|
|
sorted(result["role_labels"]), ["role:author", "role:reviewer"]
|
|
)
|
|
|
|
|
|
class TestLifecycleHazardLabels(unittest.TestCase):
|
|
def test_hazards_are_additive_and_multiple(self):
|
|
one = labels.add_hazard_label(["type:bug", "status:blocked"], "conflicted")
|
|
two = labels.add_hazard_label(one, "stale-lease")
|
|
self.assertIn("hazard:conflicted", two)
|
|
self.assertIn("hazard:stale-lease", two)
|
|
# status/type untouched
|
|
self.assertIn("status:blocked", two)
|
|
self.assertIn("type:bug", two)
|
|
self.assertEqual(len(labels.hazard_labels(two)), 2)
|
|
|
|
def test_add_hazard_is_idempotent(self):
|
|
once = labels.add_hazard_label(["type:bug", "status:ready"], "root-mutation")
|
|
twice = labels.add_hazard_label(once, "root_mutation")
|
|
self.assertEqual(twice.count("hazard:root-mutation"), 1)
|
|
|
|
def test_clear_hazard_leaves_others_intact(self):
|
|
start = ["type:bug", "status:blocked", "hazard:conflicted", "hazard:stale-lease"]
|
|
cleared = labels.clear_hazard_label(start, "conflicted")
|
|
self.assertNotIn("hazard:conflicted", cleared)
|
|
self.assertIn("hazard:stale-lease", cleared)
|
|
self.assertIn("status:blocked", cleared)
|
|
|
|
def test_terminal_blocker_hazard_normalizes(self):
|
|
self.assertEqual(
|
|
labels.canonical_hazard_label("terminal-blocker"),
|
|
"hazard:terminal-blocker",
|
|
)
|
|
|
|
def test_assess_surfaces_hazard_labels(self):
|
|
result = labels.assess_issue_labels(
|
|
["type:bug", "status:blocked", "hazard:conflicted"]
|
|
)
|
|
self.assertEqual(result["hazard_labels"], ["hazard:conflicted"])
|
|
|
|
|
|
class TestDiscussionExclusion(unittest.TestCase):
|
|
def test_discussion_issue_excluded_from_implementation_queue(self):
|
|
self.assertTrue(labels.is_discussion(["type:discussion", "status:ready"]))
|
|
self.assertFalse(
|
|
labels.is_implementation_candidate(["type:discussion", "status:ready"])
|
|
)
|
|
|
|
def test_non_discussion_issue_is_candidate(self):
|
|
self.assertTrue(
|
|
labels.is_implementation_candidate(["type:feature", "status:ready"])
|
|
)
|
|
|
|
|
|
class TestBlockingReasonRequirement(unittest.TestCase):
|
|
def test_blocked_requires_reason(self):
|
|
self.assertTrue(
|
|
labels.requires_blocking_reason(["type:bug", "status:blocked"])
|
|
)
|
|
|
|
def test_hazard_requires_reason(self):
|
|
self.assertTrue(
|
|
labels.requires_blocking_reason(
|
|
["type:bug", "status:ready", "hazard:stale-lease"]
|
|
)
|
|
)
|
|
|
|
def test_clean_ready_issue_needs_no_reason(self):
|
|
self.assertFalse(
|
|
labels.requires_blocking_reason(["type:feature", "status:ready"])
|
|
)
|
|
|
|
|
|
class TestState603SynonymTransitions(unittest.TestCase):
|
|
def test_authoring_maps_to_in_progress(self):
|
|
self.assertEqual(
|
|
labels.canonical_status_label("authoring"), "status:in-progress"
|
|
)
|
|
|
|
def test_changes_requested_transition(self):
|
|
result = labels.transition_status_labels(
|
|
["type:feature", "status:needs-review"], "changes-requested"
|
|
)
|
|
self.assertEqual(result, ["type:feature", "status:changes-requested"])
|
|
|
|
def test_merge_ready_maps_to_approved(self):
|
|
self.assertEqual(labels.canonical_status_label("merge-ready"), "status:approved")
|
|
|
|
def test_abandoned_maps_to_wontfix(self):
|
|
self.assertEqual(labels.canonical_status_label("abandoned"), "status:wontfix")
|
|
|
|
def test_blocked_and_merged_transitions(self):
|
|
blocked = labels.transition_status_labels(
|
|
["type:bug", "status:in-progress"], "blocked"
|
|
)
|
|
self.assertEqual(blocked, ["type:bug", "status:blocked"])
|
|
merged = labels.transition_status_labels(
|
|
["type:feature", "status:approved"], "merged"
|
|
)
|
|
self.assertEqual(merged, ["type:feature", "status:reconcile"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|