"""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: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)", ), ) CANONICAL_LABEL_SPECS: tuple[LabelSpec, ...] = ( TYPE_LABEL_SPECS + STATUS_LABEL_SPECS + VALIDATION_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 ) 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", "pr_open": "status:pr-open", "pr-open": "status:pr-open", "approved": "status:approved", "merge": "status:reconcile", "merged": "status:reconcile", "reconcile": "status:reconcile", "done": "status:done", "complete": "status:done", "duplicate": "status:duplicate", "wontfix": "status:wontfix", } 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 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) 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) ) 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}'") return { "valid": not errors, "labels": names, "type_labels": found_types, "status_labels": found_statuses, "errors": errors, "warnings": warnings, }