Compare commits

...
6 Commits
13 changed files with 511 additions and 129 deletions
+1 -1
View File
@@ -1 +1 @@
DEVX-3 DEVX-6
+33 -16
View File
@@ -2,10 +2,37 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [0.1.0] - 2026-06-22 ## [0.4.1] - 2026-06-22
## [0.1.0] - 2026-06-22 ### Bug Fixes
- Correct version tags, changelog, and release script recovery
## [0.4.0] - 2026-06-22
### Features
- Add DEFAULT_INFRASTRUCTURE and configurable task prefix
## [0.3.0] - 2026-06-22
### Features
- Add --no-ansible-collections option to setup tool
## [0.2.0] - 2026-06-22
### Features
- Pluggable change classification framework
## [0.1.2] - 2026-06-22
### Bug Fixes
- Make sync-wiki and vikunja depend on release
## [0.1.1] - 2026-06-22
### Bug Fixes
- Disable push whitelist, allow direct pushes to master
## [0.1.0] - 2026-06-22 ## [0.1.0] - 2026-06-22
## [0.1.0] - 2026-06-22 ## [0.1.0] - 2026-06-22
@@ -14,18 +41,8 @@ All notable changes to this project will be documented in this file.
- Extract reusable dev/CI tools from GRM into devx package - Extract reusable dev/CI tools from GRM into devx package
## [unreleased] ### Bug Fixes
### Features
- Extract reusable development and CI/CD tools from GRM into a standalone Python package
- Port core modules: config, exceptions, i18n, api_clients, gitea_cli
- Port 14 CI scripts: auto_merge, check_translations, classify_changes, detect_release_commit, discover_runners, doc_coverage, notify_failure, post_merge, pr_review, publish, push_badges, release, sync_wiki, validate_commit_msg
- Port 6 dev tools: check_test_speed, generate_badges, install_checkmake, install_tools, setup, configure_repo
- Port 5 molecule tools as optional extra: platforms, distribute_molecule, discover_runners, molecule_ci_guard, molecule_all
- Add CLI entry point with subcommands: devx ci, devx tools, devx molecule
- Add Gitea PyPI registry publishing support in publish.py
- Add configurable workflow-only patterns in classify_changes.py
- Add configurable version file path in release.py
- Replicate GRM's automated workflow: CI, auto-merge, post-merge, release, badges, wiki sync, Vikunja
- Use python3 and venv python in workflows and Makefile
- Fix post-merge job failures (configure-repo, badges, notify-failure)
- Allow release bot to push to protected master
+5 -2
View File
@@ -39,8 +39,8 @@ sort_commits = "oldest"
recurse_submodules = false recurse_submodules = false
commit_preprocessors = [ commit_preprocessors = [
# Strip DEVX-N task ID prefix from merge commits so git-cliff sees conventional commits # Strip DEVX-N: task ID prefix from squash-merge commits so git-cliff sees conventional commits
{ pattern = "^DEVX-\\d+\\s+", replace = "" }, { pattern = "^DEVX-\\d+:\\s+", replace = "" },
] ]
commit_parsers = [ commit_parsers = [
@@ -66,3 +66,6 @@ commit_parsers = [
features_always_bump_minor = true features_always_bump_minor = true
breaking_always_bump_major = false breaking_always_bump_major = false
initial_tag = "0.1.0" initial_tag = "0.1.0"
# Refactor commits bump patch — structural changes to src/ or pyproject.toml
# affect users even though no new feature was added.
refactor_always_bump_patch = true
+17 -27
View File
@@ -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
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects.""" """devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.1.0" __version__ = "0.4.1"
+10 -5
View File
@@ -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
View File
@@ -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:
+22 -4
View File
@@ -271,16 +271,34 @@ def main(dry_run: bool, skip_tests: bool) -> None:
) )
) )
# Release lock: if HEAD is already a release commit, another release # Release lock: if HEAD is already a release commit, check if the tag
# run is in progress (or already completed). Skip to prevent duplicate tags. # exists. If the tag is missing (e.g., tag push failed in a previous run),
# create and push it instead of skipping — this recovers from the
# common failure mode where the commit was pushed but the tag was not.
head_msg = run_cmd(["git", "log", "-1", "--pretty=%s"]).stdout.strip() head_msg = run_cmd(["git", "log", "-1", "--pretty=%s"]).stdout.strip()
if re.match(r"^release: v\d+\.\d+\.\d+", head_msg): release_match = re.match(r"^release: v(\d+\.\d+\.\d+)", head_msg)
if release_match:
release_version = release_match.group(1)
release_tag = f"v{release_version}"
if tag_exists(release_tag):
click.echo(
_(
"HEAD is already a release commit ('{msg}') and tag {tag} exists. Skipping.",
msg=head_msg,
tag=release_tag,
)
)
return
# Tag is missing — recover by creating and pushing it
click.echo( click.echo(
_( _(
"HEAD is already a release commit ('{msg}'). Another release may have just completed. Skipping.", "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.",
msg=head_msg, msg=head_msg,
tag=release_tag,
) )
) )
changelog = get_changelog(release_version)
create_and_push_tag(release_version, changelog, dry_run)
return return
# Check if any user-facing files changed since the last tag. # Check if any user-facing files changed since the last tag.
+22 -1
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Project setup: install Python deps, pre-commit hooks, and tea CLI login. """Project setup: install Python deps, Ansible collections, pre-commit hooks, and tea CLI login.
Usage:: Usage::
@@ -38,6 +38,16 @@ def _install_pre_commit_hooks(bin_dir: str) -> None:
_run([pre_commit, "install", "--hook-type", hook_type]) _run([pre_commit, "install", "--hook-type", hook_type])
def _install_ansible_collections(bin_dir: str) -> None:
"""Install required Ansible Galaxy collections if requirements exist."""
galaxy = str(Path(bin_dir) / "ansible-galaxy")
requirements = Path("ansible/requirements.yml")
if not requirements.exists():
click.echo(" ansible/requirements.yml not found — skipping collections.")
return
_run([galaxy, "collection", "install", "-r", str(requirements)])
def _configure_tea_login() -> None: def _configure_tea_login() -> None:
"""Configure tea CLI login from .env if REPO_TOKEN is set. """Configure tea CLI login from .env if REPO_TOKEN is set.
@@ -119,11 +129,18 @@ def _verify(bin_dir: str) -> None:
default=False, default=False,
help="Skip tea CLI login configuration.", help="Skip tea CLI login configuration.",
) )
@click.option(
"--no-ansible-collections",
is_flag=True,
default=False,
help="Skip Ansible Galaxy collection installation.",
)
def main( def main(
bin_dir: str, bin_dir: str,
extras: str, extras: str,
no_pre_commit: bool, no_pre_commit: bool,
no_tea_login: bool, no_tea_login: bool,
no_ansible_collections: bool,
) -> None: ) -> None:
"""Install Python deps, pre-commit hooks, and configure tea CLI.""" """Install Python deps, pre-commit hooks, and configure tea CLI."""
if not Path(bin_dir).exists(): if not Path(bin_dir).exists():
@@ -132,6 +149,10 @@ def main(
click.echo(f"Installing Python dependencies (extras: {extras})...") click.echo(f"Installing Python dependencies (extras: {extras})...")
_install_python_deps(bin_dir, extras) _install_python_deps(bin_dir, extras)
if not no_ansible_collections:
click.echo("Installing Ansible Galaxy collections...")
_install_ansible_collections(bin_dir)
if not no_pre_commit: if not no_pre_commit:
click.echo("Installing pre-commit hooks...") click.echo("Installing pre-commit hooks...")
_install_pre_commit_hooks(bin_dir) _install_pre_commit_hooks(bin_dir)
+18
View File
@@ -639,5 +639,23 @@
"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}"
},
"HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag.": {
"en": "HEAD is a release commit ('{msg}') but tag {tag} is missing. Recovering by creating tag."
},
"HEAD is already a release commit ('{msg}') and tag {tag} exists. Skipping.": {
"en": "HEAD is already a release commit ('{msg}') and tag {tag} exists. Skipping."
} }
} }
+136 -17
View File
@@ -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
+27 -3
View File
@@ -318,12 +318,15 @@ class TestMain:
@patch.dict("os.environ", {}) @patch.dict("os.environ", {})
@patch("devx.ci.release.has_user_facing_changes", return_value=True) @patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.run_cmd") @patch("devx.ci.release.run_cmd")
def test_release_lock_skips_when_head_is_release_commit(self, mock_run_cmd: MagicMock, mock_uf: MagicMock) -> None: def test_release_lock_skips_when_head_is_release_commit_and_tag_exists(
"""If HEAD is already a release commit, should skip to prevent duplicate releases.""" self, mock_run_cmd: MagicMock, mock_uf: MagicMock
# First call: git rev-parse (master), second: git log -1 (release commit) ) -> None:
"""If HEAD is a release commit and the tag exists, skip."""
# git rev-parse, git log -1, git tag -l (tag exists)
mock_run_cmd.side_effect = [ mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout="master\n", stderr=""), MagicMock(returncode=0, stdout="master\n", stderr=""),
MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""), MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""),
MagicMock(returncode=0, stdout="v0.5.0\n", stderr=""), # tag -l finds tag
] ]
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, []) result = runner.invoke(main, [])
@@ -331,6 +334,27 @@ class TestMain:
assert "already a release commit" in result.output assert "already a release commit" in result.output
assert "Skipping" in result.output assert "Skipping" in result.output
@patch.dict("os.environ", {})
@patch("devx.ci.release.get_changelog", return_value="## changelog")
@patch("devx.ci.release.create_and_push_tag", return_value=True)
@patch("devx.ci.release.run_cmd")
def test_release_lock_recovers_when_tag_missing(
self, mock_run_cmd: MagicMock, mock_create_tag: MagicMock, mock_changelog: MagicMock
) -> None:
"""If HEAD is a release commit but the tag is missing, create the tag."""
# git rev-parse, git log -1, git tag -l (tag NOT found)
mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout="master\n", stderr=""),
MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""),
MagicMock(returncode=0, stdout="", stderr=""), # tag -l finds nothing
]
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "tag v0.5.0 is missing" in result.output
assert "Recovering" in result.output
mock_create_tag.assert_called_once_with("0.5.0", "## changelog", False)
@patch.dict("os.environ", {}) @patch.dict("os.environ", {})
@patch("devx.ci.release.has_user_facing_changes", return_value=True) @patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.has_unreleased_changes", return_value=False) @patch("devx.ci.release.has_unreleased_changes", return_value=False)
+56 -6
View File
@@ -9,6 +9,7 @@ from click.testing import CliRunner
from devx.tools.setup import ( from devx.tools.setup import (
_configure_tea_login, _configure_tea_login,
_install_ansible_collections,
_install_pre_commit_hooks, _install_pre_commit_hooks,
_install_python_deps, _install_python_deps,
_run, _run,
@@ -58,6 +59,24 @@ class TestInstallPreCommitHooks:
assert "pre-push" in hook_types assert "pre-push" in hook_types
class TestInstallAnsibleCollections:
@patch("devx.tools.setup._run")
def test_installs_from_requirements(self, mock_run: MagicMock, tmp_path: Path) -> None:
req = tmp_path / "ansible" / "requirements.yml"
req.parent.mkdir(parents=True)
req.write_text("collections: []")
with patch("devx.tools.setup.Path") as mock_path:
mock_path.return_value.exists.return_value = True
mock_path.return_value.__str__ = lambda _: str(req)
_install_ansible_collections(".venv/bin")
mock_run.assert_called_once()
@patch("devx.tools.setup._run")
def test_skips_when_no_requirements(self, mock_run: MagicMock) -> None:
_install_ansible_collections(".venv/bin")
mock_run.assert_not_called()
class TestConfigureTeaLogin: class TestConfigureTeaLogin:
@patch("devx.tools.setup.shutil.which", return_value=None) @patch("devx.tools.setup.shutil.which", return_value=None)
def test_tea_not_installed(self, mock_which: MagicMock) -> None: def test_tea_not_installed(self, mock_which: MagicMock) -> None:
@@ -142,10 +161,12 @@ class TestMain:
@patch("devx.tools.setup._configure_tea_login") @patch("devx.tools.setup._configure_tea_login")
@patch("devx.tools.setup._verify") @patch("devx.tools.setup._verify")
@patch("devx.tools.setup._install_pre_commit_hooks") @patch("devx.tools.setup._install_pre_commit_hooks")
@patch("devx.tools.setup._install_ansible_collections")
@patch("devx.tools.setup._install_python_deps") @patch("devx.tools.setup._install_python_deps")
def test_main_success( def test_main_success(
self, self,
mock_install_deps: MagicMock, mock_install_deps: MagicMock,
mock_install_ansible: MagicMock,
mock_install_hooks: MagicMock, mock_install_hooks: MagicMock,
mock_verify: MagicMock, mock_verify: MagicMock,
mock_tea: MagicMock, mock_tea: MagicMock,
@@ -157,6 +178,7 @@ class TestMain:
result = runner.invoke(main, ["--bin", str(bin_dir)]) result = runner.invoke(main, ["--bin", str(bin_dir)])
assert result.exit_code == 0 assert result.exit_code == 0
mock_install_deps.assert_called_once() mock_install_deps.assert_called_once()
mock_install_ansible.assert_called_once()
mock_install_hooks.assert_called_once() mock_install_hooks.assert_called_once()
mock_verify.assert_called_once() mock_verify.assert_called_once()
mock_tea.assert_called_once() mock_tea.assert_called_once()
@@ -164,10 +186,12 @@ class TestMain:
@patch("devx.tools.setup._configure_tea_login") @patch("devx.tools.setup._configure_tea_login")
@patch("devx.tools.setup._verify") @patch("devx.tools.setup._verify")
@patch("devx.tools.setup._install_pre_commit_hooks") @patch("devx.tools.setup._install_pre_commit_hooks")
@patch("devx.tools.setup._install_ansible_collections")
@patch("devx.tools.setup._install_python_deps") @patch("devx.tools.setup._install_python_deps")
def test_main_no_pre_commit( def test_main_no_pre_commit(
self, self,
mock_install_deps: MagicMock, mock_install_deps: MagicMock,
mock_install_ansible: MagicMock,
mock_install_hooks: MagicMock, mock_install_hooks: MagicMock,
mock_verify: MagicMock, mock_verify: MagicMock,
mock_tea: MagicMock, mock_tea: MagicMock,
@@ -184,10 +208,33 @@ class TestMain:
@patch("devx.tools.setup._configure_tea_login") @patch("devx.tools.setup._configure_tea_login")
@patch("devx.tools.setup._verify") @patch("devx.tools.setup._verify")
@patch("devx.tools.setup._install_pre_commit_hooks") @patch("devx.tools.setup._install_pre_commit_hooks")
@patch("devx.tools.setup._install_ansible_collections")
@patch("devx.tools.setup._install_python_deps")
def test_main_no_ansible_collections(
self,
mock_install_deps: MagicMock,
mock_install_ansible: MagicMock,
mock_install_hooks: MagicMock,
mock_verify: MagicMock,
mock_tea: MagicMock,
tmp_path: Path,
) -> None:
bin_dir = tmp_path / "bin"
bin_dir.mkdir()
runner = CliRunner()
result = runner.invoke(main, ["--bin", str(bin_dir), "--no-ansible-collections"])
assert result.exit_code == 0
mock_install_ansible.assert_not_called()
@patch("devx.tools.setup._configure_tea_login")
@patch("devx.tools.setup._verify")
@patch("devx.tools.setup._install_pre_commit_hooks")
@patch("devx.tools.setup._install_ansible_collections")
@patch("devx.tools.setup._install_python_deps") @patch("devx.tools.setup._install_python_deps")
def test_main_custom_extras( def test_main_custom_extras(
self, self,
mock_install_deps: MagicMock, mock_install_deps: MagicMock,
mock_install_ansible: MagicMock,
mock_install_hooks: MagicMock, mock_install_hooks: MagicMock,
mock_verify: MagicMock, mock_verify: MagicMock,
mock_tea: MagicMock, mock_tea: MagicMock,
@@ -203,10 +250,12 @@ class TestMain:
@patch("devx.tools.setup._configure_tea_login") @patch("devx.tools.setup._configure_tea_login")
@patch("devx.tools.setup._verify") @patch("devx.tools.setup._verify")
@patch("devx.tools.setup._install_pre_commit_hooks") @patch("devx.tools.setup._install_pre_commit_hooks")
@patch("devx.tools.setup._install_ansible_collections")
@patch("devx.tools.setup._install_python_deps") @patch("devx.tools.setup._install_python_deps")
def test_main_no_tea_login( def test_main_no_tea_login(
self, self,
mock_install_deps: MagicMock, mock_install_deps: MagicMock,
mock_install_ansible: MagicMock,
mock_install_hooks: MagicMock, mock_install_hooks: MagicMock,
mock_verify: MagicMock, mock_verify: MagicMock,
mock_tea: MagicMock, mock_tea: MagicMock,
@@ -233,9 +282,10 @@ def test_main_module_block(tmp_path: Path) -> None:
with patch.dict("os.environ", {}, clear=True): with patch.dict("os.environ", {}, clear=True):
with patch("devx.tools.setup._install_python_deps") as mock_deps: with patch("devx.tools.setup._install_python_deps") as mock_deps:
with patch("devx.tools.setup._install_pre_commit_hooks"): with patch("devx.tools.setup._install_pre_commit_hooks"):
with patch("devx.tools.setup._configure_tea_login"): with patch("devx.tools.setup._install_ansible_collections"):
with patch("devx.tools.setup._verify"): with patch("devx.tools.setup._configure_tea_login"):
runner = CliRunner() with patch("devx.tools.setup._verify"):
result = runner.invoke(main, ["--bin", str(bin_dir)]) runner = CliRunner()
assert result.exit_code == 0 result = runner.invoke(main, ["--bin", str(bin_dir)])
mock_deps.assert_called_once() assert result.exit_code == 0
mock_deps.assert_called_once()