"""Canonical issue type/status label taxonomy and transition helpers (#513).""" from __future__ import annotations from dataclasses import dataclass from typing import Iterable, Mapping, Sequence @dataclass(frozen=True) class LabelSpec: name: str color: str description: str TYPE_LABEL_SPECS: tuple[LabelSpec, ...] = ( LabelSpec("type:bug", "b60205", "Bug or defect"), LabelSpec("type:feature", "a2eeef", "Feature or enhancement"), LabelSpec("type:process", "5319e7", "Process or policy work"), LabelSpec("type:workflow", "c2e0c6", "Workflow automation or guidance"), LabelSpec("type:guardrail", "d93f0b", "Safety gate or guardrail"), LabelSpec("type:docs", "006b75", "Documentation work"), LabelSpec("type:test", "0e8a16", "Tests or test infrastructure"), LabelSpec("type:discussion", "bfdadc", "Discussion-only issue"), LabelSpec("type:umbrella", "c5def5", "Umbrella or tracker issue"), LabelSpec("type:cleanup", "ededed", "Cleanup or hygiene work"), ) STATUS_LABEL_SPECS: tuple[LabelSpec, ...] = ( LabelSpec("status:triage", "fbca04", "Issue needs triage"), LabelSpec("status:ready", "0e8a16", "Issue is ready for work"), LabelSpec("status:claimed", "fefe2e", "Issue is claimed"), LabelSpec("status:in-progress", "fefe2e", "Issue is being worked on"), LabelSpec("status:blocked", "b60205", "Issue is blocked"), LabelSpec("status:needs-review", "0052cc", "Issue work needs review"), LabelSpec("status:pr-open", "1d76db", "A linked PR is open"), LabelSpec( "status:changes-requested", "e11d21", "Reviewer requested changes on the linked PR", ), LabelSpec("status:approved", "0e8a16", "Linked PR is approved"), LabelSpec("status:merged", "5319e7", "Linked PR is merged"), LabelSpec("status:reconcile", "d93f0b", "Issue needs reconciliation"), LabelSpec("status:done", "0e8a16", "Issue workflow is complete"), LabelSpec("status:duplicate", "cccccc", "Issue is a duplicate"), LabelSpec("status:wontfix", "000000", "Issue will not be fixed"), ) # Durable validation-outcome labels (#529): the four canonical distinctions a # reviewer report can carry. These are orthogonal to the single active status:* # label, so they use their own prefix and are not subject to the one-status # invariant. VALIDATION_LABEL_SPECS: tuple[LabelSpec, ...] = ( LabelSpec("validation:clean-pass", "0e8a16", "Validation was a clean pass"), LabelSpec( "validation:baseline-accepted", "fbca04", "Validation passed with a baseline-proven unrelated failure", ), LabelSpec( "validation:blocked", "b60205", "Validation blocked by an unresolved failure", ), LabelSpec( "validation:post-merge-moot", "5319e7", "Validation is post-merge moot (PR already merged/closed before review)", ), ) # Lifecycle role-ownership labels (#603): which workflow role currently owns the # item. Advisory visibility only — the control-plane lease (#601) remains the # source of truth for mutation authority. Only one role:* label is active at a # time, mirroring the single-active-status invariant. ROLE_LABEL_SPECS: tuple[LabelSpec, ...] = ( LabelSpec("role:author", "1d76db", "Author currently owns the item"), LabelSpec("role:reviewer", "5319e7", "Reviewer currently owns the item"), LabelSpec("role:merger", "0e8a16", "Merger currently owns the item"), ) # Lifecycle hazard labels (#603): orthogonal warning flags surfacing dangerous # coordination conditions. Unlike status/role, multiple hazard:* labels may be # active at once, and they never substitute for live lease / PR state checks. HAZARD_LABEL_SPECS: tuple[LabelSpec, ...] = ( LabelSpec("hazard:stale-lease", "d93f0b", "A stale or expired lease references this item"), LabelSpec( "hazard:workflow-contaminated", "b60205", "Session/workflow state is contaminated and must not mutate", ), LabelSpec("hazard:conflicted", "e11d21", "Linked PR has merge conflicts"), LabelSpec("hazard:root-mutation", "b60205", "Work was mutated in the project root checkout"), LabelSpec("hazard:manual-state", "d93f0b", "Session or lease state was edited manually"), LabelSpec( "hazard:terminal-blocker", "000000", "A terminal review/merge lock blocks progress (#332/#602)", ), ) CANONICAL_LABEL_SPECS: tuple[LabelSpec, ...] = ( TYPE_LABEL_SPECS + STATUS_LABEL_SPECS + VALIDATION_LABEL_SPECS + ROLE_LABEL_SPECS + HAZARD_LABEL_SPECS ) TYPE_LABELS: frozenset[str] = frozenset(spec.name for spec in TYPE_LABEL_SPECS) STATUS_LABELS: frozenset[str] = frozenset(spec.name for spec in STATUS_LABEL_SPECS) VALIDATION_LABELS: frozenset[str] = frozenset( spec.name for spec in VALIDATION_LABEL_SPECS ) ROLE_LABELS: frozenset[str] = frozenset(spec.name for spec in ROLE_LABEL_SPECS) HAZARD_LABELS: frozenset[str] = frozenset(spec.name for spec in HAZARD_LABEL_SPECS) CANONICAL_LABELS: frozenset[str] = frozenset( spec.name for spec in CANONICAL_LABEL_SPECS ) STATUS_TRANSITIONS: dict[str, str] = { "triage": "status:triage", "ready": "status:ready", "claim": "status:claimed", "claimed": "status:claimed", "start": "status:in-progress", "start_work": "status:in-progress", "in_progress": "status:in-progress", "in-progress": "status:in-progress", "block": "status:blocked", "blocked": "status:blocked", "needs_review": "status:needs-review", "needs-review": "status:needs-review", "reviewing": "status:needs-review", "pr_open": "status:pr-open", "pr-open": "status:pr-open", "changes_requested": "status:changes-requested", "changes-requested": "status:changes-requested", "changes": "status:changes-requested", "approved": "status:approved", "merge_ready": "status:approved", "merge-ready": "status:approved", "merge": "status:reconcile", "merged": "status:reconcile", "reconcile": "status:reconcile", "done": "status:done", "complete": "status:done", "duplicate": "status:duplicate", "wontfix": "status:wontfix", "abandoned": "status:wontfix", # #603 requested state:* synonyms folded into the canonical status vocabulary "needs_triage": "status:triage", "needs-triage": "status:triage", "authoring": "status:in-progress", } # #603: single-active role ownership. Maps role kinds / role:* labels to the # canonical role label. Mirrors STATUS_TRANSITIONS for the role dimension. ROLE_TRANSITIONS: dict[str, str] = { "author": "role:author", "reviewer": "role:reviewer", "merger": "role:merger", } # #603: hazard flag synonyms. Hazards are additive (not single-active), so this # only normalizes names; it does not drive replacement. HAZARD_TRANSITIONS: dict[str, str] = { "stale_lease": "hazard:stale-lease", "stale-lease": "hazard:stale-lease", "workflow_contaminated": "hazard:workflow-contaminated", "workflow-contaminated": "hazard:workflow-contaminated", "contaminated": "hazard:workflow-contaminated", "conflicted": "hazard:conflicted", "conflict": "hazard:conflicted", "root_mutation": "hazard:root-mutation", "root-mutation": "hazard:root-mutation", "manual_state": "hazard:manual-state", "manual-state": "hazard:manual-state", "terminal_blocker": "hazard:terminal-blocker", "terminal-blocker": "hazard:terminal-blocker", } def label_name(label: str | Mapping[str, object]) -> str: """Return a normalized label name from a Gitea label object or string.""" if isinstance(label, str): return label.strip() value = label.get("name") return str(value or "").strip() def label_names(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> list[str]: """Extract label names from a label list or issue-like mapping.""" raw: object if isinstance(labels, Mapping): raw = labels.get("labels", []) else: raw = labels return [name for name in (label_name(label) for label in raw or []) if name] def type_labels(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> list[str]: return [name for name in label_names(labels) if name.startswith("type:")] def status_labels(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> list[str]: return [name for name in label_names(labels) if name.startswith("status:")] def canonical_status_label(status_or_transition: str) -> str: status = status_or_transition.strip() if status in STATUS_LABELS: return status normalized = status.lower().replace(" ", "-") try: return STATUS_TRANSITIONS[normalized] except KeyError as exc: raise ValueError( f"unknown workflow status or transition '{status_or_transition}'" ) from exc def transition_status_labels( existing_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object], status_or_transition: str, ) -> list[str]: """Replace all active status labels with the requested canonical status.""" new_status = canonical_status_label(status_or_transition) kept = [name for name in label_names(existing_labels) if not name.startswith("status:")] if new_status not in kept: kept.append(new_status) return kept def role_labels(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> list[str]: return [name for name in label_names(labels) if name.startswith("role:")] def hazard_labels(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> list[str]: return [name for name in label_names(labels) if name.startswith("hazard:")] def canonical_role_label(role_or_transition: str) -> str: role = role_or_transition.strip() if role in ROLE_LABELS: return role normalized = role.lower().replace(" ", "-") try: return ROLE_TRANSITIONS[normalized] except KeyError as exc: raise ValueError( f"unknown workflow role or transition '{role_or_transition}'" ) from exc def transition_role_labels( existing_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object], role_or_transition: str, ) -> list[str]: """Replace all active role labels with the requested canonical role.""" new_role = canonical_role_label(role_or_transition) kept = [name for name in label_names(existing_labels) if not name.startswith("role:")] if new_role not in kept: kept.append(new_role) return kept def canonical_hazard_label(hazard: str) -> str: name = hazard.strip() if name in HAZARD_LABELS: return name normalized = name.lower().replace(" ", "-") try: return HAZARD_TRANSITIONS[normalized] except KeyError as exc: raise ValueError(f"unknown workflow hazard '{hazard}'") from exc def add_hazard_label( existing_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object], hazard: str, ) -> list[str]: """Add a hazard flag without disturbing status/role/type labels (additive).""" new_hazard = canonical_hazard_label(hazard) kept = label_names(existing_labels) if new_hazard not in kept: kept.append(new_hazard) return kept def clear_hazard_label( existing_labels: Iterable[str | Mapping[str, object]] | Mapping[str, object], hazard: str, ) -> list[str]: """Remove a single hazard flag, leaving all other labels intact.""" target = canonical_hazard_label(hazard) return [name for name in label_names(existing_labels) if name != target] def is_discussion(labels: Iterable[str | Mapping[str, object]] | Mapping[str, object]) -> bool: return "type:discussion" in type_labels(labels) def is_implementation_candidate( labels: Iterable[str | Mapping[str, object]] | Mapping[str, object], ) -> bool: """Whether an item may enter an implementation queue on labels alone. Discussion issues are excluded (#603 AC3) unless a controller explicitly selects them; the allocator still cross-checks live lease/PR state and never trusts labels alone (#603 AC2). """ return not is_discussion(labels) def requires_blocking_reason( labels: Iterable[str | Mapping[str, object]] | Mapping[str, object], ) -> bool: """Whether the item must carry a blocking-reason / next-action comment (AC4). True when blocked or when any hazard flag is present. """ names = label_names(labels) if "status:blocked" in names: return True return any(name.startswith("hazard:") for name in names) def labels_for_new_issue( issue_type: str | None = None, initial_status: str | None = None, extra_labels: Sequence[str] | None = None, *, discussion: bool = False, ) -> list[str]: names = list(extra_labels or []) if issue_type: type_name = issue_type if issue_type.startswith("type:") else f"type:{issue_type}" names.append(type_name) if discussion: names.append("type:discussion") if initial_status: names.append(canonical_status_label(initial_status)) result: list[str] = [] for name in names: if name not in result: result.append(name) return result def assess_issue_labels( labels: Iterable[str | Mapping[str, object]] | Mapping[str, object], *, discussion: bool = False, require_type: bool = True, require_status: bool = True, ) -> dict: names = label_names(labels) found_types = type_labels(names) found_statuses = status_labels(names) found_roles = role_labels(names) found_hazards = hazard_labels(names) errors: list[str] = [] warnings: list[str] = [] if require_type and not found_types: errors.append("issue is missing a type:* label") if require_status and not found_statuses: errors.append("issue is missing a status:* label") if discussion and "type:discussion" not in found_types: errors.append("discussion issue is missing type:discussion") if len(found_statuses) > 1: errors.append( "issue has multiple active status:* labels: " + ", ".join(found_statuses) ) if len(found_roles) > 1: errors.append( "issue has multiple active role:* labels: " + ", ".join(found_roles) ) for name in found_types: if name not in TYPE_LABELS: warnings.append(f"unknown type label '{name}'") for name in found_statuses: if name not in STATUS_LABELS: warnings.append(f"unknown status label '{name}'") for name in found_roles: if name not in ROLE_LABELS: warnings.append(f"unknown role label '{name}'") for name in found_hazards: if name not in HAZARD_LABELS: warnings.append(f"unknown hazard label '{name}'") return { "valid": not errors, "labels": names, "type_labels": found_types, "status_labels": found_statuses, "role_labels": found_roles, "hazard_labels": found_hazards, "errors": errors, "warnings": warnings, }