From 7edbcb86b1c88c82388cf3b489934cff88336101 Mon Sep 17 00:00:00 2001 From: malakof Date: Tue, 14 Jul 2026 03:39:41 +0200 Subject: [PATCH] =?UTF-8?q?fix(governance):=20=F0=9F=90=9B=20align=20PR=20?= =?UTF-8?q?types=20with=20canonical=20labels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .crystal-governance.yaml | 2 +- .github/workflows/enforce-conventions.yml | 4 +- .github/workflows/format-check.yml | 4 +- .github/workflows/governance-check.yml | 4 +- .github/workflows/on-release-bump.yml | 2 +- .github/workflows/test.yml | 31 ++++ .gitignore | 2 + README.md | 8 +- governance/README.md | 19 ++- governance/labels.yaml | 22 ++- scripts/conventions.py | 74 ++++++++ scripts/format_check.py | 64 +++---- scripts/open-bump-prs.py | 8 +- scripts/validate_commit_messages.py | 10 +- scripts/validate_labels.py | 59 ++++--- scripts/validate_title.py | 10 +- skills/crystal-github-conventions/SKILL.md | 10 ++ tests/test_governance.py | 187 +++++++++++++++++++++ 18 files changed, 434 insertions(+), 86 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 scripts/conventions.py create mode 100644 tests/test_governance.py diff --git a/.crystal-governance.yaml b/.crystal-governance.yaml index c2c83dd..6c9d188 100644 --- a/.crystal-governance.yaml +++ b/.crystal-governance.yaml @@ -1,5 +1,5 @@ schema: crystal-governance-pin/v1 -governance_version: v1.2.7 +governance_version: v1.4.0 source: Malakof/.github # Self-pin: Malakof/.github references its own current version. # Lets tooling (sync, governance-check) treat this repo like any other diff --git a/.github/workflows/enforce-conventions.yml b/.github/workflows/enforce-conventions.yml index 77dbe2b..e418a5e 100644 --- a/.github/workflows/enforce-conventions.yml +++ b/.github/workflows/enforce-conventions.yml @@ -23,7 +23,7 @@ jobs: contents: read steps: - name: Checkout caller repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 @@ -38,7 +38,7 @@ jobs: fi - name: Checkout governance source - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: repository: Malakof/.github path: .governance diff --git a/.github/workflows/format-check.yml b/.github/workflows/format-check.yml index 9bf5696..dfd2e7b 100644 --- a/.github/workflows/format-check.yml +++ b/.github/workflows/format-check.yml @@ -42,7 +42,7 @@ jobs: models: read steps: - name: Checkout caller repository - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 1 @@ -57,7 +57,7 @@ jobs: fi - name: Checkout governance at pin - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: repository: Malakof/.github path: .governance diff --git a/.github/workflows/governance-check.yml b/.github/workflows/governance-check.yml index fc020c9..72d9573 100644 --- a/.github/workflows/governance-check.yml +++ b/.github/workflows/governance-check.yml @@ -23,7 +23,7 @@ jobs: issues: read steps: - name: Checkout repo - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 @@ -52,7 +52,7 @@ jobs: fi - name: Checkout governance at pin - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: repository: Malakof/.github path: .governance diff --git a/.github/workflows/on-release-bump.yml b/.github/workflows/on-release-bump.yml index e4c9f71..9162c7d 100644 --- a/.github/workflows/on-release-bump.yml +++ b/.github/workflows/on-release-bump.yml @@ -17,7 +17,7 @@ jobs: pull-requests: write steps: - name: Checkout this repo - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Skip if CRYSTAL_GITHUB_PAT secret is missing id: precheck diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..5ce1899 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,31 @@ +name: Governance Tests + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" + + - name: Install dependencies + run: python3 -m pip install "PyYAML>=6.0" + + - name: Run governance tests + run: python3 -m unittest discover -s tests -v + + - name: Check whitespace + run: git diff --check diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..43ae0e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +*.py[cod] diff --git a/README.md b/README.md index 8cba180..c32ab95 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ Single source of truth for Crystal team GitHub conventions: labels, issue/PR templates, naming, reusable workflows, agent skill. -**Current version**: `v1.1.0` (see `.crystal-governance.yaml`). +**Current version**: `v1.4.0` (see `.crystal-governance.yaml`). ## For humans @@ -34,7 +34,7 @@ Add to the target repo: 1. `.crystal-governance.yaml`: ```yaml schema: crystal-governance-pin/v1 - governance_version: v1.1.0 + governance_version: v1.4.0 source: Malakof/.github ``` @@ -44,7 +44,7 @@ Add to the target repo: on: [push, pull_request] jobs: check: - uses: Malakof/.github/.github/workflows/governance-check.yml@v1.1.0 + uses: Malakof/.github/.github/workflows/governance-check.yml@v1.4.0 ``` 3. `.github/workflows/enforce-conventions.yml`: @@ -53,7 +53,7 @@ Add to the target repo: on: [pull_request] jobs: enforce: - uses: Malakof/.github/.github/workflows/enforce-conventions.yml@v1.1.0 + uses: Malakof/.github/.github/workflows/enforce-conventions.yml@v1.4.0 ``` 4. Run label sync: diff --git a/governance/README.md b/governance/README.md index 420dda2..590b12a 100644 --- a/governance/README.md +++ b/governance/README.md @@ -6,7 +6,7 @@ versioned by semver tags, propagated to target repos via the pin file `.crystal-governance.yaml` and `crystal-company/builders/sync_repo_surface.py`. **Schema**: `crystal-governance/v1` -**Version**: 1.0.0 +**Version**: 1.1.0 --- @@ -55,6 +55,21 @@ Commit subjects use Conventional Commits with a targeted emoji: **Allowed types**: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`. +PR title types map deterministically to the functional `type:*` taxonomy: + +| PR title type | Required label | +|---|---| +| `feat` | `type:feature` | +| `fix`, `revert` | `type:bug` | +| `docs` | `type:docs` | +| `refactor`, `perf` | `type:refactor` | +| `test` | `type:test` | +| `style`, `build`, `ci`, `chore` | `type:chore` | + +The map records the modal meaning of each Conventional Commit type. Use +`fix:` for a performance regression and `chore:` for a non-remedial withdrawal +when the modal `perf:` or `revert:` mapping would misclassify the delivery. + **Scopes**: per repo in [`scopes.yaml`](./scopes.yaml). Optional but recommended on repos with multiple functional domains. @@ -266,7 +281,7 @@ inactivity. ```yaml schema: crystal-governance-pin/v1 - governance_version: v1.0.0 + governance_version: v1.4.0 source: Malakof/.github ``` diff --git a/governance/labels.yaml b/governance/labels.yaml index e11545e..8487407 100644 --- a/governance/labels.yaml +++ b/governance/labels.yaml @@ -14,7 +14,27 @@ # - description max 100 characters (GitHub API limit) schema: crystal-labels/v1 -version: 1.0.0 +version: 1.1.0 + +# Conventional Commit PR types and issue-title types intentionally differ from +# the functional type:* taxonomy. This total mapping is the canonical bridge +# used by deterministic PR and issue validation. +title_type_map: + feat: type:feature + fix: type:bug + docs: type:docs + style: type:chore + refactor: type:refactor + perf: type:refactor + test: type:test + build: type:chore + ci: type:chore + chore: type:chore + revert: type:bug + feature: type:feature + bug: type:bug + spike: type:spike + epic: type:epic # ============================================================================= # 1. UNIVERSAL โ€” applied to ALL Crystal repos, mandatory diff --git a/scripts/conventions.py b/scripts/conventions.py new file mode 100644 index 0000000..1fce328 --- /dev/null +++ b/scripts/conventions.py @@ -0,0 +1,74 @@ +"""Shared Crystal title grammar and title-to-label governance policy.""" + +from __future__ import annotations + +import re +from pathlib import Path + +import yaml + +ALLOWED_CONVENTIONAL_TYPES = ( + "feat", + "fix", + "docs", + "style", + "refactor", + "perf", + "test", + "build", + "ci", + "chore", + "revert", +) +ISSUE_TITLE_TYPES = ("feature", "bug", "chore", "docs", "refactor", "spike", "test") +ALL_GOVERNED_TITLE_TYPES = frozenset((*ALLOWED_CONVENTIONAL_TYPES, *ISSUE_TITLE_TYPES, "epic")) + +_CONVENTIONAL_TYPE_PATTERN = "|".join(ALLOWED_CONVENTIONAL_TYPES) +CONVENTIONAL_PREFIX_PATTERN = ( + rf"(?P{_CONVENTIONAL_TYPE_PATTERN})" + r"(?:\((?P[a-z0-9._-]+)\))?" + r"(?P!)?" +) +CONVENTIONAL_TITLE_RE = re.compile(rf"^{CONVENTIONAL_PREFIX_PATTERN}: (?P.+)$") +CONVENTIONAL_COMMIT_RE = re.compile( + rf"^{CONVENTIONAL_PREFIX_PATTERN}: (?P\S+) (?P.+)$" +) +ISSUE_TITLE_RE = re.compile( + rf"^(?P{'|'.join(ISSUE_TITLE_TYPES)}): (?P.+)$" +) +EPIC_TITLE_RE = re.compile(r"^\[EPIC\] (?P.+)$") + + +def load_title_type_map(labels_file: Path) -> dict[str, str]: + """Load and validate the total governed title-type mapping.""" + data = yaml.safe_load(labels_file.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise ValueError(f"{labels_file}: expected a YAML object") + + raw_mapping = data.get("title_type_map") + if not isinstance(raw_mapping, dict): + raise ValueError(f"{labels_file}: title_type_map must be an object") + mapping = {str(key): str(value) for key, value in raw_mapping.items()} + + keys = set(mapping) + missing = sorted(ALL_GOVERNED_TITLE_TYPES - keys) + unexpected = sorted(keys - ALL_GOVERNED_TITLE_TYPES) + if missing or unexpected: + raise ValueError( + f"{labels_file}: title_type_map domain mismatch; missing={missing}, unexpected={unexpected}" + ) + + categories = data.get("categories") + type_category = categories.get("type") if isinstance(categories, dict) else None + raw_labels = type_category.get("labels") if isinstance(type_category, dict) else None + canonical_labels = { + item.get("name") + for item in raw_labels or [] + if isinstance(item, dict) and isinstance(item.get("name"), str) + } + invalid_values = sorted(set(mapping.values()) - canonical_labels) + if invalid_values: + raise ValueError( + f"{labels_file}: title_type_map references non-canonical labels {invalid_values}" + ) + return mapping diff --git a/scripts/format_check.py b/scripts/format_check.py index 7985e64..3dd8fa3 100755 --- a/scripts/format_check.py +++ b/scripts/format_check.py @@ -25,6 +25,13 @@ import urllib.request from pathlib import Path +from conventions import ( + CONVENTIONAL_TITLE_RE, + EPIC_TITLE_RE, + ISSUE_TITLE_RE, + load_title_type_map, +) + ENDPOINT = "https://models.github.ai/inference/chat/completions" DEFAULT_MODEL = "openai/gpt-4o-mini" COMMENT_MARKER = "" @@ -39,11 +46,13 @@ def gh_json(args: list[str]) -> object: return json.loads(result.stdout) -def load_governance(governance_root: Path) -> dict[str, str]: +def load_governance(governance_root: Path) -> dict[str, object]: + labels_path = governance_root / "governance" / "labels.yaml" pieces = { - "labels_yaml": (governance_root / "governance" / "labels.yaml").read_text(encoding="utf-8"), + "labels_yaml": labels_path.read_text(encoding="utf-8"), "scopes_yaml": (governance_root / "governance" / "scopes.yaml").read_text(encoding="utf-8"), "skill_md": (governance_root / "skills" / "crystal-github-conventions" / "SKILL.md").read_text(encoding="utf-8"), + "title_type_map": load_title_type_map(labels_path), } return pieces @@ -59,34 +68,23 @@ def fetch_artifact(repo: str, number: int, kind: str) -> dict: return payload if isinstance(payload, dict) else {} -def deterministic_precheck(artifact: dict, kind: str) -> dict: +def deterministic_precheck( + artifact: dict, kind: str, title_type_map: dict[str, str] +) -> dict: """Run mechanical checks before sending to the LLM. Returns a dict the LLM can trust as ground truth.""" - import re title = (artifact.get("title") or "").strip() label_names = {item.get("name") for item in (artifact.get("labels") or []) if isinstance(item, dict)} - cc_re = re.compile( - r"^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)" - r"(!)?(?:\(([a-z0-9._-]+)\))?: (.+)$" - ) - issue_re = re.compile(r"^(feature|bug|chore|docs|refactor|spike|test): (.+)$") - epic_re = re.compile(r"^\[EPIC\] .+$") - - is_epic = epic_re.match(title) is not None - cc_match = cc_re.match(title) - issue_match = issue_re.match(title) if kind == "issue" else None - - title_format_valid = bool(cc_match) or bool(issue_match) or is_epic - title_type = ( - cc_match.group(1) - if cc_match - else (issue_match.group(1) if issue_match else ("epic" if is_epic else None)) - ) - title_subject = ( - cc_match.group(4) - if cc_match - else (issue_match.group(2) if issue_match else (title.removeprefix("[EPIC] ") if is_epic else title)) + conventional_match = CONVENTIONAL_TITLE_RE.match(title) if kind == "pr" else None + issue_match = ISSUE_TITLE_RE.match(title) if kind == "issue" else None + epic_match = EPIC_TITLE_RE.match(title) if kind == "issue" else None + + title_format_valid = bool(conventional_match or issue_match or epic_match) + match = conventional_match or issue_match + title_type = match.group("type") if match else ("epic" if epic_match else None) + title_subject = match.group("subject") if match else ( + epic_match.group("subject") if epic_match else title ) subject_len_ok = len(title_subject or "") <= (80 if kind == "issue" else 72) @@ -97,10 +95,10 @@ def deterministic_precheck(artifact: dict, kind: str) -> dict: has_one_priority = len(priority_labels) == 1 has_one_type = len(type_labels) == 1 - type_label_matches_title = False - if has_one_type and title_type: - type_map = {"feat": "feature"} - type_label_matches_title = type_labels[0] == f"type:{type_map.get(title_type, title_type)}" + expected_type_label = title_type_map.get(title_type) if title_type else None + type_label_matches_title = bool( + has_one_type and expected_type_label and type_labels[0] == expected_type_label + ) return { "title": title, @@ -113,13 +111,17 @@ def deterministic_precheck(artifact: dict, kind: str) -> dict: "type_labels": type_labels, "has_one_priority": has_one_priority, "has_one_type": has_one_type, + "expected_type_label": expected_type_label, "type_label_matches_title": type_label_matches_title, "all_labels": sorted(label_names), } -def build_prompts(governance: dict[str, str], artifact: dict, kind: str, repo: str) -> tuple[str, str]: - precheck = deterministic_precheck(artifact, kind) +def build_prompts(governance: dict[str, object], artifact: dict, kind: str, repo: str) -> tuple[str, str]: + title_type_map = governance["title_type_map"] + if not isinstance(title_type_map, dict): + raise ValueError("governance title_type_map is not an object") + precheck = deterministic_precheck(artifact, kind, title_type_map) system = ( "You are Crystal Governance Validator, a qualitative reviewer for GitHub artifacts in the Crystal team. " "You receive (a) the canonical conventions, (b) the artifact, and (c) a deterministic mechanical pre-check that has ALREADY validated title format, label presence, type matching, and subject length. " diff --git a/scripts/open-bump-prs.py b/scripts/open-bump-prs.py index 45227e9..8190d00 100755 --- a/scripts/open-bump-prs.py +++ b/scripts/open-bump-prs.py @@ -31,6 +31,10 @@ ) +def bump_pr_title(version: str) -> str: + return f"chore: ๐Ÿงน bump governance to {version}" + + def gh_json(args: list[str]) -> object: result = subprocess.run(["gh"] + args, capture_output=True, text=True, check=True) return json.loads(result.stdout) if result.stdout.strip() else None @@ -156,7 +160,7 @@ def open_bump_pr(repo: str, new_version: str, dry_run: bool) -> dict[str, str]: if diff_check.returncode == 0: return {"repo": repo, "action": "skipped", "current": new_version, "branch": branch} subprocess.run( - ["git", "commit", "-m", f"chore: ๐Ÿงน bump governance to {new_version}"], + ["git", "commit", "-m", bump_pr_title(new_version)], cwd=cwd, check=True, ) subprocess.run(["git", "push", "-u", "origin", branch], cwd=cwd, check=True) @@ -165,7 +169,7 @@ def open_bump_pr(repo: str, new_version: str, dry_run: bool) -> dict[str, str]: "gh", "pr", "create", "--repo", repo, "--head", branch, - "--title", f"chore: ๐Ÿงน bump governance to {new_version}", + "--title", bump_pr_title(new_version), "--body", ( f"Auto-generated PR. Bumps `.crystal-governance.yaml` to `{new_version}` " diff --git a/scripts/validate_commit_messages.py b/scripts/validate_commit_messages.py index 74f8bf8..f053aaf 100644 --- a/scripts/validate_commit_messages.py +++ b/scripts/validate_commit_messages.py @@ -4,19 +4,13 @@ from __future__ import annotations import argparse -import re import subprocess import sys from pathlib import Path import yaml -COMMIT_RE = re.compile( - r"^(?Pfeat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)" - r"(?P!)?" - r"(?:\((?P[a-z0-9._-]+)\))?" - r": (?P\S+) (?P.+)$" -) +from conventions import CONVENTIONAL_COMMIT_RE ALLOWED_EMOJIS = { "โœจ", @@ -86,7 +80,7 @@ def main() -> int: if is_merge_commit(sha, subject): continue checked += 1 - match = COMMIT_RE.match(subject) + match = CONVENTIONAL_COMMIT_RE.match(subject) if not match: issues.append( f"{sha[:12]} subject must match '()?: ': {subject!r}" diff --git a/scripts/validate_labels.py b/scripts/validate_labels.py index d5dadad..6bd43bd 100755 --- a/scripts/validate_labels.py +++ b/scripts/validate_labels.py @@ -14,12 +14,41 @@ import yaml +from conventions import CONVENTIONAL_TITLE_RE, load_title_type_map + def gh_json(args: list[str]) -> object: result = subprocess.run(["gh"] + args, capture_output=True, text=True, check=True) return json.loads(result.stdout) if result.stdout.strip() else None +def validate_label_names( + label_names: set[str], title: str, title_type_map: dict[str, str] +) -> tuple[list[str], list[str], list[str]]: + issues: list[str] = [] + + priority_labels = sorted(name for name in label_names if name.startswith("priority:p")) + if len(priority_labels) != 1: + issues.append( + f"Expected exactly one priority:p* label, got {priority_labels or 'none'}." + ) + + type_labels = sorted(name for name in label_names if name.startswith("type:")) + if len(type_labels) != 1: + issues.append(f"Expected exactly one type:* label, got {type_labels or 'none'}.") + + match = CONVENTIONAL_TITLE_RE.match(title.strip()) + if match and len(type_labels) == 1: + title_type = match.group("type") + expected_type = title_type_map[title_type] + if type_labels[0] != expected_type: + issues.append( + f"Expected type label {expected_type!r} to match PR title type " + f"{title_type!r}, got {type_labels[0]!r}." + ) + return issues, priority_labels, type_labels + + def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--pr", required=True, type=int) @@ -38,34 +67,20 @@ def main() -> int: ]) or {} label_names = {label["name"] for label in payload.get("labels", [])} # type: ignore[index] - issues: list[str] = [] - - priority_labels = [name for name in label_names if name.startswith("priority:p")] - if len(priority_labels) != 1: - issues.append( - f"Expected exactly one priority:p* label, got {sorted(priority_labels) or 'none'}." - ) - - type_labels = [name for name in label_names if name.startswith("type:")] - if len(type_labels) != 1: - issues.append( - f"Expected exactly one type:* label, got {sorted(type_labels) or 'none'}." - ) - - type_from_title = None pr_payload = gh_json([ "pr", "view", str(args.pr), "--repo", args.repo, "--json", "title", ]) or {} title = pr_payload.get("title", "") if isinstance(pr_payload, dict) else "" - if ":" in title: - type_from_title = title.split(":", 1)[0].split("(", 1)[0].rstrip("!") - type_map = {"feat": "feature"} - if type_from_title: - expected_type = f"type:{type_map.get(type_from_title, type_from_title)}" - if len(type_labels) == 1 and type_labels[0] != expected_type: - issues.append(f"Expected type label {expected_type!r} to match PR title, got {type_labels[0]!r}.") + try: + title_type_map = load_title_type_map(args.labels_file) + except (OSError, ValueError, yaml.YAMLError) as exc: + print(f"::{severity}::Invalid title type mapping: {type(exc).__name__}: {exc}") + return 1 if blocking else 0 + issues, priority_labels, type_labels = validate_label_names( + label_names, title, title_type_map + ) # Enforce kernel-only namespace: if a kernel label is set, warn. The workflow # cannot reliably distinguish kernel projection from human application. diff --git a/scripts/validate_title.py b/scripts/validate_title.py index 1bba60f..3aeb884 100755 --- a/scripts/validate_title.py +++ b/scripts/validate_title.py @@ -7,18 +7,12 @@ from __future__ import annotations import argparse -import re import sys from pathlib import Path import yaml -CONVENTIONAL_RE = re.compile( - r"^(?Pfeat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)" - r"(?P!)?" - r"(?:\((?P[a-z0-9._-]+)\))?" - r": (?P.+)$" -) +from conventions import CONVENTIONAL_TITLE_RE def main() -> int: @@ -32,7 +26,7 @@ def main() -> int: blocking = args.blocking.lower() == "true" title = args.title.strip() - match = CONVENTIONAL_RE.match(title) + match = CONVENTIONAL_TITLE_RE.match(title) if not match: msg = ( f"Title '{title}' does not match Conventional Commits " diff --git a/skills/crystal-github-conventions/SKILL.md b/skills/crystal-github-conventions/SKILL.md index 5a402a9..31ca323 100644 --- a/skills/crystal-github-conventions/SKILL.md +++ b/skills/crystal-github-conventions/SKILL.md @@ -83,6 +83,16 @@ Do not add `Co-authored-by` footers. Allowed types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert`. Scopes per repo come from `scopes.yaml`. +PR title types map to the required functional label as follows: + +- `feat` โ†’ `type:feature`; `fix` and `revert` โ†’ `type:bug`; +- `docs` โ†’ `type:docs`; `refactor` and `perf` โ†’ `type:refactor`; +- `test` โ†’ `type:test`; +- `style`, `build`, `ci`, and `chore` โ†’ `type:chore`. + +Use `fix:` for a performance regression and `chore:` for a non-remedial +withdrawal when the modal `perf:` or `revert:` mapping would be misleading. + ### Issue titles - `type:epic` โ†’ `[EPIC] ` diff --git a/tests/test_governance.py b/tests/test_governance.py new file mode 100644 index 0000000..6319528 --- /dev/null +++ b/tests/test_governance.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +import importlib.util +import sys +import tempfile +import unittest +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[1] +SCRIPTS_DIR = REPO_ROOT / "scripts" +sys.path.insert(0, str(SCRIPTS_DIR)) + +from conventions import ( # noqa: E402 + ALL_GOVERNED_TITLE_TYPES, + CONVENTIONAL_COMMIT_RE, + CONVENTIONAL_TITLE_RE, + load_title_type_map, +) +from format_check import deterministic_precheck # noqa: E402 +from validate_labels import validate_label_names # noqa: E402 + + +def load_script_module(name: str, filename: str): + spec = importlib.util.spec_from_file_location(name, SCRIPTS_DIR / filename) + if spec is None or spec.loader is None: + raise AssertionError(f"Unable to load {filename}") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +OPEN_BUMP_PRS = load_script_module("open_bump_prs", "open-bump-prs.py") +LABELS_FILE = REPO_ROOT / "governance" / "labels.yaml" + +EXPECTED_PR_MAPPING = { + "feat": "type:feature", + "fix": "type:bug", + "docs": "type:docs", + "style": "type:chore", + "refactor": "type:refactor", + "perf": "type:refactor", + "test": "type:test", + "build": "type:chore", + "ci": "type:chore", + "chore": "type:chore", + "revert": "type:bug", +} +EXPECTED_ISSUE_MAPPING = { + "feature": "type:feature", + "bug": "type:bug", + "chore": "type:chore", + "docs": "type:docs", + "refactor": "type:refactor", + "spike": "type:spike", + "test": "type:test", + "epic": "type:epic", +} + + +class GovernanceTypeMappingTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.mapping = load_title_type_map(LABELS_FILE) + + def test_mapping_is_total_and_uses_only_canonical_labels(self) -> None: + expected = {**EXPECTED_PR_MAPPING, **EXPECTED_ISSUE_MAPPING} + self.assertEqual(self.mapping, expected) + self.assertEqual(set(self.mapping), set(ALL_GOVERNED_TITLE_TYPES)) + + data = yaml.safe_load(LABELS_FILE.read_text(encoding="utf-8")) + canonical = { + item["name"] for item in data["categories"]["type"]["labels"] + } + self.assertLessEqual(set(self.mapping.values()), canonical) + + def test_every_conventional_pr_type_passes_both_consumers(self) -> None: + for title_type, expected_label in EXPECTED_PR_MAPPING.items(): + with self.subTest(title_type=title_type): + title = f"{title_type}: improve governed behavior" + labels = {"priority:p1", expected_label} + issues, _, _ = validate_label_names(labels, title, self.mapping) + self.assertEqual(issues, []) + + artifact = { + "title": title, + "labels": [{"name": label} for label in sorted(labels)], + } + precheck = deterministic_precheck(artifact, "pr", self.mapping) + self.assertTrue(precheck["title_format_valid"]) + self.assertEqual(precheck["expected_type_label"], expected_label) + self.assertTrue(precheck["type_label_matches_title"]) + + def test_wrong_pr_label_fails_with_the_canonical_expectation(self) -> None: + issues, _, _ = validate_label_names( + {"priority:p0", "type:chore"}, + "fix: repair the validator", + self.mapping, + ) + self.assertEqual(len(issues), 1) + self.assertIn("'type:bug'", issues[0]) + + def test_issue_title_types_keep_their_functional_labels(self) -> None: + for title_type, expected_label in EXPECTED_ISSUE_MAPPING.items(): + with self.subTest(title_type=title_type): + title = "[EPIC] Coordinate delivery" if title_type == "epic" else ( + f"{title_type}: coordinate delivery" + ) + artifact = { + "title": title, + "labels": [ + {"name": "priority:p1"}, + {"name": expected_label}, + ], + } + precheck = deterministic_precheck(artifact, "issue", self.mapping) + self.assertTrue(precheck["title_format_valid"]) + self.assertEqual(precheck["expected_type_label"], expected_label) + self.assertTrue(precheck["type_label_matches_title"]) + + def test_title_grammars_are_artifact_specific(self) -> None: + issue_artifact = { + "title": "fix: invalid issue title", + "labels": [{"name": "priority:p1"}, {"name": "type:bug"}], + } + pr_artifact = { + "title": "bug: invalid PR title", + "labels": [{"name": "priority:p1"}, {"name": "type:bug"}], + } + self.assertFalse( + deterministic_precheck(issue_artifact, "issue", self.mapping)["title_format_valid"] + ) + self.assertFalse( + deterministic_precheck(pr_artifact, "pr", self.mapping)["title_format_valid"] + ) + + def test_breaking_change_bang_follows_conventional_commits(self) -> None: + self.assertIsNotNone(CONVENTIONAL_TITLE_RE.match("feat(api)!: change contract")) + self.assertIsNone(CONVENTIONAL_TITLE_RE.match("feat!(api): change contract")) + self.assertIsNotNone( + CONVENTIONAL_COMMIT_RE.match("feat(api)!: โœจ change contract") + ) + + def test_mapping_loader_fails_closed_on_partial_policy(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + labels_file = Path(tmp) / "labels.yaml" + labels_file.write_text( + yaml.safe_dump( + { + "title_type_map": {"feat": "type:feature"}, + "categories": { + "type": {"labels": [{"name": "type:feature"}]} + }, + } + ), + encoding="utf-8", + ) + with self.assertRaisesRegex(ValueError, "domain mismatch"): + load_title_type_map(labels_file) + + def test_generated_bump_pr_contract_passes_old_and_new_taxonomy(self) -> None: + title = OPEN_BUMP_PRS.bump_pr_title("v1.4.0") + issues, _, _ = validate_label_names( + set(OPEN_BUMP_PRS.PR_LABELS), title, self.mapping + ) + self.assertEqual(issues, []) + + +class WorkflowRuntimeTests(unittest.TestCase): + def test_workflows_use_node_24_action_majors(self) -> None: + workflow_root = REPO_ROOT / ".github" / "workflows" + contents = { + path.name: path.read_text(encoding="utf-8") + for path in sorted(workflow_root.glob("*.yml")) + } + self.assertTrue(contents) + for name, content in contents.items(): + with self.subTest(workflow=name): + self.assertNotIn("actions/checkout@v4", content) + self.assertNotIn("actions/setup-python@v5", content) + self.assertIn("actions/checkout@v5", contents["test.yml"]) + self.assertIn("actions/setup-python@v6", contents["test.yml"]) + + +if __name__ == "__main__": + unittest.main()