diff --git a/.taskid b/.taskid index 6982931..4b58f93 100644 --- a/.taskid +++ b/.taskid @@ -1 +1 @@ -DEVX-2 +DEVX-3 diff --git a/pyproject.toml b/pyproject.toml index 9725965..7c7b1c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -83,3 +83,54 @@ indent-style = "space" include = ["src"] pythonVersion = "3.12" strict = ["src/devx/config.py", "src/devx/exceptions.py", "src/devx/i18n.py", "src/devx/api_clients.py", "src/devx/gitea_cli.py"] + +# --------------------------------------------------------------------------- +# Change classification — determines which changes trigger a release +# --------------------------------------------------------------------------- +# Safe-by-default: any file NOT listed here defaults to user-facing +# (requiring a release). This prevents new file types from silently +# skipping releases. +# +# Rule priority (first match wins): +# 1. user_facing_overrides (safety — highest priority) +# 2. infrastructure_overrides (explicit per-file) +# 3. infrastructure (glob patterns) +# 4. Default: user-facing (safe) +[tool.devx.classify] +# Infrastructure paths — changes here don't trigger a release +infrastructure = [ + ".gitea/**", + "tests/**", + "docs/**", + "hooks/**", + "Makefile", + "cliff.toml", + ".pre-commit-config.yaml", + ".env.example", + ".gitignore", + ".ruff.toml", + "AGENTS.md", + "README.md", + "CHANGELOG.md", + "TROUBLESHOOTING.md", + ".ansible-lint", + ".github/**", +] + +# Infrastructure overrides — files that would default to user-facing +# but are actually infrastructure: +# - __init__.py: only contains __version__ (release artifact, not code) +# - api_clients.py: used only by CI/CD scripts, not by the CLI +infrastructure_overrides = [ + "src/devx/__init__.py", + "src/devx/api_clients.py", +] + +# User-facing overrides — safety override for broad infrastructure patterns +# (empty — add when an infrastructure pattern is too broad) +user_facing_overrides = [] + +# Tag patterns — additional categories for CI conditional execution +# Orthogonal to release impact (user-facing vs infrastructure) +[tool.devx.classify.tags] +ansible = ["ansible/**", ".ansible-lint"] diff --git a/src/devx/ci/classify_changes.py b/src/devx/ci/classify_changes.py index 6517f57..f1ffd8f 100644 --- a/src/devx/ci/classify_changes.py +++ b/src/devx/ci/classify_changes.py @@ -1,53 +1,91 @@ #!/usr/bin/env python3 -"""Classify git changes as user-facing or workflow-only. +"""Classify git changes as user-facing or infrastructure. Determines whether changes between two git refs (e.g., last tag and HEAD) -affect the tool itself (user-facing) or only the CI/CD infrastructure +affect the published package (user-facing) or only the CI/CD infrastructure (workflow-only). This is used by: -- **release.py** — skips release when only workflow files changed +- **release.py** — skips release when only infrastructure files changed - **CI workflow** — skips molecule tests and release dry-run when only - workflow files changed + infrastructure files changed -Classification strategy (safe-by-default): +== Design Philosophy == - Any file that is NOT in the explicit workflow-only allowlist is treated - as user-facing. This ensures new file types default to requiring a - release rather than silently skipping it. +**Safe-by-default**: Any file that doesn't match a rule defaults to +user-facing. This prevents new file types from accidentally skipping +releases — a critical safety property. When in doubt, release. - The workflow-only patterns are configurable via the ``patterns`` - parameter on ``classify_changes()`` and ``has_user_facing_changes()``. - The default set (``DEFAULT_WORKFLOW_ONLY_PATTERNS``) covers common - infrastructure paths. Each project can pass its own frozenset to - accommodate different source layouts. +**Config-driven**: Classification rules are read from ``[tool.devx.classify]`` +in ``pyproject.toml``. No project needs to modify the framework code. +Each project declares its own paths; the framework handles the logic. - Default workflow-only paths (infrastructure → no release needed): - - .gitea/workflows/** — Gitea Actions workflows - - scripts/** — All scripts (CI/CD, dev tools, setup) - - src/devx/__init__.py — Version file (release artifact) - - src/devx/api_clients.py — Gitea API client (CI/CD only, not used by CLI) - - docs/** — Documentation - - tests/** — Test files - - hooks/** — Git hooks - - AGENTS.md — Agent conventions - - README.md — README (lean, links to wiki) - - CHANGELOG.md — Changelog (generated) - - TROUBLESHOOTING.md — Troubleshooting guide - - cliff.toml — git-cliff config - - Makefile — Build automation - - .pre-commit-config.yaml — Pre-commit config - - .ansible-lint — Ansible lint config - - .env.example — Environment template - - .gitignore — Git ignore rules - - .ruff.toml — Ruff config (if separate) - - .github/** — GitHub config (if present) +**Layered rules** (evaluated in priority order): - Everything else is user-facing (tool changes → release needed), - including but not limited to: - - src/devx/*.py — Python CLI source (except __init__.py) - - ansible/** — Ansible role - - pyproject.toml — Package metadata - - Any new file type not in the allowlist + 1. **User-facing overrides** (highest priority — safety override) + Files that match infrastructure patterns but MUST be treated as + user-facing. Use this when an infrastructure pattern is too broad. + + 2. **Infrastructure overrides** + Files that would default to user-facing but are actually + infrastructure (e.g., ``src/pkg/__init__.py`` which only contains + ``__version__`` — a release artifact, not user-facing code). + + 3. **Infrastructure patterns** (deny-list) + Path globs matching infrastructure files. Changes to these don't + trigger a release. Examples: ``.gitea/**``, ``tests/**``, ``docs/**``. + + 4. **Default**: user-facing (lowest priority — safe default) + +**Tag system** (orthogonal to release impact): + Projects can define custom tags (e.g., ``ansible``, ``docs``) for CI + conditional execution. A file can be both infrastructure (no release) + and tagged ``ansible`` (run molecule tests). Tags are evaluated + independently of the user-facing/infrastructure classification. + +== Configuration == + +In ``pyproject.toml``:: + + [tool.devx.classify] + # Infrastructure paths — changes here don't trigger a release + infrastructure = [ + ".gitea/**", + "tests/**", + "docs/**", + "Makefile", + "AGENTS.md", + "README.md", + "CHANGELOG.md", + ] + + # Infrastructure overrides — files that would default to user-facing + # but are actually infrastructure + infrastructure_overrides = [ + "src/devx/__init__.py", + ] + + # User-facing overrides — safety override for broad infrastructure patterns + # (empty by default) + user_facing_overrides = [] + + # Tag patterns — additional categories for CI conditional execution + [tool.devx.classify.tags] + ansible = ["ansible/**", ".ansible-lint"] + +== Glob Syntax == + +Patterns support standard glob syntax: + + - ``**`` matches any number of path segments (including zero) + - ``*`` matches any characters within a single path segment + - ``?`` matches a single character within a path segment + - Everything else is matched literally + +Examples: + - ``.gitea/**`` matches ``.gitea/workflows/ci.yml``, ``.gitea/actionlint.yaml`` + - ``tests/**`` matches ``tests/unit/test_cli.py``, ``tests/conftest.py`` + - ``src/devx/__init__.py`` matches exactly that file + - ``Makefile`` matches exactly that file Usage: python3 -m devx.ci.classify_changes [--base ] [--head ] @@ -56,51 +94,270 @@ Usage: from __future__ import annotations +import os +import re import subprocess # nosec B404 import sys +import tomllib +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any import click from devx.i18n import _ -# Explicit allowlist of workflow-only path patterns. -# Anything NOT matching these is treated as user-facing (safe default). -# This is the default set — projects can override via the ``patterns`` -# parameter on classify_changes() / has_user_facing_changes(). -DEFAULT_WORKFLOW_ONLY_PATTERNS: frozenset[str] = frozenset( - [ - # CI/CD infrastructure - ".gitea/", - # All scripts are infrastructure (CI/CD, dev tools, setup) - # User-facing code lives in src/devx/ - "scripts/", - # Version file — only contains __version__, not user-facing code. - # Version bumps are a release artifact, not a feature. - "src/devx/__init__.py", - # Gitea API client — used only by CI/CD scripts, not by the CLI. - "src/devx/api_clients.py", - # Documentation - "docs/", - "AGENTS.md", - "README.md", - "CHANGELOG.md", - "TROUBLESHOOTING.md", - # Tests - "tests/", - # Config / build automation - "cliff.toml", - "Makefile", - ".pre-commit-config.yaml", - ".ansible-lint", - ".env.example", - ".gitignore", - ".ruff.toml", - # Hooks - "hooks/", - # GitHub (if ever added) - ".github/", - ] -) +# --------------------------------------------------------------------------- +# Data structures +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class FileClassification: + """Result of classifying a single file. + + Attributes: + path: The file path relative to repo root. + is_user_facing: True if changes to this file require a release. + reason: Human-readable explanation of the classification. + matched_rule: Which rule matched (e.g., "infrastructure: .gitea/**"). + None if the default rule was used. + tags: Custom category tags (e.g., {"ansible"}). + """ + + path: str + is_user_facing: bool + reason: str + matched_rule: str | None + tags: frozenset[str] = frozenset() + + +@dataclass +class ClassificationResult: + """Result of classifying a set of changed files. + + Attributes: + files: Per-file classification details. + user_facing: List of file paths classified as user-facing. + infrastructure: List of file paths classified as infrastructure. + tags: Dict mapping tag name to list of file paths matching that tag. + """ + + files: list[FileClassification] = field(default_factory=list) + user_facing: list[str] = field(default_factory=list) + infrastructure: list[str] = field(default_factory=list) + tags: dict[str, list[str]] = field(default_factory=dict) + + @property + def has_user_facing(self) -> bool: + """True if any user-facing files were found.""" + return bool(self.user_facing) + + def has_tag(self, tag: str) -> bool: + """True if any files matched the given tag.""" + return bool(self.tags.get(tag)) + + +# --------------------------------------------------------------------------- +# Glob matching +# --------------------------------------------------------------------------- + + +def _glob_to_regex(pattern: str) -> re.Pattern[str]: + """Convert a glob pattern to a compiled regex. + + Supports: + - ``**`` → matches any number of path segments (including zero) + - ``*`` → matches any chars within a single path segment + - ``?`` → matches a single char within a path segment + - All other characters are matched literally + """ + # Handle ** at the end (e.g., ".gitea/**") + # ** matches anything including slashes + parts: list[str] = [] + i = 0 + while i < len(pattern): + c = pattern[i] + if c == "*" and i + 1 < len(pattern) and pattern[i + 1] == "*": + parts.append(".*") + i += 2 + # Skip trailing slash after ** + if i < len(pattern) and pattern[i] == "/": + i += 1 + elif c == "*": + parts.append("[^/]*") + i += 1 + elif c == "?": + parts.append("[^/]") + i += 1 + else: + parts.append(re.escape(c)) + i += 1 + return re.compile("^" + "".join(parts) + "$") + + +def _matches_glob(file_path: str, pattern: str) -> bool: + """Check if a file path matches a glob pattern. + + Also supports prefix matching: if the pattern ends with ``/``, + any file starting with that prefix matches. This is a convenience + for patterns like ``.gitea/`` (equivalent to ``.gitea/**``). + """ + # Prefix matching for patterns ending with / + if pattern.endswith("/") and (file_path.startswith(pattern) or file_path == pattern.rstrip("/")): + return True + return _glob_to_regex(pattern).match(file_path) is not None + + +# --------------------------------------------------------------------------- +# Classifier +# --------------------------------------------------------------------------- + + +@dataclass +class ClassifierConfig: + """Configuration for the change classifier. + + Loaded from ``[tool.devx.classify]`` in ``pyproject.toml``. + + Attributes: + infrastructure: Glob patterns for infrastructure paths. + infrastructure_overrides: Exact paths that are infrastructure + despite not matching any infrastructure pattern. + user_facing_overrides: Exact paths that are user-facing + despite matching an infrastructure pattern (safety override). + tags: Dict mapping tag name to list of glob patterns. + """ + + infrastructure: list[str] = field(default_factory=list) + infrastructure_overrides: list[str] = field(default_factory=list) + user_facing_overrides: list[str] = field(default_factory=list) + tags: dict[str, list[str]] = field(default_factory=dict) + + @classmethod + def from_pyproject(cls, pyproject_path: str = "pyproject.toml") -> ClassifierConfig: + """Load classifier config from pyproject.toml. + + Reads the ``[tool.devx.classify]`` section. If the section or + file is missing, returns a config with empty lists (everything + defaults to user-facing — safe-by-default). + """ + path = Path(pyproject_path) + if not path.exists(): + return cls() + with open(path, "rb") as f: # noqa: PTH123 + data: dict[str, Any] = tomllib.load(f) + classify_cfg = data.get("tool", {}).get("devx", {}).get("classify", {}) + return cls( + infrastructure=list(classify_cfg.get("infrastructure", [])), + infrastructure_overrides=list(classify_cfg.get("infrastructure_overrides", [])), + user_facing_overrides=list(classify_cfg.get("user_facing_overrides", [])), + tags={k: list(v) for k, v in classify_cfg.get("tags", {}).items()}, + ) + + +class ChangeClassifier: + """Classify changed files as user-facing or infrastructure. + + Uses layered rules with safe-by-default semantics. + + Rule evaluation order (first match wins): + 1. User-facing overrides (safety — highest priority) + 2. Infrastructure overrides + 3. Infrastructure patterns + 4. Default: user-facing (safe) + """ + + def __init__(self, config: ClassifierConfig | None = None) -> None: + self.config = config or ClassifierConfig.from_pyproject() + # Pre-compile infrastructure patterns for efficiency + self._infra_patterns = list(self.config.infrastructure) + self._infra_overrides = set(self.config.infrastructure_overrides) + self._user_overrides = set(self.config.user_facing_overrides) + + def classify_file(self, file_path: str) -> FileClassification: + """Classify a single file path. + + Returns a FileClassification with the decision and reason. + """ + tags = self._compute_tags(file_path) + + # 1. User-facing overrides (highest priority — safety) + if file_path in self._user_overrides: + return FileClassification( + path=file_path, + is_user_facing=True, + reason="User-facing override (safety override)", + matched_rule="user_facing_overrides", + tags=tags, + ) + + # 2. Infrastructure overrides + if file_path in self._infra_overrides: + return FileClassification( + path=file_path, + is_user_facing=False, + reason="Infrastructure override (explicitly listed)", + matched_rule="infrastructure_overrides", + tags=tags, + ) + + # 3. Infrastructure patterns + for pattern in self._infra_patterns: + if _matches_glob(file_path, pattern): + return FileClassification( + path=file_path, + is_user_facing=False, + reason=f"Infrastructure (matches '{pattern}')", + matched_rule=f"infrastructure: {pattern}", + tags=tags, + ) + + # 4. Default: user-facing (safe-by-default) + return FileClassification( + path=file_path, + is_user_facing=True, + reason="User-facing (default — not in infrastructure patterns)", + matched_rule=None, + tags=tags, + ) + + def classify(self, files: list[str]) -> ClassificationResult: + """Classify a list of changed files. + + Returns a ClassificationResult with per-file details and + aggregated lists. + """ + result = ClassificationResult() + all_tags: dict[str, list[str]] = {} + + for f in files: + fc = self.classify_file(f) + result.files.append(fc) + if fc.is_user_facing: + result.user_facing.append(f) + else: + result.infrastructure.append(f) + for tag in fc.tags: + all_tags.setdefault(tag, []).append(f) + + result.tags = all_tags + return result + + def _compute_tags(self, file_path: str) -> frozenset[str]: + """Compute custom category tags for a file path.""" + matched: set[str] = set() + for tag_name, patterns in self.config.tags.items(): + for pattern in patterns: + if _matches_glob(file_path, pattern): + matched.add(tag_name) + break + return frozenset(matched) + + +# --------------------------------------------------------------------------- +# Git helpers +# --------------------------------------------------------------------------- def run_git(args: list[str]) -> str: @@ -126,63 +383,6 @@ def get_changed_files(base: str, head: str) -> list[str]: return output.split("\n") -def is_workflow_only( - file_path: str, - patterns: frozenset[str] | None = None, -) -> bool: - """Check if a file path is workflow-only (infrastructure, not the tool itself). - - Uses an explicit allowlist — anything not in the list is treated as - user-facing (safe default that prevents accidental release skips). - """ - p = patterns if patterns is not None else DEFAULT_WORKFLOW_ONLY_PATTERNS - return any(file_path.startswith(pattern) or file_path == pattern for pattern in p) - - -def is_user_facing( - file_path: str, - patterns: frozenset[str] | None = None, -) -> bool: - """Check if a file path is user-facing (affects the tool). - - Inverse of is_workflow_only — anything not explicitly workflow-only - is treated as user-facing. - """ - return not is_workflow_only(file_path, patterns) - - -def classify_changes( - files: list[str], - patterns: frozenset[str] | None = None, -) -> dict[str, list[str]]: - """Classify changed files into user-facing and workflow-only. - - Returns a dict with keys "user_facing" and "workflow_only". - """ - user_facing: list[str] = [] - workflow_only: list[str] = [] - for f in files: - if is_user_facing(f, patterns): - user_facing.append(f) - else: - workflow_only.append(f) - return {"user_facing": user_facing, "workflow_only": workflow_only} - - -def has_user_facing_changes( - base: str, - head: str, - patterns: frozenset[str] | None = None, -) -> bool: - """Check if any user-facing files changed between base and head. - - Imported by ``devx.ci.release`` to decide whether a release - is needed. This is a cross-CI import that requires ``PYTHONPATH=.``. - """ - files = get_changed_files(base, head) - return any(is_user_facing(f, patterns) for f in files) - - def get_latest_tag() -> str: """Get the latest git tag, or empty string if none exists.""" result = subprocess.run( # nosec B603 B607 @@ -196,10 +396,98 @@ def get_latest_tag() -> str: return result.stdout.strip() +# --------------------------------------------------------------------------- +# Backward-compatible API (used by release.py and CI workflows) +# --------------------------------------------------------------------------- + +# Singleton classifier — loaded lazily from pyproject.toml +_classifier: ChangeClassifier | None = None + + +def _get_classifier() -> ChangeClassifier: + """Get or create the singleton classifier from pyproject.toml.""" + global _classifier # noqa: PLW0603 + if _classifier is None: + _classifier = ChangeClassifier() + return _classifier + + +def is_workflow_only( + file_path: str, + patterns: frozenset[str] | None = None, +) -> bool: + """Check if a file path is infrastructure (not user-facing). + + Backward-compatible API. Prefer ``ChangeClassifier.classify_file()`` + for new code. + + Args: + file_path: Path relative to repo root. + patterns: Deprecated. If provided, uses simple prefix matching + against these patterns instead of the config-driven classifier. + """ + if patterns is not None: + # Legacy mode — simple prefix matching + return any(file_path.startswith(p) or file_path == p for p in patterns) + return not _get_classifier().classify_file(file_path).is_user_facing + + +def is_user_facing( + file_path: str, + patterns: frozenset[str] | None = None, +) -> bool: + """Check if a file path is user-facing (affects the released package). + + Inverse of ``is_workflow_only()``. + """ + return not is_workflow_only(file_path, patterns) + + +def classify_changes( + files: list[str], + patterns: frozenset[str] | None = None, +) -> dict[str, list[str]]: + """Classify changed files into user-facing and workflow-only. + + Returns a dict with keys "user_facing" and "workflow_only". + """ + if patterns is not None: + # Legacy mode + user_facing: list[str] = [] + workflow_only: list[str] = [] + for f in files: + if is_user_facing(f, patterns): + user_facing.append(f) + else: + workflow_only.append(f) + return {"user_facing": user_facing, "workflow_only": workflow_only} + + result = _get_classifier().classify(files) + return {"user_facing": result.user_facing, "workflow_only": result.infrastructure} + + +def has_user_facing_changes( + base: str, + head: str, + patterns: frozenset[str] | None = None, +) -> bool: + """Check if any user-facing files changed between base and head. + + Imported by ``devx.ci.release`` to decide whether a release is needed. + """ + files = get_changed_files(base, head) + if patterns is not None: + return any(is_user_facing(f, patterns) for f in files) + return _get_classifier().classify(files).has_user_facing + + +# --------------------------------------------------------------------------- +# Gitea Actions output +# --------------------------------------------------------------------------- + + def _write_github_output(key: str, value: str) -> None: """Append a key=value line to the $GITHUB_OUTPUT file.""" - import os - gh_output = os.environ.get("GITHUB_OUTPUT") if not gh_output: raise click.ClickException("GITHUB_OUTPUT environment variable is not set") @@ -207,6 +495,11 @@ def _write_github_output(key: str, value: str) -> None: f.write(f"{key}={value}\n") +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + @click.command() @click.option("--base", default=None, help="Base ref (default: latest tag).") @click.option("--head", default="HEAD", help="Head ref (default: HEAD).") @@ -225,6 +518,9 @@ def _write_github_output(key: str, value: str) -> None: help="Write results to $GITHUB_OUTPUT file (for CI workflow steps).", ) def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool) -> None: + """Classify git changes and output results.""" + classifier = _get_classifier() + if base is None: base = get_latest_tag() if not base: @@ -252,18 +548,17 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo click.echo(_("No changes between {base} and {head}.", base=base, head=head)) return + result = classifier.classify(files) + if github_output: - ansible_files = [f for f in files if f.startswith("ansible/") or f == ".ansible-lint"] - user_files = [f for f in files if is_user_facing(f)] - _write_github_output("ansible-changed", "true" if ansible_files else "false") - _write_github_output("user-facing-changed", "true" if user_files else "false") - click.echo(f"Ansible files changed: {bool(ansible_files)}") - click.echo(f"User-facing files changed: {bool(user_files)}") + _write_github_output("ansible-changed", "true" if result.has_tag("ansible") else "false") + _write_github_output("user-facing-changed", "true" if result.has_user_facing else "false") + click.echo(f"Ansible files changed: {result.has_tag('ansible')}") + click.echo(f"User-facing files changed: {result.has_user_facing}") return if check == "ansible": - # Check only for Ansible-related file changes - ansible_files = [f for f in files if f.startswith("ansible/") or f == ".ansible-lint"] + ansible_files = result.tags.get("ansible", []) has_ansible = bool(ansible_files) if quiet: click.echo("true" if has_ansible else "false") @@ -275,8 +570,7 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo return if check == "user-facing": - # Check only for user-facing file changes (inverse of workflow-only) - user_files = [f for f in files if is_user_facing(f)] + user_files = result.user_facing has_user = bool(user_files) if quiet: click.echo("true" if has_user else "false") @@ -289,19 +583,18 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo ) return - result = classify_changes(files) - has_user = bool(result["user_facing"]) + has_user = result.has_user_facing if quiet: click.echo("true" if has_user else "false") return click.echo(_("Comparing {base}..{head} ({count} files changed)", base=base, head=head, count=len(files))) - click.echo(_("\nUser-facing changes ({count}):", count=len(result["user_facing"]))) - for f in result["user_facing"]: + click.echo(_("\nUser-facing changes ({count}):", count=len(result.user_facing))) + for f in result.user_facing: click.echo(f" {f}") - click.echo(_("\nWorkflow-only changes ({count}):", count=len(result["workflow_only"]))) - for f in result["workflow_only"]: + click.echo(_("\nWorkflow-only changes ({count}):", count=len(result.infrastructure))) + for f in result.infrastructure: click.echo(f" {f}") if has_user: status = "USER-FACING changes detected — release needed" diff --git a/tests/unit/test_classify_changes.py b/tests/unit/test_classify_changes.py index c1dfb12..ed4e87c 100644 --- a/tests/unit/test_classify_changes.py +++ b/tests/unit/test_classify_changes.py @@ -1,4 +1,16 @@ -"""Unit tests for scripts/ci/classify_changes.py.""" +"""Unit tests for devx.ci.classify_changes. + +Tests cover: +- Glob matching (``_glob_to_regex``, ``_matches_glob``) +- Classifier config loading from pyproject.toml +- ChangeClassifier with layered rules (overrides, patterns, default) +- Tag system (orthogonal categories) +- Backward-compatible API (is_user_facing, is_workflow_only, classify_changes) +- Git helpers (get_changed_files, get_latest_tag, run_git) +- CLI (main with --quiet, --check, --github-output) +""" + +from __future__ import annotations from pathlib import Path from unittest.mock import MagicMock, patch @@ -9,6 +21,12 @@ from click.testing import CliRunner import devx.ci.classify_changes as classify_changes_mod from devx.ci.classify_changes import ( + ChangeClassifier, + ClassificationResult, + ClassifierConfig, + FileClassification, + _glob_to_regex, + _matches_glob, classify_changes, get_changed_files, get_latest_tag, @@ -19,98 +37,326 @@ from devx.ci.classify_changes import ( run_git, ) +# --------------------------------------------------------------------------- +# Glob matching tests +# --------------------------------------------------------------------------- -class TestIsUserFacing: - def test_src_is_user_facing(self) -> None: - assert is_user_facing("src/devx/cli.py") is True - def test_ansible_is_user_facing(self) -> None: - assert is_user_facing("ansible/roles/gitea-runner/tasks/main.yml") is True +class TestGlobToRegex: + def test_double_star_matches_anything(self) -> None: + regex = _glob_to_regex(".gitea/**") + assert regex.match(".gitea/workflows/ci.yml") + assert regex.match(".gitea/actionlint.yaml") + assert regex.match(".gitea/a/b/c/d.yml") + assert not regex.match("tests/test_foo.py") - def test_pyproject_is_user_facing(self) -> None: - assert is_user_facing("pyproject.toml") is True + def test_double_star_in_middle(self) -> None: + """** in the middle of a pattern matches any number of segments.""" + regex = _glob_to_regex("src/**/test_*.py") + assert regex.match("src/test_foo.py") + assert regex.match("src/devx/test_cli.py") + assert regex.match("src/a/b/c/test_bar.py") + assert not regex.match("src/cli.py") - def test_workflow_is_not_user_facing(self) -> None: - assert is_user_facing(".gitea/workflows/ci.yml") is False + def test_single_star_matches_within_segment(self) -> None: + regex = _glob_to_regex("src/*/cli.py") + assert regex.match("src/devx/cli.py") + assert regex.match("src/pkg/cli.py") + assert not regex.match("src/devx/sub/cli.py") - def test_ci_scripts_are_not_user_facing(self) -> None: - assert is_user_facing("scripts/ci/release.py") is False + def test_question_mark_matches_single_char(self) -> None: + regex = _glob_to_regex("file?.py") + assert regex.match("file1.py") + assert regex.match("fileA.py") + assert not regex.match("file12.py") - def test_dev_scripts_are_not_user_facing(self) -> None: - """All scripts under scripts/ are infrastructure (CI/CD, dev tools). - User-facing code lives in src/devx/.""" - assert is_user_facing("scripts/check_test_speed.py") is False - assert is_user_facing("scripts/configure_repo.py") is False - assert is_user_facing("scripts/install_checkmake.py") is False + def test_literal_match(self) -> None: + regex = _glob_to_regex("Makefile") + assert regex.match("Makefile") + assert not regex.match("makefile") - def test_shell_scripts_are_not_user_facing(self) -> None: - assert is_user_facing("scripts/setup.sh") is False - assert is_user_facing("scripts/molecule_all.sh") is False + def test_special_chars_escaped(self) -> None: + regex = _glob_to_regex("file.test.py") + assert regex.match("file.test.py") + assert not regex.match("fileXtest.py") - def test_scripts_init_is_not_user_facing(self) -> None: - assert is_user_facing("scripts/__init__.py") is False - def test_version_file_is_not_user_facing(self) -> None: - """__init__.py only contains __version__ — a release artifact, - not user-facing code. Version bumps alone should not trigger releases.""" - assert is_user_facing("src/devx/__init__.py") is False +class TestMatchesGlob: + def test_double_star(self) -> None: + assert _matches_glob(".gitea/workflows/ci.yml", ".gitea/**") + assert _matches_glob("tests/unit/test_cli.py", "tests/**") + assert not _matches_glob("src/devx/cli.py", "tests/**") - def test_api_clients_is_not_user_facing(self) -> None: - """api_clients.py is used only by CI/CD scripts, not by the GRM CLI.""" - assert is_user_facing("src/devx/api_clients.py") is False + def test_exact_match(self) -> None: + assert _matches_glob("Makefile", "Makefile") + assert _matches_glob("src/devx/__init__.py", "src/devx/__init__.py") + assert not _matches_glob("src/devx/cli.py", "src/devx/__init__.py") - def test_docs_are_not_user_facing(self) -> None: - assert is_user_facing("docs/user/getting-started.md") is False + def test_prefix_matching(self) -> None: + assert _matches_glob(".gitea/workflows/ci.yml", ".gitea/") + assert _matches_glob("scripts/ci/release.py", "scripts/") + assert not _matches_glob("tests/test_foo.py", "scripts/") - def test_tests_are_not_user_facing(self) -> None: - assert is_user_facing("tests/unit/test_cli.py") is False + def test_single_star(self) -> None: + assert _matches_glob("src/devx/cli.py", "src/devx/*.py") + assert not _matches_glob("src/devx/sub/cli.py", "src/devx/*.py") - def test_agents_md_is_not_user_facing(self) -> None: - assert is_user_facing("AGENTS.md") is False - def test_makefile_is_not_user_facing(self) -> None: - assert is_user_facing("Makefile") is False +# --------------------------------------------------------------------------- +# ClassifierConfig tests +# --------------------------------------------------------------------------- + + +class TestClassifierConfig: + def test_from_pyproject_loads_config(self, tmp_path: Path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text( + "[tool.devx.classify]\n" + 'infrastructure = [".gitea/**", "tests/**"]\n' + 'infrastructure_overrides = ["src/pkg/__init__.py"]\n' + 'user_facing_overrides = ["docs/important.py"]\n' + "\n" + "[tool.devx.classify.tags]\n" + 'ansible = ["ansible/**"]\n' + ) + config = ClassifierConfig.from_pyproject(str(pyproject)) + assert config.infrastructure == [".gitea/**", "tests/**"] + assert config.infrastructure_overrides == ["src/pkg/__init__.py"] + assert config.user_facing_overrides == ["docs/important.py"] + assert config.tags == {"ansible": ["ansible/**"]} + + def test_from_pyproject_missing_file(self) -> None: + config = ClassifierConfig.from_pyproject("/nonexistent/pyproject.toml") + assert config.infrastructure == [] + assert config.infrastructure_overrides == [] + assert config.user_facing_overrides == [] + assert config.tags == {} + + def test_from_pyproject_missing_section(self, tmp_path: Path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "test"\n') + config = ClassifierConfig.from_pyproject(str(pyproject)) + assert config.infrastructure == [] + + def test_from_pyproject_partial_config(self, tmp_path: Path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[tool.devx.classify]\ninfrastructure = [".gitea/**"]\n') + config = ClassifierConfig.from_pyproject(str(pyproject)) + assert config.infrastructure == [".gitea/**"] + assert config.infrastructure_overrides == [] + assert config.user_facing_overrides == [] + assert config.tags == {} + + def test_defaults_are_empty(self) -> None: + config = ClassifierConfig() + assert config.infrastructure == [] + assert config.infrastructure_overrides == [] + assert config.user_facing_overrides == [] + assert config.tags == {} + + +# --------------------------------------------------------------------------- +# ChangeClassifier tests +# --------------------------------------------------------------------------- + + +class TestChangeClassifier: + def _make_classifier(self, **kwargs: object) -> ChangeClassifier: + """Create a classifier with explicit config (no pyproject.toml needed).""" + config = ClassifierConfig(**kwargs) # type: ignore[arg-type] + return ChangeClassifier(config) + + def test_infrastructure_pattern_matches(self) -> None: + classifier = self._make_classifier(infrastructure=[".gitea/**", "tests/**"]) + fc = classifier.classify_file(".gitea/workflows/ci.yml") + assert not fc.is_user_facing + assert "infrastructure" in fc.matched_rule def test_unknown_file_defaults_to_user_facing(self) -> None: - """Safe default: unknown files are user-facing (require release).""" - assert is_user_facing("some/new/file.type") is True - assert is_user_facing("new_root_file.txt") is True + classifier = self._make_classifier(infrastructure=[".gitea/**"]) + fc = classifier.classify_file("src/devx/cli.py") + assert fc.is_user_facing + assert fc.matched_rule is None + assert "default" in fc.reason.lower() - def test_is_workflow_only_inverse(self) -> None: - assert is_workflow_only(".gitea/workflows/ci.yml") is True - assert is_workflow_only("src/devx/cli.py") is False - assert is_workflow_only("pyproject.toml") is False + def test_infrastructure_override(self) -> None: + classifier = self._make_classifier( + infrastructure=[".gitea/**"], + infrastructure_overrides=["src/devx/__init__.py"], + ) + fc = classifier.classify_file("src/devx/__init__.py") + assert not fc.is_user_facing + assert fc.matched_rule == "infrastructure_overrides" + def test_user_facing_override_beats_infrastructure(self) -> None: + """User-facing overrides have highest priority (safety).""" + classifier = self._make_classifier( + infrastructure=["tests/**"], + user_facing_overrides=["tests/test_public_api.py"], + ) + fc = classifier.classify_file("tests/test_public_api.py") + assert fc.is_user_facing + assert fc.matched_rule == "user_facing_overrides" -class TestClassifyChanges: - def test_all_user_facing(self) -> None: - files = ["src/devx/cli.py", "ansible/roles/gitea-runner/tasks/main.yml"] - result = classify_changes(files) - assert result["user_facing"] == files - assert result["workflow_only"] == [] + def test_user_facing_override_beats_infrastructure_override(self) -> None: + """User-facing overrides beat infrastructure overrides (safety first).""" + classifier = self._make_classifier( + infrastructure=[".gitea/**"], + infrastructure_overrides=["src/devx/__init__.py"], + user_facing_overrides=["src/devx/__init__.py"], + ) + fc = classifier.classify_file("src/devx/__init__.py") + assert fc.is_user_facing - def test_all_workflow_only(self) -> None: - files = [".gitea/workflows/ci.yml", "docs/index.md", "AGENTS.md"] - result = classify_changes(files) - assert result["user_facing"] == [] - assert result["workflow_only"] == files + def test_tags_are_computed(self) -> None: + classifier = self._make_classifier( + infrastructure=[".gitea/**"], + tags={"ansible": ["ansible/**", ".ansible-lint"], "docs": ["docs/**"]}, + ) + fc = classifier.classify_file("ansible/tasks/main.yml") + assert "ansible" in fc.tags + assert "docs" not in fc.tags - def test_mixed(self) -> None: + def test_tags_orthogonal_to_classification(self) -> None: + """A file can be infrastructure AND tagged.""" + classifier = self._make_classifier( + infrastructure=[".gitea/**", "docs/**"], + tags={"docs": ["docs/**"]}, + ) + fc = classifier.classify_file("docs/index.md") + assert not fc.is_user_facing # infrastructure + assert "docs" in fc.tags # also tagged + + def test_classify_multiple_files(self) -> None: + classifier = self._make_classifier( + infrastructure=[".gitea/**", "tests/**"], + infrastructure_overrides=["src/devx/__init__.py"], + tags={"ansible": ["ansible/**"]}, + ) files = [ "src/devx/cli.py", ".gitea/workflows/ci.yml", - "pyproject.toml", - "docs/index.md", + "src/devx/__init__.py", + "ansible/tasks/main.yml", + "tests/test_foo.py", ] - result = classify_changes(files) - assert "src/devx/cli.py" in result["user_facing"] - assert "pyproject.toml" in result["user_facing"] - assert ".gitea/workflows/ci.yml" in result["workflow_only"] - assert "docs/index.md" in result["workflow_only"] + result = classifier.classify(files) + assert "src/devx/cli.py" in result.user_facing + assert "ansible/tasks/main.yml" in result.user_facing + assert ".gitea/workflows/ci.yml" in result.infrastructure + assert "src/devx/__init__.py" in result.infrastructure + assert "tests/test_foo.py" in result.infrastructure + assert result.has_user_facing + assert result.has_tag("ansible") + assert "ansible/tasks/main.yml" in result.tags["ansible"] - def test_empty(self) -> None: - result = classify_changes([]) - assert result == {"user_facing": [], "workflow_only": []} + def test_classify_empty(self) -> None: + classifier = self._make_classifier(infrastructure=[".gitea/**"]) + result = classifier.classify([]) + assert not result.has_user_facing + assert result.user_facing == [] + assert result.infrastructure == [] + + def test_reason_is_human_readable(self) -> None: + classifier = self._make_classifier(infrastructure=[".gitea/**"]) + fc = classifier.classify_file(".gitea/workflows/ci.yml") + assert ".gitea/**" in fc.reason + fc2 = classifier.classify_file("src/devx/cli.py") + assert "default" in fc2.reason.lower() or "user-facing" in fc2.reason.lower() + + +class TestClassificationResult: + def test_has_user_facing(self) -> None: + result = ClassificationResult(user_facing=["src/cli.py"]) + assert result.has_user_facing + + def test_has_user_facing_empty(self) -> None: + result = ClassificationResult() + assert not result.has_user_facing + + def test_has_tag(self) -> None: + result = ClassificationResult(tags={"ansible": ["ansible/tasks/main.yml"]}) + assert result.has_tag("ansible") + assert not result.has_tag("docs") + + +# --------------------------------------------------------------------------- +# Backward-compatible API tests +# --------------------------------------------------------------------------- + + +class TestBackwardCompatibleAPI: + def test_is_workflow_only_with_config(self) -> None: + """is_workflow_only uses the config-driven classifier by default.""" + with patch.object(classify_changes_mod, "_get_classifier") as mock: + classifier = MagicMock() + classifier.classify_file.return_value = FileClassification( + path=".gitea/workflows/ci.yml", + is_user_facing=False, + reason="test", + matched_rule="infrastructure: .gitea/**", + ) + mock.return_value = classifier + assert is_workflow_only(".gitea/workflows/ci.yml") is True + + def test_is_user_facing_with_config(self) -> None: + with patch.object(classify_changes_mod, "_get_classifier") as mock: + classifier = MagicMock() + classifier.classify_file.return_value = FileClassification( + path="src/devx/cli.py", + is_user_facing=True, + reason="test", + matched_rule=None, + ) + mock.return_value = classifier + assert is_user_facing("src/devx/cli.py") is True + + def test_legacy_patterns_mode(self) -> None: + """is_workflow_only with explicit patterns uses legacy prefix matching.""" + patterns = frozenset([".gitea/", "tests/"]) + assert is_workflow_only(".gitea/workflows/ci.yml", patterns) is True + assert is_workflow_only("tests/test_foo.py", patterns) is True + assert is_workflow_only("src/devx/cli.py", patterns) is False + + def test_classify_changes_with_config(self) -> None: + with patch.object(classify_changes_mod, "_get_classifier") as mock: + classifier = MagicMock() + classifier.classify.return_value = ClassificationResult( + user_facing=["src/devx/cli.py"], + infrastructure=[".gitea/workflows/ci.yml"], + ) + mock.return_value = classifier + result = classify_changes(["src/devx/cli.py", ".gitea/workflows/ci.yml"]) + assert "src/devx/cli.py" in result["user_facing"] + assert ".gitea/workflows/ci.yml" in result["workflow_only"] + + def test_classify_changes_legacy_mode(self) -> None: + patterns = frozenset([".gitea/", "tests/"]) + result = classify_changes([".gitea/ci.yml", "src/cli.py"], patterns) + assert ".gitea/ci.yml" in result["workflow_only"] + assert "src/cli.py" in result["user_facing"] + + def test_has_user_facing_changes_with_config(self) -> None: + with ( + patch.object(classify_changes_mod, "get_changed_files", return_value=["src/devx/cli.py"]), + patch.object(classify_changes_mod, "_get_classifier") as mock, + ): + classifier = MagicMock() + classifier.classify.return_value = ClassificationResult( + user_facing=["src/devx/cli.py"], + ) + mock.return_value = classifier + assert has_user_facing_changes("v0.1.0", "HEAD") is True + + def test_has_user_facing_changes_legacy(self) -> None: + with patch.object(classify_changes_mod, "get_changed_files", return_value=[".gitea/ci.yml"]): + patterns = frozenset([".gitea/"]) + assert has_user_facing_changes("v0.1.0", "HEAD", patterns) is False + + +# --------------------------------------------------------------------------- +# Git helper tests +# --------------------------------------------------------------------------- class TestGetChangedFiles: @@ -127,23 +373,6 @@ class TestGetChangedFiles: assert result == [] -class TestHasUserFacingChanges: - @patch("devx.ci.classify_changes.get_changed_files") - def test_true_when_user_facing(self, mock_get: MagicMock) -> None: - mock_get.return_value = ["src/devx/cli.py", "docs/index.md"] - assert has_user_facing_changes("v0.1.0", "HEAD") is True - - @patch("devx.ci.classify_changes.get_changed_files") - def test_false_when_workflow_only(self, mock_get: MagicMock) -> None: - mock_get.return_value = [".gitea/workflows/ci.yml", "docs/index.md"] - assert has_user_facing_changes("v0.1.0", "HEAD") is False - - @patch("devx.ci.classify_changes.get_changed_files") - def test_false_when_no_changes(self, mock_get: MagicMock) -> None: - mock_get.return_value = [] - assert has_user_facing_changes("v0.1.0", "HEAD") is False - - class TestGetLatestTag: @patch("subprocess.run") def test_returns_tag(self, mock_run: MagicMock) -> None: @@ -170,6 +399,11 @@ class TestRunGit: run_git(["git", "bad-command"]) +# --------------------------------------------------------------------------- +# CLI tests +# --------------------------------------------------------------------------- + + class TestMain: @patch("devx.ci.classify_changes.get_latest_tag", return_value="") def test_no_tags_outputs_true(self, mock_tag: MagicMock) -> None: @@ -206,7 +440,6 @@ class TestMain: @patch("devx.ci.classify_changes.get_latest_tag", return_value="") def test_no_tags_non_quiet(self, mock_tag: MagicMock) -> None: - """Non-quiet mode with no tags prints user-facing message.""" runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code == 0 @@ -215,7 +448,6 @@ class TestMain: @patch("devx.ci.classify_changes.get_changed_files", return_value=[]) @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_no_changes_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: - """Non-quiet mode with no changes prints message.""" runner = CliRunner() result = runner.invoke(main, []) assert result.exit_code == 0 @@ -224,7 +456,6 @@ class TestMain: @patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_quiet_user_facing(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: - """Quiet mode with user-facing changes outputs true.""" mock_changes.return_value = ["src/devx/cli.py"] runner = CliRunner() result = runner.invoke(main, ["--quiet"]) @@ -234,7 +465,6 @@ class TestMain: @patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_quiet_workflow_only(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: - """Quiet mode with workflow-only changes outputs false.""" mock_changes.return_value = [".gitea/workflows/ci.yml"] runner = CliRunner() result = runner.invoke(main, ["--quiet"]) @@ -244,7 +474,6 @@ class TestMain: @patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_with_explicit_base(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: - """Explicit --base overrides latest tag.""" mock_changes.return_value = ["src/devx/cli.py"] runner = CliRunner() result = runner.invoke(main, ["--base", "v0.2.0", "--head", "HEAD"]) @@ -254,7 +483,6 @@ class TestMain: @patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_check_ansible_true(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: - """--check ansible with Ansible changes outputs true.""" mock_changes.return_value = ["ansible/tasks/main.yml", ".gitea/workflows/ci.yml"] runner = CliRunner() result = runner.invoke(main, ["--check", "ansible", "--quiet"]) @@ -264,7 +492,6 @@ class TestMain: @patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_check_ansible_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: - """--check ansible with no Ansible changes outputs false.""" mock_changes.return_value = ["src/devx/cli.py", ".gitea/workflows/ci.yml"] runner = CliRunner() result = runner.invoke(main, ["--check", "ansible", "--quiet"]) @@ -274,7 +501,6 @@ class TestMain: @patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_check_user_facing_true(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: - """--check user-facing with user-facing changes outputs true.""" mock_changes.return_value = ["src/devx/cli.py", ".gitea/workflows/ci.yml"] runner = CliRunner() result = runner.invoke(main, ["--check", "user-facing", "--quiet"]) @@ -284,7 +510,6 @@ class TestMain: @patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_check_user_facing_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: - """--check user-facing with only workflow changes outputs false.""" mock_changes.return_value = [".gitea/workflows/ci.yml", "tests/test_foo.py"] runner = CliRunner() result = runner.invoke(main, ["--check", "user-facing", "--quiet"]) @@ -294,7 +519,6 @@ class TestMain: @patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_check_ansible_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: - """--check ansible in non-quiet mode prints file list.""" mock_changes.return_value = ["ansible/tasks/main.yml"] runner = CliRunner() result = runner.invoke(main, ["--check", "ansible"]) @@ -304,7 +528,6 @@ class TestMain: @patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_check_user_facing_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: - """--check user-facing in non-quiet mode prints file list.""" mock_changes.return_value = ["src/devx/cli.py"] runner = CliRunner() result = runner.invoke(main, ["--check", "user-facing"])