Files
Gitea-Tools/issue_workflow_labels.py
sysadminandClaude Opus 4.8 f34319852e 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]>
2026-07-10 16:17:31 -04:00

407 lines
15 KiB
Python

"""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,
}