From 6601d90bee827e88c76964d83b61ef10c56d9c31 Mon Sep 17 00:00:00 2001 From: oblachno Admin Date: Fri, 7 Aug 2026 14:20:54 +0000 Subject: [PATCH] DEVX-146: feat: add --include-roles and --exclude-roles to distribute_molecule Co-authored-by: oblachno Admin --- .vale/styles/Google/Anthropomorphism.yml | 12 ++++ .vale/styles/Google/Colons.yml | 9 ++- .vale/styles/Google/DateFormat.yml | 2 +- .vale/styles/Google/ExcessiveClaims.yml | 14 ++++ .vale/styles/Google/FirstPerson.yml | 10 +-- .vale/styles/Google/Headings.yml | 7 +- .vale/styles/Google/Jargon.yml | 13 ++++ .vale/styles/Google/Latin.yml | 8 ++- .vale/styles/Google/OxfordComma.yml | 23 ++++++- .vale/styles/Google/Parens.yml | 10 ++- .vale/styles/Google/Timeless.yml | 13 ++++ .vale/styles/Google/Units.yml | 6 +- .vale/styles/Google/WordList.yml | 57 +--------------- .vale/styles/Google/WordListCase.yml | 68 +++++++++++++++++++ README.md | 2 +- docs/index.md | 2 +- ...-approval-fallback-and-ci-consolidation.md | 2 +- docs/tech/architecture.md | 12 ++-- docs/user/cli-commands.md | 10 +-- src/devx/molecule/distribute_molecule.py | 44 +++++++++++- tests/unit/test_distribute_molecule.py | 55 +++++++++++++++ 21 files changed, 294 insertions(+), 85 deletions(-) create mode 100644 .vale/styles/Google/Anthropomorphism.yml create mode 100644 .vale/styles/Google/ExcessiveClaims.yml create mode 100644 .vale/styles/Google/Jargon.yml create mode 100644 .vale/styles/Google/Timeless.yml create mode 100644 .vale/styles/Google/WordListCase.yml diff --git a/.vale/styles/Google/Anthropomorphism.yml b/.vale/styles/Google/Anthropomorphism.yml new file mode 100644 index 0000000..36137a1 --- /dev/null +++ b/.vale/styles/Google/Anthropomorphism.yml @@ -0,0 +1,12 @@ +extends: existence +message: "Don't attribute human qualities to software or hardware ('%s')." +link: https://developers.google.com/style/anthropomorphism +level: suggestion +ignorecase: true +# Limited to the two verbs the guide itself names. Broader lists (wants, knows, +# thinks) can't tell a software subject from a human one: on a 950-file corpus +# they produced 8 false positives ('the customer wants', 'your audience knows') +# for every 2 real ones. +tokens: + - sees + - tells diff --git a/.vale/styles/Google/Colons.yml b/.vale/styles/Google/Colons.yml index 4a027c3..98972b9 100644 --- a/.vale/styles/Google/Colons.yml +++ b/.vale/styles/Google/Colons.yml @@ -1,8 +1,13 @@ extends: existence message: "'%s' should be in lowercase." link: 'https://developers.google.com/style/colons' -nonword: true level: warning scope: sentence +# The match is the word itself, not ': X', and `nonword` is off. Both are +# required for a project Vocab to work: Vale compares accept.txt entries +# against the matched text, and `nonword: true` opts out of that entirely. +# So a proper noun after a colon can be exempted by adding it to accept.txt. +# The guide's other exemption, notice labels, is handled by the lookbehinds; +# headings are already excluded by `scope: sentence`. See issue #20. tokens: - - '(? An open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian). +> An open source project from **Oblachno** (облачно means *cloudy* in Bulgarian). [![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE) diff --git a/docs/index.md b/docs/index.md index d07a5b0..760904a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,7 +8,7 @@ parallel test distribution, and more into a single installable package. It was extracted from the [GRM](https://git.oblachno.oblachno.fyi/oblachno-oss/grm) project to be reusable across all oblachno-oss repositories. -> An open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian). +> An open source project from **Oblachno** (облачно means *cloudy* in Bulgarian). [![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE) diff --git a/docs/retrospectives/2026-07-12-self-approval-fallback-and-ci-consolidation.md b/docs/retrospectives/2026-07-12-self-approval-fallback-and-ci-consolidation.md index 7ae4196..138e395 100644 --- a/docs/retrospectives/2026-07-12-self-approval-fallback-and-ci-consolidation.md +++ b/docs/retrospectives/2026-07-12-self-approval-fallback-and-ci-consolidation.md @@ -132,7 +132,7 @@ unblocked auto-merge across all three repos. ### 2. Double-Prefix Detection (MEDIUM impact) `check_auto_merge_ready.py` now detects and rejects Vikunja task titles -that include the identifier prefix (for example, "DEVX-127: Fix..."). +that include the identifier prefix (for example, "DEVX-127: Fix"). The validator adds the prefix automatically, so a double prefix would fail validation. diff --git a/docs/tech/architecture.md b/docs/tech/architecture.md index 18572e5..3cc658c 100644 --- a/docs/tech/architecture.md +++ b/docs/tech/architecture.md @@ -87,11 +87,11 @@ overridden via environment variables with the `DEVX_` prefix. Provides: - `GITEA_API_URL` / `VIKUNJA_API_URL` — API endpoints - `REPO_OWNER` — repository owner (must be set per-project) -- `TASK_PREFIX` / `TASK_ID_RE` — task ID prefix and regex (for example, `DEVX-N`) +- `TASK_PREFIX` / `TASK_ID_RE` — task ID prefix and regular expression (for example, `DEVX-N`) - `VIKUNJA_PROJECT_ID` — Vikunja project for task tracking - `DEFAULT_TIMEOUT`, `DEFAULT_PER_PAGE` — HTTP client defaults - `MAX_RETRIES`, `RETRY_BACKOFF_BASE`, `RETRY_STATUS_CODES` — retry config -- `CONVENTIONAL_RE` — conventional commit format regex +- `CONVENTIONAL_RE` — conventional commit format regular expression ### `exceptions.py` @@ -109,7 +109,7 @@ wraps user-facing strings for translation. Projects can extend translations by setting `DEVX_TRANSLATIONS_PATH` to a custom JSON file. Keys from the project's file are merged on top of devx's -built-in translations, allowing projects to override or add keys without +built-in translations, allowing projects to override, or add keys without modifying the package. ### `api_clients.py` @@ -171,7 +171,7 @@ from `devx.api_clients`, `devx.config`, `devx.gitea_cli`, and `devx.i18n`. Automated release using git-cliff. Calculates the next semver version from conventional commits since the last tag, updates `__version__` in -`__init__.py` and `CHANGELOG.md`, runs lint and tests to verify the release +`__init__.py` and `CHANGELOG.md`, runs lint, and tests to verify the release is healthy, commits with `release: vX.Y.Z [skip ci]`, creates an annotated tag, and pushes both to master. @@ -288,7 +288,7 @@ Click commands from `cli.py` and verifies each has documentation in ### `discover_runners.py` Discovers available Gitea Actions runners at three levels: repository, -organization, and instance (admin). Falls back to the `MOLECULE_RUNNERS` repo +organization, and instance (administrator). Falls back to the `MOLECULE_RUNNERS` repo variable or `DEFAULT_MAX_RUNNERS` (3). Outputs runner count or a JSON index array for use as a dynamic matrix in Gitea Actions. @@ -330,7 +330,7 @@ Supports `--tool` to install specific tools and `--list` to show status. Runs unit tests and enforces execution-time budgets. Two quality gates: total suite time must not exceed `--max-seconds` (default: 10s), and no individual test may exceed `--max-single-seconds` (default: 0.5s, 0 to -disable). Runs `make test-unit` with `PYTEST_ADDOPTS=--durations=0`. +off). Runs `make test-unit` with `PYTEST_ADDOPTS=--durations=0`. ### `check_test_isolation.py` diff --git a/docs/user/cli-commands.md b/docs/user/cli-commands.md index d9cbbed..915c77c 100644 --- a/docs/user/cli-commands.md +++ b/docs/user/cli-commands.md @@ -85,7 +85,7 @@ devx ci detect-release-commit Discover available Gitea Actions runners for dynamic job distribution. Queries the Gitea API for registered runners at repository, organization, and -instance (admin) levels. Falls back to `MOLECULE_RUNNERS` repo variable or +instance (administrator) levels. Falls back to `MOLECULE_RUNNERS` repo variable or `DEFAULT_MAX_RUNNERS` (3). ```bash @@ -373,7 +373,7 @@ Run unit tests and enforce execution-time budgets. Two quality gates: - **Total suite time** must not exceed `--max-seconds` (default: 10s) - **Per-test time** — no individual test may exceed `--max-single-seconds` - (default: 0.5s, 0 to disable) + (default: 0.5s, 0 to turn off) Runs `make test-unit` with `PYTEST_ADDOPTS=--durations=0` so pytest emits per-test timing lines. @@ -419,7 +419,7 @@ devx tools check-test-isolation --src-dir src/ Pytest plugin options (automatic when devx is installed): -- `--no-test-isolation` — disable static analysis and runtime subprocess audit +- `--no-test-isolation` — turn off static analysis and runtime subprocess audit - `--test-isolation-max-loop N` — max iterations per loop (default: 100) ### `devx tools configure-repo` @@ -464,7 +464,7 @@ devx tools generate-cliff-config --prefix GRM --force # overwrite existing Options: - `--prefix ` — task ID prefix (default: `DEVX_TASK_PREFIX` env var or `DEVX`) -- `--output ` — output file path (default: `cliff.toml`) +- `--output ` — output path (default: `cliff.toml`) - `--force` — overwrite existing file ### `devx tools install-checkmake` @@ -585,7 +585,7 @@ devx tools check-alert-rules \ Options: - `--template-path ` — path to templates directory (required) -- `--template-name ` — template file name (default: `alert-rules.yml.j2`) +- `--template-name ` — template filename (default: `alert-rules.yml.j2`) - `--var key=value` — template variables (repeatable) ## Molecule Commands diff --git a/src/devx/molecule/distribute_molecule.py b/src/devx/molecule/distribute_molecule.py index a1f5841..7d27274 100644 --- a/src/devx/molecule/distribute_molecule.py +++ b/src/devx/molecule/distribute_molecule.py @@ -104,21 +104,36 @@ def discover_scenarios(root: Path | None = None) -> list[str]: return sorted(scenarios) -def discover_multi_role_scenarios(roles_root: Path | None = None) -> list[tuple[str, str]]: +def discover_multi_role_scenarios( + roles_root: Path | None = None, + include_roles: list[str] | None = None, + exclude_roles: list[str] | None = None, +) -> list[tuple[str, str]]: """Discover (role, scenario) pairs across all roles under *roles_root*. Scans ``roles_root/*/molecule/*/`` for scenario directories, skipping ``common`` and directories starting with ``_``. Returns a sorted list of ``(role_name, scenario_name)`` tuples. + + If *include_roles* is given, only roles whose name is in the list are + returned. If *exclude_roles* is given, roles whose name is in the list + are skipped. Both filters are case-insensitive. """ if roles_root is None: roles_root = DEFAULT_ROLES_ROOT if not roles_root.is_dir(): raise click.ClickException(_("Roles directory not found: {path}", path=str(roles_root))) + include_set = {r.lower() for r in include_roles} if include_roles else None + exclude_set = {r.lower() for r in exclude_roles} if exclude_roles else None pairs: list[tuple[str, str]] = [] for role_dir in sorted(roles_root.iterdir()): if not role_dir.is_dir(): continue + role_name = role_dir.name + if include_set is not None and role_name.lower() not in include_set: + continue + if exclude_set is not None and role_name.lower() in exclude_set: + continue mol_dir = role_dir / "molecule" if not mol_dir.is_dir(): continue @@ -348,6 +363,24 @@ def _write_github_env(key: str, value: str) -> None: help="JSON file with custom platform list (each entry: name, image, command). " "Overrides the default platform matrix. Useful for projects with custom test images.", ) +@click.option( + "--include-roles", + "include_roles", + type=str, + default=None, + help="Comma-separated list of role names to include (multi-role mode only). " + "Only scenarios from these roles are distributed. Case-insensitive. " + "Example: --include-roles docker_base,crowdsec,disk_cleanup,app_hardening", +) +@click.option( + "--exclude-roles", + "exclude_roles", + type=str, + default=None, + help="Comma-separated list of role names to exclude (multi-role mode only). " + "Scenarios from these roles are skipped. Case-insensitive. " + "Example: --exclude-roles docker_base,crowdsec,disk_cleanup,app_hardening", +) def cli( runner_index: int | None, max_runners: int, @@ -358,11 +391,18 @@ def cli( molecule_root: Path | None, roles_root: Path | None, platforms_file: Path | None, + include_roles: str | None, + exclude_roles: str | None, ) -> None: platforms = load_platforms(platforms_file) + # Parse role filters + include_list = [r.strip() for r in include_roles.split(",")] if include_roles else None + exclude_list = [r.strip() for r in exclude_roles.split(",")] if exclude_roles else None # Multi-role mode: discover (role, scenario) pairs across all roles if roles_root is not None: - role_scenarios = discover_multi_role_scenarios(roles_root) + role_scenarios = discover_multi_role_scenarios( + roles_root, include_roles=include_list, exclude_roles=exclude_list + ) if list_all: for role, scenario in role_scenarios: click.echo(f"{role}|{scenario}") diff --git a/tests/unit/test_distribute_molecule.py b/tests/unit/test_distribute_molecule.py index 4dc1fcb..c3c154f 100644 --- a/tests/unit/test_distribute_molecule.py +++ b/tests/unit/test_distribute_molecule.py @@ -311,6 +311,61 @@ class TestDiscoverMultiRole: with pytest.raises(click.ClickException): discover_multi_role_scenarios() + def test_include_roles_filters_to_subset(self, tmp_path: Path) -> None: + roles = tmp_path / "roles" + for scenario in ["default"]: + (roles / "docker_base" / "molecule" / scenario).mkdir(parents=True) + (roles / "crowdsec" / "molecule" / scenario).mkdir(parents=True) + (roles / "app_container" / "molecule" / scenario).mkdir(parents=True) + result = discover_multi_role_scenarios(roles, include_roles=["docker_base", "crowdsec"]) + assert ("docker_base", "default") in result + assert ("crowdsec", "default") in result + assert ("app_container", "default") not in result + assert len(result) == 2 + + def test_include_roles_case_insensitive(self, tmp_path: Path) -> None: + roles = tmp_path / "roles" + (roles / "Docker_Base" / "molecule" / "default").mkdir(parents=True) + (roles / "other" / "molecule" / "default").mkdir(parents=True) + result = discover_multi_role_scenarios(roles, include_roles=["docker_base"]) + assert ("Docker_Base", "default") in result + assert len(result) == 1 + + def test_exclude_roles_skips_subset(self, tmp_path: Path) -> None: + roles = tmp_path / "roles" + for scenario in ["default"]: + (roles / "docker_base" / "molecule" / scenario).mkdir(parents=True) + (roles / "crowdsec" / "molecule" / scenario).mkdir(parents=True) + (roles / "app_container" / "molecule" / scenario).mkdir(parents=True) + result = discover_multi_role_scenarios(roles, exclude_roles=["docker_base", "crowdsec"]) + assert ("docker_base", "default") not in result + assert ("crowdsec", "default") not in result + assert ("app_container", "default") in result + assert len(result) == 1 + + def test_exclude_roles_case_insensitive(self, tmp_path: Path) -> None: + roles = tmp_path / "roles" + (roles / "Docker_Base" / "molecule" / "default").mkdir(parents=True) + (roles / "other" / "molecule" / "default").mkdir(parents=True) + result = discover_multi_role_scenarios(roles, exclude_roles=["docker_base"]) + assert ("Docker_Base", "default") not in result + assert ("other", "default") in result + assert len(result) == 1 + + def test_include_and_exclude_combined(self, tmp_path: Path) -> None: + roles = tmp_path / "roles" + for scenario in ["default"]: + (roles / "docker_base" / "molecule" / scenario).mkdir(parents=True) + (roles / "crowdsec" / "molecule" / scenario).mkdir(parents=True) + (roles / "app_container" / "molecule" / scenario).mkdir(parents=True) + result = discover_multi_role_scenarios( + roles, include_roles=["docker_base", "crowdsec", "app_container"], exclude_roles=["crowdsec"] + ) + assert ("docker_base", "default") in result + assert ("crowdsec", "default") not in result + assert ("app_container", "default") in result + assert len(result) == 2 + def test_default_roles_root_constant(self) -> None: assert Path("ansible/roles") == DEFAULT_ROLES_ROOT