Compare commits

...
6 Commits
Author SHA1 Message Date
devx-ci-bot 7d9a081c92 release: v0.2.0 [skip ci] 2026-06-22 19:32:37 +02:00
emil 87d730d8be DEVX-3: feat: pluggable change classification framework 2026-06-22 17:31:30 +00:00
devx-ci-bot 89a165be46 release: v0.1.2 [skip ci] 2026-06-22 19:17:18 +02:00
emil 388c3df043 DEVX-2: fix: make sync-wiki and vikunja depend on release 2026-06-22 17:16:02 +00:00
devx-ci-bot ac8a1d3be4 release: v0.1.1 [skip ci] 2026-06-22 19:04:48 +02:00
emil 07cca5de36 DEVX-1: fix: disable push whitelist, allow direct pushes to master
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / vikunja (push) Successful in 33s
Post-merge / configure-repo (push) Successful in 12s
Post-merge / release (push) Successful in 54s
Post-merge / sync-wiki (push) Successful in 1m0s
Post-merge / badges (push) Successful in 37s
2026-06-22 17:03:41 +00:00
8 changed files with 837 additions and 271 deletions
+10 -6
View File
@@ -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
+1 -1
View File
@@ -1 +1 @@
DEVX-1 DEVX-3
+6
View File
@@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file.
## [0.1.0] - 2026-06-22 ## [0.1.0] - 2026-06-22
## [0.1.0] - 2026-06-22
## [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
+51
View File
@@ -83,3 +83,54 @@ 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
# ---------------------------------------------------------------------------
# Safe-by-default: any file NOT listed here defaults to user-facing
# (requiring a release). This prevents new file types from silently
# skipping releases.
#
# Rule priority (first match wins):
# 1. user_facing_overrides (safety — highest priority)
# 2. infrastructure_overrides (explicit per-file)
# 3. infrastructure (glob patterns)
# 4. Default: user-facing (safe)
[tool.devx.classify]
# Infrastructure paths — changes here don't trigger a release
infrastructure = [
".gitea/**",
"tests/**",
"docs/**",
"hooks/**",
"Makefile",
"cliff.toml",
".pre-commit-config.yaml",
".env.example",
".gitignore",
".ruff.toml",
"AGENTS.md",
"README.md",
"CHANGELOG.md",
"TROUBLESHOOTING.md",
".ansible-lint",
".github/**",
]
# Infrastructure overrides — files that would default to user-facing
# but are actually infrastructure:
# - __init__.py: only contains __version__ (release artifact, not code)
# - api_clients.py: used only by CI/CD scripts, not by the CLI
infrastructure_overrides = [
"src/devx/__init__.py",
"src/devx/api_clients.py",
]
# User-facing overrides — safety override for broad infrastructure patterns
# (empty — add when an infrastructure pattern is too broad)
user_facing_overrides = []
# Tag patterns — additional categories for CI conditional execution
# Orthogonal to release impact (user-facing vs infrastructure)
[tool.devx.classify.tags]
ansible = ["ansible/**", ".ansible-lint"]
+445 -152
View File
@@ -1,53 +1,91 @@
#!/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`` **Config-driven**: Classification rules are read from ``[tool.devx.classify]``
parameter on ``classify_changes()`` and ``has_user_facing_changes()``. in ``pyproject.toml``. No project needs to modify the framework code.
The default set (``DEFAULT_WORKFLOW_ONLY_PATTERNS``) covers common Each project declares its own paths; the framework handles the logic.
infrastructure paths. Each project can pass its own frozenset to
accommodate different source layouts.
Default workflow-only paths (infrastructure → no release needed): **Layered rules** (evaluated in priority order):
- .gitea/workflows/** — Gitea Actions workflows
- scripts/** — All scripts (CI/CD, dev tools, setup)
- src/devx/__init__.py — Version file (release artifact)
- src/devx/api_clients.py — Gitea API client (CI/CD only, not used by CLI)
- docs/** — Documentation
- tests/** — Test files
- hooks/** — Git hooks
- AGENTS.md — Agent conventions
- README.md — README (lean, links to wiki)
- CHANGELOG.md — Changelog (generated)
- TROUBLESHOOTING.md — Troubleshooting guide
- cliff.toml — git-cliff config
- Makefile — Build automation
- .pre-commit-config.yaml — Pre-commit config
- .ansible-lint — Ansible lint config
- .env.example — Environment template
- .gitignore — Git ignore rules
- .ruff.toml — Ruff config (if separate)
- .github/** — GitHub config (if present)
Everything else is user-facing (tool changes → release needed), 1. **User-facing overrides** (highest priority — safety override)
including but not limited to: Files that match infrastructure patterns but MUST be treated as
- src/devx/*.py — Python CLI source (except __init__.py) user-facing. Use this when an infrastructure pattern is too broad.
- ansible/** — Ansible role
- pyproject.toml — Package metadata 2. **Infrastructure overrides**
- Any new file type not in the allowlist Files that would default to user-facing but are actually
infrastructure (e.g., ``src/pkg/__init__.py`` which only contains
``__version__`` — a release artifact, not user-facing code).
3. **Infrastructure patterns** (deny-list)
Path globs matching infrastructure files. Changes to these don't
trigger a release. Examples: ``.gitea/**``, ``tests/**``, ``docs/**``.
4. **Default**: user-facing (lowest priority — safe default)
**Tag system** (orthogonal to release impact):
Projects can define custom tags (e.g., ``ansible``, ``docs``) for CI
conditional execution. A file can be both infrastructure (no release)
and tagged ``ansible`` (run molecule tests). Tags are evaluated
independently of the user-facing/infrastructure classification.
== Configuration ==
In ``pyproject.toml``::
[tool.devx.classify]
# Infrastructure paths — changes here don't trigger a release
infrastructure = [
".gitea/**",
"tests/**",
"docs/**",
"Makefile",
"AGENTS.md",
"README.md",
"CHANGELOG.md",
]
# Infrastructure overrides — files that would default to user-facing
# but are actually infrastructure
infrastructure_overrides = [
"src/devx/__init__.py",
]
# User-facing overrides — safety override for broad infrastructure patterns
# (empty by default)
user_facing_overrides = []
# Tag patterns — additional categories for CI conditional execution
[tool.devx.classify.tags]
ansible = ["ansible/**", ".ansible-lint"]
== Glob Syntax ==
Patterns support standard glob syntax:
- ``**`` matches any number of path segments (including zero)
- ``*`` matches any characters within a single path segment
- ``?`` matches a single character within a path segment
- Everything else is matched literally
Examples:
- ``.gitea/**`` matches ``.gitea/workflows/ci.yml``, ``.gitea/actionlint.yaml``
- ``tests/**`` matches ``tests/unit/test_cli.py``, ``tests/conftest.py``
- ``src/devx/__init__.py`` matches exactly that file
- ``Makefile`` matches exactly that file
Usage: Usage:
python3 -m devx.ci.classify_changes [--base <ref>] [--head <ref>] python3 -m devx.ci.classify_changes [--base <ref>] [--head <ref>]
@@ -56,51 +94,270 @@ Usage:
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
# ---------------------------------------------------------------------------
@dataclass
class ClassifierConfig:
"""Configuration for the change classifier.
Loaded from ``[tool.devx.classify]`` in ``pyproject.toml``.
Attributes:
infrastructure: Glob patterns for infrastructure paths.
infrastructure_overrides: Exact paths that are infrastructure
despite not matching any infrastructure pattern.
user_facing_overrides: Exact paths that are user-facing
despite matching an infrastructure pattern (safety override).
tags: Dict mapping tag name to list of glob patterns.
"""
infrastructure: list[str] = field(default_factory=list)
infrastructure_overrides: list[str] = field(default_factory=list)
user_facing_overrides: list[str] = field(default_factory=list)
tags: dict[str, list[str]] = field(default_factory=dict)
@classmethod
def from_pyproject(cls, pyproject_path: str = "pyproject.toml") -> ClassifierConfig:
"""Load classifier config from pyproject.toml.
Reads the ``[tool.devx.classify]`` section. If the section or
file is missing, returns a config with empty lists (everything
defaults to user-facing — safe-by-default).
"""
path = Path(pyproject_path)
if not path.exists():
return cls()
with open(path, "rb") as f: # noqa: PTH123
data: dict[str, Any] = tomllib.load(f)
classify_cfg = data.get("tool", {}).get("devx", {}).get("classify", {})
return cls(
infrastructure=list(classify_cfg.get("infrastructure", [])),
infrastructure_overrides=list(classify_cfg.get("infrastructure_overrides", [])),
user_facing_overrides=list(classify_cfg.get("user_facing_overrides", [])),
tags={k: list(v) for k, v in classify_cfg.get("tags", {}).items()},
)
class ChangeClassifier:
"""Classify changed files as user-facing or infrastructure.
Uses layered rules with safe-by-default semantics.
Rule evaluation order (first match wins):
1. User-facing overrides (safety — highest priority)
2. Infrastructure overrides
3. Infrastructure patterns
4. Default: user-facing (safe)
"""
def __init__(self, config: ClassifierConfig | None = None) -> None:
self.config = config or ClassifierConfig.from_pyproject()
# Pre-compile infrastructure patterns for efficiency
self._infra_patterns = list(self.config.infrastructure)
self._infra_overrides = set(self.config.infrastructure_overrides)
self._user_overrides = set(self.config.user_facing_overrides)
def classify_file(self, file_path: str) -> FileClassification:
"""Classify a single file path.
Returns a FileClassification with the decision and reason.
"""
tags = self._compute_tags(file_path)
# 1. User-facing overrides (highest priority — safety)
if file_path in self._user_overrides:
return FileClassification(
path=file_path,
is_user_facing=True,
reason="User-facing override (safety override)",
matched_rule="user_facing_overrides",
tags=tags,
)
# 2. Infrastructure overrides
if file_path in self._infra_overrides:
return FileClassification(
path=file_path,
is_user_facing=False,
reason="Infrastructure override (explicitly listed)",
matched_rule="infrastructure_overrides",
tags=tags,
)
# 3. Infrastructure patterns
for pattern in self._infra_patterns:
if _matches_glob(file_path, pattern):
return FileClassification(
path=file_path,
is_user_facing=False,
reason=f"Infrastructure (matches '{pattern}')",
matched_rule=f"infrastructure: {pattern}",
tags=tags,
)
# 4. Default: user-facing (safe-by-default)
return FileClassification(
path=file_path,
is_user_facing=True,
reason="User-facing (default — not in infrastructure patterns)",
matched_rule=None,
tags=tags,
)
def classify(self, files: list[str]) -> ClassificationResult:
"""Classify a list of changed files.
Returns a ClassificationResult with per-file details and
aggregated lists.
"""
result = ClassificationResult()
all_tags: dict[str, list[str]] = {}
for f in files:
fc = self.classify_file(f)
result.files.append(fc)
if fc.is_user_facing:
result.user_facing.append(f)
else:
result.infrastructure.append(f)
for tag in fc.tags:
all_tags.setdefault(tag, []).append(f)
result.tags = all_tags
return result
def _compute_tags(self, file_path: str) -> frozenset[str]:
"""Compute custom category tags for a file path."""
matched: set[str] = set()
for tag_name, patterns in self.config.tags.items():
for pattern in patterns:
if _matches_glob(file_path, pattern):
matched.add(tag_name)
break
return frozenset(matched)
# ---------------------------------------------------------------------------
# Git helpers
# ---------------------------------------------------------------------------
def run_git(args: list[str]) -> str: def run_git(args: list[str]) -> str:
@@ -126,63 +383,6 @@ def get_changed_files(base: str, head: str) -> list[str]:
return output.split("\n") 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 +396,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,6 +495,11 @@ 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).")
@@ -225,6 +518,9 @@ def _write_github_output(key: str, value: str) -> None:
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps).", help="Write results to $GITHUB_OUTPUT file (for CI workflow steps).",
) )
def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool) -> None: def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool) -> None:
"""Classify git changes and output results."""
classifier = _get_classifier()
if base is None: if base is None:
base = get_latest_tag() base = get_latest_tag()
if not base: if not base:
@@ -252,18 +548,17 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
click.echo(_("No changes between {base} and {head}.", base=base, head=head)) 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("ansible-changed", "true" if result.has_tag("ansible") else "false")
user_files = [f for f in files if is_user_facing(f)] _write_github_output("user-facing-changed", "true" if result.has_user_facing else "false")
_write_github_output("ansible-changed", "true" if ansible_files else "false") click.echo(f"Ansible files changed: {result.has_tag('ansible')}")
_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)}")
click.echo(f"User-facing files changed: {bool(user_files)}")
return return
if check == "ansible": if check == "ansible":
# Check only for Ansible-related file changes ansible_files = result.tags.get("ansible", [])
ansible_files = [f for f in files if f.startswith("ansible/") or f == ".ansible-lint"]
has_ansible = bool(ansible_files) has_ansible = bool(ansible_files)
if quiet: if quiet:
click.echo("true" if has_ansible else "false") click.echo("true" if has_ansible else "false")
@@ -275,8 +570,7 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
return return
if check == "user-facing": if check == "user-facing":
# Check only for user-facing file changes (inverse of workflow-only) user_files = result.user_facing
user_files = [f for f in files if is_user_facing(f)]
has_user = bool(user_files) has_user = bool(user_files)
if quiet: if quiet:
click.echo("true" if has_user else "false") click.echo("true" if has_user else "false")
@@ -289,19 +583,18 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
) )
return 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}")
if has_user: if has_user:
status = "USER-FACING changes detected — release needed" status = "USER-FACING changes detected — release needed"
+5 -6
View File
@@ -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,
+318 -95
View File
@@ -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,12 @@ 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 (
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 +37,326 @@ 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_loads_config(self, tmp_path: Path) -> None:
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text(
"[tool.devx.classify]\n"
'infrastructure = [".gitea/**", "tests/**"]\n'
'infrastructure_overrides = ["src/pkg/__init__.py"]\n'
'user_facing_overrides = ["docs/important.py"]\n'
"\n"
"[tool.devx.classify.tags]\n"
'ansible = ["ansible/**"]\n'
)
config = ClassifierConfig.from_pyproject(str(pyproject))
assert config.infrastructure == [".gitea/**", "tests/**"]
assert config.infrastructure_overrides == ["src/pkg/__init__.py"]
assert config.user_facing_overrides == ["docs/important.py"]
assert config.tags == {"ansible": ["ansible/**"]}
def test_from_pyproject_missing_file(self) -> None:
config = ClassifierConfig.from_pyproject("/nonexistent/pyproject.toml")
assert config.infrastructure == []
assert config.infrastructure_overrides == []
assert config.user_facing_overrides == []
assert config.tags == {}
def test_from_pyproject_missing_section(self, tmp_path: Path) -> None:
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[project]\nname = "test"\n')
config = ClassifierConfig.from_pyproject(str(pyproject))
assert config.infrastructure == []
def test_from_pyproject_partial_config(self, tmp_path: Path) -> None:
pyproject = tmp_path / "pyproject.toml"
pyproject.write_text('[tool.devx.classify]\ninfrastructure = [".gitea/**"]\n')
config = ClassifierConfig.from_pyproject(str(pyproject))
assert config.infrastructure == [".gitea/**"]
assert config.infrastructure_overrides == []
assert config.user_facing_overrides == []
assert config.tags == {}
def test_defaults_are_empty(self) -> None:
config = ClassifierConfig()
assert config.infrastructure == []
assert config.infrastructure_overrides == []
assert config.user_facing_overrides == []
assert config.tags == {}
# ---------------------------------------------------------------------------
# ChangeClassifier tests
# ---------------------------------------------------------------------------
class TestChangeClassifier:
def _make_classifier(self, **kwargs: object) -> ChangeClassifier:
"""Create a classifier with explicit config (no pyproject.toml needed)."""
config = ClassifierConfig(**kwargs) # type: ignore[arg-type]
return ChangeClassifier(config)
def test_infrastructure_pattern_matches(self) -> None:
classifier = self._make_classifier(infrastructure=[".gitea/**", "tests/**"])
fc = classifier.classify_file(".gitea/workflows/ci.yml")
assert not fc.is_user_facing
assert "infrastructure" in fc.matched_rule
def test_unknown_file_defaults_to_user_facing(self) -> None: 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 +373,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 +399,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:
@@ -206,7 +440,6 @@ 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_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 +448,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 +456,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 +465,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,7 +474,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_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"])
@@ -254,7 +483,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_ansible_true(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: def test_check_ansible_true(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""--check ansible with Ansible changes outputs true."""
mock_changes.return_value = ["ansible/tasks/main.yml", ".gitea/workflows/ci.yml"] mock_changes.return_value = ["ansible/tasks/main.yml", ".gitea/workflows/ci.yml"]
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["--check", "ansible", "--quiet"]) result = runner.invoke(main, ["--check", "ansible", "--quiet"])
@@ -264,7 +492,6 @@ class TestMain:
@patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_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) -> 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"]
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["--check", "ansible", "--quiet"]) result = runner.invoke(main, ["--check", "ansible", "--quiet"])
@@ -274,7 +501,6 @@ class TestMain:
@patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_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,7 +510,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_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"])
@@ -294,7 +519,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_ansible_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: def test_check_ansible_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""--check ansible in non-quiet mode prints file list."""
mock_changes.return_value = ["ansible/tasks/main.yml"] mock_changes.return_value = ["ansible/tasks/main.yml"]
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["--check", "ansible"]) result = runner.invoke(main, ["--check", "ansible"])
@@ -304,7 +528,6 @@ class TestMain:
@patch("devx.ci.classify_changes.get_changed_files") @patch("devx.ci.classify_changes.get_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"])
+1 -11
View File
@@ -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)