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]>
407 lines
15 KiB
Python
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,
|
|
}
|