Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c3e8a7b8d | ||
|
|
6436c5dd38 |
@@ -12,6 +12,8 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
## [0.1.0] - 2026-06-22
|
## [0.1.0] - 2026-06-22
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-06-22
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- Extract reusable dev/CI tools from GRM into devx package
|
- Extract reusable dev/CI tools from GRM into devx package
|
||||||
|
|||||||
+17
-27
@@ -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
|
# Change classification — determines which changes trigger a release
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Safe-by-default: any file NOT listed here defaults to user-facing
|
# The framework provides DEFAULT_INFRASTRUCTURE (CI workflows, tests, docs,
|
||||||
# (requiring a release). This prevents new file types from silently
|
# lint config, etc.) that applies to any Python project. We only specify
|
||||||
# skipping releases.
|
# what's different about devx.
|
||||||
#
|
#
|
||||||
# Rule priority (first match wins):
|
# Rule priority (first match wins):
|
||||||
# 1. user_facing_overrides (safety — highest priority)
|
# 1. user_facing_overrides (safety — highest priority)
|
||||||
# 2. infrastructure_overrides (explicit per-file)
|
# 2. infrastructure_overrides (explicit per-file)
|
||||||
# 3. infrastructure (glob patterns)
|
# 3. infrastructure (DEFAULT_INFRASTRUCTURE + project-specific patterns)
|
||||||
# 4. Default: user-facing (safe)
|
# 4. Default: user-facing (safe)
|
||||||
[tool.devx.classify]
|
[tool.devx.classify]
|
||||||
# Infrastructure paths — changes here don't trigger a release
|
# use_defaults = true # (default) merge with DEFAULT_INFRASTRUCTURE
|
||||||
infrastructure = [
|
|
||||||
".gitea/**",
|
# Project-specific infrastructure paths (merged with defaults).
|
||||||
"tests/**",
|
# devx has no additional infrastructure paths — everything not in the
|
||||||
"docs/**",
|
# defaults is user-facing (src/devx/**, pyproject.toml, translations.json).
|
||||||
"hooks/**",
|
infrastructure = []
|
||||||
"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
|
# Infrastructure overrides — files that would default to user-facing
|
||||||
# but are actually infrastructure:
|
# but are actually infrastructure:
|
||||||
# - __init__.py: only contains __version__ (release artifact, not code)
|
# - __init__.py: only contains __version__ (set by release.py, not user code)
|
||||||
# - api_clients.py: used only by CI/CD scripts, not by the CLI
|
#
|
||||||
|
# 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 = [
|
infrastructure_overrides = [
|
||||||
"src/devx/__init__.py",
|
"src/devx/__init__.py",
|
||||||
"src/devx/api_clients.py",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# User-facing overrides — safety override for broad infrastructure patterns
|
# User-facing overrides — safety override for broad infrastructure patterns
|
||||||
@@ -133,4 +123,4 @@ user_facing_overrides = []
|
|||||||
# Tag patterns — additional categories for CI conditional execution
|
# Tag patterns — additional categories for CI conditional execution
|
||||||
# Orthogonal to release impact (user-facing vs infrastructure)
|
# Orthogonal to release impact (user-facing vs infrastructure)
|
||||||
[tool.devx.classify.tags]
|
[tool.devx.classify.tags]
|
||||||
ansible = ["ansible/**", ".ansible-lint"]
|
# No tags needed for devx itself — it has no ansible/ directory
|
||||||
|
|||||||
@@ -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
|
validates the PR title, and squash-merges with a conventional commit
|
||||||
message prefixed by the task ID.
|
message prefixed by the task ID.
|
||||||
|
|
||||||
PR title format: ``DEVX-N: <vikunja task title>``
|
PR title format: ``{PREFIX}-N: <vikunja task title>``
|
||||||
Merge commit format: ``DEVX-N: <conventional commit message>``
|
Merge commit format: ``{PREFIX}-N <conventional commit message>``
|
||||||
|
|
||||||
|
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.
|
The conventional commit message is extracted from the PR commits.
|
||||||
This allows the PR title to be a human-friendly Vikunja task title
|
This allows the PR title to be a human-friendly Vikunja task title
|
||||||
@@ -32,6 +35,7 @@ from devx.config import (
|
|||||||
DEFAULT_PER_PAGE,
|
DEFAULT_PER_PAGE,
|
||||||
GITEA_API_URL,
|
GITEA_API_URL,
|
||||||
TASK_ID_RE,
|
TASK_ID_RE,
|
||||||
|
TASK_PREFIX,
|
||||||
VIKUNJA_API_URL,
|
VIKUNJA_API_URL,
|
||||||
VIKUNJA_PROJECT_ID,
|
VIKUNJA_PROJECT_ID,
|
||||||
)
|
)
|
||||||
@@ -39,7 +43,7 @@ from devx.exceptions import APIError
|
|||||||
from devx.i18n import _
|
from devx.i18n import _
|
||||||
|
|
||||||
TASKID_FILE = ".taskid"
|
TASKID_FILE = ".taskid"
|
||||||
PR_TITLE_RE = re.compile(r"^DEVX-\d+:\s+.+")
|
PR_TITLE_RE = re.compile(rf"^{TASK_PREFIX}-\d+:\s+.+")
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
@@ -84,14 +88,15 @@ def extract_task_id(branch: str) -> str:
|
|||||||
def validate_pr_title(pr_title: str, task_id: str) -> None:
|
def validate_pr_title(pr_title: str, task_id: str) -> None:
|
||||||
"""Raise ClickException if PR title does not follow the required format.
|
"""Raise ClickException if PR title does not follow the required format.
|
||||||
|
|
||||||
Expected: ``DEVX-N: <vikunja task title>``
|
Expected: ``{PREFIX}-N: <vikunja task title>``
|
||||||
"""
|
"""
|
||||||
if not PR_TITLE_RE.match(pr_title):
|
if not PR_TITLE_RE.match(pr_title):
|
||||||
raise click.ClickException(
|
raise click.ClickException(
|
||||||
_(
|
_(
|
||||||
"Oops! PR title must follow format 'DEVX-N: <task title>'.\n"
|
"Oops! PR title must follow format '{prefix}-N: <task title>'.\n"
|
||||||
" Expected: {task_id}: <task title>\n"
|
" Expected: {task_id}: <task title>\n"
|
||||||
" Got: {pr_title}",
|
" Got: {pr_title}",
|
||||||
|
prefix=TASK_PREFIX,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
pr_title=pr_title,
|
pr_title=pr_title,
|
||||||
)
|
)
|
||||||
|
|||||||
+163
-46
@@ -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
|
user-facing. This prevents new file types from accidentally skipping
|
||||||
releases — a critical safety property. When in doubt, release.
|
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]``
|
**Config-driven**: Classification rules are read from ``[tool.devx.classify]``
|
||||||
in ``pyproject.toml``. No project needs to modify the framework code.
|
in ``pyproject.toml``. No project needs to modify the framework code.
|
||||||
Each project declares its own paths; the framework handles the logic.
|
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).
|
``__version__`` — a release artifact, not user-facing code).
|
||||||
|
|
||||||
3. **Infrastructure patterns** (deny-list)
|
3. **Infrastructure patterns** (deny-list)
|
||||||
Path globs matching infrastructure files. Changes to these don't
|
Path globs matching infrastructure files. This is the union of
|
||||||
trigger a release. Examples: ``.gitea/**``, ``tests/**``, ``docs/**``.
|
``DEFAULT_INFRASTRUCTURE`` and the project's ``infrastructure`` list.
|
||||||
|
Changes to these don't trigger a release.
|
||||||
|
|
||||||
4. **Default**: user-facing (lowest priority — safe default)
|
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)
|
conditional execution. A file can be both infrastructure (no release)
|
||||||
and tagged ``ansible`` (run molecule tests). Tags are evaluated
|
and tagged ``ansible`` (run molecule tests). Tags are evaluated
|
||||||
independently of the user-facing/infrastructure classification.
|
independently of the user-facing/infrastructure classification.
|
||||||
|
The ``--check`` CLI option accepts any tag name defined in the config,
|
||||||
|
and ``--github-output`` writes ``<tag>-changed`` for each configured tag.
|
||||||
|
|
||||||
== Configuration ==
|
== Configuration ==
|
||||||
|
|
||||||
In ``pyproject.toml``::
|
In ``pyproject.toml``::
|
||||||
|
|
||||||
[tool.devx.classify]
|
[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 = [
|
infrastructure = [
|
||||||
".gitea/**",
|
"scripts/**", # e.g., if scripts/ is dev-only tooling
|
||||||
"tests/**",
|
|
||||||
"docs/**",
|
|
||||||
"Makefile",
|
|
||||||
"AGENTS.md",
|
|
||||||
"README.md",
|
|
||||||
"CHANGELOG.md",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Infrastructure overrides — files that would default to user-facing
|
# Infrastructure overrides — files that would default to user-facing
|
||||||
# but are actually infrastructure
|
# but are actually infrastructure
|
||||||
infrastructure_overrides = [
|
infrastructure_overrides = [
|
||||||
"src/devx/__init__.py",
|
"src/mypkg/__init__.py", # only contains __version__
|
||||||
]
|
]
|
||||||
|
|
||||||
# User-facing overrides — safety override for broad infrastructure patterns
|
# User-facing overrides — safety override for broad infrastructure patterns
|
||||||
@@ -72,13 +80,36 @@ In ``pyproject.toml``::
|
|||||||
[tool.devx.classify.tags]
|
[tool.devx.classify.tags]
|
||||||
ansible = ["ansible/**", ".ansible-lint"]
|
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 ==
|
== Glob Syntax ==
|
||||||
|
|
||||||
Patterns support standard glob syntax:
|
Patterns support standard glob syntax:
|
||||||
|
|
||||||
- ``**`` matches any number of path segments (including zero)
|
- ``**`` matches any number of path segments (including zero)
|
||||||
- ``*`` matches any characters within a single path segment
|
- ``*`` 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
|
- Everything else is matched literally
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
@@ -90,6 +121,8 @@ Examples:
|
|||||||
Usage:
|
Usage:
|
||||||
python3 -m devx.ci.classify_changes [--base <ref>] [--head <ref>]
|
python3 -m devx.ci.classify_changes [--base <ref>] [--head <ref>]
|
||||||
python3 -m devx.ci.classify_changes --base v0.3.0 --head 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
|
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.
|
"""Convert a glob pattern to a compiled regex.
|
||||||
|
|
||||||
Supports:
|
Supports:
|
||||||
- ``**`` → matches any number of path segments (including zero)
|
- ``**`` -> matches any number of path segments (including zero)
|
||||||
- ``*`` → matches any chars within a single path segment
|
- ``*`` -> matches any chars within a single path segment
|
||||||
- ``?`` → matches a single char within a path segment
|
- ``?`` -> matches a single char within a path segment
|
||||||
- All other characters are matched literally
|
- All other characters are matched literally
|
||||||
"""
|
"""
|
||||||
# Handle ** at the end (e.g., ".gitea/**")
|
# 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
|
@dataclass
|
||||||
class ClassifierConfig:
|
class ClassifierConfig:
|
||||||
"""Configuration for the change classifier.
|
"""Configuration for the change classifier.
|
||||||
|
|
||||||
Loaded from ``[tool.devx.classify]`` in ``pyproject.toml``.
|
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:
|
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
|
infrastructure_overrides: Exact paths that are infrastructure
|
||||||
despite not matching any infrastructure pattern.
|
despite not matching any infrastructure pattern.
|
||||||
user_facing_overrides: Exact paths that are user-facing
|
user_facing_overrides: Exact paths that are user-facing
|
||||||
despite matching an infrastructure pattern (safety override).
|
despite matching an infrastructure pattern (safety override).
|
||||||
tags: Dict mapping tag name to list of glob patterns.
|
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: list[str] = field(default_factory=list)
|
||||||
infrastructure_overrides: list[str] = field(default_factory=list)
|
infrastructure_overrides: list[str] = field(default_factory=list)
|
||||||
user_facing_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)
|
tags: dict[str, list[str]] = field(default_factory=dict)
|
||||||
|
use_defaults: bool = True
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_pyproject(cls, pyproject_path: str = "pyproject.toml") -> ClassifierConfig:
|
def from_pyproject(cls, pyproject_path: str = "pyproject.toml") -> ClassifierConfig:
|
||||||
"""Load classifier config from pyproject.toml.
|
"""Load classifier config from pyproject.toml.
|
||||||
|
|
||||||
Reads the ``[tool.devx.classify]`` section. If the section or
|
Reads the ``[tool.devx.classify]`` section. If the section or
|
||||||
file is missing, returns a config with empty lists (everything
|
file is missing, returns a config with only DEFAULT_INFRASTRUCTURE
|
||||||
defaults to user-facing — safe-by-default).
|
(everything else defaults to user-facing — safe-by-default).
|
||||||
"""
|
"""
|
||||||
path = Path(pyproject_path)
|
path = Path(pyproject_path)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
return cls()
|
return cls(infrastructure=list(DEFAULT_INFRASTRUCTURE))
|
||||||
with open(path, "rb") as f: # noqa: PTH123
|
with open(path, "rb") as f: # noqa: PTH123
|
||||||
data: dict[str, Any] = tomllib.load(f)
|
data: dict[str, Any] = tomllib.load(f)
|
||||||
classify_cfg = data.get("tool", {}).get("devx", {}).get("classify", {})
|
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(
|
return cls(
|
||||||
infrastructure=list(classify_cfg.get("infrastructure", [])),
|
infrastructure=infrastructure,
|
||||||
infrastructure_overrides=list(classify_cfg.get("infrastructure_overrides", [])),
|
infrastructure_overrides=list(classify_cfg.get("infrastructure_overrides", [])),
|
||||||
user_facing_overrides=list(classify_cfg.get("user_facing_overrides", [])),
|
user_facing_overrides=list(classify_cfg.get("user_facing_overrides", [])),
|
||||||
tags={k: list(v) for k, v in classify_cfg.get("tags", {}).items()},
|
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("--quiet", is_flag=True, default=False, help="Only output true/false.")
|
||||||
@click.option(
|
@click.option(
|
||||||
"--check",
|
"--check",
|
||||||
type=click.Choice(["all", "ansible", "user-facing"]),
|
|
||||||
default="all",
|
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(
|
@click.option(
|
||||||
"--github-output",
|
"--github-output",
|
||||||
"github_output",
|
"github_output",
|
||||||
is_flag=True,
|
is_flag=True,
|
||||||
default=False,
|
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 '<tag>-changed' for each configured tag.",
|
||||||
)
|
)
|
||||||
def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool) -> None:
|
def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool) -> None:
|
||||||
"""Classify git changes and output results."""
|
"""Classify git changes and output results."""
|
||||||
classifier = _get_classifier()
|
classifier = _get_classifier()
|
||||||
|
available_tags = list(classifier.config.tags.keys())
|
||||||
|
|
||||||
if base is None:
|
if base is None:
|
||||||
base = get_latest_tag()
|
base = get_latest_tag()
|
||||||
if not base:
|
if not base:
|
||||||
if github_output:
|
if github_output:
|
||||||
_write_github_output("ansible-changed", "true")
|
|
||||||
_write_github_output("user-facing-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.")
|
click.echo("No tags found — treating all changes as user-facing.")
|
||||||
return
|
return
|
||||||
if quiet:
|
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)
|
files = get_changed_files(base, head)
|
||||||
if not files:
|
if not files:
|
||||||
if github_output:
|
if github_output:
|
||||||
_write_github_output("ansible-changed", "false")
|
|
||||||
_write_github_output("user-facing-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}.")
|
click.echo(f"No changes between {base} and {head}.")
|
||||||
return
|
return
|
||||||
if quiet:
|
if quiet:
|
||||||
@@ -551,35 +657,40 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
|
|||||||
result = classifier.classify(files)
|
result = classifier.classify(files)
|
||||||
|
|
||||||
if github_output:
|
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")
|
_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}")
|
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
|
return
|
||||||
|
|
||||||
if check == "ansible":
|
# --check: check a specific tag or user-facing
|
||||||
ansible_files = result.tags.get("ansible", [])
|
if check != "all":
|
||||||
has_ansible = bool(ansible_files)
|
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:
|
if quiet:
|
||||||
click.echo("true" if has_ansible else "false")
|
click.echo("true" if has_checked else "false")
|
||||||
return
|
return
|
||||||
click.echo(_("\nAnsible files changed ({count}):", count=len(ansible_files)))
|
click.echo(_("\n{label} files changed ({count}):", label=label, count=len(checked_files)))
|
||||||
for f in ansible_files:
|
for f in checked_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(f" {f}")
|
click.echo(f" {f}")
|
||||||
click.echo(
|
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
|
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)))
|
click.echo(_("\nWorkflow-only changes ({count}):", count=len(result.infrastructure)))
|
||||||
for f in result.infrastructure:
|
for f in result.infrastructure:
|
||||||
click.echo(f" {f}")
|
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:
|
if has_user:
|
||||||
status = "USER-FACING changes detected — release needed"
|
status = "USER-FACING changes detected — release needed"
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -639,5 +639,17 @@
|
|||||||
"de": "unbekannt",
|
"de": "unbekannt",
|
||||||
"ru": "неизвестно",
|
"ru": "неизвестно",
|
||||||
"zh": "未知"
|
"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: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}": {
|
||||||
|
"en": "Oops! PR title must follow format '{prefix}-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}"
|
||||||
|
},
|
||||||
|
"Unknown check category '{check}'. Available: all, user-facing{tags}": {
|
||||||
|
"en": "Unknown check category '{check}'. Available: all, user-facing{tags}"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from click.testing import CliRunner
|
|||||||
|
|
||||||
import devx.ci.classify_changes as classify_changes_mod
|
import devx.ci.classify_changes as classify_changes_mod
|
||||||
from devx.ci.classify_changes import (
|
from devx.ci.classify_changes import (
|
||||||
|
DEFAULT_INFRASTRUCTURE,
|
||||||
ChangeClassifier,
|
ChangeClassifier,
|
||||||
ClassificationResult,
|
ClassificationResult,
|
||||||
ClassifierConfig,
|
ClassifierConfig,
|
||||||
@@ -108,11 +109,11 @@ class TestMatchesGlob:
|
|||||||
|
|
||||||
|
|
||||||
class TestClassifierConfig:
|
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 = tmp_path / "pyproject.toml"
|
||||||
pyproject.write_text(
|
pyproject.write_text(
|
||||||
"[tool.devx.classify]\n"
|
"[tool.devx.classify]\n"
|
||||||
'infrastructure = [".gitea/**", "tests/**"]\n'
|
'infrastructure = ["scripts/**"]\n'
|
||||||
'infrastructure_overrides = ["src/pkg/__init__.py"]\n'
|
'infrastructure_overrides = ["src/pkg/__init__.py"]\n'
|
||||||
'user_facing_overrides = ["docs/important.py"]\n'
|
'user_facing_overrides = ["docs/important.py"]\n'
|
||||||
"\n"
|
"\n"
|
||||||
@@ -120,39 +121,62 @@ class TestClassifierConfig:
|
|||||||
'ansible = ["ansible/**"]\n'
|
'ansible = ["ansible/**"]\n'
|
||||||
)
|
)
|
||||||
config = ClassifierConfig.from_pyproject(str(pyproject))
|
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.infrastructure_overrides == ["src/pkg/__init__.py"]
|
||||||
assert config.user_facing_overrides == ["docs/important.py"]
|
assert config.user_facing_overrides == ["docs/important.py"]
|
||||||
assert config.tags == {"ansible": ["ansible/**"]}
|
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")
|
config = ClassifierConfig.from_pyproject("/nonexistent/pyproject.toml")
|
||||||
assert config.infrastructure == []
|
assert config.infrastructure == list(DEFAULT_INFRASTRUCTURE)
|
||||||
assert config.infrastructure_overrides == []
|
assert config.infrastructure_overrides == []
|
||||||
assert config.user_facing_overrides == []
|
assert config.user_facing_overrides == []
|
||||||
assert config.tags == {}
|
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 = tmp_path / "pyproject.toml"
|
||||||
pyproject.write_text('[project]\nname = "test"\n')
|
pyproject.write_text('[project]\nname = "test"\n')
|
||||||
config = ClassifierConfig.from_pyproject(str(pyproject))
|
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:
|
def test_from_pyproject_partial_config(self, tmp_path: Path) -> None:
|
||||||
pyproject = tmp_path / "pyproject.toml"
|
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))
|
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.infrastructure_overrides == []
|
||||||
assert config.user_facing_overrides == []
|
assert config.user_facing_overrides == []
|
||||||
assert config.tags == {}
|
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()
|
config = ClassifierConfig()
|
||||||
assert config.infrastructure == []
|
assert config.infrastructure == []
|
||||||
assert config.infrastructure_overrides == []
|
assert config.infrastructure_overrides == []
|
||||||
assert config.user_facing_overrides == []
|
assert config.user_facing_overrides == []
|
||||||
assert config.tags == {}
|
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 result.exit_code == 0
|
||||||
assert "release needed" in result.output
|
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="")
|
@patch("devx.ci.classify_changes.get_latest_tag", return_value="")
|
||||||
def test_no_tags_non_quiet(self, mock_tag: MagicMock) -> None:
|
def test_no_tags_non_quiet(self, mock_tag: MagicMock) -> None:
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
@@ -480,19 +524,33 @@ class TestMain:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "release needed" in result.output
|
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_changed_files")
|
||||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
@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_changes.return_value = ["ansible/tasks/main.yml", ".gitea/workflows/ci.yml"]
|
||||||
|
mock_clf.return_value = ChangeClassifier(
|
||||||
|
ClassifierConfig(
|
||||||
|
infrastructure=[".gitea/**"],
|
||||||
|
tags={"ansible": ["ansible/**"]},
|
||||||
|
)
|
||||||
|
)
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, ["--check", "ansible", "--quiet"])
|
result = runner.invoke(main, ["--check", "ansible", "--quiet"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "true" in result.output
|
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_changed_files")
|
||||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
@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_changes.return_value = ["src/devx/cli.py", ".gitea/workflows/ci.yml"]
|
||||||
|
mock_clf.return_value = ChangeClassifier(
|
||||||
|
ClassifierConfig(
|
||||||
|
infrastructure=[".gitea/**"],
|
||||||
|
tags={"ansible": ["ansible/**"]},
|
||||||
|
)
|
||||||
|
)
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, ["--check", "ansible", "--quiet"])
|
result = runner.invoke(main, ["--check", "ansible", "--quiet"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@@ -516,15 +574,38 @@ class TestMain:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "false" in result.output
|
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_changed_files")
|
||||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
@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_changes.return_value = ["ansible/tasks/main.yml"]
|
||||||
|
mock_clf.return_value = ChangeClassifier(
|
||||||
|
ClassifierConfig(
|
||||||
|
infrastructure=[".gitea/**"],
|
||||||
|
tags={"ansible": ["ansible/**"]},
|
||||||
|
)
|
||||||
|
)
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, ["--check", "ansible"])
|
result = runner.invoke(main, ["--check", "ansible"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Ansible changes detected" in result.output
|
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_changed_files")
|
||||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
@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:
|
def test_check_user_facing_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||||
@@ -536,7 +617,17 @@ class TestMain:
|
|||||||
|
|
||||||
|
|
||||||
class TestGithubOutput:
|
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"
|
gh_file = tmp_path / "output.txt"
|
||||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||||
with patch.object(
|
with patch.object(
|
||||||
@@ -549,7 +640,9 @@ class TestGithubOutput:
|
|||||||
assert "ansible-changed=true" in content
|
assert "ansible-changed=true" in content
|
||||||
assert "user-facing-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"
|
gh_file = tmp_path / "output.txt"
|
||||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||||
with patch.object(classify_changes_mod, "get_changed_files", return_value=[]):
|
with patch.object(classify_changes_mod, "get_changed_files", return_value=[]):
|
||||||
@@ -560,7 +653,9 @@ class TestGithubOutput:
|
|||||||
assert "ansible-changed=false" in content
|
assert "ansible-changed=false" in content
|
||||||
assert "user-facing-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"
|
gh_file = tmp_path / "output.txt"
|
||||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||||
with patch.object(classify_changes_mod, "get_latest_tag", return_value=""):
|
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"])
|
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
|
||||||
assert result.exit_code != 0
|
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"
|
gh_file = tmp_path / "output.txt"
|
||||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||||
with patch.object(
|
with patch.object(
|
||||||
@@ -590,3 +687,25 @@ class TestGithubOutput:
|
|||||||
content = gh_file.read_text()
|
content = gh_file.read_text()
|
||||||
assert "ansible-changed=false" in content
|
assert "ansible-changed=false" in content
|
||||||
assert "user-facing-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
|
||||||
|
|||||||
Reference in New Issue
Block a user