Files
Gitea-Tools/issue_workflow_labels.py
T

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