diff --git a/.taskid b/.taskid index e4b81e6..ef4c28c 100644 --- a/.taskid +++ b/.taskid @@ -1 +1 @@ -DEVX-4 +DEVX-5 diff --git a/pyproject.toml b/pyproject.toml index 7c7b1c9..bdd28f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -87,43 +87,33 @@ strict = ["src/devx/config.py", "src/devx/exceptions.py", "src/devx/i18n.py", "s # --------------------------------------------------------------------------- # 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. +# The framework provides DEFAULT_INFRASTRUCTURE (CI workflows, tests, docs, +# lint config, etc.) that applies to any Python project. We only specify +# what's different about devx. # # Rule priority (first match wins): # 1. user_facing_overrides (safety — highest priority) # 2. infrastructure_overrides (explicit per-file) -# 3. infrastructure (glob patterns) +# 3. infrastructure (DEFAULT_INFRASTRUCTURE + project-specific 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/**", -] +# use_defaults = true # (default) merge with DEFAULT_INFRASTRUCTURE + +# Project-specific infrastructure paths (merged with defaults). +# devx has no additional infrastructure paths — everything not in the +# defaults is user-facing (src/devx/**, pyproject.toml, translations.json). +infrastructure = [] # 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 +# - __init__.py: only contains __version__ (set by release.py, not user code) +# +# NOTE: api_clients.py is NOT here — it's used by devx's CI modules +# (auto_merge.py, release.py, pr_review.py, etc.) which consumer projects +# call via `python -m devx.ci.*`. Changes to api_clients.py affect consumer +# projects' CI behavior, so it IS user-facing. infrastructure_overrides = [ "src/devx/__init__.py", - "src/devx/api_clients.py", ] # User-facing overrides — safety override for broad infrastructure patterns @@ -133,4 +123,4 @@ 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"] +# No tags needed for devx itself — it has no ansible/ directory diff --git a/src/devx/ci/auto_merge.py b/src/devx/ci/auto_merge.py index 884807b..ae91a29 100644 --- a/src/devx/ci/auto_merge.py +++ b/src/devx/ci/auto_merge.py @@ -6,8 +6,11 @@ Runs as the final job in ci.yml. Reads the task ID from ``.taskid`` file validates the PR title, and squash-merges with a conventional commit message prefixed by the task ID. -PR title format: ``DEVX-N: `` -Merge commit format: ``DEVX-N: `` +PR title format: ``{PREFIX}-N: `` +Merge commit format: ``{PREFIX}-N `` + +The ``{PREFIX}`` is determined by ``DEVX_TASK_PREFIX`` (default: ``DEVX``). +Each project sets its own prefix (e.g., ``GRM``, ``INFRA``). The conventional commit message is extracted from the PR commits. This allows the PR title to be a human-friendly Vikunja task title @@ -32,6 +35,7 @@ from devx.config import ( DEFAULT_PER_PAGE, GITEA_API_URL, TASK_ID_RE, + TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID, ) @@ -39,7 +43,7 @@ from devx.exceptions import APIError from devx.i18n import _ TASKID_FILE = ".taskid" -PR_TITLE_RE = re.compile(r"^DEVX-\d+:\s+.+") +PR_TITLE_RE = re.compile(rf"^{TASK_PREFIX}-\d+:\s+.+") load_dotenv() @@ -84,14 +88,15 @@ def extract_task_id(branch: str) -> str: def validate_pr_title(pr_title: str, task_id: str) -> None: """Raise ClickException if PR title does not follow the required format. - Expected: ``DEVX-N: `` + Expected: ``{PREFIX}-N: `` """ if not PR_TITLE_RE.match(pr_title): raise click.ClickException( _( - "Oops! PR title must follow format 'DEVX-N: '.\n" + "Oops! PR title must follow format '{prefix}-N: '.\n" " Expected: {task_id}: \n" " Got: {pr_title}", + prefix=TASK_PREFIX, task_id=task_id, pr_title=pr_title, ) diff --git a/src/devx/ci/classify_changes.py b/src/devx/ci/classify_changes.py index f1ffd8f..6e62e23 100644 --- a/src/devx/ci/classify_changes.py +++ b/src/devx/ci/classify_changes.py @@ -15,6 +15,12 @@ affect the published package (user-facing) or only the CI/CD infrastructure user-facing. This prevents new file types from accidentally skipping releases — a critical safety property. When in doubt, release. +**Framework-provided defaults**: The framework ships with +``DEFAULT_INFRASTRUCTURE`` — a curated list of paths that are +infrastructure for ANY Python project (CI workflows, tests, docs, +lint config, etc.). Projects inherit these automatically and only +need to specify what's *different* about their project. + **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. @@ -31,8 +37,9 @@ Each project declares its own paths; the framework handles the logic. ``__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/**``. + Path globs matching infrastructure files. This is the union of + ``DEFAULT_INFRASTRUCTURE`` and the project's ``infrastructure`` list. + Changes to these don't trigger a release. 4. **Default**: user-facing (lowest priority — safe default) @@ -41,27 +48,28 @@ Each project declares its own paths; the framework handles the logic. 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. + The ``--check`` CLI option accepts any tag name defined in the config, + and ``--github-output`` writes ``-changed`` for each configured tag. == Configuration == In ``pyproject.toml``:: [tool.devx.classify] - # Infrastructure paths — changes here don't trigger a release + # Whether to merge with DEFAULT_INFRASTRUCTURE (default: true). + # Set to false to specify all patterns explicitly. + # use_defaults = true + + # Project-specific infrastructure paths (merged with defaults). + # Only list paths NOT already in DEFAULT_INFRASTRUCTURE. infrastructure = [ - ".gitea/**", - "tests/**", - "docs/**", - "Makefile", - "AGENTS.md", - "README.md", - "CHANGELOG.md", + "scripts/**", # e.g., if scripts/ is dev-only tooling ] # Infrastructure overrides — files that would default to user-facing # but are actually infrastructure infrastructure_overrides = [ - "src/devx/__init__.py", + "src/mypkg/__init__.py", # only contains __version__ ] # User-facing overrides — safety override for broad infrastructure patterns @@ -72,13 +80,36 @@ In ``pyproject.toml``:: [tool.devx.classify.tags] ansible = ["ansible/**", ".ansible-lint"] +== What counts as "user-facing" == + +A change is user-facing if it affects the behavior of the installed +package. For a library/CLI tool, this means: + + - Source code in ``src/`` (except ``__init__.py`` which only holds + ``__version__``) + - Package metadata (``pyproject.toml`` — dependencies, entry points) + - Ansible roles, playbooks, templates (if the project ships Ansible) + - Translation files (user-visible messages) + - Any file not explicitly classified as infrastructure + +A change is infrastructure if it only affects the project's own +development/CI environment: + + - CI/CD workflows (``.gitea/**``, ``.github/**``) + - Tests (``tests/**``) + - Documentation (``docs/**``, ``README.md``, ``CHANGELOG.md``) + - Linting/formatting config (``.ruff.toml``, ``.pre-commit-config.yaml``) + - Build tooling (``Makefile``, ``cliff.toml``) + - Git hooks (``hooks/**``) + - Generated scripts (``activate.sh``, ``activate.fish``, ``activate.zsh``) + == 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 + - ``?`` matches a single character within a single path segment - Everything else is matched literally Examples: @@ -90,6 +121,8 @@ Examples: Usage: python3 -m devx.ci.classify_changes [--base ] [--head ] python3 -m devx.ci.classify_changes --base v0.3.0 --head HEAD + python3 -m devx.ci.classify_changes --check ansible --quiet + python3 -m devx.ci.classify_changes --github-output """ from __future__ import annotations @@ -167,9 +200,9 @@ 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 + - ``**`` -> 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/**") @@ -214,45 +247,114 @@ def _matches_glob(file_path: str, pattern: str) -> bool: # --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# Default infrastructure patterns +# --------------------------------------------------------------------------- + +# Common infrastructure paths that apply to ANY Python project using devx. +# Projects inherit these automatically and only need to specify project-specific +# paths in their [tool.devx.classify] section. +# +# Rationale: these files/directories are development tooling, CI/CD config, +# or generated artifacts. Changes to them don't affect the installed package's +# behavior, so they don't warrant a release. +DEFAULT_INFRASTRUCTURE: list[str] = [ + # CI/CD workflow definitions + ".gitea/**", + ".github/**", + # Test files + "tests/**", + # Documentation + "docs/**", + # Git hooks + "hooks/**", + # Build tooling + "Makefile", + "cliff.toml", + # Linting / formatting config + ".pre-commit-config.yaml", + ".ruff.toml", + ".ansible-lint", + # Environment templates (not the actual .env which is gitignored) + ".env.example", + # Git config + ".gitignore", + # Project-level documentation (not part of the installed package) + "AGENTS.md", + "README.md", + "CHANGELOG.md", + "TROUBLESHOOTING.md", + # Generated venv activation scripts (created by `make setup`) + "activate.sh", + "activate.fish", + "activate.zsh", + # CI task tracking file (written by CI, not by developers) + ".taskid", +] + + @dataclass class ClassifierConfig: """Configuration for the change classifier. Loaded from ``[tool.devx.classify]`` in ``pyproject.toml``. + By default, the framework's ``DEFAULT_INFRASTRUCTURE`` patterns are + merged with the project's ``infrastructure`` list. Set + ``use_defaults = false`` to disable defaults and specify all + patterns explicitly. + Attributes: - infrastructure: Glob patterns for infrastructure paths. + infrastructure: Glob patterns for infrastructure paths + (merged with DEFAULT_INFRASTRUCTURE unless use_defaults is False). 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. + use_defaults: If True (default), merge with DEFAULT_INFRASTRUCTURE. """ 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) + use_defaults: bool = True @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). + file is missing, returns a config with only DEFAULT_INFRASTRUCTURE + (everything else defaults to user-facing — safe-by-default). """ path = Path(pyproject_path) if not path.exists(): - return cls() + return cls(infrastructure=list(DEFAULT_INFRASTRUCTURE)) with open(path, "rb") as f: # noqa: PTH123 data: dict[str, Any] = tomllib.load(f) classify_cfg = data.get("tool", {}).get("devx", {}).get("classify", {}) + + use_defaults = classify_cfg.get("use_defaults", True) + project_infra = list(classify_cfg.get("infrastructure", [])) + + if use_defaults: + # Merge defaults with project-specific patterns (deduplicated) + merged = list(DEFAULT_INFRASTRUCTURE) + for p in project_infra: + if p not in merged: + merged.append(p) + infrastructure = merged + else: + infrastructure = project_infra + return cls( - infrastructure=list(classify_cfg.get("infrastructure", [])), + infrastructure=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()}, + use_defaults=use_defaults, ) @@ -506,27 +608,30 @@ def _write_github_output(key: str, value: str) -> None: @click.option("--quiet", is_flag=True, default=False, help="Only output true/false.") @click.option( "--check", - type=click.Choice(["all", "ansible", "user-facing"]), default="all", - help="Check specific category: all (default), ansible, or user-facing.", + help="Check specific category: 'all' (default), 'user-facing', or any tag name " + "defined in [tool.devx.classify.tags] (e.g., 'ansible').", ) @click.option( "--github-output", "github_output", is_flag=True, default=False, - help="Write results to $GITHUB_OUTPUT file (for CI workflow steps).", + help="Write results to $GITHUB_OUTPUT file (for CI workflow steps). " + "Outputs 'user-facing-changed' and '-changed' for each configured tag.", ) def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool) -> None: """Classify git changes and output results.""" classifier = _get_classifier() + available_tags = list(classifier.config.tags.keys()) if base is None: base = get_latest_tag() if not base: if github_output: - _write_github_output("ansible-changed", "true") _write_github_output("user-facing-changed", "true") + for tag in available_tags: + _write_github_output(f"{tag}-changed", "true") click.echo("No tags found — treating all changes as user-facing.") return if quiet: @@ -538,8 +643,9 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo files = get_changed_files(base, head) if not files: if github_output: - _write_github_output("ansible-changed", "false") _write_github_output("user-facing-changed", "false") + for tag in available_tags: + _write_github_output(f"{tag}-changed", "false") click.echo(f"No changes between {base} and {head}.") return if quiet: @@ -551,35 +657,40 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo result = classifier.classify(files) if github_output: - _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')}") + for tag in available_tags: + _write_github_output(f"{tag}-changed", "true" if result.has_tag(tag) else "false") click.echo(f"User-facing files changed: {result.has_user_facing}") + for tag in available_tags: + click.echo(f"{tag.capitalize()} files changed: {result.has_tag(tag)}") return - if check == "ansible": - ansible_files = result.tags.get("ansible", []) - has_ansible = bool(ansible_files) + # --check: check a specific tag or user-facing + if check != "all": + if check == "user-facing": + checked_files = result.user_facing + has_checked = bool(checked_files) + label = "User-facing" + elif check in available_tags: + checked_files = result.tags.get(check, []) + has_checked = bool(checked_files) + label = check.capitalize() + else: + raise click.ClickException( + _( + "Unknown check category '{check}'. Available: all, user-facing{tags}", + check=check, + tags=", " + ", ".join(available_tags) if available_tags else "", + ) + ) if quiet: - click.echo("true" if has_ansible else "false") + click.echo("true" if has_checked else "false") return - click.echo(_("\nAnsible files changed ({count}):", count=len(ansible_files))) - for f in ansible_files: - click.echo(f" {f}") - click.echo(_("\nResult: {status}", status="Ansible changes detected" if has_ansible else "No Ansible changes")) - return - - if check == "user-facing": - user_files = result.user_facing - has_user = bool(user_files) - if quiet: - click.echo("true" if has_user else "false") - return - click.echo(_("\nUser-facing files changed ({count}):", count=len(user_files))) - for f in user_files: + click.echo(_("\n{label} files changed ({count}):", label=label, count=len(checked_files))) + for f in checked_files: click.echo(f" {f}") click.echo( - _("\nResult: {status}", status="User-facing changes detected" if has_user else "No user-facing changes") + _("\nResult: {status}", status=f"{label} changes detected" if has_checked else f"No {label} changes") ) return @@ -596,6 +707,12 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo click.echo(_("\nWorkflow-only changes ({count}):", count=len(result.infrastructure))) for f in result.infrastructure: click.echo(f" {f}") + for tag in available_tags: + tag_files = result.tags.get(tag, []) + if tag_files: + click.echo(_("\n{tag} files ({count}):", tag=tag.capitalize(), count=len(tag_files))) + for f in tag_files: + click.echo(f" {f}") if has_user: status = "USER-FACING changes detected — release needed" else: diff --git a/src/devx/translations.json b/src/devx/translations.json index bef52bf..6d04cd6 100644 --- a/src/devx/translations.json +++ b/src/devx/translations.json @@ -639,5 +639,17 @@ "de": "unbekannt", "ru": "неизвестно", "zh": "未知" + }, + "\n{label} files changed ({count}):": { + "en": "\n{label} files changed ({count}):" + }, + "\n{tag} files ({count}):": { + "en": "\n{tag} files ({count}):" + }, + "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}": { + "en": "Oops! PR title must follow format '{prefix}-N: '.\n Expected: {task_id}: \n Got: {pr_title}" + }, + "Unknown check category '{check}'. Available: all, user-facing{tags}": { + "en": "Unknown check category '{check}'. Available: all, user-facing{tags}" } } diff --git a/tests/unit/test_classify_changes.py b/tests/unit/test_classify_changes.py index ed4e87c..b9b5a12 100644 --- a/tests/unit/test_classify_changes.py +++ b/tests/unit/test_classify_changes.py @@ -21,6 +21,7 @@ from click.testing import CliRunner import devx.ci.classify_changes as classify_changes_mod from devx.ci.classify_changes import ( + DEFAULT_INFRASTRUCTURE, ChangeClassifier, ClassificationResult, ClassifierConfig, @@ -108,11 +109,11 @@ class TestMatchesGlob: class TestClassifierConfig: - def test_from_pyproject_loads_config(self, tmp_path: Path) -> None: + def test_from_pyproject_merges_with_defaults(self, tmp_path: Path) -> None: pyproject = tmp_path / "pyproject.toml" pyproject.write_text( "[tool.devx.classify]\n" - 'infrastructure = [".gitea/**", "tests/**"]\n' + 'infrastructure = ["scripts/**"]\n' 'infrastructure_overrides = ["src/pkg/__init__.py"]\n' 'user_facing_overrides = ["docs/important.py"]\n' "\n" @@ -120,39 +121,62 @@ class TestClassifierConfig: 'ansible = ["ansible/**"]\n' ) config = ClassifierConfig.from_pyproject(str(pyproject)) - assert config.infrastructure == [".gitea/**", "tests/**"] + # Project-specific path is merged with defaults + assert "scripts/**" in config.infrastructure + assert ".gitea/**" in config.infrastructure # from DEFAULT_INFRASTRUCTURE + assert "tests/**" in config.infrastructure # from DEFAULT_INFRASTRUCTURE + assert config.use_defaults is True 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: + def test_from_pyproject_use_defaults_false(self, tmp_path: Path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[tool.devx.classify]\nuse_defaults = false\ninfrastructure = [".gitea/**"]\n') + config = ClassifierConfig.from_pyproject(str(pyproject)) + assert config.infrastructure == [".gitea/**"] + assert "tests/**" not in config.infrastructure # no defaults + assert config.use_defaults is False + + def test_from_pyproject_missing_file_returns_defaults(self) -> None: config = ClassifierConfig.from_pyproject("/nonexistent/pyproject.toml") - assert config.infrastructure == [] + assert config.infrastructure == list(DEFAULT_INFRASTRUCTURE) assert config.infrastructure_overrides == [] assert config.user_facing_overrides == [] assert config.tags == {} + assert config.use_defaults is True - def test_from_pyproject_missing_section(self, tmp_path: Path) -> None: + def test_from_pyproject_missing_section_returns_defaults(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 == [] + assert config.infrastructure == list(DEFAULT_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') + pyproject.write_text('[tool.devx.classify]\ninfrastructure = ["scripts/**"]\n') config = ClassifierConfig.from_pyproject(str(pyproject)) - assert config.infrastructure == [".gitea/**"] + assert "scripts/**" in config.infrastructure + assert ".gitea/**" in config.infrastructure # merged with defaults assert config.infrastructure_overrides == [] assert config.user_facing_overrides == [] assert config.tags == {} - def test_defaults_are_empty(self) -> None: + def test_defaults_are_empty_for_bare_constructor(self) -> None: + """ClassifierConfig() without from_pyproject has empty lists.""" config = ClassifierConfig() assert config.infrastructure == [] assert config.infrastructure_overrides == [] assert config.user_facing_overrides == [] assert config.tags == {} + assert config.use_defaults is True + + def test_default_infrastructure_is_non_empty(self) -> None: + """The framework ships with a curated default infrastructure list.""" + assert len(DEFAULT_INFRASTRUCTURE) > 0 + assert ".gitea/**" in DEFAULT_INFRASTRUCTURE + assert "tests/**" in DEFAULT_INFRASTRUCTURE + assert "docs/**" in DEFAULT_INFRASTRUCTURE # --------------------------------------------------------------------------- @@ -438,6 +462,26 @@ class TestMain: assert result.exit_code == 0 assert "release needed" in result.output + @patch("devx.ci.classify_changes._get_classifier") + @patch("devx.ci.classify_changes.get_changed_files") + @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") + def test_default_mode_displays_tags( + self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock + ) -> None: + """Default mode shows tag files when tags are configured.""" + mock_changes.return_value = ["src/devx/cli.py", "ansible/tasks/main.yml"] + mock_clf.return_value = ChangeClassifier( + ClassifierConfig( + infrastructure=[".gitea/**"], + tags={"ansible": ["ansible/**"]}, + ) + ) + runner = CliRunner() + result = runner.invoke(main, []) + assert result.exit_code == 0 + assert "Ansible files" in result.output + assert "ansible/tasks/main.yml" in result.output + @patch("devx.ci.classify_changes.get_latest_tag", return_value="") def test_no_tags_non_quiet(self, mock_tag: MagicMock) -> None: runner = CliRunner() @@ -480,19 +524,33 @@ class TestMain: assert result.exit_code == 0 assert "release needed" in result.output + @patch("devx.ci.classify_changes._get_classifier") @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: + def test_check_ansible_true(self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock) -> None: mock_changes.return_value = ["ansible/tasks/main.yml", ".gitea/workflows/ci.yml"] + mock_clf.return_value = ChangeClassifier( + ClassifierConfig( + infrastructure=[".gitea/**"], + tags={"ansible": ["ansible/**"]}, + ) + ) runner = CliRunner() result = runner.invoke(main, ["--check", "ansible", "--quiet"]) assert result.exit_code == 0 assert "true" in result.output + @patch("devx.ci.classify_changes._get_classifier") @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: + def test_check_ansible_false(self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock) -> None: mock_changes.return_value = ["src/devx/cli.py", ".gitea/workflows/ci.yml"] + mock_clf.return_value = ChangeClassifier( + ClassifierConfig( + infrastructure=[".gitea/**"], + tags={"ansible": ["ansible/**"]}, + ) + ) runner = CliRunner() result = runner.invoke(main, ["--check", "ansible", "--quiet"]) assert result.exit_code == 0 @@ -516,15 +574,38 @@ class TestMain: assert result.exit_code == 0 assert "false" in result.output + @patch("devx.ci.classify_changes._get_classifier") @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: + def test_check_ansible_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock) -> None: mock_changes.return_value = ["ansible/tasks/main.yml"] + mock_clf.return_value = ChangeClassifier( + ClassifierConfig( + infrastructure=[".gitea/**"], + tags={"ansible": ["ansible/**"]}, + ) + ) runner = CliRunner() result = runner.invoke(main, ["--check", "ansible"]) assert result.exit_code == 0 assert "Ansible changes detected" in result.output + @patch("devx.ci.classify_changes._get_classifier") + @patch("devx.ci.classify_changes.get_changed_files") + @patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0") + def test_check_unknown_tag_raises(self, mock_tag: MagicMock, mock_changes: MagicMock, mock_clf: MagicMock) -> None: + mock_changes.return_value = ["src/devx/cli.py"] + mock_clf.return_value = ChangeClassifier( + ClassifierConfig( + infrastructure=[".gitea/**"], + tags={"ansible": ["ansible/**"]}, + ) + ) + runner = CliRunner() + result = runner.invoke(main, ["--check", "nonexistent"]) + assert result.exit_code != 0 + assert "Unknown check category" in result.output + @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: @@ -536,7 +617,17 @@ class TestMain: class TestGithubOutput: - def test_writes_outputs(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + def _make_classifier_with_ansible(self) -> ChangeClassifier: + return ChangeClassifier( + ClassifierConfig( + infrastructure=[".gitea/**", "AGENTS.md"], + tags={"ansible": ["ansible/**"]}, + ) + ) + + @patch("devx.ci.classify_changes._get_classifier") + def test_writes_outputs(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + mock_clf.return_value = self._make_classifier_with_ansible() gh_file = tmp_path / "output.txt" monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file)) with patch.object( @@ -549,7 +640,9 @@ class TestGithubOutput: assert "ansible-changed=true" in content assert "user-facing-changed=true" in content - def test_no_changes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + @patch("devx.ci.classify_changes._get_classifier") + def test_no_changes(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + mock_clf.return_value = self._make_classifier_with_ansible() gh_file = tmp_path / "output.txt" monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file)) with patch.object(classify_changes_mod, "get_changed_files", return_value=[]): @@ -560,7 +653,9 @@ class TestGithubOutput: assert "ansible-changed=false" in content assert "user-facing-changed=false" in content - def test_no_tags(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + @patch("devx.ci.classify_changes._get_classifier") + def test_no_tags(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + mock_clf.return_value = self._make_classifier_with_ansible() gh_file = tmp_path / "output.txt" monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file)) with patch.object(classify_changes_mod, "get_latest_tag", return_value=""): @@ -578,7 +673,9 @@ class TestGithubOutput: result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"]) assert result.exit_code != 0 - def test_workflow_only(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + @patch("devx.ci.classify_changes._get_classifier") + def test_workflow_only(self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + mock_clf.return_value = self._make_classifier_with_ansible() gh_file = tmp_path / "output.txt" monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file)) with patch.object( @@ -590,3 +687,25 @@ class TestGithubOutput: content = gh_file.read_text() assert "ansible-changed=false" in content assert "user-facing-changed=false" in content + + @patch("devx.ci.classify_changes._get_classifier") + def test_no_tags_outputs_all_tags_true( + self, mock_clf: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """When no tags exist, only user-facing-changed is written.""" + mock_clf.return_value = ChangeClassifier( + ClassifierConfig( + infrastructure=[".gitea/**"], + tags={}, + ) + ) + gh_file = tmp_path / "output.txt" + monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file)) + with patch.object(classify_changes_mod, "get_latest_tag", return_value=""): + runner = CliRunner() + result = runner.invoke(main, ["--github-output"]) + assert result.exit_code == 0 + content = gh_file.read_text() + assert "user-facing-changed=true" in content + # No tag outputs since no tags are configured + assert "ansible-changed" not in content