195 lines
6.8 KiB
Python
195 lines
6.8 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: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"),
|
|
)
|
|
|
|
CANONICAL_LABEL_SPECS: tuple[LabelSpec, ...] = (
|
|
TYPE_LABEL_SPECS + STATUS_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)
|
|
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,
|
|
}
|