From 5511cba1b0f244a99c50583b2f9afffb4f3df83a Mon Sep 17 00:00:00 2001 From: emil Date: Sun, 21 Jun 2026 20:44:39 +0000 Subject: [PATCH] GRM-37: refactor: Split CI scripts, fix release PYTHONPATH, dynamic runner discovery --- .gitea/workflows/auto-merge.yml | 2 +- .gitea/workflows/ci.yml | 39 +++- .gitea/workflows/post-merge.yml | 2 +- .gitea/workflows/publish.yml | 4 +- .gitea/workflows/release.yml | 4 +- .gitea/workflows/sync-wiki.yml | 2 +- .pre-commit-config.yaml | 4 +- AGENTS.md | 67 +++++-- Makefile | 4 +- docs/tech/ci-cd-workflow.md | 42 ++-- pyproject.toml | 4 +- scripts/ci/__init__.py | 0 scripts/{ => ci}/auto_merge.py | 0 scripts/{ => ci}/classify_changes.py | 77 ++++++-- scripts/ci/discover_runners.py | 149 ++++++++++++++ scripts/{ => ci}/distribute_molecule.py | 0 scripts/{ => ci}/doc_coverage.py | 8 +- scripts/{ => ci}/molecule_ci_guard.py | 0 scripts/{ => ci}/notify_failure.py | 0 scripts/{ => ci}/post_merge.py | 0 scripts/{ => ci}/publish.py | 0 scripts/{ => ci}/release.py | 2 +- scripts/{ => ci}/review_pr.py | 0 scripts/{ => ci}/sync_wiki.py | 2 +- scripts/{ => ci}/validate_commit_msg.py | 0 tests/unit/test_auto_merge.py | 104 +++++----- tests/unit/test_classify_changes.py | 79 +++++--- tests/unit/test_discover_runners.py | 190 ++++++++++++++++++ tests/unit/test_distribute_molecule.py | 20 +- tests/unit/test_doc_coverage.py | 7 +- tests/unit/test_molecule_ci_guard.py | 50 ++--- tests/unit/test_notify_failure.py | 10 +- tests/unit/test_post_merge.py | 14 +- tests/unit/test_publish.py | 80 ++++---- tests/unit/test_release.py | 246 ++++++++++++------------ tests/unit/test_review_pr.py | 28 +-- tests/unit/test_sync_wiki.py | 68 +++---- tests/unit/test_validate_commit_msg.py | 22 +-- 38 files changed, 908 insertions(+), 422 deletions(-) create mode 100644 scripts/ci/__init__.py rename scripts/{ => ci}/auto_merge.py (100%) rename scripts/{ => ci}/classify_changes.py (71%) create mode 100644 scripts/ci/discover_runners.py rename scripts/{ => ci}/distribute_molecule.py (100%) rename scripts/{ => ci}/doc_coverage.py (95%) rename scripts/{ => ci}/molecule_ci_guard.py (100%) rename scripts/{ => ci}/notify_failure.py (100%) rename scripts/{ => ci}/post_merge.py (100%) rename scripts/{ => ci}/publish.py (100%) rename scripts/{ => ci}/release.py (99%) rename scripts/{ => ci}/review_pr.py (100%) rename scripts/{ => ci}/sync_wiki.py (98%) rename scripts/{ => ci}/validate_commit_msg.py (100%) create mode 100644 tests/unit/test_discover_runners.py diff --git a/.gitea/workflows/auto-merge.yml b/.gitea/workflows/auto-merge.yml index 5093ecc..fb79964 100644 --- a/.gitea/workflows/auto-merge.yml +++ b/.gitea/workflows/auto-merge.yml @@ -17,7 +17,7 @@ jobs: VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }} PYTHONPATH: src run: | - python3 scripts/auto_merge.py \ + python3 scripts/ci/auto_merge.py \ "${{ github.head_ref }}" \ "${{ github.event.pull_request.title }}" \ "${{ github.repository }}" \ diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 75acbd9..a14a36c 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -29,7 +29,7 @@ jobs: - name: Documentation coverage check run: | . .venv/bin/activate - PYTHONPATH=src python3 scripts/doc_coverage.py + PYTHONPATH=src python3 scripts/ci/doc_coverage.py release-dry-run: needs: [quality, detect-changes] @@ -54,7 +54,7 @@ jobs: - name: Release dry-run validation run: | . .venv/bin/activate - PYTHONPATH=src python3 scripts/release.py --dry-run || true + PYTHONPATH=src python3 scripts/ci/release.py --dry-run || true detect-changes: runs-on: docker @@ -94,13 +94,40 @@ jobs: echo "No user-facing files changed — skipping release dry-run." fi + discover-runners: + needs: [detect-changes] + if: needs.detect-changes.outputs.ansible-changed == 'true' + runs-on: docker + outputs: + runner-count: ${{ steps.discover.outputs.runner-count }} + runner-indices: ${{ steps.discover.outputs.runner-indices }} + steps: + - uses: actions/checkout@v4 + - name: Set up environment + run: make setup + - name: Discover available runners + id: discover + env: + REPO_TOKEN: ${{ secrets.REPO_TOKEN }} + MOLECULE_RUNNERS: ${{ vars.MOLECULE_RUNNERS }} + PYTHONPATH: src + run: | + . .venv/bin/activate + OUTPUT=$(python3 scripts/ci/discover_runners.py --owner "${{ github.repository_owner }}" --repo "${{ github.event.repository.name }}") + echo "$OUTPUT" + # Parse outputs + RUNNER_COUNT=$(echo "$OUTPUT" | grep '^count=' | cut -d= -f2) + RUNNER_INDICES=$(echo "$OUTPUT" | grep '^indices=' | cut -d= -f2) + echo "runner-count=$RUNNER_COUNT" >> "$GITHUB_OUTPUT" + echo "runner-indices=$RUNNER_INDICES" >> "$GITHUB_OUTPUT" + molecule-tests: - needs: [quality, detect-changes] + needs: [quality, detect-changes, discover-runners] if: needs.detect-changes.outputs.ansible-changed == 'true' runs-on: docker strategy: matrix: - runner-index: [0, 1, 2] + runner-index: ${{ fromJSON(needs.discover-runners.outputs.runner-indices) }} steps: - uses: actions/checkout@v4 - name: Set up environment @@ -108,14 +135,14 @@ jobs: - name: Discover assigned test pairs run: | . .venv/bin/activate - PAIRS=$(python3 scripts/distribute_molecule.py --runner-index ${{ matrix.runner-index }} --max-runners 3) + PAIRS=$(python3 scripts/ci/distribute_molecule.py --runner-index ${{ matrix.runner-index }} --max-runners ${{ needs.discover-runners.outputs.runner-count }}) echo "Assigned pairs: $PAIRS" echo "TEST_PAIRS=$PAIRS" >> $GITHUB_ENV - name: Run molecule tests run: | set -euo pipefail . .venv/bin/activate - python3 scripts/molecule_ci_guard.py $TEST_PAIRS + python3 scripts/ci/molecule_ci_guard.py $TEST_PAIRS env: GITEA_URL: ${{ github.server_url }} REPO_TOKEN: ${{ secrets.REPO_TOKEN }} diff --git a/.gitea/workflows/post-merge.yml b/.gitea/workflows/post-merge.yml index 8f70484..bafbdff 100644 --- a/.gitea/workflows/post-merge.yml +++ b/.gitea/workflows/post-merge.yml @@ -18,6 +18,6 @@ jobs: VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }} PYTHONPATH: src run: | - python3 scripts/post_merge.py \ + python3 scripts/ci/post_merge.py \ "$(git log -1 --pretty=%B)" \ --commit-sha "$(git rev-parse HEAD)" diff --git a/.gitea/workflows/publish.yml b/.gitea/workflows/publish.yml index 2028ba9..041d3f7 100644 --- a/.gitea/workflows/publish.yml +++ b/.gitea/workflows/publish.yml @@ -37,7 +37,7 @@ jobs: PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} PYTHONPATH: src run: | - python3 scripts/publish.py \ + python3 scripts/ci/publish.py \ "${{ github.ref_name }}" \ "${{ github.repository }}" - name: Notify on failure @@ -46,7 +46,7 @@ jobs: REPO_TOKEN: ${{ secrets.REPO_TOKEN }} PYTHONPATH: src run: | - python3 scripts/notify_failure.py \ + python3 scripts/ci/notify_failure.py \ --repo "${{ github.repository }}" \ --run-id "${{ github.run_id }}" \ --workflow "publish" \ diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index d73af70..fc37b9e 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -34,7 +34,7 @@ jobs: PYTHONPATH: src run: | . .venv/bin/activate - python3 scripts/release.py + python3 scripts/ci/release.py - name: Notify on failure if: failure() env: @@ -42,7 +42,7 @@ jobs: PYTHONPATH: src run: | . .venv/bin/activate - python3 scripts/notify_failure.py \ + python3 scripts/ci/notify_failure.py \ --repo "${{ github.repository }}" \ --run-id "${{ github.run_id }}" \ --workflow "release" \ diff --git a/.gitea/workflows/sync-wiki.yml b/.gitea/workflows/sync-wiki.yml index 55c4a5d..d89918d 100644 --- a/.gitea/workflows/sync-wiki.yml +++ b/.gitea/workflows/sync-wiki.yml @@ -22,7 +22,7 @@ jobs: PYTHONPATH: src run: | . .venv/bin/activate - python3 scripts/sync_wiki.py --repo "${{ github.repository }}" + python3 scripts/ci/sync_wiki.py --repo "${{ github.repository }}" - name: Tag wiki on release if: startsWith(github.ref, 'refs/tags/v') env: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b453d1b..58fcec4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ repos: hooks: - id: validate-commit-msg name: validate commit message - entry: .venv/bin/python scripts/validate_commit_msg.py + entry: .venv/bin/python scripts/ci/validate_commit_msg.py language: system stages: [commit-msg] pass_filenames: true @@ -58,7 +58,7 @@ repos: - id: commit-msg name: validate commit message - entry: .venv/bin/python scripts/validate_commit_msg.py + entry: .venv/bin/python scripts/ci/validate_commit_msg.py language: system stages: [commit-msg] pass_filenames: true diff --git a/AGENTS.md b/AGENTS.md index cb3c7cb..f7d76c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,9 +83,9 @@ Review the full diff (`git diff master...HEAD`) focusing on: - **User experience**: Clear error messages, intuitive CLI flags, helpful output - **Documentation**: Completeness and relevance of docs, CHANGELOG entries, AGENTS.md updates -Post review comments using `scripts/review_pr.py`: +Post review comments using `scripts/ci/review_pr.py`: ```bash -REPO_TOKEN= python3 scripts/review_pr.py \ +REPO_TOKEN= python3 scripts/ci/review_pr.py \ --event REQUEST_CHANGES \ --body "Review summary" \ --comments-json comments.json @@ -97,7 +97,7 @@ Fix each comment one by one, commit, and push. Re-review until satisfied. ### 8. Approve and Merge Once all comments are addressed: ```bash -REPO_TOKEN= python3 scripts/review_pr.py \ +REPO_TOKEN= python3 scripts/ci/review_pr.py \ --event APPROVE \ --body "All comments addressed. LGTM." ``` @@ -118,6 +118,20 @@ changed, molecule tests are skipped — this prevents non-Ansible changes (e.g., Python scripts, workflow YAML, docs) from being blocked by molecule test infrastructure flakiness. +### Dynamic Runner Discovery + +Molecule tests are distributed across available Gitea Actions runners +dynamically via `scripts/ci/discover_runners.py`. The `discover-runners` +job queries the Gitea API for runners at all levels (repo, org, instance) +and generates a dynamic matrix. If the API can't see instance-level runners +(no admin scope), it falls back to the `MOLECULE_RUNNERS` repo variable, +then to a default of 3. + +**When adding/removing Gitea runners:** +1. Repo/org-level runners are auto-detected via the API +2. For instance-level runners, update the `MOLECULE_RUNNERS` repo variable +3. The workflow automatically scales the matrix to match available runners + ### Automated Release Pipeline After a PR is merged to master, the release pipeline runs automatically: @@ -125,8 +139,8 @@ After a PR is merged to master, the release pipeline runs automatically: 1. **Release workflow** (`.gitea/workflows/release.yml`): - Triggers on push to master - Sets up full dev environment (`make setup`) so lint and tests can run - - Runs `scripts/release.py` which: - - **Checks for user-facing changes** via `scripts/classify_changes.py` — if only + - Runs `scripts/ci/release.py` which: + - **Checks for user-facing changes** via `scripts/ci/classify_changes.py` — if only workflow/infrastructure files changed (`.gitea/`, `scripts/`, `docs/`, `tests/`, `AGENTS.md`, `Makefile`, etc.), the release is **skipped entirely** — no version bump, no tag, no publish. This prevents unnecessary releases for CI/docs-only changes. @@ -140,26 +154,38 @@ After a PR is merged to master, the release pipeline runs automatically: - Pushes both the commit and tag to master - `--skip-tests` flag bypasses test verification (emergency use only, not recommended) - Loops are prevented by `has_unreleased_changes` — after a release commit is tagged, the next run finds no unreleased changes and exits - - On failure, creates a Gitea issue via `scripts/notify_failure.py` + - On failure, creates a Gitea issue via `scripts/ci/notify_failure.py` ### Smart CI: User-Facing vs Workflow-Only Changes Not all changes require the full CI pipeline or a new release. The project -classifies changes into two categories: +classifies changes into two categories using `scripts/ci/classify_changes.py`: -**User-facing paths** (tool changes → release needed): +**Classification strategy (safe-by-default):** Any file NOT in the explicit +workflow-only allowlist is treated as user-facing. This prevents new file +types from accidentally skipping releases. + +**Workflow-only paths** (infrastructure → no release needed): +- `.gitea/**` — Gitea Actions workflows +- `scripts/ci/**` — CI/CD automation scripts +- `scripts/setup.sh`, `scripts/molecule_all.sh`, `scripts/__init__.py` — Shell scripts and package init +- `docs/**` — Documentation +- `tests/**` — Test files +- `AGENTS.md`, `README.md`, `CHANGELOG.md`, `TROUBLESHOOTING.md` — Project docs +- `Makefile`, `cliff.toml`, `.pre-commit-config.yaml`, `.ansible-lint` — Config +- `.env.example`, `.gitignore`, `.ruff.toml` — Config +- `hooks/**` — Git hooks + +**User-facing paths** (tool changes → release needed) — everything else: - `src/gitea_runner_manager/**` — Python CLI source - `ansible/**` — Ansible role - `pyproject.toml` — Package metadata +- `scripts/check_test_speed.py`, `scripts/configure_repo.py`, `scripts/install_checkmake.py` — Dev tools +- Any new file type not in the allowlist -**Workflow-only paths** (infrastructure → no release needed): -- `.gitea/workflows/**` — Gitea Actions workflows -- `scripts/**` — CI/CD automation scripts -- `docs/**` — Documentation -- `tests/**` — Test files -- `AGENTS.md`, `README.md`, `CHANGELOG.md` — Project docs -- `Makefile`, `cliff.toml`, `.pre-commit-config.yaml`, `.ansible-lint` — Config -- `hooks/**` — Git hooks +**Script directory structure:** +- `scripts/` — Dev tools (run locally by developers): `check_test_speed.py`, `configure_repo.py`, `install_checkmake.py`, `setup.sh`, `molecule_all.sh` +- `scripts/ci/` — CI/CD automation (run by workflows): `release.py`, `publish.py`, `auto_merge.py`, `classify_changes.py`, `doc_coverage.py`, `sync_wiki.py`, etc. **CI behavior based on classification:** - **Molecule tests**: Only run when `ansible/` or `.ansible-lint` files change @@ -171,6 +197,7 @@ classifies changes into two categories: - When working on workflow/CI/docs-only changes, use `ci:` or `docs:` commit prefixes - Do NOT bump the version or create tags for workflow-only changes - The `classify_changes.py` script enforces this automatically — no manual intervention needed +- When adding a new CI script, place it in `scripts/ci/`. Dev tools go in `scripts/`. 2. **Publish workflow** (`.gitea/workflows/publish.yml`): - Triggers on tag push (`v*`) @@ -178,7 +205,7 @@ classifies changes into two categories: - Builds the Python package - Optionally publishes to PyPI (if `PYPI_TOKEN` is set) - Creates a Gitea release with git-cliff-generated release notes - - On failure, creates a Gitea issue via `scripts/notify_failure.py` + - On failure, creates a Gitea issue via `scripts/ci/notify_failure.py` ### git-cliff Commit Preprocessing @@ -232,7 +259,7 @@ main.yml → systemd_check → user_setup → rootless_docker → install_runner 6 scenarios: `default`, `multi-instance`, `lifecycle`, `template-content`, `deregister`, `update` 4 platforms: `ubuntu-2204`, `ubuntu-2404`, `debian-12`, `archlinux` -Platform list is defined in `scripts/distribute_molecule.py` (single source of truth) +Platform list is defined in `scripts/ci/distribute_molecule.py` (single source of truth) ## Known Issues @@ -266,14 +293,14 @@ docs/ ### Wiki Sync -- **On merge to master**: `sync-wiki.yml` workflow runs `scripts/sync_wiki.py` which pushes all `/docs/` content to the Gitea wiki via API +- **On merge to master**: `sync-wiki.yml` workflow runs `scripts/ci/sync_wiki.py` which pushes all `/docs/` content to the Gitea wiki via API - **On release tag**: Same sync runs, plus the wiki is tagged with the release version - `mapping.json` maps each file path to a wiki page title (e.g., `user/getting-started.md` → `Getting-Started`) - README.md is a lean entry point with links to the wiki — no detailed content ### Documentation Coverage -- `scripts/doc_coverage.py` checks that all CLI commands, Python modules, and CI scripts are documented +- `scripts/ci/doc_coverage.py` checks that all CLI commands, Python modules, and CI scripts are documented - Runs as a CI step in the quality job - Goal: 100% coverage for public CLI commands and major architectural components diff --git a/Makefile b/Makefile index 8f81fac..edcc3cc 100644 --- a/Makefile +++ b/Makefile @@ -78,7 +78,7 @@ typecheck: lint: lint-ruff lint-format typecheck lint-bandit lint-bandit: - $(BIN)/bandit -r src/ scripts/ + $(BIN)/bandit -r src/ scripts/ scripts/ci/ ansible-lint: $(BIN)/ansible-lint ansible/ @@ -95,7 +95,7 @@ test-integration: $(BIN)/pytest tests/integration/ -v --no-cov pytest-cov: - $(BIN)/pytest tests/unit/ -v --cov=src/gitea_runner_manager --cov=scripts --cov-report=term-missing --cov-fail-under=100 + $(BIN)/pytest tests/unit/ -v --cov=src/gitea_runner_manager --cov=scripts --cov=scripts/ci --cov-report=term-missing --cov-fail-under=100 MOLECULE := $(realpath $(BIN))/molecule MOLECULE_BASE := cd $(CURDIR)/ansible/roles/gitea-runner && ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true ANSIBLE_INJECT_INVOCATION=1 $(MOLECULE) diff --git a/docs/tech/ci-cd-workflow.md b/docs/tech/ci-cd-workflow.md index 0ab2d1f..ae4be07 100644 --- a/docs/tech/ci-cd-workflow.md +++ b/docs/tech/ci-cd-workflow.md @@ -60,10 +60,10 @@ Review the full diff (`git diff master...HEAD`) focusing on: - **User experience**: Clear error messages, intuitive CLI flags, helpful output - **Documentation**: Completeness and relevance of docs, CHANGELOG entries, AGENTS.md updates -Post review comments using `scripts/review_pr.py`: +Post review comments using `scripts/ci/review_pr.py`: ```bash -REPO_TOKEN= python3 scripts/review_pr.py \ +REPO_TOKEN= python3 scripts/ci/review_pr.py \ --event REQUEST_CHANGES \ --body "Review summary" \ --comments-json comments.json @@ -78,7 +78,7 @@ Fix each comment one by one, commit, and push. Re-review until satisfied. Once all comments are addressed: ```bash -REPO_TOKEN= python3 scripts/review_pr.py \ +REPO_TOKEN= python3 scripts/ci/review_pr.py \ --event APPROVE \ --body "All comments addressed. LGTM." ``` @@ -96,7 +96,7 @@ Then add the `ready-to-merge` label. The auto-merge workflow will: After the squash-merge: -- The **post-merge workflow** (`.gitea/workflows/post-merge.yml`) triggers on push to `master` and runs `scripts/post_merge.py` to mark the Vikunja task as done, extracting the task ID from the merge commit message. +- The **post-merge workflow** (`.gitea/workflows/post-merge.yml`) triggers on push to `master` and runs `scripts/ci/post_merge.py` to mark the Vikunja task as done, extracting the task ID from the merge commit message. - The **release workflow** (`.gitea/workflows/release.yml`) triggers on push to `master` and automatically versions, tags, and publishes (see below). ## Branch Protection (Required Gitea Settings) @@ -132,7 +132,7 @@ The `quality` job in `.gitea/workflows/ci.yml` runs: 2. `make lint-all` — ruff + pyright + bandit + ansible-lint + checkmake 3. `make pytest-cov` — unit tests with 100% coverage enforcement 4. `python3 scripts/check_test_speed.py --max-seconds 10` — verify unit tests run fast -5. `PYTHONPATH=src python3 scripts/release.py --dry-run` — release dry-run validation +5. `PYTHONPATH=src python3 scripts/ci/release.py --dry-run` — release dry-run validation ## Automated Release Pipeline @@ -144,7 +144,7 @@ After a PR is merged to master, the release pipeline runs automatically. - Sets up full dev environment (`make setup`) so lint and tests can run - Installs git-cliff (version 2.13.0) - Configures git as `grm-ci-bot` -- Runs `scripts/release.py` which uses **git-cliff** to: +- Runs `scripts/ci/release.py` which uses **git-cliff** to: - Calculate the next semver version from conventional commits since the last tag - Update `__version__` in `src/gitea_runner_manager/__init__.py` (single source of truth) - Update `CHANGELOG.md` with the new version section @@ -155,7 +155,7 @@ After a PR is merged to master, the release pipeline runs automatically. - Push both the commit and tag to master - `--skip-tests` flag bypasses test verification (emergency use only, not recommended) - Loops are prevented by `has_unreleased_changes` — after a release commit is tagged, the next run finds no unreleased changes and exits -- On failure, creates a Gitea issue via `scripts/notify_failure.py` +- On failure, creates a Gitea issue via `scripts/ci/notify_failure.py` ### Publish Workflow (`.gitea/workflows/publish.yml`) @@ -166,25 +166,25 @@ After a PR is merged to master, the release pipeline runs automatically. - Builds the Python package - Optionally publishes to PyPI (if `PYPI_TOKEN` is set) - Creates a Gitea release with git-cliff-generated release notes -- Uses `scripts/publish.py` for build and publish orchestration -- On failure, creates a Gitea issue via `scripts/notify_failure.py` +- Uses `scripts/ci/publish.py` for build and publish orchestration +- On failure, creates a Gitea issue via `scripts/ci/notify_failure.py` ### Auto-Merge Workflow (`.gitea/workflows/auto-merge.yml`) - Triggers on `pull_request` labeled events -- Runs `scripts/auto_merge.py` with the branch name, PR title, repository, PR number, and label name +- Runs `scripts/ci/auto_merge.py` with the branch name, PR title, repository, PR number, and label name - Validates PR title format, checks for APPROVE review, waits for CI, and squash-merges ### Post-Merge Workflow (`.gitea/workflows/post-merge.yml`) - Triggers on push to `master` -- Runs `scripts/post_merge.py` with the latest commit message and commit SHA +- Runs `scripts/ci/post_merge.py` with the latest commit message and commit SHA - Marks the corresponding Vikunja task as done ### Smart CI: User-Facing vs Workflow-Only Changes Not all changes require the full CI pipeline or a new release. The project uses -`scripts/classify_changes.py` to classify changed files into two categories: +`scripts/ci/classify_changes.py` to classify changed files into two categories: **User-facing paths** (tool changes → release needed): - `src/gitea_runner_manager/**` — Python CLI source @@ -203,6 +203,24 @@ Not all changes require the full CI pipeline or a new release. The project uses user-facing files changed since the last tag. If not, the release is skipped entirely — no version bump, no tag, no publish. +### Dynamic Runner Discovery + +Molecule tests are distributed across available Gitea Actions runners +dynamically. The `discover-runners` job runs `scripts/ci/discover_runners.py` which queries the Gitea API for +registered runners at three levels (repo, org, instance) and generates +a matrix of runner indices. If the API query fails (e.g., no admin +access for instance-level runners), it falls back to the +`MOLECULE_RUNNERS` repo variable, then to a default of 3. + +The `molecule-tests` job uses `fromJSON()` to consume the dynamic +matrix, and passes the runner count to `distribute_molecule.py +--max-runners` so test pairs are evenly distributed. + +When adding or removing Gitea runners: +1. If runners are registered at the repo/org level, they're auto-detected +2. If runners are at the instance level, update the `MOLECULE_RUNNERS` repo variable +3. The workflow automatically scales the matrix to match available runners + ## git-cliff Commit Preprocessing Merge commits on master have the format `GRM-N `. The `GRM-N ` prefix is not a valid conventional commit prefix, so `cliff.toml` includes a `commit_preprocessors` entry that strips it before parsing: diff --git a/pyproject.toml b/pyproject.toml index dcb12e0..81e55ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ gitea_runner_manager = ["translations.json"] [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src", "."] -addopts = "--cov=src/gitea_runner_manager --cov=scripts --cov-report=term-missing --cov-fail-under=100" +addopts = "--cov=src/gitea_runner_manager --cov=scripts --cov=scripts/ci --cov-report=term-missing --cov-fail-under=100" markers = [ "integration: marks tests as integration tests (not counted in coverage)", ] @@ -68,6 +68,6 @@ quote-style = "double" indent-style = "space" [tool.pyright] -include = ["src", "scripts"] +include = ["src", "scripts", "scripts/ci"] pythonVersion = "3.12" strict = ["src/gitea_runner_manager"] diff --git a/scripts/ci/__init__.py b/scripts/ci/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/scripts/auto_merge.py b/scripts/ci/auto_merge.py similarity index 100% rename from scripts/auto_merge.py rename to scripts/ci/auto_merge.py diff --git a/scripts/classify_changes.py b/scripts/ci/classify_changes.py similarity index 71% rename from scripts/classify_changes.py rename to scripts/ci/classify_changes.py index 29d85f4..7e30ff9 100644 --- a/scripts/classify_changes.py +++ b/scripts/ci/classify_changes.py @@ -9,16 +9,17 @@ affect the GRM tool itself (user-facing) or only the CI/CD infrastructure - **CI workflow** — skips molecule tests and release dry-run when only workflow files changed -Classification rules: +Classification strategy (safe-by-default): - User-facing paths (tool changes → release needed): - - src/gitea_runner_manager/** — Python CLI source - - ansible/** — Ansible role - - pyproject.toml — Package metadata + Any file that is NOT in the explicit workflow-only allowlist is treated + as user-facing. This ensures new file types default to requiring a + release rather than silently skipping it. Workflow-only paths (infrastructure → no release needed): - .gitea/workflows/** — Gitea Actions workflows - - scripts/** — CI/CD automation scripts + - scripts/ci/** — CI/CD automation scripts + - scripts/*.sh — Shell scripts (setup, molecule runners) + - scripts/__init__.py — Package init for scripts - docs/** — Documentation - tests/** — Test files - hooks/** — Git hooks @@ -32,10 +33,19 @@ Classification rules: - .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), + including but not limited to: + - src/gitea_runner_manager/** — Python CLI source + - ansible/** — Ansible role + - pyproject.toml — Package metadata + - Any new file type not in the allowlist Usage: - python3 scripts/classify_changes.py [--base ] [--head ] - python3 scripts/classify_changes.py --base v0.3.0 --head HEAD + python3 scripts/ci/classify_changes.py [--base ] [--head ] + python3 scripts/ci/classify_changes.py --base v0.3.0 --head HEAD """ from __future__ import annotations @@ -47,12 +57,36 @@ import click from gitea_runner_manager.i18n import _ -# Paths that count as user-facing (tool changes) -USER_FACING_PATTERNS = frozenset( +# Explicit allowlist of workflow-only path patterns. +# Anything NOT matching these is treated as user-facing (safe default). +WORKFLOW_ONLY_PATTERNS = frozenset( [ - "src/gitea_runner_manager/", - "ansible/", - "pyproject.toml", + # CI/CD infrastructure + ".gitea/", + "scripts/ci/", + "scripts/setup.sh", + "scripts/molecule_all.sh", + "scripts/__init__.py", + # Documentation + "docs/", + "AGENTS.md", + "README.md", + "CHANGELOG.md", + "TROUBLESHOOTING.md", + # Tests + "tests/", + # Config / build automation + "cliff.toml", + "Makefile", + ".pre-commit-config.yaml", + ".ansible-lint", + ".env.example", + ".gitignore", + ".ruff.toml", + # Hooks + "hooks/", + # GitHub (if ever added) + ".github/", ] ) @@ -80,9 +114,22 @@ def get_changed_files(base: str, head: str) -> list[str]: return output.split("\n") +def is_workflow_only(file_path: str) -> 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). + """ + return any(file_path.startswith(pattern) or file_path == pattern for pattern in WORKFLOW_ONLY_PATTERNS) + + def is_user_facing(file_path: str) -> bool: - """Check if a file path is user-facing (affects the GRM tool).""" - return any(file_path.startswith(pattern) or file_path == pattern for pattern in USER_FACING_PATTERNS) + """Check if a file path is user-facing (affects the GRM tool). + + Inverse of is_workflow_only — anything not explicitly workflow-only + is treated as user-facing. + """ + return not is_workflow_only(file_path) def classify_changes(files: list[str]) -> dict[str, list[str]]: diff --git a/scripts/ci/discover_runners.py b/scripts/ci/discover_runners.py new file mode 100644 index 0000000..bb63dce --- /dev/null +++ b/scripts/ci/discover_runners.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python3 +"""Discover available Gitea Actions runners for dynamic job distribution. + +Queries the Gitea API for registered runners at three levels: + 1. Repository level: GET /repos/{owner}/{repo}/actions/runners + 2. Organization level: GET /orgs/{org}/actions/runners + 3. Instance (admin) level: GET /admin/actions/runners + +Falls back to the ``MOLECULE_RUNNERS`` repo variable or environment +variable, then to ``DEFAULT_MAX_RUNNERS`` (3). + +Outputs: + - ``--count``: prints the number of available runners + - ``--indices``: prints a JSON array [0, 1, ..., N-1] for use as a + dynamic matrix in Gitea Actions + - (default): prints both as ``count=N`` and ``indices=[0,1,...]`` + +Usage: + python3 scripts/ci/discover_runners.py --owner oblachno-oss --repo grm + python3 scripts/ci/discover_runners.py --indices + python3 scripts/ci/discover_runners.py --count +""" + +from __future__ import annotations + +import json +import os + +import click +import requests + +from gitea_runner_manager.config import GITEA_API_URL + +DEFAULT_MAX_RUNNERS = 3 + + +def query_runners(api_url: str, token: str, owner: str, repo: str) -> int: + """Query the Gitea API for registered runners at all levels. + + Returns the total count of active runners. If the API call fails + (e.g., no admin access for instance-level runners), falls back to + what we can see. + """ + headers = {"Authorization": f"token {token}"} + total = 0 + + # 1. Repository-level runners + try: + r = requests.get( + f"{api_url}/repos/{owner}/{repo}/actions/runners", + headers=headers, + timeout=10, + ) + if r.status_code == 200: + data = r.json() + total += data.get("total_count", 0) + except (requests.RequestException, ValueError): + pass + + # 2. Organization-level runners + try: + r = requests.get( + f"{api_url}/orgs/{owner}/actions/runners", + headers=headers, + timeout=10, + ) + if r.status_code == 200: + data = r.json() + total += data.get("total_count", 0) + except (requests.RequestException, ValueError): + pass + + # 3. Instance-level runners (requires admin scope) + try: + r = requests.get( + f"{api_url}/admin/actions/runners", + headers=headers, + timeout=10, + ) + if r.status_code == 200: + data = r.json() + total += data.get("total_count", 0) + except (requests.RequestException, ValueError): + pass + + return total + + +def get_runner_count(api_url: str, token: str, owner: str, repo: str) -> int: + """Determine the number of available runners. + + Tries the Gitea API first, then falls back to env vars, then default. + """ + # Try API query if we have a token + if token: + api_count = query_runners(api_url, token, owner, repo) + if api_count > 0: + return api_count + + # Fall back to MOLECULE_RUNNERS env var (set by CI from repo variable) + env_count = os.environ.get("MOLECULE_RUNNERS") + if env_count: + try: + count = int(env_count) + if count > 0: + return count + except ValueError: + pass + + # Fall back to default + return DEFAULT_MAX_RUNNERS + + +def generate_indices(count: int) -> list[int]: + """Generate a list of runner indices [0, 1, ..., count-1].""" + return list(range(count)) + + +@click.command() +@click.option("--owner", default=None, help="Repository owner (for API query).") +@click.option("--repo", default=None, help="Repository name (for API query).") +@click.option("--count", "output_count", is_flag=True, help="Output only the count.") +@click.option("--indices", "output_indices", is_flag=True, help="Output only the JSON indices array.") +def main(owner: str | None, repo: str | None, output_count: bool, output_indices: bool) -> None: + token = os.environ.get("REPO_TOKEN", "") + + if owner is None: + owner = os.environ.get("GRM_REPO_OWNER", "oblachno-oss") + if repo is None: + repo = os.environ.get("GRM_REPO_NAME", "grm") + + count = get_runner_count(GITEA_API_URL, token, owner, repo) + indices = generate_indices(count) + + if output_count: + click.echo(str(count)) + return + + if output_indices: + click.echo(json.dumps(indices)) + return + + # Default: output both as key=value pairs for CI consumption + click.echo(f"count={count}") + click.echo(f"indices={json.dumps(indices)}") + + +if __name__ == "__main__": # pragma: no cover + main() diff --git a/scripts/distribute_molecule.py b/scripts/ci/distribute_molecule.py similarity index 100% rename from scripts/distribute_molecule.py rename to scripts/ci/distribute_molecule.py diff --git a/scripts/doc_coverage.py b/scripts/ci/doc_coverage.py similarity index 95% rename from scripts/doc_coverage.py rename to scripts/ci/doc_coverage.py index 3256744..bd2e95b 100644 --- a/scripts/doc_coverage.py +++ b/scripts/ci/doc_coverage.py @@ -6,7 +6,7 @@ has corresponding documentation in the wiki/docs. Reports missing documentation as warnings and exits with non-zero if coverage is below 100%. Usage: - python3 scripts/doc_coverage.py [--docs-dir docs/] [--fail-on-missing] + python3 scripts/ci/doc_coverage.py [--docs-dir docs/] [--fail-on-missing] """ from __future__ import annotations @@ -19,8 +19,9 @@ import click from gitea_runner_manager.i18n import _ -DOCS_DIR = Path(__file__).resolve().parent.parent / "docs" -CLI_FILE = Path(__file__).resolve().parent.parent / "src" / "gitea_runner_manager" / "cli.py" +REPO_ROOT = Path(__file__).resolve().parent.parent.parent +DOCS_DIR = REPO_ROOT / "docs" +CLI_FILE = REPO_ROOT / "src" / "gitea_runner_manager" / "cli.py" # Major modules that should be documented in tech/architecture.md REQUIRED_MODULES = [ @@ -43,6 +44,7 @@ REQUIRED_SCRIPTS = [ "notify_failure.py", "post_merge.py", "classify_changes.py", + "discover_runners.py", ] diff --git a/scripts/molecule_ci_guard.py b/scripts/ci/molecule_ci_guard.py similarity index 100% rename from scripts/molecule_ci_guard.py rename to scripts/ci/molecule_ci_guard.py diff --git a/scripts/notify_failure.py b/scripts/ci/notify_failure.py similarity index 100% rename from scripts/notify_failure.py rename to scripts/ci/notify_failure.py diff --git a/scripts/post_merge.py b/scripts/ci/post_merge.py similarity index 100% rename from scripts/post_merge.py rename to scripts/ci/post_merge.py diff --git a/scripts/publish.py b/scripts/ci/publish.py similarity index 100% rename from scripts/publish.py rename to scripts/ci/publish.py diff --git a/scripts/release.py b/scripts/ci/release.py similarity index 99% rename from scripts/release.py rename to scripts/ci/release.py index 26bd04b..08f4835 100644 --- a/scripts/release.py +++ b/scripts/ci/release.py @@ -36,7 +36,7 @@ import click from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from gitea_runner_manager.i18n import _ -from scripts.classify_changes import has_user_facing_changes +from scripts.ci.classify_changes import has_user_facing_changes load_dotenv(override=True) diff --git a/scripts/review_pr.py b/scripts/ci/review_pr.py similarity index 100% rename from scripts/review_pr.py rename to scripts/ci/review_pr.py diff --git a/scripts/sync_wiki.py b/scripts/ci/sync_wiki.py similarity index 98% rename from scripts/sync_wiki.py rename to scripts/ci/sync_wiki.py index f699c15..6815bd0 100644 --- a/scripts/sync_wiki.py +++ b/scripts/ci/sync_wiki.py @@ -32,7 +32,7 @@ from gitea_runner_manager.i18n import _ load_dotenv(override=True) -DOCS_DIR = Path(__file__).resolve().parent.parent / "docs" +DOCS_DIR = Path(__file__).resolve().parent.parent.parent / "docs" MAPPING_FILE = DOCS_DIR / "mapping.json" diff --git a/scripts/validate_commit_msg.py b/scripts/ci/validate_commit_msg.py similarity index 100% rename from scripts/validate_commit_msg.py rename to scripts/ci/validate_commit_msg.py diff --git a/tests/unit/test_auto_merge.py b/tests/unit/test_auto_merge.py index 6e29f37..fa08e4d 100644 --- a/tests/unit/test_auto_merge.py +++ b/tests/unit/test_auto_merge.py @@ -1,4 +1,4 @@ -"""Unit tests for scripts/auto_merge.py.""" +"""Unit tests for scripts/ci/auto_merge.py.""" import http from unittest.mock import MagicMock, patch @@ -9,7 +9,7 @@ from click.testing import CliRunner from gitea_runner_manager.config import CONVENTIONAL_RE, TASK_ID_RE from gitea_runner_manager.exceptions import APIError -from scripts.auto_merge import ( +from scripts.ci.auto_merge import ( PR_TITLE_RE, extract_conventional_msg, extract_task_id, @@ -162,25 +162,25 @@ class TestHasApprovalReview: class TestValidatePrTitleMatchesVikunja: - @patch("scripts.auto_merge.get_vikunja_task_title", return_value="") + @patch("scripts.ci.auto_merge.get_vikunja_task_title", return_value="") @patch.dict("os.environ", {}, clear=True) def test_no_vikunja_token_skips(self, mock_get: MagicMock) -> None: """Should skip validation when VIKUNJA_TOKEN is not set.""" validate_pr_title_matches_vikunja("GRM-19: Some title", "GRM-19") - @patch("scripts.auto_merge.get_vikunja_task_title", return_value="Some task title") + @patch("scripts.ci.auto_merge.get_vikunja_task_title", return_value="Some task title") @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True) def test_matching_title_passes(self, mock_get: MagicMock) -> None: validate_pr_title_matches_vikunja("GRM-19: Some task title", "GRM-19") - @patch("scripts.auto_merge.get_vikunja_task_title", return_value="Some task title") + @patch("scripts.ci.auto_merge.get_vikunja_task_title", return_value="Some task title") @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True) def test_mismatched_title_raises(self, mock_get: MagicMock) -> None: with pytest.raises(click.ClickException) as exc: validate_pr_title_matches_vikunja("GRM-19: Different title", "GRM-19") assert "does not match" in str(exc.value) - @patch("scripts.auto_merge.get_vikunja_task_title", return_value="") + @patch("scripts.ci.auto_merge.get_vikunja_task_title", return_value="") @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True) def test_task_not_found_skips(self, mock_get: MagicMock) -> None: """Should skip validation when Vikunja task is not found.""" @@ -190,14 +190,14 @@ class TestValidatePrTitleMatchesVikunja: class TestGetVikunjaTaskTitle: @patch.dict("os.environ", {}, clear=True) def test_no_token_returns_empty(self) -> None: - from scripts.auto_merge import get_vikunja_task_title + from scripts.ci.auto_merge import get_vikunja_task_title assert get_vikunja_task_title("GRM-19") == "" - @patch("scripts.auto_merge.VikunjaClient") + @patch("scripts.ci.auto_merge.VikunjaClient") @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True) def test_finds_task(self, mock_client_cls: MagicMock) -> None: - from scripts.auto_merge import get_vikunja_task_title + from scripts.ci.auto_merge import get_vikunja_task_title mock_client = MagicMock() mock_client.list_project_tasks.return_value = [ @@ -206,10 +206,10 @@ class TestGetVikunjaTaskTitle: mock_client_cls.return_value = mock_client assert get_vikunja_task_title("GRM-19") == "Some task title" - @patch("scripts.auto_merge.VikunjaClient") + @patch("scripts.ci.auto_merge.VikunjaClient") @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True) def test_task_not_found_returns_empty(self, mock_client_cls: MagicMock) -> None: - from scripts.auto_merge import get_vikunja_task_title + from scripts.ci.auto_merge import get_vikunja_task_title mock_client = MagicMock() mock_client.list_project_tasks.return_value = [ @@ -218,10 +218,10 @@ class TestGetVikunjaTaskTitle: mock_client_cls.return_value = mock_client assert get_vikunja_task_title("GRM-19") == "" - @patch("scripts.auto_merge.VikunjaClient") + @patch("scripts.ci.auto_merge.VikunjaClient") @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True) def test_paginates_to_find_task(self, mock_client_cls: MagicMock) -> None: - from scripts.auto_merge import get_vikunja_task_title + from scripts.ci.auto_merge import get_vikunja_task_title mock_client = MagicMock() # First page: full page of 50 tasks, no match; second page: match @@ -231,10 +231,10 @@ class TestGetVikunjaTaskTitle: mock_client_cls.return_value = mock_client assert get_vikunja_task_title("GRM-99") == "Found task" - @patch("scripts.auto_merge.VikunjaClient") + @patch("scripts.ci.auto_merge.VikunjaClient") @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True) def test_empty_pages_returns_empty(self, mock_client_cls: MagicMock) -> None: - from scripts.auto_merge import get_vikunja_task_title + from scripts.ci.auto_merge import get_vikunja_task_title mock_client = MagicMock() mock_client.list_project_tasks.return_value = [] @@ -257,7 +257,7 @@ class TestWaitForCi: [_status("CI / quality (pull_request)", CI_PENDING)], [_status("CI / quality (pull_request)", CI_SUCCESS)], ] - with patch("scripts.auto_merge.time.sleep"): + with patch("scripts.ci.auto_merge.time.sleep"): assert wait_for_ci(client, "abc123", max_wait=10, poll_interval=5) is True def test_fails_on_failed_check(self) -> None: @@ -273,7 +273,7 @@ class TestWaitForCi: client.get_commit_status.return_value = [ _status("CI / quality (pull_request)", CI_PENDING), ] - with patch("scripts.auto_merge.time.sleep"): + with patch("scripts.ci.auto_merge.time.sleep"): assert wait_for_ci(client, "abc123", max_wait=5) is False def test_no_statuses_waits(self) -> None: @@ -282,7 +282,7 @@ class TestWaitForCi: [], [_status("CI / quality (pull_request)", CI_SUCCESS)], ] - with patch("scripts.auto_merge.time.sleep"): + with patch("scripts.ci.auto_merge.time.sleep"): assert wait_for_ci(client, "abc123", max_wait=10, poll_interval=5) is True def test_ignores_non_ci_contexts(self) -> None: @@ -307,7 +307,7 @@ class TestWaitForCi: [_status("Auto-merge / merge (pull_request)", CI_PENDING)], [_status("CI / quality (pull_request)", CI_SUCCESS)], ] - with patch("scripts.auto_merge.time.sleep"): + with patch("scripts.ci.auto_merge.time.sleep"): assert wait_for_ci(client, "abc123", max_wait=10, poll_interval=5) is True @@ -325,9 +325,9 @@ def _mock_commits() -> list[dict[str, dict[str, str]]]: class TestMain: @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.auto_merge.validate_pr_title_matches_vikunja") - @patch("scripts.auto_merge.has_approval_review", return_value=True) - @patch("scripts.auto_merge.GiteaClient") + @patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja") + @patch("scripts.ci.auto_merge.has_approval_review", return_value=True) + @patch("scripts.ci.auto_merge.GiteaClient") def test_successful_flow_with_label_arg( self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock ) -> None: @@ -346,9 +346,9 @@ class TestMain: mock_client.merge_pr.assert_called_once_with("7", "GRM-19 fix: resolve timeout") @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.auto_merge.validate_pr_title_matches_vikunja") - @patch("scripts.auto_merge.has_approval_review", return_value=True) - @patch("scripts.auto_merge.GiteaClient") + @patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja") + @patch("scripts.ci.auto_merge.has_approval_review", return_value=True) + @patch("scripts.ci.auto_merge.GiteaClient") def test_successful_flow_label_fallback( self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock ) -> None: @@ -369,9 +369,9 @@ class TestMain: mock_client.merge_pr.assert_called_once_with("7", "GRM-19 fix: resolve timeout") @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.auto_merge.validate_pr_title_matches_vikunja") - @patch("scripts.auto_merge.has_approval_review", return_value=True) - @patch("scripts.auto_merge.GiteaClient") + @patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja") + @patch("scripts.ci.auto_merge.has_approval_review", return_value=True) + @patch("scripts.ci.auto_merge.GiteaClient") def test_wrong_label_skips_merge( self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock ) -> None: @@ -389,9 +389,9 @@ class TestMain: mock_client.merge_pr.assert_not_called() @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.auto_merge.validate_pr_title_matches_vikunja") - @patch("scripts.auto_merge.has_approval_review", return_value=True) - @patch("scripts.auto_merge.GiteaClient") + @patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja") + @patch("scripts.ci.auto_merge.has_approval_review", return_value=True) + @patch("scripts.ci.auto_merge.GiteaClient") def test_empty_label_falls_back_to_api( self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock ) -> None: @@ -418,7 +418,7 @@ class TestMain: assert "REPO_TOKEN" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.auto_merge.GiteaClient") + @patch("scripts.ci.auto_merge.GiteaClient") def test_missing_task_id_exits(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}] @@ -429,7 +429,7 @@ class TestMain: assert "task ID" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.auto_merge.GiteaClient") + @patch("scripts.ci.auto_merge.GiteaClient") def test_invalid_pr_title_exits(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}] @@ -440,7 +440,7 @@ class TestMain: assert "GRM-N" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.auto_merge.GiteaClient") + @patch("scripts.ci.auto_merge.GiteaClient") def test_pr_title_task_id_mismatch_exits(self, mock_client_cls: MagicMock) -> None: """PR title has a different task ID than the branch.""" mock_client = MagicMock() @@ -452,9 +452,9 @@ class TestMain: assert "mismatch" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.auto_merge.validate_pr_title_matches_vikunja") - @patch("scripts.auto_merge.has_approval_review", return_value=False) - @patch("scripts.auto_merge.GiteaClient") + @patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja") + @patch("scripts.ci.auto_merge.has_approval_review", return_value=False) + @patch("scripts.ci.auto_merge.GiteaClient") def test_no_approval_review_blocks_merge( self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock ) -> None: @@ -469,9 +469,9 @@ class TestMain: mock_client.merge_pr.assert_not_called() @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.auto_merge.validate_pr_title_matches_vikunja") - @patch("scripts.auto_merge.has_approval_review", return_value=True) - @patch("scripts.auto_merge.GiteaClient") + @patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja") + @patch("scripts.ci.auto_merge.has_approval_review", return_value=True) + @patch("scripts.ci.auto_merge.GiteaClient") def test_empty_commits_exits( self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock ) -> None: @@ -489,9 +489,9 @@ class TestMain: mock_client.merge_pr.assert_not_called() @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.auto_merge.validate_pr_title_matches_vikunja") - @patch("scripts.auto_merge.has_approval_review", return_value=True) - @patch("scripts.auto_merge.GiteaClient") + @patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja") + @patch("scripts.ci.auto_merge.has_approval_review", return_value=True) + @patch("scripts.ci.auto_merge.GiteaClient") def test_merge_pr_failure_raises_click( self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock ) -> None: @@ -508,9 +508,9 @@ class TestMain: assert "HTTP" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.auto_merge.validate_pr_title_matches_vikunja") - @patch("scripts.auto_merge.has_approval_review", return_value=True) - @patch("scripts.auto_merge.GiteaClient") + @patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja") + @patch("scripts.ci.auto_merge.has_approval_review", return_value=True) + @patch("scripts.ci.auto_merge.GiteaClient") def test_merge_pr_json_parse_failure( self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock ) -> None: @@ -527,9 +527,9 @@ class TestMain: assert str(http.HTTPStatus.BAD_GATEWAY) in result.output @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.auto_merge.validate_pr_title_matches_vikunja") - @patch("scripts.auto_merge.has_approval_review", return_value=True) - @patch("scripts.auto_merge.GiteaClient") + @patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja") + @patch("scripts.ci.auto_merge.has_approval_review", return_value=True) + @patch("scripts.ci.auto_merge.GiteaClient") def test_ci_failure_blocks_merge( self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock ) -> None: @@ -548,9 +548,9 @@ class TestMain: mock_client.merge_pr.assert_not_called() @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.auto_merge.validate_pr_title_matches_vikunja") - @patch("scripts.auto_merge.has_approval_review", return_value=True) - @patch("scripts.auto_merge.GiteaClient") + @patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja") + @patch("scripts.ci.auto_merge.has_approval_review", return_value=True) + @patch("scripts.ci.auto_merge.GiteaClient") def test_no_sha_proceeds_without_wait( self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock ) -> None: diff --git a/tests/unit/test_classify_changes.py b/tests/unit/test_classify_changes.py index 3ecbcf3..cbb3136 100644 --- a/tests/unit/test_classify_changes.py +++ b/tests/unit/test_classify_changes.py @@ -1,4 +1,4 @@ -"""Unit tests for scripts/classify_changes.py.""" +"""Unit tests for scripts/ci/classify_changes.py.""" from unittest.mock import MagicMock, patch @@ -6,12 +6,13 @@ import click import pytest from click.testing import CliRunner -from scripts.classify_changes import ( +from scripts.ci.classify_changes import ( classify_changes, get_changed_files, get_latest_tag, has_user_facing_changes, is_user_facing, + is_workflow_only, main, run_git, ) @@ -30,8 +31,22 @@ class TestIsUserFacing: def test_workflow_is_not_user_facing(self) -> None: assert is_user_facing(".gitea/workflows/ci.yml") is False - def test_scripts_are_not_user_facing(self) -> None: - assert is_user_facing("scripts/release.py") is False + def test_ci_scripts_are_not_user_facing(self) -> None: + assert is_user_facing("scripts/ci/release.py") is False + + def test_dev_scripts_are_user_facing(self) -> None: + """Dev scripts (check_test_speed, configure_repo) are NOT in the + workflow-only allowlist, so they default to user-facing.""" + assert is_user_facing("scripts/check_test_speed.py") is True + assert is_user_facing("scripts/configure_repo.py") is True + assert is_user_facing("scripts/install_checkmake.py") is True + + def test_shell_scripts_are_not_user_facing(self) -> None: + assert is_user_facing("scripts/setup.sh") is False + assert is_user_facing("scripts/molecule_all.sh") is False + + def test_scripts_init_is_not_user_facing(self) -> None: + assert is_user_facing("scripts/__init__.py") is False def test_docs_are_not_user_facing(self) -> None: assert is_user_facing("docs/user/getting-started.md") is False @@ -45,6 +60,16 @@ class TestIsUserFacing: def test_makefile_is_not_user_facing(self) -> None: assert is_user_facing("Makefile") is False + def test_unknown_file_defaults_to_user_facing(self) -> None: + """Safe default: unknown files are user-facing (require release).""" + assert is_user_facing("some/new/file.type") is True + assert is_user_facing("new_root_file.txt") is True + + def test_is_workflow_only_inverse(self) -> None: + assert is_workflow_only(".gitea/workflows/ci.yml") is True + assert is_workflow_only("src/gitea_runner_manager/cli.py") is False + assert is_workflow_only("pyproject.toml") is False + class TestClassifyChanges: def test_all_user_facing(self) -> None: @@ -78,13 +103,13 @@ class TestClassifyChanges: class TestGetChangedFiles: - @patch("scripts.classify_changes.run_git") + @patch("scripts.ci.classify_changes.run_git") def test_returns_file_list(self, mock_run_git: MagicMock) -> None: mock_run_git.return_value = "file1.py\nfile2.py\nfile3.md" result = get_changed_files("v0.1.0", "HEAD") assert result == ["file1.py", "file2.py", "file3.md"] - @patch("scripts.classify_changes.run_git") + @patch("scripts.ci.classify_changes.run_git") def test_empty_when_no_changes(self, mock_run_git: MagicMock) -> None: mock_run_git.return_value = "" result = get_changed_files("v0.1.0", "HEAD") @@ -92,17 +117,17 @@ class TestGetChangedFiles: class TestHasUserFacingChanges: - @patch("scripts.classify_changes.get_changed_files") + @patch("scripts.ci.classify_changes.get_changed_files") def test_true_when_user_facing(self, mock_get: MagicMock) -> None: mock_get.return_value = ["src/gitea_runner_manager/cli.py", "docs/index.md"] assert has_user_facing_changes("v0.1.0", "HEAD") is True - @patch("scripts.classify_changes.get_changed_files") + @patch("scripts.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("scripts.classify_changes.get_changed_files") + @patch("scripts.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 @@ -121,13 +146,13 @@ class TestGetLatestTag: class TestRunGit: - @patch("scripts.classify_changes.subprocess.run") + @patch("scripts.ci.classify_changes.subprocess.run") def test_success(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=0, stdout="file1.py\n", stderr="") result = run_git(["git", "diff", "--name-only", "v0.1.0", "HEAD"]) assert result == "file1.py" - @patch("scripts.classify_changes.subprocess.run") + @patch("scripts.ci.classify_changes.subprocess.run") def test_failure_raises(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="git error") with pytest.raises(click.ClickException): @@ -135,23 +160,23 @@ class TestRunGit: class TestMain: - @patch("scripts.classify_changes.get_latest_tag", return_value="") + @patch("scripts.ci.classify_changes.get_latest_tag", return_value="") def test_no_tags_outputs_true(self, mock_tag: MagicMock) -> None: runner = CliRunner() result = runner.invoke(main, ["--quiet"]) assert result.exit_code == 0 assert "true" in result.output - @patch("scripts.classify_changes.get_changed_files", return_value=[]) - @patch("scripts.classify_changes.get_latest_tag", return_value="v0.3.0") + @patch("scripts.ci.classify_changes.get_changed_files", return_value=[]) + @patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_no_changes_outputs_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: runner = CliRunner() result = runner.invoke(main, ["--quiet"]) assert result.exit_code == 0 assert "false" in result.output - @patch("scripts.classify_changes.get_changed_files") - @patch("scripts.classify_changes.get_latest_tag", return_value="v0.3.0") + @patch("scripts.ci.classify_changes.get_changed_files") + @patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_workflow_only_exits_2(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: mock_changes.return_value = [".gitea/workflows/ci.yml", "docs/index.md"] runner = CliRunner() @@ -159,8 +184,8 @@ class TestMain: assert result.exit_code == 2 assert "no release needed" in result.output - @patch("scripts.classify_changes.get_changed_files") - @patch("scripts.classify_changes.get_latest_tag", return_value="v0.3.0") + @patch("scripts.ci.classify_changes.get_changed_files") + @patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_user_facing_exits_0(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: mock_changes.return_value = ["src/gitea_runner_manager/cli.py", "docs/index.md"] runner = CliRunner() @@ -168,7 +193,7 @@ class TestMain: assert result.exit_code == 0 assert "release needed" in result.output - @patch("scripts.classify_changes.get_latest_tag", return_value="") + @patch("scripts.ci.classify_changes.get_latest_tag", return_value="") def test_no_tags_non_quiet(self, mock_tag: MagicMock) -> None: """Non-quiet mode with no tags prints user-facing message.""" runner = CliRunner() @@ -176,8 +201,8 @@ class TestMain: assert result.exit_code == 0 assert "No tags found" in result.output - @patch("scripts.classify_changes.get_changed_files", return_value=[]) - @patch("scripts.classify_changes.get_latest_tag", return_value="v0.3.0") + @patch("scripts.ci.classify_changes.get_changed_files", return_value=[]) + @patch("scripts.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: """Non-quiet mode with no changes prints message.""" runner = CliRunner() @@ -185,8 +210,8 @@ class TestMain: assert result.exit_code == 0 assert "No changes" in result.output - @patch("scripts.classify_changes.get_changed_files") - @patch("scripts.classify_changes.get_latest_tag", return_value="v0.3.0") + @patch("scripts.ci.classify_changes.get_changed_files") + @patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0") 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/gitea_runner_manager/cli.py"] @@ -195,8 +220,8 @@ class TestMain: assert result.exit_code == 0 assert "true" in result.output - @patch("scripts.classify_changes.get_changed_files") - @patch("scripts.classify_changes.get_latest_tag", return_value="v0.3.0") + @patch("scripts.ci.classify_changes.get_changed_files") + @patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0") 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"] @@ -205,8 +230,8 @@ class TestMain: assert result.exit_code == 0 assert "false" in result.output - @patch("scripts.classify_changes.get_changed_files") - @patch("scripts.classify_changes.get_latest_tag", return_value="v0.3.0") + @patch("scripts.ci.classify_changes.get_changed_files") + @patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0") def test_with_explicit_base(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None: """Explicit --base overrides latest tag.""" mock_changes.return_value = ["src/gitea_runner_manager/cli.py"] diff --git a/tests/unit/test_discover_runners.py b/tests/unit/test_discover_runners.py new file mode 100644 index 0000000..9cac15d --- /dev/null +++ b/tests/unit/test_discover_runners.py @@ -0,0 +1,190 @@ +"""Unit tests for scripts/ci/discover_runners.py.""" + +import json +from unittest.mock import MagicMock, patch + +from click.testing import CliRunner + +from scripts.ci.discover_runners import ( + DEFAULT_MAX_RUNNERS, + generate_indices, + get_runner_count, + main, + query_runners, +) + + +class TestGenerateIndices: + def test_zero(self) -> None: + assert generate_indices(0) == [] + + def test_one(self) -> None: + assert generate_indices(1) == [0] + + def test_three(self) -> None: + assert generate_indices(3) == [0, 1, 2] + + def test_five(self) -> None: + assert generate_indices(5) == [0, 1, 2, 3, 4] + + +class TestQueryRunners: + @patch("scripts.ci.discover_runners.requests.get") + def test_returns_total_from_all_levels(self, mock_get: MagicMock) -> None: + """Runners from repo, org, and admin levels are summed.""" + responses = [ + MagicMock(status_code=200, json=lambda: {"runners": [], "total_count": 2}), + MagicMock(status_code=200, json=lambda: {"runners": [], "total_count": 1}), + MagicMock(status_code=200, json=lambda: {"runners": [], "total_count": 3}), + ] + mock_get.side_effect = responses + result = query_runners("https://api.example.com", "token", "owner", "repo") + assert result == 6 + + @patch("scripts.ci.discover_runners.requests.get") + def test_skips_non_200(self, mock_get: MagicMock) -> None: + """Non-200 responses (e.g., 403 for admin) are skipped.""" + responses = [ + MagicMock(status_code=200, json=lambda: {"total_count": 2}), + MagicMock(status_code=200, json=lambda: {"total_count": 1}), + MagicMock(status_code=403, json=lambda: {"message": "forbidden"}), + ] + mock_get.side_effect = responses + result = query_runners("https://api.example.com", "token", "owner", "repo") + assert result == 3 + + @patch("scripts.ci.discover_runners.requests.get") + def test_handles_request_exception(self, mock_get: MagicMock) -> None: + """Network errors are caught and don't crash.""" + mock_get.side_effect = [ + MagicMock(status_code=200, json=lambda: {"total_count": 1}), + MagicMock(side_effect=__import__("requests").RequestException("network error")), + MagicMock(status_code=200, json=lambda: {"total_count": 2}), + ] + result = query_runners("https://api.example.com", "token", "owner", "repo") + assert result == 3 + + @patch("scripts.ci.discover_runners.requests.get") + def test_all_failures_return_zero(self, mock_get: MagicMock) -> None: + """When all API calls fail, returns 0.""" + mock_get.side_effect = [ + MagicMock(status_code=404), + MagicMock(status_code=404), + MagicMock(status_code=403), + ] + result = query_runners("https://api.example.com", "token", "owner", "repo") + assert result == 0 + + @patch("scripts.ci.discover_runners.requests.get") + def test_value_error_on_repo_level(self, mock_get: MagicMock) -> None: + """JSON parse error on repo level is caught.""" + responses = [ + MagicMock(status_code=200, json=MagicMock(side_effect=ValueError("bad json"))), + MagicMock(status_code=200, json=lambda: {"total_count": 2}), + MagicMock(status_code=200, json=lambda: {"total_count": 1}), + ] + mock_get.side_effect = responses + result = query_runners("https://api.example.com", "token", "owner", "repo") + assert result == 3 + + @patch("scripts.ci.discover_runners.requests.get") + def test_value_error_on_org_level(self, mock_get: MagicMock) -> None: + """JSON parse error on org level is caught.""" + responses = [ + MagicMock(status_code=200, json=lambda: {"total_count": 1}), + MagicMock(status_code=200, json=MagicMock(side_effect=ValueError("bad json"))), + MagicMock(status_code=200, json=lambda: {"total_count": 2}), + ] + mock_get.side_effect = responses + result = query_runners("https://api.example.com", "token", "owner", "repo") + assert result == 3 + + @patch("scripts.ci.discover_runners.requests.get") + def test_value_error_on_admin_level(self, mock_get: MagicMock) -> None: + """JSON parse error on admin level is caught.""" + responses = [ + MagicMock(status_code=200, json=lambda: {"total_count": 1}), + MagicMock(status_code=200, json=lambda: {"total_count": 2}), + MagicMock(status_code=200, json=MagicMock(side_effect=ValueError("bad json"))), + ] + mock_get.side_effect = responses + result = query_runners("https://api.example.com", "token", "owner", "repo") + assert result == 3 + + @patch("scripts.ci.discover_runners.requests.get") + def test_request_exception_on_all_levels(self, mock_get: MagicMock) -> None: + """Network errors on all levels return 0.""" + mock_get.side_effect = __import__("requests").RequestException("network error") + result = query_runners("https://api.example.com", "token", "owner", "repo") + assert result == 0 + + +class TestGetRunnerCount: + @patch("scripts.ci.discover_runners.query_runners", return_value=5) + def test_uses_api_count_when_positive(self, mock_query: MagicMock) -> None: + result = get_runner_count("https://api.example.com", "token", "owner", "repo") + assert result == 5 + + @patch("scripts.ci.discover_runners.query_runners", return_value=0) + @patch.dict("os.environ", {"MOLECULE_RUNNERS": "4"}) + def test_falls_back_to_env_var(self, mock_query: MagicMock) -> None: + result = get_runner_count("https://api.example.com", "token", "owner", "repo") + assert result == 4 + + @patch("scripts.ci.discover_runners.query_runners", return_value=0) + @patch.dict("os.environ", {"MOLECULE_RUNNERS": "invalid"}) + def test_falls_back_to_default_on_invalid_env(self, mock_query: MagicMock) -> None: + result = get_runner_count("https://api.example.com", "token", "owner", "repo") + assert result == DEFAULT_MAX_RUNNERS + + @patch("scripts.ci.discover_runners.query_runners", return_value=0) + @patch.dict("os.environ", {}, clear=True) + def test_falls_back_to_default_when_no_env(self, mock_query: MagicMock) -> None: + result = get_runner_count("https://api.example.com", "token", "owner", "repo") + assert result == DEFAULT_MAX_RUNNERS + + @patch("scripts.ci.discover_runners.query_runners", return_value=0) + @patch.dict("os.environ", {"MOLECULE_RUNNERS": "0"}) + def test_env_var_zero_falls_back_to_default(self, mock_query: MagicMock) -> None: + """MOLECULE_RUNNERS=0 is invalid, falls back to default.""" + result = get_runner_count("https://api.example.com", "token", "owner", "repo") + assert result == DEFAULT_MAX_RUNNERS + + @patch("scripts.ci.discover_runners.query_runners", return_value=0) + @patch.dict("os.environ", {}, clear=True) + def test_no_token_uses_env_var(self, mock_query: MagicMock) -> None: + """When no token, skips API and uses env/default.""" + with patch.dict("os.environ", {"MOLECULE_RUNNERS": "2"}): + result = get_runner_count("https://api.example.com", "", "owner", "repo") + assert result == 2 + + +class TestMain: + @patch("scripts.ci.discover_runners.get_runner_count", return_value=3) + def test_default_output(self, mock_count: MagicMock) -> None: + runner = CliRunner() + result = runner.invoke(main, []) + assert result.exit_code == 0 + assert "count=3" in result.output + assert "indices=[0, 1, 2]" in result.output + + @patch("scripts.ci.discover_runners.get_runner_count", return_value=5) + def test_count_only(self, mock_count: MagicMock) -> None: + runner = CliRunner() + result = runner.invoke(main, ["--count"]) + assert result.exit_code == 0 + assert result.output.strip() == "5" + + @patch("scripts.ci.discover_runners.get_runner_count", return_value=4) + def test_indices_only(self, mock_count: MagicMock) -> None: + runner = CliRunner() + result = runner.invoke(main, ["--indices"]) + assert result.exit_code == 0 + assert json.loads(result.output.strip()) == [0, 1, 2, 3] + + @patch("scripts.ci.discover_runners.get_runner_count", return_value=1) + def test_single_runner(self, mock_count: MagicMock) -> None: + runner = CliRunner() + result = runner.invoke(main, ["--indices"]) + assert result.exit_code == 0 + assert json.loads(result.output.strip()) == [0] diff --git a/tests/unit/test_distribute_molecule.py b/tests/unit/test_distribute_molecule.py index 47b86ec..15277d7 100644 --- a/tests/unit/test_distribute_molecule.py +++ b/tests/unit/test_distribute_molecule.py @@ -1,4 +1,4 @@ -"""Unit tests for scripts/distribute_molecule.py.""" +"""Unit tests for scripts/ci/distribute_molecule.py.""" from pathlib import Path from unittest.mock import patch @@ -6,7 +6,7 @@ from unittest.mock import patch import click import pytest -from scripts.distribute_molecule import ( +from scripts.ci.distribute_molecule import ( MOLECULE_ROOT, PLATFORMS, TestPair, @@ -134,12 +134,12 @@ class TestCli: def test_list_flag(self, tmp_path: Path) -> None: from click.testing import CliRunner - from scripts.distribute_molecule import cli + from scripts.ci.distribute_molecule import cli root = tmp_path / "molecule" (root / "alpha").mkdir(parents=True) (root / "beta").mkdir(parents=True) - with patch("scripts.distribute_molecule.MOLECULE_ROOT", root): + with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root): runner = CliRunner() result = runner.invoke(cli, ["--list"]) assert result.exit_code == 0 @@ -149,7 +149,7 @@ class TestCli: def test_list_platforms_flag(self) -> None: from click.testing import CliRunner - from scripts.distribute_molecule import cli + from scripts.ci.distribute_molecule import cli runner = CliRunner() result = runner.invoke(cli, ["--list-platforms"]) @@ -162,12 +162,12 @@ class TestCli: def test_no_runner_index_prints_all_groups(self, tmp_path: Path) -> None: from click.testing import CliRunner - from scripts.distribute_molecule import cli + from scripts.ci.distribute_molecule import cli root = tmp_path / "molecule" for s in ["a", "b", "c"]: (root / s).mkdir(parents=True) - with patch("scripts.distribute_molecule.MOLECULE_ROOT", root): + with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root): runner = CliRunner() result = runner.invoke(cli, ["--max-runners", "3"]) assert result.exit_code == 0 @@ -178,11 +178,11 @@ class TestCli: def test_runner_index_prints_assigned(self, tmp_path: Path) -> None: from click.testing import CliRunner - from scripts.distribute_molecule import cli + from scripts.ci.distribute_molecule import cli root = tmp_path / "molecule" (root / "alpha").mkdir(parents=True) - with patch("scripts.distribute_molecule.MOLECULE_ROOT", root): + with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root): runner = CliRunner() result = runner.invoke(cli, ["--runner-index", "0", "--max-runners", "3"]) assert result.exit_code == 0 @@ -192,7 +192,7 @@ class TestCli: def test_main_module_block() -> None: - import scripts.distribute_molecule as dm + import scripts.ci.distribute_molecule as dm with open(dm.__file__) as f: source = f.read() diff --git a/tests/unit/test_doc_coverage.py b/tests/unit/test_doc_coverage.py index d62b6bf..a9fe359 100644 --- a/tests/unit/test_doc_coverage.py +++ b/tests/unit/test_doc_coverage.py @@ -1,10 +1,10 @@ -"""Unit tests for scripts/doc_coverage.py.""" +"""Unit tests for scripts/ci/doc_coverage.py.""" from pathlib import Path from click.testing import CliRunner -from scripts.doc_coverage import ( +from scripts.ci.doc_coverage import ( check_command_documented, check_module_documented, extract_cli_commands, @@ -76,7 +76,8 @@ class TestMain: ) # Write ci-cd-workflow.md with all scripts (docs / "tech" / "ci-cd-workflow.md").write_text( - "auto_merge.py release.py publish.py review_pr.py notify_failure.py post_merge.py classify_changes.py" + "auto_merge.py release.py publish.py review_pr.py " + "notify_failure.py post_merge.py classify_changes.py discover_runners.py" ) runner = CliRunner() result = runner.invoke(main, ["--docs-dir", str(docs)]) diff --git a/tests/unit/test_molecule_ci_guard.py b/tests/unit/test_molecule_ci_guard.py index 16cb24f..51533f4 100644 --- a/tests/unit/test_molecule_ci_guard.py +++ b/tests/unit/test_molecule_ci_guard.py @@ -1,4 +1,4 @@ -"""Unit tests for scripts/molecule_ci_guard.py.""" +"""Unit tests for scripts/ci/molecule_ci_guard.py.""" from __future__ import annotations @@ -10,7 +10,7 @@ from unittest.mock import MagicMock, patch import pytest import requests -from scripts.molecule_ci_guard import ( +from scripts.ci.molecule_ci_guard import ( any_other_runner_failed, build_env_for_pair, build_molecule_cmd, @@ -22,7 +22,7 @@ from scripts.molecule_ci_guard import ( class TestGetRunningJobs: def test_returns_jobs(self) -> None: - with patch("scripts.molecule_ci_guard.requests.get") as mock_get: + with patch("scripts.ci.molecule_ci_guard.requests.get") as mock_get: mock_response = MagicMock() mock_response.json.return_value = { "jobs": [ @@ -38,7 +38,7 @@ class TestGetRunningJobs: mock_get.assert_called_once() def test_raises_on_request_error(self) -> None: - with patch("scripts.molecule_ci_guard.requests.get") as mock_get: + with patch("scripts.ci.molecule_ci_guard.requests.get") as mock_get: mock_get.side_effect = requests.RequestException("boom") with pytest.raises(requests.RequestException): get_running_jobs("https://gitea.example", "owner", "repo", "token", 123) @@ -107,7 +107,7 @@ class TestPollForOtherFailures: ] return [] - with patch("scripts.molecule_ci_guard.get_running_jobs") as mock_get_jobs: + with patch("scripts.ci.molecule_ci_guard.get_running_jobs") as mock_get_jobs: mock_get_jobs.side_effect = side_effect stop_event.is_set.side_effect = [False, False] stop_event.wait.return_value = True @@ -130,7 +130,7 @@ class TestPollForOtherFailures: stop_event = MagicMock() failed_event = MagicMock() - with patch("scripts.molecule_ci_guard.get_running_jobs") as mock_get_jobs: + with patch("scripts.ci.molecule_ci_guard.get_running_jobs") as mock_get_jobs: mock_get_jobs.side_effect = requests.RequestException("boom") stop_event.is_set.side_effect = [False, True] stop_event.wait.return_value = True @@ -155,7 +155,7 @@ class TestCli: from click.testing import CliRunner with ( - patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen, + patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen, patch("time.sleep"), ): proc = MagicMock() @@ -172,7 +172,7 @@ class TestCli: from click.testing import CliRunner with ( - patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen, + patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen, patch("time.sleep"), ): proc = MagicMock() @@ -202,9 +202,9 @@ class TestCli: }, clear=True, ), - patch("scripts.molecule_ci_guard.POLL_INTERVAL", 0.01), - patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen, - patch("scripts.molecule_ci_guard.get_running_jobs") as mock_get_jobs, + patch("scripts.ci.molecule_ci_guard.POLL_INTERVAL", 0.01), + patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen, + patch("scripts.ci.molecule_ci_guard.get_running_jobs") as mock_get_jobs, patch("time.sleep"), ): mock_get_jobs.return_value = [ @@ -225,7 +225,7 @@ class TestCli: from click.testing import CliRunner with ( - patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen, + patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen, patch("time.sleep", side_effect=KeyboardInterrupt), patch("os.killpg") as mock_killpg, patch("os.getpgid") as mock_getpgid, @@ -270,9 +270,9 @@ class TestCli: }, clear=True, ), - patch("scripts.molecule_ci_guard.POLL_INTERVAL", 0.01), - patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen, - patch("scripts.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect), + patch("scripts.ci.molecule_ci_guard.POLL_INTERVAL", 0.01), + patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen, + patch("scripts.ci.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect), patch("os.killpg") as mock_killpg, patch("os.getpgid") as mock_getpgid, patch("time.sleep", side_effect=lambda x: real_sleep(0.1)), @@ -307,9 +307,9 @@ class TestCli: }, clear=True, ), - patch("scripts.molecule_ci_guard.POLL_INTERVAL", 0.01), - patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen, - patch("scripts.molecule_ci_guard.get_running_jobs") as mock_get_jobs, + patch("scripts.ci.molecule_ci_guard.POLL_INTERVAL", 0.01), + patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen, + patch("scripts.ci.molecule_ci_guard.get_running_jobs") as mock_get_jobs, patch("time.sleep", side_effect=lambda x: real_sleep(0.05)), ): mock_get_jobs.return_value = [{"name": "molecule-tests (1)", "conclusion": "success"}] @@ -351,9 +351,9 @@ class TestCli: }, clear=True, ), - patch("scripts.molecule_ci_guard.POLL_INTERVAL", 0.01), - patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen, - patch("scripts.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect), + patch("scripts.ci.molecule_ci_guard.POLL_INTERVAL", 0.01), + patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen, + patch("scripts.ci.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect), patch("os.killpg") as mock_killpg, patch("os.getpgid") as mock_getpgid, patch("time.sleep", side_effect=lambda x: real_sleep(0.1)), @@ -398,9 +398,9 @@ class TestCli: }, clear=True, ), - patch("scripts.molecule_ci_guard.POLL_INTERVAL", 0.01), - patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen, - patch("scripts.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect), + patch("scripts.ci.molecule_ci_guard.POLL_INTERVAL", 0.01), + patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen, + patch("scripts.ci.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect), patch("os.killpg") as mock_killpg, patch("os.getpgid") as mock_getpgid, patch("time.sleep", side_effect=lambda x: real_sleep(0.1)), @@ -418,7 +418,7 @@ class TestCli: def test_main_module_block() -> None: - import scripts.molecule_ci_guard as mg + import scripts.ci.molecule_ci_guard as mg with open(mg.__file__) as f: source = f.read() diff --git a/tests/unit/test_notify_failure.py b/tests/unit/test_notify_failure.py index 789feba..16afad0 100644 --- a/tests/unit/test_notify_failure.py +++ b/tests/unit/test_notify_failure.py @@ -1,16 +1,16 @@ -"""Unit tests for scripts/notify_failure.py.""" +"""Unit tests for scripts/ci/notify_failure.py.""" from unittest.mock import MagicMock, patch from click.testing import CliRunner from gitea_runner_manager.exceptions import APIError -from scripts.notify_failure import main +from scripts.ci.notify_failure import main class TestNotifyFailure: @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.notify_failure.GiteaClient") + @patch("scripts.ci.notify_failure.GiteaClient") def test_creates_issue_with_labels(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() mock_client.list_labels.return_value = [{"id": 5, "name": "bug"}] @@ -38,7 +38,7 @@ class TestNotifyFailure: assert call_kwargs.kwargs["labels"] == [5] @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.notify_failure.GiteaClient") + @patch("scripts.ci.notify_failure.GiteaClient") def test_creates_issue_without_bug_label(self, mock_client_cls: MagicMock) -> None: """When 'bug' label doesn't exist, create issue without labels.""" mock_client = MagicMock() @@ -67,7 +67,7 @@ class TestNotifyFailure: assert call_kwargs.kwargs.get("labels") is None @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.notify_failure.GiteaClient") + @patch("scripts.ci.notify_failure.GiteaClient") def test_api_error_raises(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() mock_client.list_labels.return_value = [] diff --git a/tests/unit/test_post_merge.py b/tests/unit/test_post_merge.py index 6e590f1..a22fd3f 100644 --- a/tests/unit/test_post_merge.py +++ b/tests/unit/test_post_merge.py @@ -1,4 +1,4 @@ -"""Unit tests for scripts/post_merge.py.""" +"""Unit tests for scripts/ci/post_merge.py.""" import http from unittest.mock import MagicMock, patch @@ -8,7 +8,7 @@ import pytest from click.testing import CliRunner from gitea_runner_manager.exceptions import APIError -from scripts.post_merge import ( +from scripts.ci.post_merge import ( build_comment, extract_conventional_msg, extract_task_id, @@ -90,7 +90,7 @@ class TestResolveTaskId: class TestMain: @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) - @patch("scripts.post_merge.VikunjaClient") + @patch("scripts.ci.post_merge.VikunjaClient") def test_full_flow(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() mock_client.list_project_tasks.return_value = [ @@ -108,7 +108,7 @@ class TestMain: mock_client.update_task.assert_called_once_with(267, done=True) @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) - @patch("scripts.post_merge.VikunjaClient") + @patch("scripts.ci.post_merge.VikunjaClient") def test_no_commit_sha(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() mock_client.list_project_tasks.return_value = [ @@ -137,7 +137,7 @@ class TestMain: assert "skipping" in result.output @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) - @patch("scripts.post_merge.VikunjaClient") + @patch("scripts.ci.post_merge.VikunjaClient") def test_resolve_failure_propagates(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() mock_client.list_project_tasks.return_value = [] @@ -148,7 +148,7 @@ class TestMain: assert "Could not find" in result.output @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) - @patch("scripts.post_merge.VikunjaClient") + @patch("scripts.ci.post_merge.VikunjaClient") def test_post_comment_failure_raises_click(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() mock_client.list_project_tasks.return_value = [ @@ -162,7 +162,7 @@ class TestMain: assert "HTTP" in result.output @patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}) - @patch("scripts.post_merge.VikunjaClient") + @patch("scripts.ci.post_merge.VikunjaClient") def test_mark_done_failure_raises_click(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() mock_client.list_project_tasks.return_value = [ diff --git a/tests/unit/test_publish.py b/tests/unit/test_publish.py index bf92872..5c331f2 100644 --- a/tests/unit/test_publish.py +++ b/tests/unit/test_publish.py @@ -1,4 +1,4 @@ -"""Unit tests for scripts/publish.py.""" +"""Unit tests for scripts/ci/publish.py.""" import http from unittest.mock import MagicMock, patch @@ -7,7 +7,7 @@ import click import pytest from click.testing import CliRunner -from scripts.publish import ( +from scripts.ci.publish import ( build_package, generate_release_notes, main, @@ -16,44 +16,44 @@ from scripts.publish import ( class TestGenerateReleaseNotes: - @patch("scripts.publish.subprocess.run") - @patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff") + @patch("scripts.ci.publish.subprocess.run") + @patch("scripts.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff") def test_generates_from_git_cliff(self, mock_which: MagicMock, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=0, stdout="## v1.0.0\n- feat: x") result = generate_release_notes("v1.0.0") assert "## v1.0.0" in result assert "feat: x" in result - @patch("scripts.publish.subprocess.run") - @patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff") + @patch("scripts.ci.publish.subprocess.run") + @patch("scripts.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff") def test_strips_whitespace(self, mock_which: MagicMock, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=0, stdout=" changelog \n") result = generate_release_notes("v1.0.0") assert result == "changelog" - @patch("scripts.publish.subprocess.run") - @patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff") + @patch("scripts.ci.publish.subprocess.run") + @patch("scripts.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff") def test_falls_back_on_failure(self, mock_which: MagicMock, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=1, stdout="") result = generate_release_notes("v1.0.0") assert "Release v1.0.0" in result assert "CHANGELOG.md" in result - @patch("scripts.publish.subprocess.run") - @patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff") + @patch("scripts.ci.publish.subprocess.run") + @patch("scripts.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff") def test_falls_back_on_empty_output(self, mock_which: MagicMock, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=0, stdout=" ") result = generate_release_notes("v1.0.0") assert "Release v1.0.0" in result - @patch("scripts.publish.subprocess.run", side_effect=FileNotFoundError) - @patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff") + @patch("scripts.ci.publish.subprocess.run", side_effect=FileNotFoundError) + @patch("scripts.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff") def test_falls_back_on_file_not_found(self, mock_which: MagicMock, mock_run: MagicMock) -> None: result = generate_release_notes("v1.0.0") assert "Release v1.0.0" in result assert "CHANGELOG.md" in result - @patch("scripts.publish.shutil.which", return_value=None) + @patch("scripts.ci.publish.shutil.which", return_value=None) def test_falls_back_when_not_installed(self, mock_which: MagicMock) -> None: result = generate_release_notes("v1.0.0") assert "Release v1.0.0" in result @@ -61,7 +61,7 @@ class TestGenerateReleaseNotes: class TestBuildPackage: - @patch("scripts.publish.subprocess.run") + @patch("scripts.ci.publish.subprocess.run") def test_success(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=0, stderr="") build_package() @@ -69,7 +69,7 @@ class TestBuildPackage: assert args[0][1] == "-m" assert args[0][2] == "build" - @patch("scripts.publish.subprocess.run") + @patch("scripts.ci.publish.subprocess.run") def test_failure_raises(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=1, stderr="build error") with pytest.raises(click.ClickException) as exc: @@ -78,7 +78,7 @@ class TestBuildPackage: class TestPublishToPypi: - @patch("scripts.publish.subprocess.run") + @patch("scripts.ci.publish.subprocess.run") def test_success(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=0, stderr="") publish_to_pypi("pypi-tok") @@ -86,7 +86,7 @@ class TestPublishToPypi: assert "twine" in args[0] assert "pypi-tok" in args[0] - @patch("scripts.publish.subprocess.run") + @patch("scripts.ci.publish.subprocess.run") def test_failure_raises(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=1, stderr="upload failed") with pytest.raises(click.ClickException) as exc: @@ -96,10 +96,10 @@ class TestPublishToPypi: class TestMain: @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) - @patch("scripts.publish.generate_release_notes", return_value="Release notes") - @patch("scripts.publish.GiteaClient") - @patch("scripts.publish.publish_to_pypi") - @patch("scripts.publish.build_package") + @patch("scripts.ci.publish.generate_release_notes", return_value="Release notes") + @patch("scripts.ci.publish.GiteaClient") + @patch("scripts.ci.publish.publish_to_pypi") + @patch("scripts.ci.publish.build_package") def test_full_flow_with_pypi( self, mock_build: MagicMock, @@ -119,9 +119,9 @@ class TestMain: assert call_args.kwargs["body"] == "Release notes" @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True) - @patch("scripts.publish.generate_release_notes", return_value="Release notes") - @patch("scripts.publish.GiteaClient") - @patch("scripts.publish.build_package") + @patch("scripts.ci.publish.generate_release_notes", return_value="Release notes") + @patch("scripts.ci.publish.GiteaClient") + @patch("scripts.ci.publish.build_package") def test_without_pypi( self, mock_build: MagicMock, @@ -143,10 +143,10 @@ class TestMain: assert "REPO_TOKEN" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) - @patch("scripts.publish.generate_release_notes", return_value="Release notes") - @patch("scripts.publish.GiteaClient") - @patch("scripts.publish.publish_to_pypi") - @patch("scripts.publish.build_package") + @patch("scripts.ci.publish.generate_release_notes", return_value="Release notes") + @patch("scripts.ci.publish.GiteaClient") + @patch("scripts.ci.publish.publish_to_pypi") + @patch("scripts.ci.publish.build_package") def test_build_failure_raises_click( self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock, mock_notes: MagicMock ) -> None: @@ -157,10 +157,10 @@ class TestMain: assert "build" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) - @patch("scripts.publish.generate_release_notes", return_value="Release notes") - @patch("scripts.publish.GiteaClient") - @patch("scripts.publish.publish_to_pypi") - @patch("scripts.publish.build_package") + @patch("scripts.ci.publish.generate_release_notes", return_value="Release notes") + @patch("scripts.ci.publish.GiteaClient") + @patch("scripts.ci.publish.publish_to_pypi") + @patch("scripts.ci.publish.build_package") def test_publish_failure_raises_click( self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock, mock_notes: MagicMock ) -> None: @@ -171,10 +171,10 @@ class TestMain: assert "publish" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) - @patch("scripts.publish.generate_release_notes", return_value="Release notes") - @patch("scripts.publish.GiteaClient") - @patch("scripts.publish.publish_to_pypi") - @patch("scripts.publish.build_package") + @patch("scripts.ci.publish.generate_release_notes", return_value="Release notes") + @patch("scripts.ci.publish.GiteaClient") + @patch("scripts.ci.publish.publish_to_pypi") + @patch("scripts.ci.publish.build_package") def test_release_failure_raises_click( self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock, mock_notes: MagicMock ) -> None: @@ -189,10 +189,10 @@ class TestMain: assert "HTTP" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) - @patch("scripts.publish.generate_release_notes", return_value="Release notes") - @patch("scripts.publish.GiteaClient") - @patch("scripts.publish.publish_to_pypi") - @patch("scripts.publish.build_package") + @patch("scripts.ci.publish.generate_release_notes", return_value="Release notes") + @patch("scripts.ci.publish.GiteaClient") + @patch("scripts.ci.publish.publish_to_pypi") + @patch("scripts.ci.publish.build_package") def test_release_json_parse_failure( self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock, mock_notes: MagicMock ) -> None: diff --git a/tests/unit/test_release.py b/tests/unit/test_release.py index 88f5a04..0011c13 100644 --- a/tests/unit/test_release.py +++ b/tests/unit/test_release.py @@ -1,4 +1,4 @@ -"""Unit tests for scripts/release.py.""" +"""Unit tests for scripts/ci/release.py.""" from unittest.mock import MagicMock, patch @@ -6,7 +6,7 @@ import click import pytest from click.testing import CliRunner -from scripts.release import ( +from scripts.ci.release import ( commit_release_changes, create_and_push_tag, get_bumped_version, @@ -23,20 +23,20 @@ from scripts.release import ( class TestRunCmd: - @patch("scripts.release.subprocess.run") + @patch("scripts.ci.release.subprocess.run") def test_success(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=0, stderr="", stdout="") result = run_cmd(["echo", "hi"]) assert result.returncode == 0 mock_run.assert_called_once() - @patch("scripts.release.subprocess.run") + @patch("scripts.ci.release.subprocess.run") def test_failure_raises(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=1, stderr="err", stdout="") with pytest.raises(click.ClickException): run_cmd(["false"]) - @patch("scripts.release.subprocess.run") + @patch("scripts.ci.release.subprocess.run") def test_check_false_no_raise(self, mock_run: MagicMock) -> None: mock_run.return_value = MagicMock(returncode=1, stderr="err", stdout="") result = run_cmd(["false"], check=False) @@ -44,41 +44,41 @@ class TestRunCmd: class TestGetLatestTag: - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.run_cmd") def test_returns_tag(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.1.0\n") assert get_latest_tag() == "v0.1.0" - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.run_cmd") def test_no_tags_returns_empty(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=1, stdout="") assert get_latest_tag() == "" class TestTagExists: - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.run_cmd") def test_exists(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.1.0\n") assert tag_exists("v0.1.0") is True - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.run_cmd") def test_not_exists(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="") assert tag_exists("v0.2.0") is False class TestGetBumpedVersion: - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.run_cmd") def test_returns_version(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="0.2.0\n") assert get_bumped_version() == "0.2.0" - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.run_cmd") def test_strips_v_prefix(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.2.0\n") assert get_bumped_version() == "0.2.0" - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.run_cmd") def test_empty_raises(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="") with pytest.raises(click.ClickException): @@ -86,48 +86,48 @@ class TestGetBumpedVersion: class TestGetChangelog: - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.run_cmd") def test_returns_changelog(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="## 0.2.0\n- fix\n") assert get_changelog("0.2.0") == "## 0.2.0\n- fix" - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.run_cmd") def test_strips_whitespace(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout=" text \n") assert get_changelog("0.2.0") == "text" class TestHasUnreleasedChanges: - @patch("scripts.release.get_latest_tag") + @patch("scripts.ci.release.get_latest_tag") def test_with_bumped_version_no_changes(self, mock_latest: MagicMock) -> None: mock_latest.return_value = "v0.2.0" assert has_unreleased_changes(bumped_version="0.2.0") is False - @patch("scripts.release.get_latest_tag") + @patch("scripts.ci.release.get_latest_tag") def test_with_bumped_version_has_changes(self, mock_latest: MagicMock) -> None: mock_latest.return_value = "v0.2.0" assert has_unreleased_changes(bumped_version="0.3.0") is True - @patch("scripts.release.get_latest_tag") + @patch("scripts.ci.release.get_latest_tag") def test_with_bumped_version_no_tags(self, mock_latest: MagicMock) -> None: mock_latest.return_value = "" assert has_unreleased_changes(bumped_version="0.1.0") is True - @patch("scripts.release.get_latest_tag") - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.get_latest_tag") + @patch("scripts.ci.release.run_cmd") def test_without_bumped_version_no_changes(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.2.0\n") mock_latest.return_value = "v0.2.0" assert has_unreleased_changes() is False - @patch("scripts.release.get_latest_tag") - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.get_latest_tag") + @patch("scripts.ci.release.run_cmd") def test_without_bumped_version_has_changes(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.3.0\n") mock_latest.return_value = "v0.2.0" assert has_unreleased_changes() is True - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.run_cmd") def test_cliff_fails_returns_false(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=1, stdout="") assert has_unreleased_changes() is False @@ -137,7 +137,7 @@ class TestUpdateInitVersion: def test_updates_version(self, tmp_path, monkeypatch) -> None: init_file = tmp_path / "__init__.py" init_file.write_text('__version__ = "0.1.0"\n') - monkeypatch.setattr("scripts.release.INIT_FILE", str(init_file)) + monkeypatch.setattr("scripts.ci.release.INIT_FILE", str(init_file)) update_init_version("0.2.0") assert '__version__ = "0.2.0"' in init_file.read_text() @@ -145,14 +145,14 @@ class TestUpdateInitVersion: """Updating to the same version should not raise.""" init_file = tmp_path / "__init__.py" init_file.write_text('__version__ = "0.1.0"\n') - monkeypatch.setattr("scripts.release.INIT_FILE", str(init_file)) + monkeypatch.setattr("scripts.ci.release.INIT_FILE", str(init_file)) update_init_version("0.1.0") assert '__version__ = "0.1.0"' in init_file.read_text() def test_no_version_raises(self, tmp_path, monkeypatch) -> None: init_file = tmp_path / "__init__.py" init_file.write_text('"""module"""\n') - monkeypatch.setattr("scripts.release.INIT_FILE", str(init_file)) + monkeypatch.setattr("scripts.ci.release.INIT_FILE", str(init_file)) with pytest.raises(click.ClickException): update_init_version("0.2.0") @@ -160,7 +160,7 @@ class TestUpdateInitVersion: class TestUpdateChangelog: def test_creates_new_file(self, tmp_path, monkeypatch) -> None: changelog_file = tmp_path / "CHANGELOG.md" - monkeypatch.setattr("scripts.release.CHANGELOG_FILE", str(changelog_file)) + monkeypatch.setattr("scripts.ci.release.CHANGELOG_FILE", str(changelog_file)) update_changelog("## [0.2.0] - 2026-06-21\n\n### Features\n- new thing") content = changelog_file.read_text() assert "## [0.2.0]" in content @@ -169,7 +169,7 @@ class TestUpdateChangelog: def test_prepends_to_existing(self, tmp_path, monkeypatch) -> None: changelog_file = tmp_path / "CHANGELOG.md" changelog_file.write_text("# Changelog\n\n## [0.1.0] - 2026-06-20\n\n### Features\n- old thing\n") - monkeypatch.setattr("scripts.release.CHANGELOG_FILE", str(changelog_file)) + monkeypatch.setattr("scripts.ci.release.CHANGELOG_FILE", str(changelog_file)) update_changelog("## [0.2.0] - 2026-06-21\n\n### Features\n- new thing") content = changelog_file.read_text() assert "# Changelog" in content @@ -181,7 +181,7 @@ class TestUpdateChangelog: def test_appends_when_no_version_sections(self, tmp_path, monkeypatch) -> None: changelog_file = tmp_path / "CHANGELOG.md" changelog_file.write_text("# Changelog\n\nSome intro text.\n") - monkeypatch.setattr("scripts.release.CHANGELOG_FILE", str(changelog_file)) + monkeypatch.setattr("scripts.ci.release.CHANGELOG_FILE", str(changelog_file)) update_changelog("## [0.2.0] - 2026-06-21\n\n### Features\n- new thing") content = changelog_file.read_text() assert "Some intro text" in content @@ -191,7 +191,7 @@ class TestUpdateChangelog: """git-cliff output includes a header — should be stripped before inserting.""" changelog_file = tmp_path / "CHANGELOG.md" changelog_file.write_text("# Changelog\n\n## [0.1.0] - 2026-06-20\n\n### Features\n- old thing\n") - monkeypatch.setattr("scripts.release.CHANGELOG_FILE", str(changelog_file)) + monkeypatch.setattr("scripts.ci.release.CHANGELOG_FILE", str(changelog_file)) # Simulate git-cliff output with header cliff_output = "# Changelog\n\nAll notable changes...\n\n## [0.2.0] - 2026-06-21\n\n### Features\n- new thing" update_changelog(cliff_output) @@ -204,7 +204,7 @@ class TestUpdateChangelog: def test_strips_header_when_creating_new_file(self, tmp_path, monkeypatch) -> None: """When creating a new file, strip the git-cliff header.""" changelog_file = tmp_path / "CHANGELOG.md" - monkeypatch.setattr("scripts.release.CHANGELOG_FILE", str(changelog_file)) + monkeypatch.setattr("scripts.ci.release.CHANGELOG_FILE", str(changelog_file)) cliff_output = "# Changelog\n\nAll notable changes...\n\n## [0.2.0] - 2026-06-21\n\n### Features\n- new thing" update_changelog(cliff_output) content = changelog_file.read_text() @@ -213,7 +213,7 @@ class TestUpdateChangelog: class TestCommitReleaseChanges: - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.run_cmd") def test_commits_when_changes(self, mock_run_cmd: MagicMock) -> None: # git diff --cached --quiet returns 1 (changes exist) mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="") @@ -223,7 +223,7 @@ class TestCommitReleaseChanges: assert ["git", "add", "src/gitea_runner_manager/__init__.py", "CHANGELOG.md"] in calls assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0"] in calls - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.run_cmd") def test_skips_when_no_changes(self, mock_run_cmd: MagicMock) -> None: # git diff --cached --quiet returns 0 (no changes) mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") @@ -234,16 +234,16 @@ class TestCommitReleaseChanges: class TestCreateAndPushTag: - @patch("scripts.release.tag_exists", return_value=False) - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.tag_exists", return_value=False) + @patch("scripts.ci.release.run_cmd") def test_creates_tag(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None: create_and_push_tag("0.2.0", "changelog", dry_run=False) calls = [c.args[0] for c in mock_run_cmd.call_args_list] assert ["git", "tag", "-a", "v0.2.0", "-m", "Release v0.2.0\n\nchangelog"] in calls assert ["git", "push", "origin", "v0.2.0"] in calls - @patch("scripts.release.tag_exists", return_value=False) - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.tag_exists", return_value=False) + @patch("scripts.ci.release.run_cmd") def test_dry_run_no_push(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None: result = create_and_push_tag("0.2.0", "changelog", dry_run=True) assert result is True @@ -251,8 +251,8 @@ class TestCreateAndPushTag: assert call.args[0][0:2] != ["git", "push"] assert call.args[0][0:2] != ["git", "tag"] - @patch("scripts.release.tag_exists", return_value=True) - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.tag_exists", return_value=True) + @patch("scripts.ci.release.run_cmd") def test_tag_exists_skips_creation(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None: result = create_and_push_tag("0.1.0", "changelog", dry_run=False) assert result is False @@ -261,8 +261,8 @@ class TestCreateAndPushTag: assert ["git", "tag", "-a"] not in [c[:3] for c in calls] assert ["git", "push", "origin", "v0.1.0"] in calls - @patch("scripts.release.tag_exists", return_value=True) - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.tag_exists", return_value=True) + @patch("scripts.ci.release.run_cmd") def test_tag_exists_dry_run_no_push(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None: result = create_and_push_tag("0.1.0", "changelog", dry_run=True) assert result is False @@ -271,18 +271,18 @@ class TestCreateAndPushTag: class TestRunTests: - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.run_cmd") def test_lint_and_tests_pass(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="") run_tests() # should not raise - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.run_cmd") def test_lint_fails_raises(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="lint error") with pytest.raises(click.ClickException, match="Lint failed"): run_tests() - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.run_cmd") def test_tests_fail_raises(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.side_effect = [ MagicMock(returncode=0, stdout="", stderr=""), # lint passes @@ -294,7 +294,7 @@ class TestRunTests: class TestMain: @patch.dict("os.environ", {}) - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.run_cmd") def test_not_on_master_exits(self, mock_run_cmd: MagicMock) -> None: mock_run_cmd.return_value = MagicMock(returncode=0, stdout="feature-branch\n", stderr="") runner = CliRunner() @@ -303,10 +303,10 @@ class TestMain: assert "master" in result.output @patch.dict("os.environ", {}) - @patch("scripts.release.has_user_facing_changes", return_value=True) - @patch("scripts.release.has_unreleased_changes", return_value=False) - @patch("scripts.release.get_bumped_version", return_value="0.2.0") - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.has_user_facing_changes", return_value=True) + @patch("scripts.ci.release.has_unreleased_changes", return_value=False) + @patch("scripts.ci.release.get_bumped_version", return_value="0.2.0") + @patch("scripts.ci.release.run_cmd") def test_no_unreleased_changes( self, mock_run_cmd: MagicMock, @@ -321,16 +321,16 @@ class TestMain: assert "No unreleased changes" in result.output @patch.dict("os.environ", {}) - @patch("scripts.release.has_user_facing_changes", return_value=True) - @patch("scripts.release.create_and_push_tag") - @patch("scripts.release.commit_release_changes") - @patch("scripts.release.update_changelog") - @patch("scripts.release.update_init_version") - @patch("scripts.release.get_changelog", return_value="") - @patch("scripts.release.get_latest_tag", return_value="v0.1.0") - @patch("scripts.release.get_bumped_version", return_value="0.2.0") - @patch("scripts.release.has_unreleased_changes", return_value=True) - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.has_user_facing_changes", return_value=True) + @patch("scripts.ci.release.create_and_push_tag") + @patch("scripts.ci.release.commit_release_changes") + @patch("scripts.ci.release.update_changelog") + @patch("scripts.ci.release.update_init_version") + @patch("scripts.ci.release.get_changelog", return_value="") + @patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0") + @patch("scripts.ci.release.get_bumped_version", return_value="0.2.0") + @patch("scripts.ci.release.has_unreleased_changes", return_value=True) + @patch("scripts.ci.release.run_cmd") def test_dry_run_empty_changelog( self, mock_run_cmd: MagicMock, @@ -351,16 +351,16 @@ class TestMain: assert "empty changelog" in result.output @patch.dict("os.environ", {}) - @patch("scripts.release.has_user_facing_changes", return_value=True) - @patch("scripts.release.create_and_push_tag") - @patch("scripts.release.commit_release_changes") - @patch("scripts.release.update_changelog") - @patch("scripts.release.update_init_version") - @patch("scripts.release.get_changelog", return_value="changelog") - @patch("scripts.release.get_latest_tag", return_value="v0.1.0") - @patch("scripts.release.get_bumped_version", return_value="0.2.0") - @patch("scripts.release.has_unreleased_changes", return_value=True) - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.has_user_facing_changes", return_value=True) + @patch("scripts.ci.release.create_and_push_tag") + @patch("scripts.ci.release.commit_release_changes") + @patch("scripts.ci.release.update_changelog") + @patch("scripts.ci.release.update_init_version") + @patch("scripts.ci.release.get_changelog", return_value="changelog") + @patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0") + @patch("scripts.ci.release.get_bumped_version", return_value="0.2.0") + @patch("scripts.ci.release.has_unreleased_changes", return_value=True) + @patch("scripts.ci.release.run_cmd") def test_dry_run( self, mock_run_cmd: MagicMock, @@ -385,9 +385,9 @@ class TestMain: mock_tag.assert_not_called() @patch.dict("os.environ", {}) - @patch("scripts.release.get_latest_tag", return_value="v0.3.0") - @patch("scripts.release.has_user_facing_changes", return_value=False) - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.get_latest_tag", return_value="v0.3.0") + @patch("scripts.ci.release.has_user_facing_changes", return_value=False) + @patch("scripts.ci.release.run_cmd") def test_skips_release_when_workflow_only( self, mock_run_cmd: MagicMock, @@ -403,17 +403,17 @@ class TestMain: assert "Skipping release" in result.output @patch.dict("os.environ", {}) - @patch("scripts.release.has_user_facing_changes", return_value=True) - @patch("scripts.release.run_tests") - @patch("scripts.release.create_and_push_tag", return_value=True) - @patch("scripts.release.commit_release_changes", return_value=True) - @patch("scripts.release.update_changelog") - @patch("scripts.release.update_init_version") - @patch("scripts.release.get_changelog", return_value="changelog") - @patch("scripts.release.get_latest_tag", return_value="v0.1.0") - @patch("scripts.release.get_bumped_version", return_value="0.2.0") - @patch("scripts.release.has_unreleased_changes", return_value=True) - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.has_user_facing_changes", return_value=True) + @patch("scripts.ci.release.run_tests") + @patch("scripts.ci.release.create_and_push_tag", return_value=True) + @patch("scripts.ci.release.commit_release_changes", return_value=True) + @patch("scripts.ci.release.update_changelog") + @patch("scripts.ci.release.update_init_version") + @patch("scripts.ci.release.get_changelog", return_value="changelog") + @patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0") + @patch("scripts.ci.release.get_bumped_version", return_value="0.2.0") + @patch("scripts.ci.release.has_unreleased_changes", return_value=True) + @patch("scripts.ci.release.run_cmd") def test_full_flow( self, mock_run_cmd: MagicMock, @@ -440,17 +440,17 @@ class TestMain: mock_tag.assert_called_once_with("0.2.0", "changelog", False) @patch.dict("os.environ", {}) - @patch("scripts.release.has_user_facing_changes", return_value=True) - @patch("scripts.release.run_tests") - @patch("scripts.release.create_and_push_tag", return_value=False) - @patch("scripts.release.commit_release_changes", return_value=False) - @patch("scripts.release.update_changelog") - @patch("scripts.release.update_init_version") - @patch("scripts.release.get_changelog", return_value="changelog") - @patch("scripts.release.get_latest_tag", return_value="v0.1.0") - @patch("scripts.release.get_bumped_version", return_value="0.1.0") - @patch("scripts.release.has_unreleased_changes", return_value=True) - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.has_user_facing_changes", return_value=True) + @patch("scripts.ci.release.run_tests") + @patch("scripts.ci.release.create_and_push_tag", return_value=False) + @patch("scripts.ci.release.commit_release_changes", return_value=False) + @patch("scripts.ci.release.update_changelog") + @patch("scripts.ci.release.update_init_version") + @patch("scripts.ci.release.get_changelog", return_value="changelog") + @patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0") + @patch("scripts.ci.release.get_bumped_version", return_value="0.1.0") + @patch("scripts.ci.release.has_unreleased_changes", return_value=True) + @patch("scripts.ci.release.run_cmd") def test_full_flow_tag_exists( self, mock_run_cmd: MagicMock, @@ -474,16 +474,16 @@ class TestMain: mock_tag.assert_called_once_with("0.1.0", "changelog", False) @patch.dict("os.environ", {}) - @patch("scripts.release.has_user_facing_changes", return_value=True) - @patch("scripts.release.create_and_push_tag", return_value=True) - @patch("scripts.release.commit_release_changes", return_value=True) - @patch("scripts.release.update_changelog") - @patch("scripts.release.update_init_version") - @patch("scripts.release.get_changelog", return_value="changelog") - @patch("scripts.release.get_latest_tag", return_value="v0.1.0") - @patch("scripts.release.get_bumped_version", return_value="0.2.0") - @patch("scripts.release.has_unreleased_changes", return_value=True) - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.has_user_facing_changes", return_value=True) + @patch("scripts.ci.release.create_and_push_tag", return_value=True) + @patch("scripts.ci.release.commit_release_changes", return_value=True) + @patch("scripts.ci.release.update_changelog") + @patch("scripts.ci.release.update_init_version") + @patch("scripts.ci.release.get_changelog", return_value="changelog") + @patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0") + @patch("scripts.ci.release.get_bumped_version", return_value="0.2.0") + @patch("scripts.ci.release.has_unreleased_changes", return_value=True) + @patch("scripts.ci.release.run_cmd") def test_full_flow_skip_tests( self, mock_run_cmd: MagicMock, @@ -508,16 +508,16 @@ class TestMain: assert make_calls == [] @patch.dict("os.environ", {}) - @patch("scripts.release.has_user_facing_changes", return_value=True) - @patch("scripts.release.create_and_push_tag") - @patch("scripts.release.commit_release_changes") - @patch("scripts.release.update_changelog") - @patch("scripts.release.update_init_version") - @patch("scripts.release.get_changelog", return_value="changelog") - @patch("scripts.release.get_latest_tag", return_value="v0.1.0") - @patch("scripts.release.get_bumped_version", return_value="0.2.0") - @patch("scripts.release.has_unreleased_changes", return_value=True) - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.has_user_facing_changes", return_value=True) + @patch("scripts.ci.release.create_and_push_tag") + @patch("scripts.ci.release.commit_release_changes") + @patch("scripts.ci.release.update_changelog") + @patch("scripts.ci.release.update_init_version") + @patch("scripts.ci.release.get_changelog", return_value="changelog") + @patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0") + @patch("scripts.ci.release.get_bumped_version", return_value="0.2.0") + @patch("scripts.ci.release.has_unreleased_changes", return_value=True) + @patch("scripts.ci.release.run_cmd") def test_tests_fail_aborts_before_tag( self, mock_run_cmd: MagicMock, @@ -547,16 +547,16 @@ class TestMain: mock_tag.assert_not_called() @patch.dict("os.environ", {}) - @patch("scripts.release.has_user_facing_changes", return_value=True) - @patch("scripts.release.create_and_push_tag") - @patch("scripts.release.commit_release_changes") - @patch("scripts.release.update_changelog") - @patch("scripts.release.update_init_version") - @patch("scripts.release.get_changelog", return_value="changelog") - @patch("scripts.release.get_latest_tag", return_value="v0.1.0") - @patch("scripts.release.get_bumped_version", return_value="0.2.0") - @patch("scripts.release.has_unreleased_changes", return_value=True) - @patch("scripts.release.run_cmd") + @patch("scripts.ci.release.has_user_facing_changes", return_value=True) + @patch("scripts.ci.release.create_and_push_tag") + @patch("scripts.ci.release.commit_release_changes") + @patch("scripts.ci.release.update_changelog") + @patch("scripts.ci.release.update_init_version") + @patch("scripts.ci.release.get_changelog", return_value="changelog") + @patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0") + @patch("scripts.ci.release.get_bumped_version", return_value="0.2.0") + @patch("scripts.ci.release.has_unreleased_changes", return_value=True) + @patch("scripts.ci.release.run_cmd") def test_lint_fail_aborts_before_tag( self, mock_run_cmd: MagicMock, diff --git a/tests/unit/test_review_pr.py b/tests/unit/test_review_pr.py index 140876b..2343a76 100644 --- a/tests/unit/test_review_pr.py +++ b/tests/unit/test_review_pr.py @@ -1,4 +1,4 @@ -"""Unit tests for scripts/review_pr.py.""" +"""Unit tests for scripts/ci/review_pr.py.""" import http import json @@ -9,7 +9,7 @@ import pytest from click.testing import CliRunner from gitea_runner_manager.exceptions import APIError -from scripts.review_pr import main, parse_comments +from scripts.ci.review_pr import main, parse_comments class TestParseComments: @@ -21,7 +21,7 @@ class TestParseComments: def test_parse_from_stdin(self) -> None: comments = [{"path": "a.py", "body": "fix", "new_position": 1}] - with patch("scripts.review_pr.sys.stdin") as mock_stdin: + with patch("scripts.ci.review_pr.sys.stdin") as mock_stdin: mock_stdin.read.return_value = json.dumps(comments) assert parse_comments(None, True) == comments @@ -41,26 +41,26 @@ class TestParseComments: parse_comments(str(f), False) def test_stdin_non_list_raises(self) -> None: - with patch("scripts.review_pr.sys.stdin") as mock_stdin: + with patch("scripts.ci.review_pr.sys.stdin") as mock_stdin: mock_stdin.read.return_value = json.dumps({"path": "a.py"}) with pytest.raises(click.ClickException): parse_comments(None, True) def test_stdin_invalid_json_raises(self) -> None: - with patch("scripts.review_pr.sys.stdin") as mock_stdin: + with patch("scripts.ci.review_pr.sys.stdin") as mock_stdin: mock_stdin.read.return_value = "not json{" with pytest.raises(click.ClickException): parse_comments(None, True) def test_stdin_empty_returns_empty(self) -> None: - with patch("scripts.review_pr.sys.stdin") as mock_stdin: + with patch("scripts.ci.review_pr.sys.stdin") as mock_stdin: mock_stdin.read.return_value = " " assert parse_comments(None, True) == [] class TestMain: @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.review_pr.GiteaClient") + @patch("scripts.ci.review_pr.GiteaClient") def test_successful_comment_review(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() mock_client.create_review.return_value = {"id": 42} @@ -75,7 +75,7 @@ class TestMain: mock_client.create_review.assert_called_once_with("5", event="COMMENT", body="LGTM", comments=[]) @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.review_pr.GiteaClient") + @patch("scripts.ci.review_pr.GiteaClient") def test_successful_approve_review(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() mock_client.create_review.return_value = {"id": 7} @@ -87,7 +87,7 @@ class TestMain: mock_client.create_review.assert_called_once_with("5", event="APPROVE", body="", comments=[]) @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.review_pr.GiteaClient") + @patch("scripts.ci.review_pr.GiteaClient") def test_successful_with_inline_comments(self, mock_client_cls: MagicMock, tmp_path) -> None: comments = [{"path": "a.py", "body": "fix", "new_position": 1}] f = tmp_path / "comments.json" @@ -104,7 +104,7 @@ class TestMain: mock_client.create_review.assert_called_once_with("5", event="COMMENT", body="", comments=comments) @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.review_pr.GiteaClient") + @patch("scripts.ci.review_pr.GiteaClient") def test_successful_with_stdin_comments(self, mock_client_cls: MagicMock) -> None: comments = [{"path": "a.py", "body": "fix", "new_position": 1}] mock_client = MagicMock() @@ -127,7 +127,7 @@ class TestMain: assert "REPO_TOKEN" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.review_pr.GiteaClient") + @patch("scripts.ci.review_pr.GiteaClient") def test_no_body_or_comments_for_comment_event(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() mock_client_cls.return_value = mock_client @@ -138,7 +138,7 @@ class TestMain: mock_client.create_review.assert_not_called() @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.review_pr.GiteaClient") + @patch("scripts.ci.review_pr.GiteaClient") def test_no_body_or_comments_for_request_changes(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() mock_client_cls.return_value = mock_client @@ -149,7 +149,7 @@ class TestMain: mock_client.create_review.assert_not_called() @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.review_pr.GiteaClient") + @patch("scripts.ci.review_pr.GiteaClient") def test_api_error_raises_click(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() mock_client.create_review.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error") @@ -160,7 +160,7 @@ class TestMain: assert "HTTP" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.review_pr.GiteaClient") + @patch("scripts.ci.review_pr.GiteaClient") def test_invalid_event_choice(self, mock_client_cls: MagicMock) -> None: mock_client = MagicMock() mock_client_cls.return_value = mock_client diff --git a/tests/unit/test_sync_wiki.py b/tests/unit/test_sync_wiki.py index 615f3f9..46c295e 100644 --- a/tests/unit/test_sync_wiki.py +++ b/tests/unit/test_sync_wiki.py @@ -1,4 +1,4 @@ -"""Unit tests for scripts/sync_wiki.py.""" +"""Unit tests for scripts/ci/sync_wiki.py.""" import json from pathlib import Path @@ -7,7 +7,7 @@ from unittest.mock import MagicMock, patch import pytest from click.testing import CliRunner -from scripts.sync_wiki import ( +from scripts.ci.sync_wiki import ( list_wiki_pages, load_mapping, main, @@ -20,12 +20,12 @@ class TestLoadMapping: def test_loads_mapping(self, tmp_path: Path) -> None: mapping_file = tmp_path / "mapping.json" mapping_file.write_text(json.dumps({"user/getting-started.md": "Getting-Started"})) - with patch("scripts.sync_wiki.MAPPING_FILE", mapping_file): + with patch("scripts.ci.sync_wiki.MAPPING_FILE", mapping_file): result = load_mapping() assert result == {"user/getting-started.md": "Getting-Started"} def test_missing_mapping_raises(self, tmp_path: Path) -> None: - with patch("scripts.sync_wiki.MAPPING_FILE", tmp_path / "nonexistent.json"): + with patch("scripts.ci.sync_wiki.MAPPING_FILE", tmp_path / "nonexistent.json"): with pytest.raises(FileNotFoundError): load_mapping() @@ -35,12 +35,12 @@ class TestReadDocContent: docs_dir = tmp_path / "docs" docs_dir.mkdir() (docs_dir / "test.md").write_text("# Test\n\nContent") - with patch("scripts.sync_wiki.DOCS_DIR", docs_dir): + with patch("scripts.ci.sync_wiki.DOCS_DIR", docs_dir): content = read_doc_content("test.md") assert content == "# Test\n\nContent" def test_missing_file_raises(self, tmp_path: Path) -> None: - with patch("scripts.sync_wiki.DOCS_DIR", tmp_path): + with patch("scripts.ci.sync_wiki.DOCS_DIR", tmp_path): with pytest.raises(FileNotFoundError): read_doc_content("nonexistent.md") @@ -93,15 +93,15 @@ class TestSyncPage: class TestMain: @patch.dict("os.environ", {"REPO_TOKEN": "tok"}) - @patch("scripts.sync_wiki.MAPPING_FILE") - @patch("scripts.sync_wiki.DOCS_DIR") - @patch("scripts.sync_wiki.GiteaClient") + @patch("scripts.ci.sync_wiki.MAPPING_FILE") + @patch("scripts.ci.sync_wiki.DOCS_DIR") + @patch("scripts.ci.sync_wiki.GiteaClient") def test_dry_run(self, mock_client_cls: MagicMock, mock_docs_dir: Path, mock_mapping_file: Path) -> None: mock_mapping_file.exists.return_value = True mock_mapping_file.__str__ = lambda _: "/docs/mapping.json" - with patch("scripts.sync_wiki.load_mapping", return_value={"index.md": "Home"}): - with patch("scripts.sync_wiki.read_doc_content", return_value="# Home"): - with patch("scripts.sync_wiki.list_wiki_pages", return_value={}): + with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}): + with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"): + with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={}): runner = CliRunner() result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"]) assert result.exit_code == 0 @@ -115,24 +115,24 @@ class TestMain: assert "REPO_TOKEN" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "tok", "GRM_REPO_OWNER": "me", "GRM_REPO_NAME": "myrepo"}, clear=True) - @patch("scripts.sync_wiki.GiteaClient") + @patch("scripts.ci.sync_wiki.GiteaClient") def test_auto_detect_repo(self, mock_client_cls: MagicMock) -> None: """Test that repo is auto-detected from env vars when --repo is not passed.""" - with patch("scripts.sync_wiki.MAPPING_FILE") as mock_mapping: + with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping: mock_mapping.exists.return_value = True - with patch("scripts.sync_wiki.load_mapping", return_value={"index.md": "Home"}): - with patch("scripts.sync_wiki.read_doc_content", return_value="# Home"): - with patch("scripts.sync_wiki.list_wiki_pages", return_value={}): + with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}): + with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"): + with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={}): runner = CliRunner() result = runner.invoke(main, ["--dry-run"]) assert result.exit_code == 0 mock_client_cls.assert_called_once() @patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) - @patch("scripts.sync_wiki.GiteaClient") + @patch("scripts.ci.sync_wiki.GiteaClient") def test_missing_mapping_file(self, mock_client_cls: MagicMock) -> None: """Test that missing mapping.json exits with error.""" - with patch("scripts.sync_wiki.MAPPING_FILE") as mock_mapping: + with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping: mock_mapping.exists.return_value = False runner = CliRunner() result = runner.invoke(main, ["--repo", "owner/repo"]) @@ -140,28 +140,28 @@ class TestMain: assert "mapping.json" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) - @patch("scripts.sync_wiki.GiteaClient") + @patch("scripts.ci.sync_wiki.GiteaClient") def test_existing_pages_message(self, mock_client_cls: MagicMock) -> None: """Test that existing wiki pages are reported.""" - with patch("scripts.sync_wiki.MAPPING_FILE") as mock_mapping: + with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping: mock_mapping.exists.return_value = True - with patch("scripts.sync_wiki.load_mapping", return_value={"index.md": "Home"}): - with patch("scripts.sync_wiki.read_doc_content", return_value="# Home"): - with patch("scripts.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}): + with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}): + with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"): + with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}): runner = CliRunner() result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"]) assert result.exit_code == 0 assert "existing wiki pages" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) - @patch("scripts.sync_wiki.GiteaClient") + @patch("scripts.ci.sync_wiki.GiteaClient") def test_file_not_found_warning(self, mock_client_cls: MagicMock) -> None: """Test that missing doc files are skipped with a warning.""" - with patch("scripts.sync_wiki.MAPPING_FILE") as mock_mapping: + with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping: mock_mapping.exists.return_value = True - with patch("scripts.sync_wiki.load_mapping", return_value={"missing.md": "Missing"}): - with patch("scripts.sync_wiki.read_doc_content", side_effect=FileNotFoundError): - with patch("scripts.sync_wiki.list_wiki_pages", return_value={}): + with patch("scripts.ci.sync_wiki.load_mapping", return_value={"missing.md": "Missing"}): + with patch("scripts.ci.sync_wiki.read_doc_content", side_effect=FileNotFoundError): + with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={}): runner = CliRunner() result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"]) assert result.exit_code == 0 @@ -169,17 +169,17 @@ class TestMain: assert "Skipped: 1" in result.output @patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) - @patch("scripts.sync_wiki.GiteaClient") + @patch("scripts.ci.sync_wiki.GiteaClient") def test_create_and_update(self, mock_client_cls: MagicMock) -> None: """Test that pages are created and updated correctly (non-dry-run).""" mock_client = MagicMock() mock_client_cls.return_value = mock_client - with patch("scripts.sync_wiki.MAPPING_FILE") as mock_mapping: + with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping: mock_mapping.exists.return_value = True mapping = {"new.md": "New-Page", "existing.md": "Existing-Page"} - with patch("scripts.sync_wiki.load_mapping", return_value=mapping): - with patch("scripts.sync_wiki.read_doc_content", return_value="# Content"): - with patch("scripts.sync_wiki.list_wiki_pages", return_value={"Existing-Page": "Existing-Page"}): + with patch("scripts.ci.sync_wiki.load_mapping", return_value=mapping): + with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Content"): + with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Existing-Page": "Existing-Page"}): runner = CliRunner() result = runner.invoke(main, ["--repo", "owner/repo"]) assert result.exit_code == 0 diff --git a/tests/unit/test_validate_commit_msg.py b/tests/unit/test_validate_commit_msg.py index 4d86ad2..2f38651 100644 --- a/tests/unit/test_validate_commit_msg.py +++ b/tests/unit/test_validate_commit_msg.py @@ -1,4 +1,4 @@ -"""Unit tests for scripts/validate_commit_msg.py.""" +"""Unit tests for scripts/ci/validate_commit_msg.py.""" import os import subprocess @@ -8,7 +8,7 @@ from unittest.mock import patch from click.testing import CliRunner from gitea_runner_manager.config import CONVENTIONAL_RE, TASK_ID_RE -from scripts.validate_commit_msg import first_line, get_branch, main +from scripts.ci.validate_commit_msg import first_line, get_branch, main class TestHelpers: @@ -72,7 +72,7 @@ class TestMain: def test_rejects_task_id_on_feature_branch(self) -> None: msg_path = self._write_msg("GRM-19: feat: add feature") - with patch("scripts.validate_commit_msg.get_branch", return_value="GRM-19"): + with patch("scripts.ci.validate_commit_msg.get_branch", return_value="GRM-19"): runner = CliRunner() result = runner.invoke(main, [msg_path]) assert result.exit_code == 1 @@ -80,21 +80,21 @@ class TestMain: def test_accepts_conventional_on_feature_branch(self) -> None: msg_path = self._write_msg("feat: add feature") - with patch("scripts.validate_commit_msg.get_branch", return_value="GRM-19"): + with patch("scripts.ci.validate_commit_msg.get_branch", return_value="GRM-19"): runner = CliRunner() result = runner.invoke(main, [msg_path]) assert result.exit_code == 0 def test_accepts_valid_master_commit(self) -> None: msg_path = self._write_msg("GRM-19: feat: add feature") - with patch("scripts.validate_commit_msg.get_branch", return_value="master"): + with patch("scripts.ci.validate_commit_msg.get_branch", return_value="master"): runner = CliRunner() result = runner.invoke(main, [msg_path]) assert result.exit_code == 0 def test_rejects_master_without_task_id(self) -> None: msg_path = self._write_msg("feat: add feature") - with patch("scripts.validate_commit_msg.get_branch", return_value="master"): + with patch("scripts.ci.validate_commit_msg.get_branch", return_value="master"): runner = CliRunner() result = runner.invoke(main, [msg_path]) assert result.exit_code == 1 @@ -102,7 +102,7 @@ class TestMain: def test_rejects_master_with_non_conventional_after_task_id(self) -> None: msg_path = self._write_msg("GRM-19: random message") - with patch("scripts.validate_commit_msg.get_branch", return_value="master"): + with patch("scripts.ci.validate_commit_msg.get_branch", return_value="master"): runner = CliRunner() result = runner.invoke(main, [msg_path]) assert result.exit_code == 1 @@ -110,7 +110,7 @@ class TestMain: def test_rejects_non_conventional_on_feature_branch(self) -> None: msg_path = self._write_msg("random message") - with patch("scripts.validate_commit_msg.get_branch", return_value="feature"): + with patch("scripts.ci.validate_commit_msg.get_branch", return_value="feature"): runner = CliRunner() result = runner.invoke(main, [msg_path]) assert result.exit_code == 1 @@ -118,7 +118,7 @@ class TestMain: def test_accepts_multiline_conventional(self) -> None: msg_path = self._write_msg("feat: add feature\n\nBody text.\nMore text.") - with patch("scripts.validate_commit_msg.get_branch", return_value="feature"): + with patch("scripts.ci.validate_commit_msg.get_branch", return_value="feature"): runner = CliRunner() result = runner.invoke(main, [msg_path]) assert result.exit_code == 0 @@ -136,8 +136,8 @@ def test_main_module_block() -> None: f.write("GRM-1: feat: test") msg_path = f.name - with patch("scripts.validate_commit_msg.get_branch", return_value="master"): - import scripts.validate_commit_msg as vcm + with patch("scripts.ci.validate_commit_msg.get_branch", return_value="master"): + import scripts.ci.validate_commit_msg as vcm with open(vcm.__file__) as f: source = f.read()