Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
621b9936c8 | ||
|
|
33063038a1 | ||
|
|
0c3e8a7b8d | ||
|
|
6436c5dd38 | ||
|
|
33b09c162d | ||
|
|
a2c856d8b2 | ||
|
|
7d9a081c92 | ||
|
|
87d730d8be | ||
|
|
89a165be46 | ||
|
|
388c3df043 | ||
|
|
ac8a1d3be4 | ||
|
|
07cca5de36 |
@@ -7,10 +7,15 @@ name: Post-merge
|
|||||||
# Job dependency graph:
|
# Job dependency graph:
|
||||||
#
|
#
|
||||||
# detect-type ──┬── release (skip if release commit)
|
# detect-type ──┬── release (skip if release commit)
|
||||||
# ├── sync-wiki (skip if release commit)
|
|
||||||
# ├── badges (ALWAYS runs — even on release commits)
|
# ├── badges (ALWAYS runs — even on release commits)
|
||||||
# ├── vikunja (skip if release commit)
|
# ├── configure-repo (independent — skip if release commit)
|
||||||
# └── configure-repo (skip if release commit)
|
# ├── sync-wiki (needs release — skip if release commit/fails)
|
||||||
|
# └── vikunja (needs release — skip if release commit/fails)
|
||||||
|
#
|
||||||
|
# sync-wiki and vikunja depend on release succeeding so that the wiki
|
||||||
|
# and task tracker are only updated when the code is actually released.
|
||||||
|
# If release fails, they are skipped to avoid leaving the wiki or
|
||||||
|
# Vikunja in an inconsistent state with the codebase on master.
|
||||||
#
|
#
|
||||||
# The badges job depends on release so it picks up the latest version
|
# The badges job depends on release so it picks up the latest version
|
||||||
# number. It uses `if: always()` with no is-release condition so it
|
# number. It uses `if: always()` with no is-release condition so it
|
||||||
@@ -106,7 +111,7 @@ jobs:
|
|||||||
--commit "${{ github.sha }}"
|
--commit "${{ github.sha }}"
|
||||||
|
|
||||||
sync-wiki:
|
sync-wiki:
|
||||||
needs: [detect-type]
|
needs: [detect-type, release]
|
||||||
if: needs.detect-type.outputs.is-release == 'false'
|
if: needs.detect-type.outputs.is-release == 'false'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
@@ -173,7 +178,7 @@ jobs:
|
|||||||
--commit "${{ github.sha }}"
|
--commit "${{ github.sha }}"
|
||||||
|
|
||||||
vikunja:
|
vikunja:
|
||||||
needs: [detect-type]
|
needs: [detect-type, release]
|
||||||
if: needs.detect-type.outputs.is-release == 'false'
|
if: needs.detect-type.outputs.is-release == 'false'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
@@ -221,7 +226,6 @@ jobs:
|
|||||||
- name: Ensure branch protection and labels
|
- name: Ensure branch protection and labels
|
||||||
env:
|
env:
|
||||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
DEVX_PUSH_WHITELIST: "emil"
|
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: python3 -m devx.tools.configure_repo --repo devx --owner oblachno-oss
|
run: python3 -m devx.tools.configure_repo --repo devx --owner oblachno-oss
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
|
|||||||
+37
-14
@@ -2,24 +2,47 @@
|
|||||||
|
|
||||||
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.4.1] - 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
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|
||||||
- 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
@@ -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
|
||||||
|
|||||||
@@ -83,3 +83,44 @@ indent-style = "space"
|
|||||||
include = ["src"]
|
include = ["src"]
|
||||||
pythonVersion = "3.12"
|
pythonVersion = "3.12"
|
||||||
strict = ["src/devx/config.py", "src/devx/exceptions.py", "src/devx/i18n.py", "src/devx/api_clients.py", "src/devx/gitea_cli.py"]
|
strict = ["src/devx/config.py", "src/devx/exceptions.py", "src/devx/i18n.py", "src/devx/api_clients.py", "src/devx/gitea_cli.py"]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Change classification — determines which changes trigger a release
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The framework provides DEFAULT_INFRASTRUCTURE (CI workflows, tests, docs,
|
||||||
|
# lint config, etc.) that applies to any Python project. We only specify
|
||||||
|
# what's different about devx.
|
||||||
|
#
|
||||||
|
# Rule priority (first match wins):
|
||||||
|
# 1. user_facing_overrides (safety — highest priority)
|
||||||
|
# 2. infrastructure_overrides (explicit per-file)
|
||||||
|
# 3. infrastructure (DEFAULT_INFRASTRUCTURE + project-specific patterns)
|
||||||
|
# 4. Default: user-facing (safe)
|
||||||
|
[tool.devx.classify]
|
||||||
|
# use_defaults = true # (default) merge with DEFAULT_INFRASTRUCTURE
|
||||||
|
|
||||||
|
# Project-specific infrastructure paths (merged with defaults).
|
||||||
|
# devx has no additional infrastructure paths — everything not in the
|
||||||
|
# defaults is user-facing (src/devx/**, pyproject.toml, translations.json).
|
||||||
|
infrastructure = []
|
||||||
|
|
||||||
|
# Infrastructure overrides — files that would default to user-facing
|
||||||
|
# but are actually infrastructure:
|
||||||
|
# - __init__.py: only contains __version__ (set by release.py, not user code)
|
||||||
|
#
|
||||||
|
# NOTE: api_clients.py is NOT here — it's used by devx's CI modules
|
||||||
|
# (auto_merge.py, release.py, pr_review.py, etc.) which consumer projects
|
||||||
|
# call via `python -m devx.ci.*`. Changes to api_clients.py affect consumer
|
||||||
|
# projects' CI behavior, so it IS user-facing.
|
||||||
|
infrastructure_overrides = [
|
||||||
|
"src/devx/__init__.py",
|
||||||
|
]
|
||||||
|
|
||||||
|
# User-facing overrides — safety override for broad infrastructure patterns
|
||||||
|
# (empty — add when an infrastructure pattern is too broad)
|
||||||
|
user_facing_overrides = []
|
||||||
|
|
||||||
|
# Tag patterns — additional categories for CI conditional execution
|
||||||
|
# Orthogonal to release impact (user-facing vs infrastructure)
|
||||||
|
[tool.devx.classify.tags]
|
||||||
|
# No tags needed for devx itself — it has no ansible/ directory
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
+584
-174
@@ -1,106 +1,465 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Classify git changes as user-facing or workflow-only.
|
"""Classify git changes as user-facing or infrastructure.
|
||||||
|
|
||||||
Determines whether changes between two git refs (e.g., last tag and HEAD)
|
Determines whether changes between two git refs (e.g., last tag and HEAD)
|
||||||
affect the tool itself (user-facing) or only the CI/CD infrastructure
|
affect the published package (user-facing) or only the CI/CD infrastructure
|
||||||
(workflow-only). This is used by:
|
(workflow-only). This is used by:
|
||||||
|
|
||||||
- **release.py** — skips release when only workflow files changed
|
- **release.py** — skips release when only infrastructure files changed
|
||||||
- **CI workflow** — skips molecule tests and release dry-run when only
|
- **CI workflow** — skips molecule tests and release dry-run when only
|
||||||
workflow files changed
|
infrastructure files changed
|
||||||
|
|
||||||
Classification strategy (safe-by-default):
|
== Design Philosophy ==
|
||||||
|
|
||||||
Any file that is NOT in the explicit workflow-only allowlist is treated
|
**Safe-by-default**: Any file that doesn't match a rule defaults to
|
||||||
as user-facing. This ensures new file types default to requiring a
|
user-facing. This prevents new file types from accidentally skipping
|
||||||
release rather than silently skipping it.
|
releases — a critical safety property. When in doubt, release.
|
||||||
|
|
||||||
The workflow-only patterns are configurable via the ``patterns``
|
**Framework-provided defaults**: The framework ships with
|
||||||
parameter on ``classify_changes()`` and ``has_user_facing_changes()``.
|
``DEFAULT_INFRASTRUCTURE`` — a curated list of paths that are
|
||||||
The default set (``DEFAULT_WORKFLOW_ONLY_PATTERNS``) covers common
|
infrastructure for ANY Python project (CI workflows, tests, docs,
|
||||||
infrastructure paths. Each project can pass its own frozenset to
|
lint config, etc.). Projects inherit these automatically and only
|
||||||
accommodate different source layouts.
|
need to specify what's *different* about their project.
|
||||||
|
|
||||||
Default workflow-only paths (infrastructure → no release needed):
|
**Config-driven**: Classification rules are read from ``[tool.devx.classify]``
|
||||||
- .gitea/workflows/** — Gitea Actions workflows
|
in ``pyproject.toml``. No project needs to modify the framework code.
|
||||||
- scripts/** — All scripts (CI/CD, dev tools, setup)
|
Each project declares its own paths; the framework handles the logic.
|
||||||
- src/devx/__init__.py — Version file (release artifact)
|
|
||||||
- src/devx/api_clients.py — Gitea API client (CI/CD only, not used by CLI)
|
|
||||||
- docs/** — Documentation
|
|
||||||
- tests/** — Test files
|
|
||||||
- hooks/** — Git hooks
|
|
||||||
- AGENTS.md — Agent conventions
|
|
||||||
- README.md — README (lean, links to wiki)
|
|
||||||
- CHANGELOG.md — Changelog (generated)
|
|
||||||
- TROUBLESHOOTING.md — Troubleshooting guide
|
|
||||||
- cliff.toml — git-cliff config
|
|
||||||
- Makefile — Build automation
|
|
||||||
- .pre-commit-config.yaml — Pre-commit config
|
|
||||||
- .ansible-lint — Ansible lint config
|
|
||||||
- .env.example — Environment template
|
|
||||||
- .gitignore — Git ignore rules
|
|
||||||
- .ruff.toml — Ruff config (if separate)
|
|
||||||
- .github/** — GitHub config (if present)
|
|
||||||
|
|
||||||
Everything else is user-facing (tool changes → release needed),
|
**Layered rules** (evaluated in priority order):
|
||||||
including but not limited to:
|
|
||||||
- src/devx/*.py — Python CLI source (except __init__.py)
|
1. **User-facing overrides** (highest priority — safety override)
|
||||||
- ansible/** — Ansible role
|
Files that match infrastructure patterns but MUST be treated as
|
||||||
- pyproject.toml — Package metadata
|
user-facing. Use this when an infrastructure pattern is too broad.
|
||||||
- Any new file type not in the allowlist
|
|
||||||
|
2. **Infrastructure overrides**
|
||||||
|
Files that would default to user-facing but are actually
|
||||||
|
infrastructure (e.g., ``src/pkg/__init__.py`` which only contains
|
||||||
|
``__version__`` — a release artifact, not user-facing code).
|
||||||
|
|
||||||
|
3. **Infrastructure patterns** (deny-list)
|
||||||
|
Path globs matching infrastructure files. This is the union of
|
||||||
|
``DEFAULT_INFRASTRUCTURE`` and the project's ``infrastructure`` list.
|
||||||
|
Changes to these don't trigger a release.
|
||||||
|
|
||||||
|
4. **Default**: user-facing (lowest priority — safe default)
|
||||||
|
|
||||||
|
**Tag system** (orthogonal to release impact):
|
||||||
|
Projects can define custom tags (e.g., ``ansible``, ``docs``) for CI
|
||||||
|
conditional execution. A file can be both infrastructure (no release)
|
||||||
|
and tagged ``ansible`` (run molecule tests). Tags are evaluated
|
||||||
|
independently of the user-facing/infrastructure classification.
|
||||||
|
The ``--check`` CLI option accepts any tag name defined in the config,
|
||||||
|
and ``--github-output`` writes ``<tag>-changed`` for each configured tag.
|
||||||
|
|
||||||
|
== Configuration ==
|
||||||
|
|
||||||
|
In ``pyproject.toml``::
|
||||||
|
|
||||||
|
[tool.devx.classify]
|
||||||
|
# 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 = [
|
||||||
|
"scripts/**", # e.g., if scripts/ is dev-only tooling
|
||||||
|
]
|
||||||
|
|
||||||
|
# Infrastructure overrides — files that would default to user-facing
|
||||||
|
# but are actually infrastructure
|
||||||
|
infrastructure_overrides = [
|
||||||
|
"src/mypkg/__init__.py", # only contains __version__
|
||||||
|
]
|
||||||
|
|
||||||
|
# User-facing overrides — safety override for broad infrastructure patterns
|
||||||
|
# (empty by default)
|
||||||
|
user_facing_overrides = []
|
||||||
|
|
||||||
|
# Tag patterns — additional categories for CI conditional execution
|
||||||
|
[tool.devx.classify.tags]
|
||||||
|
ansible = ["ansible/**", ".ansible-lint"]
|
||||||
|
|
||||||
|
== What counts as "user-facing" ==
|
||||||
|
|
||||||
|
A change is user-facing if it affects the behavior of the installed
|
||||||
|
package. For a library/CLI tool, this means:
|
||||||
|
|
||||||
|
- Source code in ``src/`` (except ``__init__.py`` which only holds
|
||||||
|
``__version__``)
|
||||||
|
- Package metadata (``pyproject.toml`` — dependencies, entry points)
|
||||||
|
- Ansible roles, playbooks, templates (if the project ships Ansible)
|
||||||
|
- Translation files (user-visible messages)
|
||||||
|
- Any file not explicitly classified as infrastructure
|
||||||
|
|
||||||
|
A change is infrastructure if it only affects the project's own
|
||||||
|
development/CI environment:
|
||||||
|
|
||||||
|
- CI/CD workflows (``.gitea/**``, ``.github/**``)
|
||||||
|
- Tests (``tests/**``)
|
||||||
|
- Documentation (``docs/**``, ``README.md``, ``CHANGELOG.md``)
|
||||||
|
- Linting/formatting config (``.ruff.toml``, ``.pre-commit-config.yaml``)
|
||||||
|
- Build tooling (``Makefile``, ``cliff.toml``)
|
||||||
|
- Git hooks (``hooks/**``)
|
||||||
|
- Generated scripts (``activate.sh``, ``activate.fish``, ``activate.zsh``)
|
||||||
|
|
||||||
|
== Glob Syntax ==
|
||||||
|
|
||||||
|
Patterns support standard glob syntax:
|
||||||
|
|
||||||
|
- ``**`` matches any number of path segments (including zero)
|
||||||
|
- ``*`` matches any characters within a single path segment
|
||||||
|
- ``?`` matches a single character within a single path segment
|
||||||
|
- Everything else is matched literally
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
- ``.gitea/**`` matches ``.gitea/workflows/ci.yml``, ``.gitea/actionlint.yaml``
|
||||||
|
- ``tests/**`` matches ``tests/unit/test_cli.py``, ``tests/conftest.py``
|
||||||
|
- ``src/devx/__init__.py`` matches exactly that file
|
||||||
|
- ``Makefile`` matches exactly that file
|
||||||
|
|
||||||
Usage:
|
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
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
import subprocess # nosec B404
|
import subprocess # nosec B404
|
||||||
import sys
|
import sys
|
||||||
|
import tomllib
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
from devx.i18n import _
|
from devx.i18n import _
|
||||||
|
|
||||||
# Explicit allowlist of workflow-only path patterns.
|
# ---------------------------------------------------------------------------
|
||||||
# Anything NOT matching these is treated as user-facing (safe default).
|
# Data structures
|
||||||
# This is the default set — projects can override via the ``patterns``
|
# ---------------------------------------------------------------------------
|
||||||
# parameter on classify_changes() / has_user_facing_changes().
|
|
||||||
DEFAULT_WORKFLOW_ONLY_PATTERNS: frozenset[str] = frozenset(
|
|
||||||
[
|
@dataclass(frozen=True)
|
||||||
# CI/CD infrastructure
|
class FileClassification:
|
||||||
".gitea/",
|
"""Result of classifying a single file.
|
||||||
# All scripts are infrastructure (CI/CD, dev tools, setup)
|
|
||||||
# User-facing code lives in src/devx/
|
Attributes:
|
||||||
"scripts/",
|
path: The file path relative to repo root.
|
||||||
# Version file — only contains __version__, not user-facing code.
|
is_user_facing: True if changes to this file require a release.
|
||||||
# Version bumps are a release artifact, not a feature.
|
reason: Human-readable explanation of the classification.
|
||||||
"src/devx/__init__.py",
|
matched_rule: Which rule matched (e.g., "infrastructure: .gitea/**").
|
||||||
# Gitea API client — used only by CI/CD scripts, not by the CLI.
|
None if the default rule was used.
|
||||||
"src/devx/api_clients.py",
|
tags: Custom category tags (e.g., {"ansible"}).
|
||||||
# Documentation
|
"""
|
||||||
"docs/",
|
|
||||||
"AGENTS.md",
|
path: str
|
||||||
"README.md",
|
is_user_facing: bool
|
||||||
"CHANGELOG.md",
|
reason: str
|
||||||
"TROUBLESHOOTING.md",
|
matched_rule: str | None
|
||||||
# Tests
|
tags: frozenset[str] = frozenset()
|
||||||
"tests/",
|
|
||||||
# Config / build automation
|
|
||||||
"cliff.toml",
|
@dataclass
|
||||||
"Makefile",
|
class ClassificationResult:
|
||||||
".pre-commit-config.yaml",
|
"""Result of classifying a set of changed files.
|
||||||
".ansible-lint",
|
|
||||||
".env.example",
|
Attributes:
|
||||||
".gitignore",
|
files: Per-file classification details.
|
||||||
".ruff.toml",
|
user_facing: List of file paths classified as user-facing.
|
||||||
# Hooks
|
infrastructure: List of file paths classified as infrastructure.
|
||||||
"hooks/",
|
tags: Dict mapping tag name to list of file paths matching that tag.
|
||||||
# GitHub (if ever added)
|
"""
|
||||||
".github/",
|
|
||||||
]
|
files: list[FileClassification] = field(default_factory=list)
|
||||||
)
|
user_facing: list[str] = field(default_factory=list)
|
||||||
|
infrastructure: list[str] = field(default_factory=list)
|
||||||
|
tags: dict[str, list[str]] = field(default_factory=dict)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_user_facing(self) -> bool:
|
||||||
|
"""True if any user-facing files were found."""
|
||||||
|
return bool(self.user_facing)
|
||||||
|
|
||||||
|
def has_tag(self, tag: str) -> bool:
|
||||||
|
"""True if any files matched the given tag."""
|
||||||
|
return bool(self.tags.get(tag))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Glob matching
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _glob_to_regex(pattern: str) -> re.Pattern[str]:
|
||||||
|
"""Convert a glob pattern to a compiled regex.
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
- ``**`` -> matches any number of path segments (including zero)
|
||||||
|
- ``*`` -> matches any chars within a single path segment
|
||||||
|
- ``?`` -> matches a single char within a path segment
|
||||||
|
- All other characters are matched literally
|
||||||
|
"""
|
||||||
|
# Handle ** at the end (e.g., ".gitea/**")
|
||||||
|
# ** matches anything including slashes
|
||||||
|
parts: list[str] = []
|
||||||
|
i = 0
|
||||||
|
while i < len(pattern):
|
||||||
|
c = pattern[i]
|
||||||
|
if c == "*" and i + 1 < len(pattern) and pattern[i + 1] == "*":
|
||||||
|
parts.append(".*")
|
||||||
|
i += 2
|
||||||
|
# Skip trailing slash after **
|
||||||
|
if i < len(pattern) and pattern[i] == "/":
|
||||||
|
i += 1
|
||||||
|
elif c == "*":
|
||||||
|
parts.append("[^/]*")
|
||||||
|
i += 1
|
||||||
|
elif c == "?":
|
||||||
|
parts.append("[^/]")
|
||||||
|
i += 1
|
||||||
|
else:
|
||||||
|
parts.append(re.escape(c))
|
||||||
|
i += 1
|
||||||
|
return re.compile("^" + "".join(parts) + "$")
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_glob(file_path: str, pattern: str) -> bool:
|
||||||
|
"""Check if a file path matches a glob pattern.
|
||||||
|
|
||||||
|
Also supports prefix matching: if the pattern ends with ``/``,
|
||||||
|
any file starting with that prefix matches. This is a convenience
|
||||||
|
for patterns like ``.gitea/`` (equivalent to ``.gitea/**``).
|
||||||
|
"""
|
||||||
|
# Prefix matching for patterns ending with /
|
||||||
|
if pattern.endswith("/") and (file_path.startswith(pattern) or file_path == pattern.rstrip("/")):
|
||||||
|
return True
|
||||||
|
return _glob_to_regex(pattern).match(file_path) is not None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Classifier
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Default infrastructure patterns
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Common infrastructure paths that apply to ANY Python project using devx.
|
||||||
|
# Projects inherit these automatically and only need to specify project-specific
|
||||||
|
# paths in their [tool.devx.classify] section.
|
||||||
|
#
|
||||||
|
# Rationale: these files/directories are development tooling, CI/CD config,
|
||||||
|
# or generated artifacts. Changes to them don't affect the installed package's
|
||||||
|
# behavior, so they don't warrant a release.
|
||||||
|
DEFAULT_INFRASTRUCTURE: list[str] = [
|
||||||
|
# CI/CD workflow definitions
|
||||||
|
".gitea/**",
|
||||||
|
".github/**",
|
||||||
|
# Test files
|
||||||
|
"tests/**",
|
||||||
|
# Documentation
|
||||||
|
"docs/**",
|
||||||
|
# Git hooks
|
||||||
|
"hooks/**",
|
||||||
|
# Build tooling
|
||||||
|
"Makefile",
|
||||||
|
"cliff.toml",
|
||||||
|
# Linting / formatting config
|
||||||
|
".pre-commit-config.yaml",
|
||||||
|
".ruff.toml",
|
||||||
|
".ansible-lint",
|
||||||
|
# Environment templates (not the actual .env which is gitignored)
|
||||||
|
".env.example",
|
||||||
|
# Git config
|
||||||
|
".gitignore",
|
||||||
|
# Project-level documentation (not part of the installed package)
|
||||||
|
"AGENTS.md",
|
||||||
|
"README.md",
|
||||||
|
"CHANGELOG.md",
|
||||||
|
"TROUBLESHOOTING.md",
|
||||||
|
# Generated venv activation scripts (created by `make setup`)
|
||||||
|
"activate.sh",
|
||||||
|
"activate.fish",
|
||||||
|
"activate.zsh",
|
||||||
|
# CI task tracking file (written by CI, not by developers)
|
||||||
|
".taskid",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ClassifierConfig:
|
||||||
|
"""Configuration for the change classifier.
|
||||||
|
|
||||||
|
Loaded from ``[tool.devx.classify]`` in ``pyproject.toml``.
|
||||||
|
|
||||||
|
By default, the framework's ``DEFAULT_INFRASTRUCTURE`` patterns are
|
||||||
|
merged with the project's ``infrastructure`` list. Set
|
||||||
|
``use_defaults = false`` to disable defaults and specify all
|
||||||
|
patterns explicitly.
|
||||||
|
|
||||||
|
Attributes:
|
||||||
|
infrastructure: Glob patterns for infrastructure paths
|
||||||
|
(merged with DEFAULT_INFRASTRUCTURE unless use_defaults is False).
|
||||||
|
infrastructure_overrides: Exact paths that are infrastructure
|
||||||
|
despite not matching any infrastructure pattern.
|
||||||
|
user_facing_overrides: Exact paths that are user-facing
|
||||||
|
despite matching an infrastructure pattern (safety override).
|
||||||
|
tags: Dict mapping tag name to list of glob patterns.
|
||||||
|
use_defaults: If True (default), merge with DEFAULT_INFRASTRUCTURE.
|
||||||
|
"""
|
||||||
|
|
||||||
|
infrastructure: list[str] = field(default_factory=list)
|
||||||
|
infrastructure_overrides: list[str] = field(default_factory=list)
|
||||||
|
user_facing_overrides: list[str] = field(default_factory=list)
|
||||||
|
tags: dict[str, list[str]] = field(default_factory=dict)
|
||||||
|
use_defaults: bool = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_pyproject(cls, pyproject_path: str = "pyproject.toml") -> ClassifierConfig:
|
||||||
|
"""Load classifier config from pyproject.toml.
|
||||||
|
|
||||||
|
Reads the ``[tool.devx.classify]`` section. If the section or
|
||||||
|
file is missing, returns a config with only DEFAULT_INFRASTRUCTURE
|
||||||
|
(everything else defaults to user-facing — safe-by-default).
|
||||||
|
"""
|
||||||
|
path = Path(pyproject_path)
|
||||||
|
if not path.exists():
|
||||||
|
return cls(infrastructure=list(DEFAULT_INFRASTRUCTURE))
|
||||||
|
with open(path, "rb") as f: # noqa: PTH123
|
||||||
|
data: dict[str, Any] = tomllib.load(f)
|
||||||
|
classify_cfg = data.get("tool", {}).get("devx", {}).get("classify", {})
|
||||||
|
|
||||||
|
use_defaults = classify_cfg.get("use_defaults", True)
|
||||||
|
project_infra = list(classify_cfg.get("infrastructure", []))
|
||||||
|
|
||||||
|
if use_defaults:
|
||||||
|
# Merge defaults with project-specific patterns (deduplicated)
|
||||||
|
merged = list(DEFAULT_INFRASTRUCTURE)
|
||||||
|
for p in project_infra:
|
||||||
|
if p not in merged:
|
||||||
|
merged.append(p)
|
||||||
|
infrastructure = merged
|
||||||
|
else:
|
||||||
|
infrastructure = project_infra
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
infrastructure=infrastructure,
|
||||||
|
infrastructure_overrides=list(classify_cfg.get("infrastructure_overrides", [])),
|
||||||
|
user_facing_overrides=list(classify_cfg.get("user_facing_overrides", [])),
|
||||||
|
tags={k: list(v) for k, v in classify_cfg.get("tags", {}).items()},
|
||||||
|
use_defaults=use_defaults,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeClassifier:
|
||||||
|
"""Classify changed files as user-facing or infrastructure.
|
||||||
|
|
||||||
|
Uses layered rules with safe-by-default semantics.
|
||||||
|
|
||||||
|
Rule evaluation order (first match wins):
|
||||||
|
1. User-facing overrides (safety — highest priority)
|
||||||
|
2. Infrastructure overrides
|
||||||
|
3. Infrastructure patterns
|
||||||
|
4. Default: user-facing (safe)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: ClassifierConfig | None = None) -> None:
|
||||||
|
self.config = config or ClassifierConfig.from_pyproject()
|
||||||
|
# Pre-compile infrastructure patterns for efficiency
|
||||||
|
self._infra_patterns = list(self.config.infrastructure)
|
||||||
|
self._infra_overrides = set(self.config.infrastructure_overrides)
|
||||||
|
self._user_overrides = set(self.config.user_facing_overrides)
|
||||||
|
|
||||||
|
def classify_file(self, file_path: str) -> FileClassification:
|
||||||
|
"""Classify a single file path.
|
||||||
|
|
||||||
|
Returns a FileClassification with the decision and reason.
|
||||||
|
"""
|
||||||
|
tags = self._compute_tags(file_path)
|
||||||
|
|
||||||
|
# 1. User-facing overrides (highest priority — safety)
|
||||||
|
if file_path in self._user_overrides:
|
||||||
|
return FileClassification(
|
||||||
|
path=file_path,
|
||||||
|
is_user_facing=True,
|
||||||
|
reason="User-facing override (safety override)",
|
||||||
|
matched_rule="user_facing_overrides",
|
||||||
|
tags=tags,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 2. Infrastructure overrides
|
||||||
|
if file_path in self._infra_overrides:
|
||||||
|
return FileClassification(
|
||||||
|
path=file_path,
|
||||||
|
is_user_facing=False,
|
||||||
|
reason="Infrastructure override (explicitly listed)",
|
||||||
|
matched_rule="infrastructure_overrides",
|
||||||
|
tags=tags,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Infrastructure patterns
|
||||||
|
for pattern in self._infra_patterns:
|
||||||
|
if _matches_glob(file_path, pattern):
|
||||||
|
return FileClassification(
|
||||||
|
path=file_path,
|
||||||
|
is_user_facing=False,
|
||||||
|
reason=f"Infrastructure (matches '{pattern}')",
|
||||||
|
matched_rule=f"infrastructure: {pattern}",
|
||||||
|
tags=tags,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Default: user-facing (safe-by-default)
|
||||||
|
return FileClassification(
|
||||||
|
path=file_path,
|
||||||
|
is_user_facing=True,
|
||||||
|
reason="User-facing (default — not in infrastructure patterns)",
|
||||||
|
matched_rule=None,
|
||||||
|
tags=tags,
|
||||||
|
)
|
||||||
|
|
||||||
|
def classify(self, files: list[str]) -> ClassificationResult:
|
||||||
|
"""Classify a list of changed files.
|
||||||
|
|
||||||
|
Returns a ClassificationResult with per-file details and
|
||||||
|
aggregated lists.
|
||||||
|
"""
|
||||||
|
result = ClassificationResult()
|
||||||
|
all_tags: dict[str, list[str]] = {}
|
||||||
|
|
||||||
|
for f in files:
|
||||||
|
fc = self.classify_file(f)
|
||||||
|
result.files.append(fc)
|
||||||
|
if fc.is_user_facing:
|
||||||
|
result.user_facing.append(f)
|
||||||
|
else:
|
||||||
|
result.infrastructure.append(f)
|
||||||
|
for tag in fc.tags:
|
||||||
|
all_tags.setdefault(tag, []).append(f)
|
||||||
|
|
||||||
|
result.tags = all_tags
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _compute_tags(self, file_path: str) -> frozenset[str]:
|
||||||
|
"""Compute custom category tags for a file path."""
|
||||||
|
matched: set[str] = set()
|
||||||
|
for tag_name, patterns in self.config.tags.items():
|
||||||
|
for pattern in patterns:
|
||||||
|
if _matches_glob(file_path, pattern):
|
||||||
|
matched.add(tag_name)
|
||||||
|
break
|
||||||
|
return frozenset(matched)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Git helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def run_git(args: list[str]) -> str:
|
def run_git(args: list[str]) -> str:
|
||||||
@@ -126,63 +485,6 @@ def get_changed_files(base: str, head: str) -> list[str]:
|
|||||||
return output.split("\n")
|
return output.split("\n")
|
||||||
|
|
||||||
|
|
||||||
def is_workflow_only(
|
|
||||||
file_path: str,
|
|
||||||
patterns: frozenset[str] | None = None,
|
|
||||||
) -> bool:
|
|
||||||
"""Check if a file path is workflow-only (infrastructure, not the tool itself).
|
|
||||||
|
|
||||||
Uses an explicit allowlist — anything not in the list is treated as
|
|
||||||
user-facing (safe default that prevents accidental release skips).
|
|
||||||
"""
|
|
||||||
p = patterns if patterns is not None else DEFAULT_WORKFLOW_ONLY_PATTERNS
|
|
||||||
return any(file_path.startswith(pattern) or file_path == pattern for pattern in p)
|
|
||||||
|
|
||||||
|
|
||||||
def is_user_facing(
|
|
||||||
file_path: str,
|
|
||||||
patterns: frozenset[str] | None = None,
|
|
||||||
) -> bool:
|
|
||||||
"""Check if a file path is user-facing (affects the tool).
|
|
||||||
|
|
||||||
Inverse of is_workflow_only — anything not explicitly workflow-only
|
|
||||||
is treated as user-facing.
|
|
||||||
"""
|
|
||||||
return not is_workflow_only(file_path, patterns)
|
|
||||||
|
|
||||||
|
|
||||||
def classify_changes(
|
|
||||||
files: list[str],
|
|
||||||
patterns: frozenset[str] | None = None,
|
|
||||||
) -> dict[str, list[str]]:
|
|
||||||
"""Classify changed files into user-facing and workflow-only.
|
|
||||||
|
|
||||||
Returns a dict with keys "user_facing" and "workflow_only".
|
|
||||||
"""
|
|
||||||
user_facing: list[str] = []
|
|
||||||
workflow_only: list[str] = []
|
|
||||||
for f in files:
|
|
||||||
if is_user_facing(f, patterns):
|
|
||||||
user_facing.append(f)
|
|
||||||
else:
|
|
||||||
workflow_only.append(f)
|
|
||||||
return {"user_facing": user_facing, "workflow_only": workflow_only}
|
|
||||||
|
|
||||||
|
|
||||||
def has_user_facing_changes(
|
|
||||||
base: str,
|
|
||||||
head: str,
|
|
||||||
patterns: frozenset[str] | None = None,
|
|
||||||
) -> bool:
|
|
||||||
"""Check if any user-facing files changed between base and head.
|
|
||||||
|
|
||||||
Imported by ``devx.ci.release`` to decide whether a release
|
|
||||||
is needed. This is a cross-CI import that requires ``PYTHONPATH=.``.
|
|
||||||
"""
|
|
||||||
files = get_changed_files(base, head)
|
|
||||||
return any(is_user_facing(f, patterns) for f in files)
|
|
||||||
|
|
||||||
|
|
||||||
def get_latest_tag() -> str:
|
def get_latest_tag() -> str:
|
||||||
"""Get the latest git tag, or empty string if none exists."""
|
"""Get the latest git tag, or empty string if none exists."""
|
||||||
result = subprocess.run( # nosec B603 B607
|
result = subprocess.run( # nosec B603 B607
|
||||||
@@ -196,10 +498,98 @@ def get_latest_tag() -> str:
|
|||||||
return result.stdout.strip()
|
return result.stdout.strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Backward-compatible API (used by release.py and CI workflows)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Singleton classifier — loaded lazily from pyproject.toml
|
||||||
|
_classifier: ChangeClassifier | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_classifier() -> ChangeClassifier:
|
||||||
|
"""Get or create the singleton classifier from pyproject.toml."""
|
||||||
|
global _classifier # noqa: PLW0603
|
||||||
|
if _classifier is None:
|
||||||
|
_classifier = ChangeClassifier()
|
||||||
|
return _classifier
|
||||||
|
|
||||||
|
|
||||||
|
def is_workflow_only(
|
||||||
|
file_path: str,
|
||||||
|
patterns: frozenset[str] | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Check if a file path is infrastructure (not user-facing).
|
||||||
|
|
||||||
|
Backward-compatible API. Prefer ``ChangeClassifier.classify_file()``
|
||||||
|
for new code.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path relative to repo root.
|
||||||
|
patterns: Deprecated. If provided, uses simple prefix matching
|
||||||
|
against these patterns instead of the config-driven classifier.
|
||||||
|
"""
|
||||||
|
if patterns is not None:
|
||||||
|
# Legacy mode — simple prefix matching
|
||||||
|
return any(file_path.startswith(p) or file_path == p for p in patterns)
|
||||||
|
return not _get_classifier().classify_file(file_path).is_user_facing
|
||||||
|
|
||||||
|
|
||||||
|
def is_user_facing(
|
||||||
|
file_path: str,
|
||||||
|
patterns: frozenset[str] | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Check if a file path is user-facing (affects the released package).
|
||||||
|
|
||||||
|
Inverse of ``is_workflow_only()``.
|
||||||
|
"""
|
||||||
|
return not is_workflow_only(file_path, patterns)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_changes(
|
||||||
|
files: list[str],
|
||||||
|
patterns: frozenset[str] | None = None,
|
||||||
|
) -> dict[str, list[str]]:
|
||||||
|
"""Classify changed files into user-facing and workflow-only.
|
||||||
|
|
||||||
|
Returns a dict with keys "user_facing" and "workflow_only".
|
||||||
|
"""
|
||||||
|
if patterns is not None:
|
||||||
|
# Legacy mode
|
||||||
|
user_facing: list[str] = []
|
||||||
|
workflow_only: list[str] = []
|
||||||
|
for f in files:
|
||||||
|
if is_user_facing(f, patterns):
|
||||||
|
user_facing.append(f)
|
||||||
|
else:
|
||||||
|
workflow_only.append(f)
|
||||||
|
return {"user_facing": user_facing, "workflow_only": workflow_only}
|
||||||
|
|
||||||
|
result = _get_classifier().classify(files)
|
||||||
|
return {"user_facing": result.user_facing, "workflow_only": result.infrastructure}
|
||||||
|
|
||||||
|
|
||||||
|
def has_user_facing_changes(
|
||||||
|
base: str,
|
||||||
|
head: str,
|
||||||
|
patterns: frozenset[str] | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""Check if any user-facing files changed between base and head.
|
||||||
|
|
||||||
|
Imported by ``devx.ci.release`` to decide whether a release is needed.
|
||||||
|
"""
|
||||||
|
files = get_changed_files(base, head)
|
||||||
|
if patterns is not None:
|
||||||
|
return any(is_user_facing(f, patterns) for f in files)
|
||||||
|
return _get_classifier().classify(files).has_user_facing
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Gitea Actions output
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _write_github_output(key: str, value: str) -> None:
|
def _write_github_output(key: str, value: str) -> None:
|
||||||
"""Append a key=value line to the $GITHUB_OUTPUT file."""
|
"""Append a key=value line to the $GITHUB_OUTPUT file."""
|
||||||
import os
|
|
||||||
|
|
||||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
gh_output = os.environ.get("GITHUB_OUTPUT")
|
||||||
if not gh_output:
|
if not gh_output:
|
||||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
||||||
@@ -207,30 +597,41 @@ def _write_github_output(key: str, value: str) -> None:
|
|||||||
f.write(f"{key}={value}\n")
|
f.write(f"{key}={value}\n")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
@click.command()
|
||||||
@click.option("--base", default=None, help="Base ref (default: latest tag).")
|
@click.option("--base", default=None, help="Base ref (default: latest tag).")
|
||||||
@click.option("--head", default="HEAD", help="Head ref (default: HEAD).")
|
@click.option("--head", default="HEAD", help="Head ref (default: HEAD).")
|
||||||
@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."""
|
||||||
|
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:
|
||||||
@@ -242,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:
|
||||||
@@ -252,57 +654,65 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
|
|||||||
click.echo(_("No changes between {base} and {head}.", base=base, head=head))
|
click.echo(_("No changes between {base} and {head}.", base=base, head=head))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
result = classifier.classify(files)
|
||||||
|
|
||||||
if github_output:
|
if github_output:
|
||||||
ansible_files = [f for f in files if f.startswith("ansible/") or f == ".ansible-lint"]
|
_write_github_output("user-facing-changed", "true" if result.has_user_facing else "false")
|
||||||
user_files = [f for f in files if is_user_facing(f)]
|
for tag in available_tags:
|
||||||
_write_github_output("ansible-changed", "true" if ansible_files else "false")
|
_write_github_output(f"{tag}-changed", "true" if result.has_tag(tag) else "false")
|
||||||
_write_github_output("user-facing-changed", "true" if user_files else "false")
|
click.echo(f"User-facing files changed: {result.has_user_facing}")
|
||||||
click.echo(f"Ansible files changed: {bool(ansible_files)}")
|
for tag in available_tags:
|
||||||
click.echo(f"User-facing files changed: {bool(user_files)}")
|
click.echo(f"{tag.capitalize()} files changed: {result.has_tag(tag)}")
|
||||||
return
|
return
|
||||||
|
|
||||||
if check == "ansible":
|
# --check: check a specific tag or user-facing
|
||||||
# Check only for Ansible-related file changes
|
if check != "all":
|
||||||
ansible_files = [f for f in files if f.startswith("ansible/") or f == ".ansible-lint"]
|
if check == "user-facing":
|
||||||
has_ansible = bool(ansible_files)
|
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":
|
|
||||||
# Check only for user-facing file changes (inverse of workflow-only)
|
|
||||||
user_files = [f for f in files if is_user_facing(f)]
|
|
||||||
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
|
||||||
|
|
||||||
result = classify_changes(files)
|
has_user = result.has_user_facing
|
||||||
has_user = bool(result["user_facing"])
|
|
||||||
|
|
||||||
if quiet:
|
if quiet:
|
||||||
click.echo("true" if has_user else "false")
|
click.echo("true" if has_user else "false")
|
||||||
return
|
return
|
||||||
|
|
||||||
click.echo(_("Comparing {base}..{head} ({count} files changed)", base=base, head=head, count=len(files)))
|
click.echo(_("Comparing {base}..{head} ({count} files changed)", base=base, head=head, count=len(files)))
|
||||||
click.echo(_("\nUser-facing changes ({count}):", count=len(result["user_facing"])))
|
click.echo(_("\nUser-facing changes ({count}):", count=len(result.user_facing)))
|
||||||
for f in result["user_facing"]:
|
for f in result.user_facing:
|
||||||
click.echo(f" {f}")
|
click.echo(f" {f}")
|
||||||
click.echo(_("\nWorkflow-only changes ({count}):", count=len(result["workflow_only"])))
|
click.echo(_("\nWorkflow-only changes ({count}):", count=len(result.infrastructure)))
|
||||||
for f in result["workflow_only"]:
|
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
@@ -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.
|
||||||
|
|||||||
@@ -39,16 +39,15 @@ def _default_branch_protection_config() -> dict[str, Any]:
|
|||||||
environment variable (comma-separated) or default to just the quality
|
environment variable (comma-separated) or default to just the quality
|
||||||
check context.
|
check context.
|
||||||
|
|
||||||
The ``push_whitelist_usernames`` is read from ``DEVX_PUSH_WHITELIST``
|
Push whitelist is disabled — the release script pushes directly to
|
||||||
(comma-separated) to allow the release bot to push directly to master.
|
master (release commits). Since there are no manual reviews yet,
|
||||||
|
requiring PRs for every push adds complexity without benefit.
|
||||||
"""
|
"""
|
||||||
push_whitelist = os.environ.get("DEVX_PUSH_WHITELIST", "")
|
|
||||||
whitelist = [u.strip() for u in push_whitelist.split(",") if u.strip()]
|
|
||||||
return {
|
return {
|
||||||
"branch_name": "master",
|
"branch_name": "master",
|
||||||
"enable_push": True,
|
"enable_push": True,
|
||||||
"enable_push_whitelist": True,
|
"enable_push_whitelist": False,
|
||||||
"push_whitelist_usernames": whitelist,
|
"push_whitelist_usernames": [],
|
||||||
"enable_status_check": True,
|
"enable_status_check": True,
|
||||||
"status_check_contexts": _default_status_checks(),
|
"status_check_contexts": _default_status_checks(),
|
||||||
"required_approvals": 0,
|
"required_approvals": 0,
|
||||||
|
|||||||
+22
-1
@@ -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)
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+444
-102
@@ -1,4 +1,16 @@
|
|||||||
"""Unit tests for scripts/ci/classify_changes.py."""
|
"""Unit tests for devx.ci.classify_changes.
|
||||||
|
|
||||||
|
Tests cover:
|
||||||
|
- Glob matching (``_glob_to_regex``, ``_matches_glob``)
|
||||||
|
- Classifier config loading from pyproject.toml
|
||||||
|
- ChangeClassifier with layered rules (overrides, patterns, default)
|
||||||
|
- Tag system (orthogonal categories)
|
||||||
|
- Backward-compatible API (is_user_facing, is_workflow_only, classify_changes)
|
||||||
|
- Git helpers (get_changed_files, get_latest_tag, run_git)
|
||||||
|
- CLI (main with --quiet, --check, --github-output)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
@@ -9,6 +21,13 @@ 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,
|
||||||
|
ClassificationResult,
|
||||||
|
ClassifierConfig,
|
||||||
|
FileClassification,
|
||||||
|
_glob_to_regex,
|
||||||
|
_matches_glob,
|
||||||
classify_changes,
|
classify_changes,
|
||||||
get_changed_files,
|
get_changed_files,
|
||||||
get_latest_tag,
|
get_latest_tag,
|
||||||
@@ -19,98 +38,349 @@ from devx.ci.classify_changes import (
|
|||||||
run_git,
|
run_git,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Glob matching tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class TestIsUserFacing:
|
|
||||||
def test_src_is_user_facing(self) -> None:
|
|
||||||
assert is_user_facing("src/devx/cli.py") is True
|
|
||||||
|
|
||||||
def test_ansible_is_user_facing(self) -> None:
|
class TestGlobToRegex:
|
||||||
assert is_user_facing("ansible/roles/gitea-runner/tasks/main.yml") is True
|
def test_double_star_matches_anything(self) -> None:
|
||||||
|
regex = _glob_to_regex(".gitea/**")
|
||||||
|
assert regex.match(".gitea/workflows/ci.yml")
|
||||||
|
assert regex.match(".gitea/actionlint.yaml")
|
||||||
|
assert regex.match(".gitea/a/b/c/d.yml")
|
||||||
|
assert not regex.match("tests/test_foo.py")
|
||||||
|
|
||||||
def test_pyproject_is_user_facing(self) -> None:
|
def test_double_star_in_middle(self) -> None:
|
||||||
assert is_user_facing("pyproject.toml") is True
|
"""** in the middle of a pattern matches any number of segments."""
|
||||||
|
regex = _glob_to_regex("src/**/test_*.py")
|
||||||
|
assert regex.match("src/test_foo.py")
|
||||||
|
assert regex.match("src/devx/test_cli.py")
|
||||||
|
assert regex.match("src/a/b/c/test_bar.py")
|
||||||
|
assert not regex.match("src/cli.py")
|
||||||
|
|
||||||
def test_workflow_is_not_user_facing(self) -> None:
|
def test_single_star_matches_within_segment(self) -> None:
|
||||||
assert is_user_facing(".gitea/workflows/ci.yml") is False
|
regex = _glob_to_regex("src/*/cli.py")
|
||||||
|
assert regex.match("src/devx/cli.py")
|
||||||
|
assert regex.match("src/pkg/cli.py")
|
||||||
|
assert not regex.match("src/devx/sub/cli.py")
|
||||||
|
|
||||||
def test_ci_scripts_are_not_user_facing(self) -> None:
|
def test_question_mark_matches_single_char(self) -> None:
|
||||||
assert is_user_facing("scripts/ci/release.py") is False
|
regex = _glob_to_regex("file?.py")
|
||||||
|
assert regex.match("file1.py")
|
||||||
|
assert regex.match("fileA.py")
|
||||||
|
assert not regex.match("file12.py")
|
||||||
|
|
||||||
def test_dev_scripts_are_not_user_facing(self) -> None:
|
def test_literal_match(self) -> None:
|
||||||
"""All scripts under scripts/ are infrastructure (CI/CD, dev tools).
|
regex = _glob_to_regex("Makefile")
|
||||||
User-facing code lives in src/devx/."""
|
assert regex.match("Makefile")
|
||||||
assert is_user_facing("scripts/check_test_speed.py") is False
|
assert not regex.match("makefile")
|
||||||
assert is_user_facing("scripts/configure_repo.py") is False
|
|
||||||
assert is_user_facing("scripts/install_checkmake.py") is False
|
|
||||||
|
|
||||||
def test_shell_scripts_are_not_user_facing(self) -> None:
|
def test_special_chars_escaped(self) -> None:
|
||||||
assert is_user_facing("scripts/setup.sh") is False
|
regex = _glob_to_regex("file.test.py")
|
||||||
assert is_user_facing("scripts/molecule_all.sh") is False
|
assert regex.match("file.test.py")
|
||||||
|
assert not regex.match("fileXtest.py")
|
||||||
|
|
||||||
def test_scripts_init_is_not_user_facing(self) -> None:
|
|
||||||
assert is_user_facing("scripts/__init__.py") is False
|
|
||||||
|
|
||||||
def test_version_file_is_not_user_facing(self) -> None:
|
class TestMatchesGlob:
|
||||||
"""__init__.py only contains __version__ — a release artifact,
|
def test_double_star(self) -> None:
|
||||||
not user-facing code. Version bumps alone should not trigger releases."""
|
assert _matches_glob(".gitea/workflows/ci.yml", ".gitea/**")
|
||||||
assert is_user_facing("src/devx/__init__.py") is False
|
assert _matches_glob("tests/unit/test_cli.py", "tests/**")
|
||||||
|
assert not _matches_glob("src/devx/cli.py", "tests/**")
|
||||||
|
|
||||||
def test_api_clients_is_not_user_facing(self) -> None:
|
def test_exact_match(self) -> None:
|
||||||
"""api_clients.py is used only by CI/CD scripts, not by the GRM CLI."""
|
assert _matches_glob("Makefile", "Makefile")
|
||||||
assert is_user_facing("src/devx/api_clients.py") is False
|
assert _matches_glob("src/devx/__init__.py", "src/devx/__init__.py")
|
||||||
|
assert not _matches_glob("src/devx/cli.py", "src/devx/__init__.py")
|
||||||
|
|
||||||
def test_docs_are_not_user_facing(self) -> None:
|
def test_prefix_matching(self) -> None:
|
||||||
assert is_user_facing("docs/user/getting-started.md") is False
|
assert _matches_glob(".gitea/workflows/ci.yml", ".gitea/")
|
||||||
|
assert _matches_glob("scripts/ci/release.py", "scripts/")
|
||||||
|
assert not _matches_glob("tests/test_foo.py", "scripts/")
|
||||||
|
|
||||||
def test_tests_are_not_user_facing(self) -> None:
|
def test_single_star(self) -> None:
|
||||||
assert is_user_facing("tests/unit/test_cli.py") is False
|
assert _matches_glob("src/devx/cli.py", "src/devx/*.py")
|
||||||
|
assert not _matches_glob("src/devx/sub/cli.py", "src/devx/*.py")
|
||||||
|
|
||||||
def test_agents_md_is_not_user_facing(self) -> None:
|
|
||||||
assert is_user_facing("AGENTS.md") is False
|
|
||||||
|
|
||||||
def test_makefile_is_not_user_facing(self) -> None:
|
# ---------------------------------------------------------------------------
|
||||||
assert is_user_facing("Makefile") is False
|
# ClassifierConfig tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestClassifierConfig:
|
||||||
|
def test_from_pyproject_merges_with_defaults(self, tmp_path: Path) -> None:
|
||||||
|
pyproject = tmp_path / "pyproject.toml"
|
||||||
|
pyproject.write_text(
|
||||||
|
"[tool.devx.classify]\n"
|
||||||
|
'infrastructure = ["scripts/**"]\n'
|
||||||
|
'infrastructure_overrides = ["src/pkg/__init__.py"]\n'
|
||||||
|
'user_facing_overrides = ["docs/important.py"]\n'
|
||||||
|
"\n"
|
||||||
|
"[tool.devx.classify.tags]\n"
|
||||||
|
'ansible = ["ansible/**"]\n'
|
||||||
|
)
|
||||||
|
config = ClassifierConfig.from_pyproject(str(pyproject))
|
||||||
|
# Project-specific path is merged with defaults
|
||||||
|
assert "scripts/**" in config.infrastructure
|
||||||
|
assert ".gitea/**" in config.infrastructure # from DEFAULT_INFRASTRUCTURE
|
||||||
|
assert "tests/**" in config.infrastructure # from DEFAULT_INFRASTRUCTURE
|
||||||
|
assert config.use_defaults is True
|
||||||
|
assert config.infrastructure_overrides == ["src/pkg/__init__.py"]
|
||||||
|
assert config.user_facing_overrides == ["docs/important.py"]
|
||||||
|
assert config.tags == {"ansible": ["ansible/**"]}
|
||||||
|
|
||||||
|
def test_from_pyproject_use_defaults_false(self, tmp_path: Path) -> None:
|
||||||
|
pyproject = tmp_path / "pyproject.toml"
|
||||||
|
pyproject.write_text('[tool.devx.classify]\nuse_defaults = false\ninfrastructure = [".gitea/**"]\n')
|
||||||
|
config = ClassifierConfig.from_pyproject(str(pyproject))
|
||||||
|
assert config.infrastructure == [".gitea/**"]
|
||||||
|
assert "tests/**" not in config.infrastructure # no defaults
|
||||||
|
assert config.use_defaults is False
|
||||||
|
|
||||||
|
def test_from_pyproject_missing_file_returns_defaults(self) -> None:
|
||||||
|
config = ClassifierConfig.from_pyproject("/nonexistent/pyproject.toml")
|
||||||
|
assert config.infrastructure == list(DEFAULT_INFRASTRUCTURE)
|
||||||
|
assert config.infrastructure_overrides == []
|
||||||
|
assert config.user_facing_overrides == []
|
||||||
|
assert config.tags == {}
|
||||||
|
assert config.use_defaults is True
|
||||||
|
|
||||||
|
def test_from_pyproject_missing_section_returns_defaults(self, tmp_path: Path) -> None:
|
||||||
|
pyproject = tmp_path / "pyproject.toml"
|
||||||
|
pyproject.write_text('[project]\nname = "test"\n')
|
||||||
|
config = ClassifierConfig.from_pyproject(str(pyproject))
|
||||||
|
assert config.infrastructure == list(DEFAULT_INFRASTRUCTURE)
|
||||||
|
|
||||||
|
def test_from_pyproject_partial_config(self, tmp_path: Path) -> None:
|
||||||
|
pyproject = tmp_path / "pyproject.toml"
|
||||||
|
pyproject.write_text('[tool.devx.classify]\ninfrastructure = ["scripts/**"]\n')
|
||||||
|
config = ClassifierConfig.from_pyproject(str(pyproject))
|
||||||
|
assert "scripts/**" in config.infrastructure
|
||||||
|
assert ".gitea/**" in config.infrastructure # merged with defaults
|
||||||
|
assert config.infrastructure_overrides == []
|
||||||
|
assert config.user_facing_overrides == []
|
||||||
|
assert config.tags == {}
|
||||||
|
|
||||||
|
def test_defaults_are_empty_for_bare_constructor(self) -> None:
|
||||||
|
"""ClassifierConfig() without from_pyproject has empty lists."""
|
||||||
|
config = ClassifierConfig()
|
||||||
|
assert config.infrastructure == []
|
||||||
|
assert config.infrastructure_overrides == []
|
||||||
|
assert config.user_facing_overrides == []
|
||||||
|
assert config.tags == {}
|
||||||
|
assert config.use_defaults is True
|
||||||
|
|
||||||
|
def test_default_infrastructure_is_non_empty(self) -> None:
|
||||||
|
"""The framework ships with a curated default infrastructure list."""
|
||||||
|
assert len(DEFAULT_INFRASTRUCTURE) > 0
|
||||||
|
assert ".gitea/**" in DEFAULT_INFRASTRUCTURE
|
||||||
|
assert "tests/**" in DEFAULT_INFRASTRUCTURE
|
||||||
|
assert "docs/**" in DEFAULT_INFRASTRUCTURE
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ChangeClassifier tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestChangeClassifier:
|
||||||
|
def _make_classifier(self, **kwargs: object) -> ChangeClassifier:
|
||||||
|
"""Create a classifier with explicit config (no pyproject.toml needed)."""
|
||||||
|
config = ClassifierConfig(**kwargs) # type: ignore[arg-type]
|
||||||
|
return ChangeClassifier(config)
|
||||||
|
|
||||||
|
def test_infrastructure_pattern_matches(self) -> None:
|
||||||
|
classifier = self._make_classifier(infrastructure=[".gitea/**", "tests/**"])
|
||||||
|
fc = classifier.classify_file(".gitea/workflows/ci.yml")
|
||||||
|
assert not fc.is_user_facing
|
||||||
|
assert "infrastructure" in fc.matched_rule
|
||||||
|
|
||||||
def test_unknown_file_defaults_to_user_facing(self) -> None:
|
def test_unknown_file_defaults_to_user_facing(self) -> None:
|
||||||
"""Safe default: unknown files are user-facing (require release)."""
|
classifier = self._make_classifier(infrastructure=[".gitea/**"])
|
||||||
assert is_user_facing("some/new/file.type") is True
|
fc = classifier.classify_file("src/devx/cli.py")
|
||||||
assert is_user_facing("new_root_file.txt") is True
|
assert fc.is_user_facing
|
||||||
|
assert fc.matched_rule is None
|
||||||
|
assert "default" in fc.reason.lower()
|
||||||
|
|
||||||
def test_is_workflow_only_inverse(self) -> None:
|
def test_infrastructure_override(self) -> None:
|
||||||
assert is_workflow_only(".gitea/workflows/ci.yml") is True
|
classifier = self._make_classifier(
|
||||||
assert is_workflow_only("src/devx/cli.py") is False
|
infrastructure=[".gitea/**"],
|
||||||
assert is_workflow_only("pyproject.toml") is False
|
infrastructure_overrides=["src/devx/__init__.py"],
|
||||||
|
)
|
||||||
|
fc = classifier.classify_file("src/devx/__init__.py")
|
||||||
|
assert not fc.is_user_facing
|
||||||
|
assert fc.matched_rule == "infrastructure_overrides"
|
||||||
|
|
||||||
|
def test_user_facing_override_beats_infrastructure(self) -> None:
|
||||||
|
"""User-facing overrides have highest priority (safety)."""
|
||||||
|
classifier = self._make_classifier(
|
||||||
|
infrastructure=["tests/**"],
|
||||||
|
user_facing_overrides=["tests/test_public_api.py"],
|
||||||
|
)
|
||||||
|
fc = classifier.classify_file("tests/test_public_api.py")
|
||||||
|
assert fc.is_user_facing
|
||||||
|
assert fc.matched_rule == "user_facing_overrides"
|
||||||
|
|
||||||
class TestClassifyChanges:
|
def test_user_facing_override_beats_infrastructure_override(self) -> None:
|
||||||
def test_all_user_facing(self) -> None:
|
"""User-facing overrides beat infrastructure overrides (safety first)."""
|
||||||
files = ["src/devx/cli.py", "ansible/roles/gitea-runner/tasks/main.yml"]
|
classifier = self._make_classifier(
|
||||||
result = classify_changes(files)
|
infrastructure=[".gitea/**"],
|
||||||
assert result["user_facing"] == files
|
infrastructure_overrides=["src/devx/__init__.py"],
|
||||||
assert result["workflow_only"] == []
|
user_facing_overrides=["src/devx/__init__.py"],
|
||||||
|
)
|
||||||
|
fc = classifier.classify_file("src/devx/__init__.py")
|
||||||
|
assert fc.is_user_facing
|
||||||
|
|
||||||
def test_all_workflow_only(self) -> None:
|
def test_tags_are_computed(self) -> None:
|
||||||
files = [".gitea/workflows/ci.yml", "docs/index.md", "AGENTS.md"]
|
classifier = self._make_classifier(
|
||||||
result = classify_changes(files)
|
infrastructure=[".gitea/**"],
|
||||||
assert result["user_facing"] == []
|
tags={"ansible": ["ansible/**", ".ansible-lint"], "docs": ["docs/**"]},
|
||||||
assert result["workflow_only"] == files
|
)
|
||||||
|
fc = classifier.classify_file("ansible/tasks/main.yml")
|
||||||
|
assert "ansible" in fc.tags
|
||||||
|
assert "docs" not in fc.tags
|
||||||
|
|
||||||
def test_mixed(self) -> None:
|
def test_tags_orthogonal_to_classification(self) -> None:
|
||||||
|
"""A file can be infrastructure AND tagged."""
|
||||||
|
classifier = self._make_classifier(
|
||||||
|
infrastructure=[".gitea/**", "docs/**"],
|
||||||
|
tags={"docs": ["docs/**"]},
|
||||||
|
)
|
||||||
|
fc = classifier.classify_file("docs/index.md")
|
||||||
|
assert not fc.is_user_facing # infrastructure
|
||||||
|
assert "docs" in fc.tags # also tagged
|
||||||
|
|
||||||
|
def test_classify_multiple_files(self) -> None:
|
||||||
|
classifier = self._make_classifier(
|
||||||
|
infrastructure=[".gitea/**", "tests/**"],
|
||||||
|
infrastructure_overrides=["src/devx/__init__.py"],
|
||||||
|
tags={"ansible": ["ansible/**"]},
|
||||||
|
)
|
||||||
files = [
|
files = [
|
||||||
"src/devx/cli.py",
|
"src/devx/cli.py",
|
||||||
".gitea/workflows/ci.yml",
|
".gitea/workflows/ci.yml",
|
||||||
"pyproject.toml",
|
"src/devx/__init__.py",
|
||||||
"docs/index.md",
|
"ansible/tasks/main.yml",
|
||||||
|
"tests/test_foo.py",
|
||||||
]
|
]
|
||||||
result = classify_changes(files)
|
result = classifier.classify(files)
|
||||||
assert "src/devx/cli.py" in result["user_facing"]
|
assert "src/devx/cli.py" in result.user_facing
|
||||||
assert "pyproject.toml" in result["user_facing"]
|
assert "ansible/tasks/main.yml" in result.user_facing
|
||||||
assert ".gitea/workflows/ci.yml" in result["workflow_only"]
|
assert ".gitea/workflows/ci.yml" in result.infrastructure
|
||||||
assert "docs/index.md" in result["workflow_only"]
|
assert "src/devx/__init__.py" in result.infrastructure
|
||||||
|
assert "tests/test_foo.py" in result.infrastructure
|
||||||
|
assert result.has_user_facing
|
||||||
|
assert result.has_tag("ansible")
|
||||||
|
assert "ansible/tasks/main.yml" in result.tags["ansible"]
|
||||||
|
|
||||||
def test_empty(self) -> None:
|
def test_classify_empty(self) -> None:
|
||||||
result = classify_changes([])
|
classifier = self._make_classifier(infrastructure=[".gitea/**"])
|
||||||
assert result == {"user_facing": [], "workflow_only": []}
|
result = classifier.classify([])
|
||||||
|
assert not result.has_user_facing
|
||||||
|
assert result.user_facing == []
|
||||||
|
assert result.infrastructure == []
|
||||||
|
|
||||||
|
def test_reason_is_human_readable(self) -> None:
|
||||||
|
classifier = self._make_classifier(infrastructure=[".gitea/**"])
|
||||||
|
fc = classifier.classify_file(".gitea/workflows/ci.yml")
|
||||||
|
assert ".gitea/**" in fc.reason
|
||||||
|
fc2 = classifier.classify_file("src/devx/cli.py")
|
||||||
|
assert "default" in fc2.reason.lower() or "user-facing" in fc2.reason.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestClassificationResult:
|
||||||
|
def test_has_user_facing(self) -> None:
|
||||||
|
result = ClassificationResult(user_facing=["src/cli.py"])
|
||||||
|
assert result.has_user_facing
|
||||||
|
|
||||||
|
def test_has_user_facing_empty(self) -> None:
|
||||||
|
result = ClassificationResult()
|
||||||
|
assert not result.has_user_facing
|
||||||
|
|
||||||
|
def test_has_tag(self) -> None:
|
||||||
|
result = ClassificationResult(tags={"ansible": ["ansible/tasks/main.yml"]})
|
||||||
|
assert result.has_tag("ansible")
|
||||||
|
assert not result.has_tag("docs")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Backward-compatible API tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackwardCompatibleAPI:
|
||||||
|
def test_is_workflow_only_with_config(self) -> None:
|
||||||
|
"""is_workflow_only uses the config-driven classifier by default."""
|
||||||
|
with patch.object(classify_changes_mod, "_get_classifier") as mock:
|
||||||
|
classifier = MagicMock()
|
||||||
|
classifier.classify_file.return_value = FileClassification(
|
||||||
|
path=".gitea/workflows/ci.yml",
|
||||||
|
is_user_facing=False,
|
||||||
|
reason="test",
|
||||||
|
matched_rule="infrastructure: .gitea/**",
|
||||||
|
)
|
||||||
|
mock.return_value = classifier
|
||||||
|
assert is_workflow_only(".gitea/workflows/ci.yml") is True
|
||||||
|
|
||||||
|
def test_is_user_facing_with_config(self) -> None:
|
||||||
|
with patch.object(classify_changes_mod, "_get_classifier") as mock:
|
||||||
|
classifier = MagicMock()
|
||||||
|
classifier.classify_file.return_value = FileClassification(
|
||||||
|
path="src/devx/cli.py",
|
||||||
|
is_user_facing=True,
|
||||||
|
reason="test",
|
||||||
|
matched_rule=None,
|
||||||
|
)
|
||||||
|
mock.return_value = classifier
|
||||||
|
assert is_user_facing("src/devx/cli.py") is True
|
||||||
|
|
||||||
|
def test_legacy_patterns_mode(self) -> None:
|
||||||
|
"""is_workflow_only with explicit patterns uses legacy prefix matching."""
|
||||||
|
patterns = frozenset([".gitea/", "tests/"])
|
||||||
|
assert is_workflow_only(".gitea/workflows/ci.yml", patterns) is True
|
||||||
|
assert is_workflow_only("tests/test_foo.py", patterns) is True
|
||||||
|
assert is_workflow_only("src/devx/cli.py", patterns) is False
|
||||||
|
|
||||||
|
def test_classify_changes_with_config(self) -> None:
|
||||||
|
with patch.object(classify_changes_mod, "_get_classifier") as mock:
|
||||||
|
classifier = MagicMock()
|
||||||
|
classifier.classify.return_value = ClassificationResult(
|
||||||
|
user_facing=["src/devx/cli.py"],
|
||||||
|
infrastructure=[".gitea/workflows/ci.yml"],
|
||||||
|
)
|
||||||
|
mock.return_value = classifier
|
||||||
|
result = classify_changes(["src/devx/cli.py", ".gitea/workflows/ci.yml"])
|
||||||
|
assert "src/devx/cli.py" in result["user_facing"]
|
||||||
|
assert ".gitea/workflows/ci.yml" in result["workflow_only"]
|
||||||
|
|
||||||
|
def test_classify_changes_legacy_mode(self) -> None:
|
||||||
|
patterns = frozenset([".gitea/", "tests/"])
|
||||||
|
result = classify_changes([".gitea/ci.yml", "src/cli.py"], patterns)
|
||||||
|
assert ".gitea/ci.yml" in result["workflow_only"]
|
||||||
|
assert "src/cli.py" in result["user_facing"]
|
||||||
|
|
||||||
|
def test_has_user_facing_changes_with_config(self) -> None:
|
||||||
|
with (
|
||||||
|
patch.object(classify_changes_mod, "get_changed_files", return_value=["src/devx/cli.py"]),
|
||||||
|
patch.object(classify_changes_mod, "_get_classifier") as mock,
|
||||||
|
):
|
||||||
|
classifier = MagicMock()
|
||||||
|
classifier.classify.return_value = ClassificationResult(
|
||||||
|
user_facing=["src/devx/cli.py"],
|
||||||
|
)
|
||||||
|
mock.return_value = classifier
|
||||||
|
assert has_user_facing_changes("v0.1.0", "HEAD") is True
|
||||||
|
|
||||||
|
def test_has_user_facing_changes_legacy(self) -> None:
|
||||||
|
with patch.object(classify_changes_mod, "get_changed_files", return_value=[".gitea/ci.yml"]):
|
||||||
|
patterns = frozenset([".gitea/"])
|
||||||
|
assert has_user_facing_changes("v0.1.0", "HEAD", patterns) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Git helper tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestGetChangedFiles:
|
class TestGetChangedFiles:
|
||||||
@@ -127,23 +397,6 @@ class TestGetChangedFiles:
|
|||||||
assert result == []
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
class TestHasUserFacingChanges:
|
|
||||||
@patch("devx.ci.classify_changes.get_changed_files")
|
|
||||||
def test_true_when_user_facing(self, mock_get: MagicMock) -> None:
|
|
||||||
mock_get.return_value = ["src/devx/cli.py", "docs/index.md"]
|
|
||||||
assert has_user_facing_changes("v0.1.0", "HEAD") is True
|
|
||||||
|
|
||||||
@patch("devx.ci.classify_changes.get_changed_files")
|
|
||||||
def test_false_when_workflow_only(self, mock_get: MagicMock) -> None:
|
|
||||||
mock_get.return_value = [".gitea/workflows/ci.yml", "docs/index.md"]
|
|
||||||
assert has_user_facing_changes("v0.1.0", "HEAD") is False
|
|
||||||
|
|
||||||
@patch("devx.ci.classify_changes.get_changed_files")
|
|
||||||
def test_false_when_no_changes(self, mock_get: MagicMock) -> None:
|
|
||||||
mock_get.return_value = []
|
|
||||||
assert has_user_facing_changes("v0.1.0", "HEAD") is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetLatestTag:
|
class TestGetLatestTag:
|
||||||
@patch("subprocess.run")
|
@patch("subprocess.run")
|
||||||
def test_returns_tag(self, mock_run: MagicMock) -> None:
|
def test_returns_tag(self, mock_run: MagicMock) -> None:
|
||||||
@@ -170,6 +423,11 @@ class TestRunGit:
|
|||||||
run_git(["git", "bad-command"])
|
run_git(["git", "bad-command"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class TestMain:
|
class TestMain:
|
||||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="")
|
@patch("devx.ci.classify_changes.get_latest_tag", return_value="")
|
||||||
def test_no_tags_outputs_true(self, mock_tag: MagicMock) -> None:
|
def test_no_tags_outputs_true(self, mock_tag: MagicMock) -> None:
|
||||||
@@ -204,9 +462,28 @@ 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:
|
||||||
"""Non-quiet mode with no tags prints user-facing message."""
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, [])
|
result = runner.invoke(main, [])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@@ -215,7 +492,6 @@ class TestMain:
|
|||||||
@patch("devx.ci.classify_changes.get_changed_files", return_value=[])
|
@patch("devx.ci.classify_changes.get_changed_files", return_value=[])
|
||||||
@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_no_changes_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
def test_no_changes_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||||
"""Non-quiet mode with no changes prints message."""
|
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, [])
|
result = runner.invoke(main, [])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
@@ -224,7 +500,6 @@ class TestMain:
|
|||||||
@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_quiet_user_facing(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
def test_quiet_user_facing(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||||
"""Quiet mode with user-facing changes outputs true."""
|
|
||||||
mock_changes.return_value = ["src/devx/cli.py"]
|
mock_changes.return_value = ["src/devx/cli.py"]
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, ["--quiet"])
|
result = runner.invoke(main, ["--quiet"])
|
||||||
@@ -234,7 +509,6 @@ class TestMain:
|
|||||||
@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_quiet_workflow_only(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
def test_quiet_workflow_only(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||||
"""Quiet mode with workflow-only changes outputs false."""
|
|
||||||
mock_changes.return_value = [".gitea/workflows/ci.yml"]
|
mock_changes.return_value = [".gitea/workflows/ci.yml"]
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, ["--quiet"])
|
result = runner.invoke(main, ["--quiet"])
|
||||||
@@ -244,28 +518,39 @@ class TestMain:
|
|||||||
@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_with_explicit_base(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
def test_with_explicit_base(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||||
"""Explicit --base overrides latest tag."""
|
|
||||||
mock_changes.return_value = ["src/devx/cli.py"]
|
mock_changes.return_value = ["src/devx/cli.py"]
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, ["--base", "v0.2.0", "--head", "HEAD"])
|
result = runner.invoke(main, ["--base", "v0.2.0", "--head", "HEAD"])
|
||||||
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:
|
||||||
"""--check ansible with Ansible changes outputs true."""
|
|
||||||
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:
|
||||||
"""--check ansible with no Ansible changes outputs false."""
|
|
||||||
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
|
||||||
@@ -274,7 +559,6 @@ class TestMain:
|
|||||||
@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_true(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
def test_check_user_facing_true(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||||
"""--check user-facing with user-facing changes outputs true."""
|
|
||||||
mock_changes.return_value = ["src/devx/cli.py", ".gitea/workflows/ci.yml"]
|
mock_changes.return_value = ["src/devx/cli.py", ".gitea/workflows/ci.yml"]
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, ["--check", "user-facing", "--quiet"])
|
result = runner.invoke(main, ["--check", "user-facing", "--quiet"])
|
||||||
@@ -284,27 +568,47 @@ class TestMain:
|
|||||||
@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_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
def test_check_user_facing_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||||
"""--check user-facing with only workflow changes outputs false."""
|
|
||||||
mock_changes.return_value = [".gitea/workflows/ci.yml", "tests/test_foo.py"]
|
mock_changes.return_value = [".gitea/workflows/ci.yml", "tests/test_foo.py"]
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, ["--check", "user-facing", "--quiet"])
|
result = runner.invoke(main, ["--check", "user-facing", "--quiet"])
|
||||||
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:
|
||||||
"""--check ansible in non-quiet mode prints file list."""
|
|
||||||
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:
|
||||||
"""--check user-facing in non-quiet mode prints file list."""
|
|
||||||
mock_changes.return_value = ["src/devx/cli.py"]
|
mock_changes.return_value = ["src/devx/cli.py"]
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
result = runner.invoke(main, ["--check", "user-facing"])
|
result = runner.invoke(main, ["--check", "user-facing"])
|
||||||
@@ -313,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(
|
||||||
@@ -326,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=[]):
|
||||||
@@ -337,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=""):
|
||||||
@@ -355,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(
|
||||||
@@ -367,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
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class TestDefaultConfigs:
|
|||||||
config = _default_branch_protection_config()
|
config = _default_branch_protection_config()
|
||||||
assert config["branch_name"] == "master"
|
assert config["branch_name"] == "master"
|
||||||
assert config["enable_push"] is True
|
assert config["enable_push"] is True
|
||||||
assert config["enable_push_whitelist"] is True
|
assert config["enable_push_whitelist"] is False
|
||||||
assert config["required_approvals"] == 0
|
assert config["required_approvals"] == 0
|
||||||
assert isinstance(config["status_check_contexts"], list)
|
assert isinstance(config["status_check_contexts"], list)
|
||||||
assert "CI / quality (pull_request)" in config["status_check_contexts"]
|
assert "CI / quality (pull_request)" in config["status_check_contexts"]
|
||||||
@@ -45,16 +45,6 @@ class TestDefaultConfigs:
|
|||||||
config = _default_branch_protection_config()
|
config = _default_branch_protection_config()
|
||||||
assert config["status_check_contexts"] == ["check1", "check2", "check3"]
|
assert config["status_check_contexts"] == ["check1", "check2", "check3"]
|
||||||
|
|
||||||
def test_push_whitelist_from_env(self) -> None:
|
|
||||||
with patch.dict("os.environ", {"DEVX_PUSH_WHITELIST": "emil, alice"}):
|
|
||||||
config = _default_branch_protection_config()
|
|
||||||
assert config["push_whitelist_usernames"] == ["emil", "alice"]
|
|
||||||
|
|
||||||
def test_push_whitelist_empty_by_default(self) -> None:
|
|
||||||
with patch.dict("os.environ", {}, clear=True):
|
|
||||||
config = _default_branch_protection_config()
|
|
||||||
assert config["push_whitelist_usernames"] == []
|
|
||||||
|
|
||||||
|
|
||||||
class TestConfigureRepo:
|
class TestConfigureRepo:
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user