feat: lifecycle role/hazard labels and canonical state mapping (#603)

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]>
This commit is contained in:
2026-07-10 16:17:31 -04:00
co-authored by Claude Opus 4.8
parent 2fa97c26fb
commit f34319852e
3 changed files with 388 additions and 1 deletions
+134
View File
@@ -69,5 +69,139 @@ class TestIssueWorkflowStatusTransitions(unittest.TestCase):
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()