GRM-64: refactor: migrate from scripts/ to devx package

This commit is contained in:
2026-06-22 19:45:24 +00:00
parent a0f997cb3e
commit 041e5ac4aa
73 changed files with 294 additions and 12114 deletions
+8
View File
@@ -36,3 +36,11 @@ GITEA_REGISTRATION_TOKEN=your-registration-token
# UI language for GRM console messages (optional, default: en) # UI language for GRM console messages (optional, default: en)
# Supported: en, bg, de, ru, zh # Supported: en, bg, de, ru, zh
# GRM_LANG=en # GRM_LANG=en
# devx configuration (GRM-specific overrides)
# Task prefix for Vikunja task IDs
DEVX_TASK_PREFIX=GRM
# Vikunja project ID for GRM
DEVX_VIKUNJA_PROJECT_ID=6
# Version file path (relative to repo root)
DEVX_VERSION_FILE=src/gitea_runner_manager/__init__.py
+38 -32
View File
@@ -12,6 +12,8 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up environment - name: Set up environment
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: make setup-quality run: make setup-quality
- name: Lint all - name: Lint all
run: | run: |
@@ -24,22 +26,10 @@ jobs:
make pytest-cov make pytest-cov
- name: Check unit test speed - name: Check unit test speed
env: env:
PYTHONPATH: .:src PYTHONPATH: src
run: | run: |
. .venv/bin/activate . .venv/bin/activate
python3 scripts/check_test_speed.py --max-seconds 10 python3 -m devx.tools.check_test_speed --max-seconds 10
- name: Documentation coverage check
env:
PYTHONPATH: .:src
run: |
. .venv/bin/activate
python3 scripts/ci/doc_coverage.py --fail-on-missing
- name: Translation completeness check
env:
PYTHONPATH: .:src
run: |
. .venv/bin/activate
python3 scripts/ci/check_translations.py
- name: Dependency security scan - name: Dependency security scan
run: | run: |
. .venv/bin/activate . .venv/bin/activate
@@ -68,14 +58,18 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up environment - name: Set up environment
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: make setup-release run: make setup-release
- name: Release dry-run validation - name: Release dry-run validation
env: env:
PYTHONPATH: .:src PYTHONPATH: src
DEVX_VERSION_FILE: src/gitea_runner_manager/__init__.py
DEVX_TASK_PREFIX: GRM
run: | run: |
. .venv/bin/activate . .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
python3 scripts/ci/release.py --dry-run || true python3 -m devx.ci.release --dry-run || true
detect-changes: detect-changes:
runs-on: docker runs-on: docker
@@ -88,14 +82,17 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up environment - name: Set up environment
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: make setup-ci run: make setup-ci
- name: Detect changed paths - name: Detect changed paths
id: detect id: detect
env: env:
PYTHONPATH: .:src PYTHONPATH: src
DEVX_TASK_PREFIX: GRM
run: | run: |
. .venv/bin/activate . .venv/bin/activate
python3 scripts/ci/classify_changes.py \ python3 -m devx.ci.classify_changes \
--base "origin/master" \ --base "origin/master" \
--head "${{ github.event.pull_request.head.sha || github.sha }}" \ --head "${{ github.event.pull_request.head.sha || github.sha }}" \
--github-output --github-output
@@ -111,16 +108,18 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up environment - name: Set up environment
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: make setup-ci run: make setup-ci
- name: Discover available runners - name: Discover available runners
id: discover id: discover
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
MOLECULE_RUNNERS: ${{ vars.MOLECULE_RUNNERS }} MOLECULE_RUNNERS: ${{ vars.MOLECULE_RUNNERS }}
PYTHONPATH: .:src PYTHONPATH: src
run: | run: |
. .venv/bin/activate . .venv/bin/activate
python3 scripts/ci/discover_runners.py \ python3 -m devx.molecule.discover_runners \
--owner "${{ github.repository_owner }}" \ --owner "${{ github.repository_owner }}" \
--repo "${{ github.event.repository.name }}" \ --repo "${{ github.event.repository.name }}" \
--github-output --github-output
@@ -136,15 +135,17 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up environment - name: Set up environment
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: make setup-molecule run: make setup-molecule
- name: Discover assigned test pairs - name: Discover assigned test pairs
env: env:
RUNNER_INDEX: ${{ matrix.runner-index }} RUNNER_INDEX: ${{ matrix.runner-index }}
MAX_RUNNERS: ${{ needs.discover-runners.outputs.runner-count }} MAX_RUNNERS: ${{ needs.discover-runners.outputs.runner-count }}
PYTHONPATH: .:src PYTHONPATH: src
run: | run: |
. .venv/bin/activate . .venv/bin/activate
python3 scripts/ci/distribute_molecule.py \ python3 -m devx.molecule.distribute_molecule \
--runner-index "$RUNNER_INDEX" \ --runner-index "$RUNNER_INDEX" \
--max-runners "$MAX_RUNNERS" \ --max-runners "$MAX_RUNNERS" \
--github-env --skip-if-excess --github-env --skip-if-excess
@@ -154,7 +155,7 @@ jobs:
. .venv/bin/activate . .venv/bin/activate
if [ -z "$TEST_PAIRS" ]; then exit 0; fi if [ -z "$TEST_PAIRS" ]; then exit 0; fi
# shellcheck disable=SC2086 # intentional word splitting for argument expansion # shellcheck disable=SC2086 # intentional word splitting for argument expansion
python3 scripts/ci/molecule_ci_guard.py $TEST_PAIRS python3 -m devx.molecule.molecule_ci_guard $TEST_PAIRS
env: env:
GITEA_URL: ${{ github.server_url }} GITEA_URL: ${{ github.server_url }}
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
@@ -162,7 +163,7 @@ jobs:
JOB_NAME: ${{ github.job }} JOB_NAME: ${{ github.job }}
MATRIX_INDEX: ${{ matrix.runner-index }} MATRIX_INDEX: ${{ matrix.runner-index }}
GITEA_REPOSITORY: ${{ github.repository }} GITEA_REPOSITORY: ${{ github.repository }}
PYTHONPATH: .:src PYTHONPATH: src
pr-review: pr-review:
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
@@ -170,16 +171,17 @@ jobs:
timeout-minutes: 10 timeout-minutes: 10
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up environment - name: Install dependencies
run: make setup-ci run: |
. .env 2>/dev/null || true
python3 -m pip install --break-system-packages "git+https://emil:${{ secrets.REPO_TOKEN }}@git.oblachno.oblachno.fyi/oblachno-oss/devx.git@master"
- name: Run automated PR review - name: Run automated PR review
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: .:src PYTHONPATH: src
run: | run: |
set -euo pipefail set -euo pipefail
. .venv/bin/activate python3 -m devx.ci.pr_review \
python3 scripts/ci/pr_review.py \
"${{ github.event.number }}" \ "${{ github.event.number }}" \
"${{ github.repository }}" "${{ github.repository }}"
@@ -197,18 +199,22 @@ jobs:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.REPO_TOKEN }} token: ${{ secrets.REPO_TOKEN }}
- name: Install dependencies - name: Install dependencies
run: python3 -m pip install --break-system-packages requests python-dotenv click run: |
. .env 2>/dev/null || true
python3 -m pip install --break-system-packages "git+https://emil:${{ secrets.REPO_TOKEN }}@git.oblachno.oblachno.fyi/oblachno-oss/devx.git@master"
- name: Squash merge with task ID - name: Squash merge with task ID
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }} VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
PYTHONPATH: .:src PYTHONPATH: src
DEVX_TASK_PREFIX: GRM
DEVX_VIKUNJA_PROJECT_ID: 6
HEAD_REF: ${{ github.head_ref }} HEAD_REF: ${{ github.head_ref }}
PR_TITLE: ${{ github.event.pull_request.title }} PR_TITLE: ${{ github.event.pull_request.title }}
REPOSITORY: ${{ github.repository }} REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.number }} PR_NUMBER: ${{ github.event.number }}
run: | run: |
python3 scripts/ci/auto_merge.py \ python3 -m devx.ci.auto_merge \
"$HEAD_REF" \ "$HEAD_REF" \
"$PR_TITLE" \ "$PR_TITLE" \
"$REPOSITORY" \ "$REPOSITORY" \
+59 -28
View File
@@ -40,11 +40,15 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Install dependencies
run: |
. .env 2>/dev/null || true
python3 -m pip install --break-system-packages "git+https://emil:${{ secrets.REPO_TOKEN }}@git.oblachno.oblachno.fyi/oblachno-oss/devx.git@master"
- name: Check if this is a release commit - name: Check if this is a release commit
id: check id: check
env: env:
PYTHONPATH: .:src PYTHONPATH: src
run: python3 scripts/ci/detect_release_commit.py run: python3 -m devx.ci.detect_release_commit
validate-commit-msg: validate-commit-msg:
needs: [detect-type] needs: [detect-type]
@@ -56,13 +60,15 @@ jobs:
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Install dependencies - name: Install dependencies
run: python3 -m pip install --break-system-packages click python-dotenv run: |
. .env 2>/dev/null || true
python3 -m pip install --break-system-packages "git+https://emil:${{ secrets.REPO_TOKEN }}@git.oblachno.oblachno.fyi/oblachno-oss/devx.git@master"
- name: Validate latest commit message - name: Validate latest commit message
env: env:
PYTHONPATH: .:src PYTHONPATH: src
run: | run: |
git log -1 --format=%B > commit-msg.txt git log -1 --format=%B > commit-msg.txt
python3 scripts/ci/validate_commit_msg.py commit-msg.txt --branch master python3 -m devx.ci.validate_commit_msg commit-msg.txt --branch master
rm -f commit-msg.txt rm -f commit-msg.txt
release: release:
@@ -76,6 +82,8 @@ jobs:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.REPO_TOKEN }} token: ${{ secrets.REPO_TOKEN }}
- name: Set up environment - name: Set up environment
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: make setup-release run: make setup-release
- name: Configure git - name: Configure git
run: | run: |
@@ -83,19 +91,25 @@ jobs:
git config user.email "grm-ci-bot@oblachno.fyi" git config user.email "grm-ci-bot@oblachno.fyi"
- name: Run release - name: Run release
env: env:
PYTHONPATH: .:src PYTHONPATH: src
DEVX_VERSION_FILE: src/gitea_runner_manager/__init__.py
DEVX_TASK_PREFIX: GRM
DEVX_VIKUNJA_PROJECT_ID: 6
run: | run: |
. .venv/bin/activate . .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
python3 scripts/ci/release.py python3 -m devx.ci.release
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: .:src PYTHONPATH: src
run: | run: |
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
python3 scripts/ci/notify_failure.py \ python3 -m devx.tools.install_tools --tool tea
tea login add --name grm --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
tea login default grm || true
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \ --repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \ --run-id "${{ github.run_id }}" \
--workflow "post-merge/release" \ --workflow "post-merge/release" \
@@ -111,22 +125,27 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up environment - name: Set up environment
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: make setup-ci run: make setup-ci
- name: Sync documentation to wiki - name: Sync documentation to wiki
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: .:src PYTHONPATH: src
run: | run: |
. .venv/bin/activate . .venv/bin/activate
python3 scripts/ci/sync_wiki.py --repo "${{ github.repository }}" --strict python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --strict
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: .:src PYTHONPATH: src
run: | run: |
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
python3 scripts/ci/notify_failure.py \ python3 -m devx.tools.install_tools --tool tea
tea login add --name grm --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
tea login default grm || true
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \ --repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \ --run-id "${{ github.run_id }}" \
--workflow "post-merge/sync-wiki" \ --workflow "post-merge/sync-wiki" \
@@ -148,21 +167,26 @@ jobs:
git fetch origin master git fetch origin master
git reset --hard origin/master git reset --hard origin/master
- name: Set up environment - name: Set up environment
env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: make setup-ci run: make setup-ci
- name: Generate and push badges - name: Generate and push badges
env: env:
PRE_COMMIT_ALLOW_NO_CONFIG: "1" PRE_COMMIT_ALLOW_NO_CONFIG: "1"
run: | run: |
. .venv/bin/activate . .venv/bin/activate
python3 scripts/ci/push_badges.py python3 -m devx.ci.push_badges
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: .:src PYTHONPATH: src
run: | run: |
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
python3 scripts/ci/notify_failure.py \ python3 -m devx.tools.install_tools --tool tea
tea login add --name grm --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
tea login default grm || true
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \ --repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \ --run-id "${{ github.run_id }}" \
--workflow "post-merge/badges" \ --workflow "post-merge/badges" \
@@ -178,23 +202,27 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Install dependencies - name: Install dependencies
run: python3 -m pip install --break-system-packages requests python-dotenv click run: |
. .env 2>/dev/null || true
python3 -m pip install --break-system-packages "git+https://emil:${{ secrets.REPO_TOKEN }}@git.oblachno.oblachno.fyi/oblachno-oss/devx.git@master"
- name: Update Vikunja task - name: Update Vikunja task
env: env:
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }} VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
PYTHONPATH: .:src PYTHONPATH: src
run: python3 scripts/ci/post_merge.py --git-sha "${{ github.sha }}" DEVX_TASK_PREFIX: GRM
DEVX_VIKUNJA_PROJECT_ID: 6
run: python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: .:src PYTHONPATH: src
run: | run: |
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
python3 scripts/install_tools.py --tool tea python3 -m devx.tools.install_tools --tool tea
tea login add --name grm --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true tea login add --name grm --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
tea login default grm || true tea login default grm || true
python3 scripts/ci/notify_failure.py \ python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \ --repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \ --run-id "${{ github.run_id }}" \
--workflow "post-merge/vikunja" \ --workflow "post-merge/vikunja" \
@@ -208,23 +236,26 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Install dependencies - name: Install dependencies
run: python3 -m pip install --break-system-packages requests python-dotenv click run: |
. .env 2>/dev/null || true
python3 -m pip install --break-system-packages "git+https://emil:${{ secrets.REPO_TOKEN }}@git.oblachno.oblachno.fyi/oblachno-oss/devx.git@master"
- name: Ensure branch protection and labels - name: Ensure branch protection and labels
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: .:src PYTHONPATH: src
run: python3 scripts/configure_repo.py DEVX_STATUS_CHECKS: "CI / quality (pull_request),CI / molecule-tests (1) (pull_request),CI / molecule-tests (2) (pull_request),CI / molecule-tests (3) (pull_request)"
run: python3 -m devx.tools.configure_repo
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: .:src PYTHONPATH: src
run: | run: |
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
python3 scripts/install_tools.py --tool tea python3 -m devx.tools.install_tools --tool tea
tea login add --name grm --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true tea login add --name grm --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
tea login default grm || true tea login default grm || true
python3 scripts/ci/notify_failure.py \ python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \ --repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \ --run-id "${{ github.run_id }}" \
--workflow "post-merge/configure-repo" \ --workflow "post-merge/configure-repo" \
+9 -6
View File
@@ -14,9 +14,12 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Install CI tools - name: Install CI tools
run: python3 scripts/install_tools.py --tool git-cliff --tool tea run: |
. .env 2>/dev/null || true
python3 -m pip install --break-system-packages "git+https://emil:${{ secrets.REPO_TOKEN }}@git.oblachno.oblachno.fyi/oblachno-oss/devx.git@master"
python3 -m devx.tools.install_tools --tool git-cliff --tool tea
- name: Install build tools - name: Install build tools
run: python3 -m pip install --break-system-packages build twine requests python-dotenv click run: python3 -m pip install --break-system-packages build twine
- name: Configure tea login - name: Configure tea login
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
@@ -28,20 +31,20 @@ jobs:
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }} PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
PYTHONPATH: .:src PYTHONPATH: src
run: | run: |
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
python3 scripts/ci/publish.py \ python3 -m devx.ci.publish \
"${{ github.ref_name }}" \ "${{ github.ref_name }}" \
"${{ github.repository }}" "${{ github.repository }}"
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
PYTHONPATH: .:src PYTHONPATH: src
run: | run: |
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
python3 scripts/ci/notify_failure.py \ python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \ --repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \ --run-id "${{ github.run_id }}" \
--workflow "publish" \ --workflow "publish" \
+2 -2
View File
@@ -3,7 +3,7 @@ repos:
hooks: hooks:
- id: validate-commit-msg - id: validate-commit-msg
name: validate commit message name: validate commit message
entry: env PYTHONPATH=. .venv/bin/python scripts/ci/validate_commit_msg.py entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.validate_commit_msg
language: system language: system
stages: [commit-msg] stages: [commit-msg]
pass_filenames: true pass_filenames: true
@@ -67,7 +67,7 @@ repos:
- id: commit-msg - id: commit-msg
name: validate commit message name: validate commit message
entry: env PYTHONPATH=. .venv/bin/python scripts/ci/validate_commit_msg.py entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.validate_commit_msg
language: system language: system
stages: [commit-msg] stages: [commit-msg]
pass_filenames: true pass_filenames: true
+1 -1
View File
@@ -1 +1 @@
GRM-63 GRM-64
+73 -61
View File
@@ -17,10 +17,11 @@ make workflow-check # workflow-lint + workflow-dryrun
``` ```
`make setup` automatically installs all development tools: `make setup` automatically installs all development tools:
- **Python deps** via `scripts/setup.py` (pip install -e .[dev], ansible-galaxy, pre-commit hooks) - **Python deps** via `devx.tools.setup` (pip install -e .[dev], ansible-galaxy, pre-commit hooks)
- **checkmake** via `scripts/install_checkmake.py` (Makefile linter) - **devx package** via `make install-devx` (installs the devx package from git, providing all CI/CD tools)
- **actionlint, git-cliff, act_runner, tea** via `scripts/install_tools.py` (CI/CD tools to ~/.local/bin) - **checkmake** via `devx.tools.install_checkmake` (Makefile linter)
- **tea CLI login** via `scripts/setup.py` (configures `tea login` from `.env` `REPO_TOKEN`) - **actionlint, git-cliff, act_runner, tea** via `devx.tools.install_tools` (CI/CD tools to ~/.local/bin)
- **tea CLI login** via `devx.tools.setup` (configures `tea login` from `.env` `REPO_TOKEN`)
## Workflow Verification (Before Push) ## Workflow Verification (Before Push)
@@ -29,7 +30,7 @@ Workflow YAML files (`.gitea/workflows/*.yml`) are verified with two tools:
1. **actionlint** — Static linter that catches syntax errors, invalid 1. **actionlint** — Static linter that catches syntax errors, invalid
expressions, unknown keys, type mismatches, and shellcheck issues. expressions, unknown keys, type mismatches, and shellcheck issues.
Config: `.gitea/actionlint.yaml` (registers custom `docker` runner label). Config: `.gitea/actionlint.yaml` (registers custom `docker` runner label).
Installed automatically by `make setup` via `scripts/install_tools.py`. Installed automatically by `make setup` via `devx.tools.install_tools`.
2. **act_runner exec --dryrun** — Gitea's own runner in dry-run mode. 2. **act_runner exec --dryrun** — Gitea's own runner in dry-run mode.
Validates job dependencies, step ordering, and Docker image selection Validates job dependencies, step ordering, and Docker image selection
@@ -44,7 +45,7 @@ CI also runs a best-effort `make workflow-dryrun` step (skipped if act_runner is
- **Python CLI** (`src/gitea_runner_manager/`) — Click-based CLI that delegates to Ansible - **Python CLI** (`src/gitea_runner_manager/`) — Click-based CLI that delegates to Ansible
- **Ansible Role** (`ansible/roles/gitea-runner/`) — Idempotent role for rootless Docker runner setup - **Ansible Role** (`ansible/roles/gitea-runner/`) — Idempotent role for rootless Docker runner setup
- **CI Scripts** (`scripts/`) — Automation for auto-merge, post-merge, release, publishing, molecule distribution, PR reviews, failure notifications - **devx package** (installed from git) — Reusable CI/CD tools: auto-merge, post-merge, release, publishing, molecule distribution, PR reviews, failure notifications
- **Versioning** (`cliff.toml`) — git-cliff configuration for automated semver versioning from conventional commits - **Versioning** (`cliff.toml`) — git-cliff configuration for automated semver versioning from conventional commits
## PR Workflow (Mandatory) ## PR Workflow (Mandatory)
@@ -54,7 +55,8 @@ Every change to master goes through this workflow. No exceptions.
### Branch Protection (Required Gitea Settings) ### Branch Protection (Required Gitea Settings)
Branch protection and labels are automatically configured by Branch protection and labels are automatically configured by
`scripts/configure_repo.py`, which runs as a `configure-repo` job in `devx.tools.configure_repo` (run as `python -m devx.tools.configure_repo`),
which runs as a `configure-repo` job in
the post-merge workflow on every push to master. the post-merge workflow on every push to master.
The following rules are enforced for `master`: The following rules are enforced for `master`:
@@ -102,7 +104,7 @@ UX, documentation, workflow compliance, maintainability, resource
management, backwards compatibility, and logging. management, backwards compatibility, and logging.
**Automated review (CI `pr-review` job):** Every PR triggers an automated **Automated review (CI `pr-review` job):** Every PR triggers an automated
review via `scripts/ci/pr_review.py`. This job posts a review with review via `devx.ci.pr_review` (run as `python -m devx.ci.pr_review`). This job posts a review with
`COMMENT` (no issues) or `REQUEST_CHANGES` (issues found) based on `COMMENT` (no issues) or `REQUEST_CHANGES` (issues found) based on
the **[auto]** items in the checklist: the **[auto]** items in the checklist:
@@ -125,9 +127,9 @@ must go through **every category** in `REVIEW_CHECKLIST.md` and verify
the **[manual]** items by reviewing the full diff the **[manual]** items by reviewing the full diff
(`git diff master...HEAD`). (`git diff master...HEAD`).
Post review comments using `scripts/ci/review_pr.py`: Post review comments using `devx.ci.review_pr` (run as `python -m devx.ci.review_pr`):
```bash ```bash
REPO_TOKEN=<token> python3 scripts/ci/review_pr.py <pr_number> <owner/repo> \ REPO_TOKEN=<token> python -m devx.ci.review_pr <pr_number> <owner/repo> \
--event REQUEST_CHANGES \ --event REQUEST_CHANGES \
--body "Review summary" \ --body "Review summary" \
--comments-json comments.json --comments-json comments.json
@@ -140,7 +142,7 @@ Fix each comment one by one, commit, and push. Re-review until satisfied.
Once all checklist items are verified and comments are addressed, post Once all checklist items are verified and comments are addressed, post
an approval review with `--checklist-confirmed` and `--checklist-categories`: an approval review with `--checklist-confirmed` and `--checklist-categories`:
```bash ```bash
REPO_TOKEN=<token> python3 scripts/ci/review_pr.py <pr_number> <owner/repo> \ REPO_TOKEN=<token> python -m devx.ci.review_pr <pr_number> <owner/repo> \
--event APPROVE --checklist-confirmed \ --event APPROVE --checklist-confirmed \
--checklist-categories 1,2,3,4,5,6,7,8,9,10,11,12,13 \ --checklist-categories 1,2,3,4,5,6,7,8,9,10,11,12,13 \
--body "All 13 REVIEW_CHECKLIST.md categories verified. Architecture: <summary>. Security: <summary>. Tests: <summary>. Docs: <summary>." --body "All 13 REVIEW_CHECKLIST.md categories verified. Architecture: <summary>. Security: <summary>. Tests: <summary>. Docs: <summary>."
@@ -179,7 +181,7 @@ test infrastructure flakiness.
### Dynamic Runner Discovery ### Dynamic Runner Discovery
Molecule tests are distributed across available Gitea Actions runners Molecule tests are distributed across available Gitea Actions runners
dynamically via `scripts/ci/discover_runners.py`. The `discover-runners` dynamically via `devx.molecule.discover_runners`. The `discover-runners`
job queries the Gitea API for runners at all levels (repo, org, instance) 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 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, (no admin scope), it falls back to the `MOLECULE_RUNNERS` repo variable,
@@ -201,9 +203,9 @@ Vikunja task updates:
release commit (`release: vX.Y.Z`). All subsequent jobs skip for release commit (`release: vX.Y.Z`). All subsequent jobs skip for
release commits (the `[skip ci]` tag also prevents re-triggering). release commits (the `[skip ci]` tag also prevents re-triggering).
2. **release** — Runs `scripts/ci/release.py` which: 2. **release** — Runs `devx.ci.release` which:
- **Checks for user-facing changes** via `scripts/ci/classify_changes.py` — if only - **Checks for user-facing changes** via `devx.ci.classify_changes` — if only
workflow/infrastructure files changed (`.gitea/`, `scripts/`, `docs/`, `tests/`, workflow/infrastructure files changed (`.gitea/`, `docs/`, `tests/`,
`AGENTS.md`, `Makefile`, etc.), the release is **skipped entirely** — no version `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. bump, no tag, no publish. This prevents unnecessary releases for CI/docs-only changes.
- Uses **git-cliff** to calculate the next semver version from conventional commits - Uses **git-cliff** to calculate the next semver version from conventional commits
@@ -233,16 +235,15 @@ which builds and publishes the package to PyPI.
### Smart CI: User-Facing vs Workflow-Only Changes ### Smart CI: User-Facing vs Workflow-Only Changes
Not all changes require the full CI pipeline or a new release. The project Not all changes require the full CI pipeline or a new release. The project
classifies changes into two categories using `scripts/ci/classify_changes.py`: classifies changes into two categories using `devx.ci.classify_changes`:
**Classification strategy (safe-by-default):** Any file NOT in the explicit **Classification strategy (safe-by-default):** Any file NOT in the explicit
workflow-only allowlist is treated as user-facing. This prevents new file workflow-only allowlist is treated as user-facing. This prevents new file
types from accidentally skipping releases. types from accidentally skipping releases. Classification is config-driven
via `[tool.devx.classify]` in `pyproject.toml`.
**Workflow-only paths** (infrastructure → no release needed): **Workflow-only paths** (infrastructure → no release needed):
- `.gitea/**` — Gitea Actions workflows - `.gitea/**` — Gitea Actions workflows
- `scripts/ci/**` — CI/CD automation scripts
- `scripts/setup.py`, `scripts/molecule_all.py`, `scripts/install_tools.py`, `scripts/__init__.py` — Dev tooling and package init
- `docs/**` — Documentation - `docs/**` — Documentation
- `tests/**` — Test files - `tests/**` — Test files
- `AGENTS.md`, `README.md`, `CHANGELOG.md`, `TROUBLESHOOTING.md` — Project docs - `AGENTS.md`, `README.md`, `CHANGELOG.md`, `TROUBLESHOOTING.md` — Project docs
@@ -256,9 +257,15 @@ types from accidentally skipping releases.
- `pyproject.toml` — Package metadata - `pyproject.toml` — Package metadata
- Any new file type not in the allowlist - Any new file type not in the allowlist
**Script directory structure:** **devx module structure** (installed from git, not in this repo):
- `scripts/` — Dev tools (run locally by developers): `check_test_speed.py`, `configure_repo.py`, `install_checkmake.py`, `install_tools.py`, `setup.py`, `molecule_all.py`, `generate_badges.py`, `gitea_cli.py` - `devx.ci.*` — CI/CD automation (run by workflows): release, publish, auto_merge, classify_changes, detect_release_commit, push_badges, doc_coverage, sync_wiki, distribute_molecule, molecule_ci_guard, discover_runners, notify_failure, post_merge, pr_review, review_pr, validate_commit_msg
- `scripts/ci/` — CI/CD automation (run by workflows): `release.py`, `publish.py`, `auto_merge.py`, `classify_changes.py`, `detect_release_commit.py`, `push_badges.py`, `doc_coverage.py`, `sync_wiki.py`, `distribute_molecule.py`, `molecule_ci_guard.py`, `discover_runners.py`, `notify_failure.py`, `post_merge.py`, `pr_review.py`, `review_pr.py`, `validate_commit_msg.py`, `platforms.py` - `devx.tools.*` — Dev tools (run locally): check_test_speed, configure_repo, install_checkmake, install_tools, setup, generate_badges
- `devx.molecule.*` — Molecule helpers: molecule_all, platforms, discover_runners, distribute_molecule, molecule_ci_guard
- `devx.gitea_cli` — Tea CLI wrapper
- `devx.i18n` — i18n translation system
- `devx.config` — Shared configuration (DEVX_* env vars)
- `devx.api_clients` — GiteaClient, VikunjaClient
- `devx.exceptions` — APIError and other exceptions
**CI behavior based on classification:** **CI behavior based on classification:**
- **Molecule tests**: Only run when `ansible/` or `.ansible-lint` files change - **Molecule tests**: Only run when `ansible/` or `.ansible-lint` files change
@@ -269,80 +276,76 @@ types from accidentally skipping releases.
**AI agents must follow these rules:** **AI agents must follow these rules:**
- When working on workflow/CI/docs-only changes, use `ci:` or `docs:` commit prefixes - 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 - Do NOT bump the version or create tags for workflow-only changes
- The `classify_changes.py` script enforces this automatically — no manual intervention needed - The `classify_changes` module enforces this automatically — no manual intervention needed
- When adding a new CI script, place it in `scripts/ci/`. Dev tools go in `scripts/`.
## Script Separation and Import Rules ## Source Code Separation and devx Integration
The codebase enforces strict separation between the GRM tool and CI/dev scripts: The codebase enforces strict separation between the GRM tool and the devx package:
### Directory Layout ### Directory Layout
| Directory | Purpose | Release impact | | Directory | Purpose | Release impact |
|-----------|---------|----------------| |-----------|---------|----------------|
| `src/gitea_runner_manager/` | User-facing GRM CLI tool | Changes trigger release | | `src/gitea_runner_manager/` | User-facing GRM CLI tool | Changes trigger release |
| `scripts/` | Dev tools (run locally) | Workflow-only (no release) | | `devx` package (installed from git) | Reusable CI/CD and dev tools | Not in this repo (no release impact) |
| `scripts/ci/` | CI/CD automation (run by workflows) | Workflow-only (no release) |
| `ansible/` | Ansible role for runner setup | Changes trigger release | | `ansible/` | Ansible role for runner setup | Changes trigger release |
### Import Rules ### Import Rules
1. **`src/gitea_runner_manager/` NEVER imports from `scripts/`** — the tool is self-contained 1. **`src/gitea_runner_manager/` NEVER imports from devx** — the GRM tool is self-contained
2. **Scripts MAY import from `gitea_runner_manager`** — one-way dependency (scripts use the tool's API clients, config, i18n) 2. **devx MAY import from `gitea_runner_manager`** — one-way dependency (devx uses the tool's API clients, config, i18n)
3. **Cross-script imports** (scripts importing from other scripts) are allowed within `scripts/ci/` but must be documented 3. **Cross-module imports within devx** are allowed (devx modules importing from other devx modules) and must be documented
4. **`scripts/gitea_cli.py`** is a shared wrapper around the `tea` CLI — CI scripts import from it for Gitea API operations (issues, labels, PRs, releases, reviews) 4. **`devx.gitea_cli`** is a shared wrapper around the `tea` CLI — devx modules import from it for Gitea API operations (issues, labels, PRs, releases, reviews)
### tea CLI Integration ### tea CLI Integration
The `tea` Gitea CLI tool is used for Gitea API interactions in CI scripts. It is installed by `scripts/install_tools.py` and configured by `scripts/setup.py` (login profile from `.env` `REPO_TOKEN`). The `tea` Gitea CLI tool is used for Gitea API interactions in devx. It is installed by `devx.tools.install_tools` and configured by `devx.tools.setup` (login profile from `.env` `REPO_TOKEN`).
**`scripts/gitea_cli.py`** — Python wrapper around `tea` CLI with JSON output parsing: **`devx.gitea_cli`** — Python wrapper around `tea` CLI with JSON output parsing:
- `TeaCLI.create_issue()` — Create issues with labels - `TeaCLI.create_issue()` — Create issues with labels
- `TeaCLI.list_labels()` / `TeaCLI.create_label()` / `TeaCLI.add_label()` — Label management - `TeaCLI.list_labels()` / `TeaCLI.create_label()` / `TeaCLI.add_label()` — Label management
- `TeaCLI.create_pr()` / `TeaCLI.merge_pr()` / `TeaCLI.review_pr()` — Pull request operations - `TeaCLI.create_pr()` / `TeaCLI.merge_pr()` / `TeaCLI.review_pr()` — Pull request operations
- `TeaCLI.create_release()` / `TeaCLI.list_releases()` — Release management - `TeaCLI.create_release()` / `TeaCLI.list_releases()` — Release management
- `TeaCLI.list_branches()` — Branch listing - `TeaCLI.list_branches()` — Branch listing
**Scripts using tea (via `gitea_cli.py`):** **Modules using tea (via `devx.gitea_cli`):**
- `scripts/ci/publish.py` — Creates Gitea releases via `tea releases create` - `devx.ci.publish` — Creates Gitea releases via `tea releases create`
- `scripts/ci/notify_failure.py` — Creates issues via `tea issues create` (falls back to `GiteaClient` if tea not installed) - `devx.ci.notify_failure` — Creates issues via `tea issues create` (falls back to `GiteaClient` if tea not installed)
- `scripts/configure_repo.py` — Creates labels via `tea labels create` (falls back to `GiteaClient` if tea fails; branch protection still uses `GiteaClient` since tea only supports basic protect/unprotect) - `devx.tools.configure_repo` — Creates labels via `tea labels create` (falls back to `GiteaClient` if tea fails; branch protection still uses `GiteaClient` since tea only supports basic protect/unprotect)
**Operations still using `GiteaClient` (not supported by tea):** **Operations still using `GiteaClient` (not supported by tea):**
- PR reviews (`review_pr.py`) — tea v0.14.1 only supports interactive reviews - PR reviews (`devx.ci.review_pr`) — tea v0.14.1 only supports interactive reviews
- Wiki page management (`sync_wiki.py`) - Wiki page management (`devx.ci.sync_wiki`)
- Commit status checks (`auto_merge.py`) - Commit status checks (`devx.ci.auto_merge`)
- Runner discovery (`discover_runners.py`) - Runner discovery (`devx.molecule.discover_runners`)
- Branch protection with detailed config (`configure_repo.py`) - Branch protection with detailed config (`devx.tools.configure_repo`)
- PR file/commit listing (`pr_review.py`) - PR file/commit listing (`devx.ci.pr_review`)
### PYTHONPATH Configuration ### PYTHONPATH Configuration
Scripts have different import requirements. Workflows must set `PYTHONPATH` accordingly: Since devx is installed as a package (via `pip install` from git), it is importable directly. Workflows only need `PYTHONPATH=src` when a devx module imports from `gitea_runner_manager`:
| PYTHONPATH | When to use | Example scripts | | PYTHONPATH | When to use | Example modules |
|------------|-------------|-----------------| |------------|-------------|-----------------|
| `src` | Script imports from `gitea_runner_manager` | `auto_merge.py`, `pr_review.py`, `review_pr.py`, `sync_wiki.py`, `post_merge.py`, `classify_changes.py`, `discover_runners.py`, `doc_coverage.py` | | `src` | Module imports from `gitea_runner_manager` | `devx.ci.auto_merge`, `devx.ci.pr_review`, `devx.ci.review_pr`, `devx.ci.sync_wiki`, `devx.ci.post_merge`, `devx.ci.classify_changes`, `devx.molecule.discover_runners`, `devx.ci.doc_coverage` |
| `.:src` | Script imports from both `gitea_runner_manager` and `scripts.gitea_cli` | `publish.py`, `notify_failure.py`, `configure_repo.py` | | (none) | Module has no GRM imports | `devx.ci.detect_release_commit`, `devx.molecule.distribute_molecule`, `devx.molecule.molecule_ci_guard`, `devx.ci.push_badges`, `devx.ci.validate_commit_msg` |
| `.` | Script imports from other `scripts.ci.*` modules | `release.py` (imports `classify_changes.has_user_facing_changes`) |
| (none) | Script has no GRM or cross-script imports | `detect_release_commit.py`, `distribute_molecule.py`, `molecule_ci_guard.py`, `push_badges.py`, `validate_commit_msg.py` |
**In workflows**, always use `env:` blocks (not inline `PYTHONPATH=value`): **In workflows**, always use `env:` blocks (not inline `PYTHONPATH=value`):
```yaml ```yaml
- name: Run script - name: Run module
env: env:
PYTHONPATH: src PYTHONPATH: src
run: python3 scripts/ci/example.py run: python -m devx.ci.example
``` ```
**Locally**, the current directory is in `sys.path` by default, so `PYTHONPATH` is usually not needed. **Locally**, devx is installed as a package, so only `PYTHONPATH=src` is needed if importing from `gitea_runner_manager`.
### Shared Constants ### Shared Constants
`scripts/ci/platforms.py` is the single source of truth for the molecule `devx.molecule.platforms` is the single source of truth for the molecule
platform matrix. Both `scripts/ci/distribute_molecule.py` (CI) and platform matrix. Both `devx.molecule.distribute_molecule` (CI) and
`scripts/molecule_all.py` (dev tool) import `PLATFORMS` from it — this `devx.molecule.molecule_all` (dev tool) import `PLATFORMS` from it — this
avoids dev tools importing directly from CI scripts. avoids dev tools importing directly from CI modules.
2. **Publish workflow** (`.gitea/workflows/publish.yml`): 2. **Publish workflow** (`.gitea/workflows/publish.yml`):
- Triggers on tag push (`v*`) - Triggers on tag push (`v*`)
@@ -350,7 +353,7 @@ avoids dev tools importing directly from CI scripts.
- Builds the Python package - Builds the Python package
- Optionally publishes to PyPI (if `PYPI_TOKEN` is set) - Optionally publishes to PyPI (if `PYPI_TOKEN` is set)
- Creates a Gitea release with git-cliff-generated release notes - Creates a Gitea release with git-cliff-generated release notes
- On failure, creates a Gitea issue via `scripts/ci/notify_failure.py` - On failure, creates a Gitea issue via `devx.ci.notify_failure`
### git-cliff Commit Preprocessing ### git-cliff Commit Preprocessing
@@ -379,6 +382,15 @@ The version source is `__version__` in `src/gitea_runner_manager/__init__.py`, r
| PR title | `GRM-N: <vikunja task title>` | `GRM-33: Add mandatory PR review step` | | PR title | `GRM-N: <vikunja task title>` | `GRM-33: Add mandatory PR review step` |
| Merge commit | `GRM-N <conventional commit>` | `GRM-33 feat: add review script` | | Merge commit | `GRM-N <conventional commit>` | `GRM-33 feat: add review script` |
### Configuration
The devx package is configured via `DEVX_*` environment variables:
- `DEVX_TASK_PREFIX=GRM` — Prefix for Vikunja task identifiers
- `DEVX_VIKUNJA_PROJECT_ID=6` — Vikunja project ID for task tracking
- `DEVX_VERSION_FILE=src/gitea_runner_manager/__init__.py` — Path to the version source file
Change classification is config-driven via `[tool.devx.classify]` in `pyproject.toml`, which defines the workflow-only and user-facing path patterns.
## Key Conventions ## Key Conventions
- Python 3.12+ required (ruff/pyright target `py312`) - Python 3.12+ required (ruff/pyright target `py312`)
@@ -404,7 +416,7 @@ main.yml → systemd_check → user_setup → rootless_docker → install_runner
6 scenarios: `default`, `multi-instance`, `lifecycle`, `template-content`, `deregister`, `update` 6 scenarios: `default`, `multi-instance`, `lifecycle`, `template-content`, `deregister`, `update`
4 platforms: `ubuntu-2204`, `ubuntu-2404`, `debian-12`, `archlinux` 4 platforms: `ubuntu-2204`, `ubuntu-2404`, `debian-12`, `archlinux`
Platform list is defined in `scripts/ci/platforms.py` (single source of truth) Platform list is defined in `devx.molecule.platforms` (single source of truth)
## Known Issues ## Known Issues
@@ -438,14 +450,14 @@ docs/
### Wiki Sync ### Wiki Sync
- **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 merge to master**: `sync-wiki.yml` workflow runs `devx.ci.sync_wiki` 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 - **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`) - `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 - README.md is a lean entry point with links to the wiki — no detailed content
### Documentation Coverage ### Documentation Coverage
- `scripts/ci/doc_coverage.py` checks that all CLI commands, Python modules, and CI scripts are documented - `devx.ci.doc_coverage` checks that all CLI commands, Python modules, and CI scripts are documented
- Runs as a CI step in the quality job with `--fail-on-missing` (blocks CI if docs are missing) - Runs as a CI step in the quality job with `--fail-on-missing` (blocks CI if docs are missing)
- Enforced: 100% coverage for public CLI commands and major architectural components - Enforced: 100% coverage for public CLI commands and major architectural components
+29 -21
View File
@@ -1,4 +1,4 @@
.PHONY: all setup setup-ci setup-quality setup-molecule setup-release install update lint ansible-lint makefile-lint lint-all test test-unit pytest-cov molecule molecule-all test-all clean workflow-lint workflow-dryrun workflow-check install-tools .PHONY: all setup setup-ci setup-quality setup-molecule setup-release install-devx install update lint ansible-lint makefile-lint lint-all test test-unit pytest-cov molecule molecule-all test-all clean workflow-lint workflow-dryrun workflow-check install-tools
PYTHON := python3 PYTHON := python3
VENV := .venv VENV := .venv
@@ -7,32 +7,40 @@ CHECKMAKE := $(shell command -v checkmake 2>/dev/null || echo $(HOME)/go/bin/che
all: setup all: setup
install-devx: $(VENV)/bin/activate
@# REPO_TOKEN may come from .env (local) or environment (CI secrets)
@if [ -z "$$REPO_TOKEN" ]; then . .env 2>/dev/null; fi; \
if [ -z "$$REPO_TOKEN" ]; then echo "REPO_TOKEN not set (check .env or environment)"; exit 1; fi; \
$(BIN)/pip install "git+https://emil:$$REPO_TOKEN@git.oblachno.oblachno.fyi/oblachno-oss/devx.git@master"
# Full setup for local development (all deps, tools, collections, hooks) # Full setup for local development (all deps, tools, collections, hooks)
setup: $(VENV)/bin/activate .env activate-scripts checkmake install-tools # install-devx must run before install-tools (which uses devx modules)
setup: $(VENV)/bin/activate .env activate-scripts install-devx checkmake install-tools
@export PATH="$(HOME)/.local/bin:$$PATH"; \ @export PATH="$(HOME)/.local/bin:$$PATH"; \
$(PYTHON) scripts/setup.py --bin "$(BIN)" $(BIN)/python -m devx.tools.setup --bin "$(BIN)"
# Lean setup for CI jobs that need pytest + lint tools + runtime deps # Lean setup for CI jobs that need pytest + lint tools + runtime deps
# (detect-changes, discover-runners, pr-review, sync-wiki, badges) # (detect-changes, discover-runners, pr-review, sync-wiki, badges)
# badges job runs generate_badges.py which needs ruff, pyright, bandit # badges job runs generate_badges.py which needs ruff, pyright, bandit
setup-ci: $(VENV)/bin/activate .env setup-ci: $(VENV)/bin/activate .env install-devx
@$(PYTHON) scripts/setup.py --bin "$(BIN)" --extras "ci,lint" --no-ansible-collections --no-pre-commit --no-tea-login @$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,lint" --no-ansible-collections --no-pre-commit --no-tea-login
# Setup for the quality job (lint + test deps, actionlint tool) # Setup for the quality job (lint + test deps, actionlint tool)
setup-quality: $(VENV)/bin/activate .env install-tools # install-devx must run before install-tools (which uses devx modules)
setup-quality: $(VENV)/bin/activate .env install-devx install-tools
@export PATH="$(HOME)/.local/bin:$$PATH"; \ @export PATH="$(HOME)/.local/bin:$$PATH"; \
$(PYTHON) scripts/setup.py --bin "$(BIN)" --extras "ci,lint" --no-ansible-collections --no-pre-commit --no-tea-login $(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,lint" --no-ansible-collections --no-pre-commit --no-tea-login
# Full setup for molecule testing (needs ansible, molecule, collections) # Full setup for molecule testing (needs ansible, molecule, collections)
setup-molecule: $(VENV)/bin/activate .env install-tools setup-molecule: $(VENV)/bin/activate .env install-devx install-tools
@export PATH="$(HOME)/.local/bin:$$PATH"; \ @export PATH="$(HOME)/.local/bin:$$PATH"; \
$(PYTHON) scripts/setup.py --bin "$(BIN)" --extras "ci,molecule" --no-pre-commit --no-tea-login $(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,molecule" --no-pre-commit --no-tea-login
# Setup for release jobs (needs git-cliff, tea, and lint tools for release.py) # Setup for release jobs (needs git-cliff, tea, and lint tools for release.py)
setup-release: $(VENV)/bin/activate .env setup-release: $(VENV)/bin/activate .env install-devx
@$(PYTHON) scripts/install_tools.py --tool git-cliff --tool tea @$(BIN)/python -m devx.tools.install_tools --tool git-cliff --tool tea
@export PATH="$(HOME)/.local/bin:$$PATH"; \ @export PATH="$(HOME)/.local/bin:$$PATH"; \
$(PYTHON) scripts/setup.py --bin "$(BIN)" --extras "ci,lint" --no-ansible-collections --no-pre-commit $(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,lint" --no-ansible-collections --no-pre-commit
.env: .env:
@if [ ! -f .env ]; then \ @if [ ! -f .env ]; then \
@@ -55,11 +63,11 @@ install-hooks:
@cp hooks/pre-push .git/hooks/pre-push && chmod +x .git/hooks/pre-push @cp hooks/pre-push .git/hooks/pre-push && chmod +x .git/hooks/pre-push
@echo "Git hooks installed." @echo "Git hooks installed."
checkmake: checkmake: install-devx
@python3 scripts/install_checkmake.py @$(BIN)/python -m devx.tools.install_checkmake
install-tools: install-tools: install-devx
@$(PYTHON) scripts/install_tools.py @$(BIN)/python -m devx.tools.install_tools
install: install:
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make install HOST=192.168.1.10"; exit 1; fi @if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make install HOST=192.168.1.10"; exit 1; fi
@@ -94,10 +102,10 @@ remove:
$(BIN)/grm remove $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(TOKEN),--token $(TOKEN),) $(if $(ASK_BECOME_PASS),--ask-become-pass,) $(BIN)/grm remove $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(TOKEN),--token $(TOKEN),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
lint-ruff: lint-ruff:
$(BIN)/ruff check src/ tests/ scripts/ $(BIN)/ruff check src/ tests/
lint-format: lint-format:
$(BIN)/ruff format --check src/ tests/ scripts/ $(BIN)/ruff format --check src/ tests/
typecheck: typecheck:
$(BIN)/pyright $(BIN)/pyright
@@ -105,7 +113,7 @@ typecheck:
lint: lint-ruff lint-format typecheck lint-bandit lint: lint-ruff lint-format typecheck lint-bandit
lint-bandit: lint-bandit:
$(BIN)/bandit -r src/ scripts/ $(BIN)/bandit -r src/
lint-deps: lint-deps:
@echo "Checking dependencies for known vulnerabilities..." @echo "Checking dependencies for known vulnerabilities..."
@@ -143,7 +151,7 @@ test-integration:
$(BIN)/pytest tests/integration/ -v --no-cov $(BIN)/pytest tests/integration/ -v --no-cov
pytest-cov: pytest-cov:
$(BIN)/pytest tests/ -v --cov=src/gitea_runner_manager --cov=scripts --cov-report=term-missing --cov-fail-under=100 $(BIN)/pytest tests/ -v --cov=src/gitea_runner_manager --cov-report=term-missing --cov-fail-under=100
MOLECULE := $(realpath $(BIN))/molecule MOLECULE := $(realpath $(BIN))/molecule
MOLECULE_BASE := cd $(CURDIR)/ansible/roles/gitea-runner && ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true ANSIBLE_INJECT_INVOCATION=1 $(MOLECULE) MOLECULE_BASE := cd $(CURDIR)/ansible/roles/gitea-runner && ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true ANSIBLE_INJECT_INVOCATION=1 $(MOLECULE)
@@ -154,7 +162,7 @@ molecule:
# All scenarios on all supported platforms (sequential; use CI matrix for parallel execution) # All scenarios on all supported platforms (sequential; use CI matrix for parallel execution)
molecule-all: molecule-all:
@$(PYTHON) scripts/molecule_all.py --bin "$(BIN)" @$(BIN)/python -m devx.molecule.molecule_all --bin "$(BIN)"
test: test-all test: test-all
+2 -2
View File
@@ -9,8 +9,8 @@
| Vikunja task not updated after merge | VIKUNJA_TOKEN expired or task ID missing from commit | Regenerate token; verify merge commit has `GRM-N:` prefix | | Vikunja task not updated after merge | VIKUNJA_TOKEN expired or task ID missing from commit | Regenerate token; verify merge commit has `GRM-N:` prefix |
| Post-merge can't find Vikunja task | Task not in project 6 or identifier mismatch | Verify task exists in Vikunja project 6 with correct identifier | | Post-merge can't find Vikunja task | Task not in project 6 or identifier mismatch | Verify task exists in Vikunja project 6 with correct identifier |
| `make pytest-cov` fails | Coverage below 100% | Add tests for new code paths | | `make pytest-cov` fails | Coverage below 100% | Add tests for new code paths |
| `scripts/configure_repo.py` fails | REPO_TOKEN missing or invalid | Set token with repo admin scope and re-run | | `devx.tools.configure_repo` fails | REPO_TOKEN missing or invalid | Set token with repo admin scope and re-run |
| `configure_repo.py` sets wrong status checks | Stale `BRANCH_PROTECTION_CONFIG` | Updated to include `(pull_request)` suffix; re-run `configure_repo.py` | | `configure_repo` sets wrong status checks | Stale `BRANCH_PROTECTION_CONFIG` | Updated to include `(pull_request)` suffix; re-run `configure_repo` |
| Token visible in `ps aux` during install | Old version passed tokens via command line | Fixed: tokens now passed via temp file with `0600` permissions | | Token visible in `ps aux` during install | Old version passed tokens via command line | Fixed: tokens now passed via temp file with `0600` permissions |
| `remove-runner.yml` leaves lingering enabled | Old version didn't disable lingering | Fixed: now runs `loginctl disable-linger` and removes subuid/subgid | | `remove-runner.yml` leaves lingering enabled | Old version didn't disable lingering | Fixed: now runs `loginctl disable-linger` and removes subuid/subgid |
| apt cache update always reports `changed` | `cache_valid_time: 0` forced update every run | Fixed: changed to `cache_valid_time: 3600` | | apt cache update always reports `changed` | `cache_valid_time: 0` forced update every run | Fixed: changed to `cache_valid_time: 3600` |
+1 -1
View File
@@ -78,7 +78,7 @@ flowchart TD
From `AGENTS.md`, the project also includes: From `AGENTS.md`, the project also includes:
- **CI Scripts** (`scripts/`) — Automation for auto-merge, post-merge, release, publishing, molecule distribution, PR reviews, failure notifications - **devx package** (installed from git) — Reusable CI/CD tools: auto-merge, post-merge, release, publishing, molecule distribution, PR reviews, failure notifications
- **Versioning** (`cliff.toml`) — git-cliff configuration for automated semver versioning from conventional commits - **Versioning** (`cliff.toml`) — git-cliff configuration for automated semver versioning from conventional commits
## Python Modules ## Python Modules
+24 -24
View File
@@ -60,10 +60,10 @@ Review the full diff (`git diff master...HEAD`) focusing on:
- **User experience**: Clear error messages, intuitive CLI flags, helpful output - **User experience**: Clear error messages, intuitive CLI flags, helpful output
- **Documentation**: Completeness and relevance of docs, CHANGELOG entries, AGENTS.md updates - **Documentation**: Completeness and relevance of docs, CHANGELOG entries, AGENTS.md updates
Post review comments using `scripts/ci/review_pr.py`: Post review comments using `devx.ci.review_pr`:
```bash ```bash
REPO_TOKEN=<token> python3 scripts/ci/review_pr.py <pr_number> <owner/repo> \ REPO_TOKEN=<token> python -m devx.ci.review_pr <pr_number> <owner/repo> \
--event REQUEST_CHANGES \ --event REQUEST_CHANGES \
--body "Review summary" \ --body "Review summary" \
--comments-json comments.json --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: Once all comments are addressed:
```bash ```bash
REPO_TOKEN=<token> python3 scripts/ci/review_pr.py <pr_number> <owner/repo> \ REPO_TOKEN=<token> python -m devx.ci.review_pr <pr_number> <owner/repo> \
--event APPROVE \ --event APPROVE \
--body "All comments addressed. LGTM." --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: After the squash-merge:
- 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 **post-merge workflow** (`.gitea/workflows/post-merge.yml`) triggers on push to `master` and runs `devx.ci.post_merge` 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). - 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) ## Branch Protection (Required Gitea Settings)
@@ -131,8 +131,8 @@ The `quality` job in `.gitea/workflows/ci.yml` runs:
1. `make setup` — full environment setup 1. `make setup` — full environment setup
2. `make lint-all` — ruff + pyright + bandit + ansible-lint + checkmake 2. `make lint-all` — ruff + pyright + bandit + ansible-lint + checkmake
3. `make pytest-cov` — unit tests with 100% coverage enforcement 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 4. `python -m devx.tools.check_test_speed --max-seconds 10` — verify unit tests run fast
5. `PYTHONPATH=src python3 scripts/ci/release.py --dry-run` — release dry-run validation 5. `PYTHONPATH=src python -m devx.ci.release --dry-run` — release dry-run validation
## Automated Release Pipeline ## 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 - Sets up full dev environment (`make setup`) so lint and tests can run
- Installs git-cliff (version 2.13.0) - Installs git-cliff (version 2.13.0)
- Configures git as `grm-ci-bot` - Configures git as `grm-ci-bot`
- Runs `scripts/ci/release.py` which uses **git-cliff** to: - Runs `devx.ci.release` which uses **git-cliff** to:
- Calculate the next semver version from conventional commits since the last tag - 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 `__version__` in `src/gitea_runner_manager/__init__.py` (single source of truth)
- Update `CHANGELOG.md` with the new version section - 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 - Push both the commit and tag to master
- `--skip-tests` flag bypasses test verification (emergency use only, not recommended) - `--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 - 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/ci/notify_failure.py` - On failure, creates a Gitea issue via `devx.ci.notify_failure`
### Publish Workflow (`.gitea/workflows/publish.yml`) ### 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 - Builds the Python package
- Optionally publishes to PyPI (if `PYPI_TOKEN` is set) - Optionally publishes to PyPI (if `PYPI_TOKEN` is set)
- Creates a Gitea release with git-cliff-generated release notes - Creates a Gitea release with git-cliff-generated release notes
- Uses `scripts/ci/publish.py` for build and publish orchestration - Uses `devx.ci.publish` for build and publish orchestration
- On failure, creates a Gitea issue via `scripts/ci/notify_failure.py` - On failure, creates a Gitea issue via `devx.ci.notify_failure`
### Auto-Merge Workflow (`.gitea/workflows/auto-merge.yml`) ### Auto-Merge Workflow (`.gitea/workflows/auto-merge.yml`)
- Triggers on `pull_request` labeled events - Triggers on `pull_request` labeled events
- Runs `scripts/ci/auto_merge.py` with the branch name, PR title, repository, PR number, and label name - Runs `devx.ci.auto_merge` 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 - Validates PR title format, checks for APPROVE review, waits for CI, and squash-merges
### Post-Merge Workflow (`.gitea/workflows/post-merge.yml`) ### Post-Merge Workflow (`.gitea/workflows/post-merge.yml`)
- Triggers on push to `master` - Triggers on push to `master`
- Runs `scripts/ci/post_merge.py` with the latest commit message and commit SHA - Runs `devx.ci.post_merge` with the latest commit message and commit SHA
- Marks the corresponding Vikunja task as done - Marks the corresponding Vikunja task as done
### Smart CI: User-Facing vs Workflow-Only Changes ### Smart CI: User-Facing vs Workflow-Only Changes
Not all changes require the full CI pipeline or a new release. The project uses Not all changes require the full CI pipeline or a new release. The project uses
`scripts/ci/classify_changes.py` to classify changed files into two categories: `devx.ci.classify_changes` to classify changed files into two categories:
**User-facing paths** (tool changes → release needed): **User-facing paths** (tool changes → release needed):
- `src/gitea_runner_manager/**` — Python CLI source - `src/gitea_runner_manager/**` — Python CLI source
@@ -192,28 +192,28 @@ Not all changes require the full CI pipeline or a new release. The project uses
- `pyproject.toml` — Package metadata - `pyproject.toml` — Package metadata
**Workflow-only paths** (infrastructure → no release needed): **Workflow-only paths** (infrastructure → no release needed):
- `.gitea/workflows/**`, `scripts/**`, `docs/**`, `tests/**` - `.gitea/workflows/**`, `docs/**`, `tests/**`
- `AGENTS.md`, `README.md`, `CHANGELOG.md`, `Makefile`, `cliff.toml`, etc. - `AGENTS.md`, `README.md`, `CHANGELOG.md`, `Makefile`, `cliff.toml`, etc.
**CI behavior based on classification:** **CI behavior based on classification:**
- **Molecule tests**: Only run when `ansible/` or `.ansible-lint` files change - **Molecule tests**: Only run when `ansible/` or `.ansible-lint` files change
- **Release dry-run**: Only runs when user-facing files change (separate `release-dry-run` job) - **Release dry-run**: Only runs when user-facing files change (separate `release-dry-run` job)
- **Quality job** (lint, unit tests, coverage, doc-coverage): Always runs - **Quality job** (lint, unit tests, coverage, doc-coverage): Always runs
- **Release workflow**: `release.py` calls `classify_changes.py` to check if any - **Release workflow**: `release.py` calls `classify_changes` to check if any
user-facing files changed since the last tag. If not, the release is skipped user-facing files changed since the last tag. If not, the release is skipped
entirely — no version bump, no tag, no publish. entirely — no version bump, no tag, no publish.
### Dynamic Runner Discovery ### Dynamic Runner Discovery
Molecule tests are distributed across available Gitea Actions runners 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 dynamically. The `discover-runners` job runs `devx.molecule.discover_runners` which queries the Gitea API for
registered runners at three levels (repo, org, instance) and generates registered runners at three levels (repo, org, instance) and generates
a matrix of runner indices. If the API query fails (e.g., no admin a matrix of runner indices. If the API query fails (e.g., no admin
access for instance-level runners), it falls back to the access for instance-level runners), it falls back to the
`MOLECULE_RUNNERS` repo variable, then to a default of 3. `MOLECULE_RUNNERS` repo variable, then to a default of 3.
The `molecule-tests` job uses `fromJSON()` to consume the dynamic The `molecule-tests` job uses `fromJSON()` to consume the dynamic
matrix, and passes the runner count to `distribute_molecule.py matrix, and passes the runner count to `python -m devx.molecule.distribute_molecule
--max-runners` so test pairs are evenly distributed. --max-runners` so test pairs are evenly distributed.
When adding or removing Gitea runners: When adding or removing Gitea runners:
@@ -223,19 +223,19 @@ When adding or removing Gitea runners:
### Molecule Test Distribution ### Molecule Test Distribution
`scripts/ci/distribute_molecule.py` discovers all molecule scenarios `devx.molecule.distribute_molecule` discovers all molecule scenarios
under `ansible/roles/*/molecule/` and crosses them with the supported under `ansible/roles/*/molecule/` and crosses them with the supported
OS platform matrix (defined in `scripts/ci/platforms.py`), then splits OS platform matrix (defined in `devx.molecule.platforms`), then splits
the resulting test pairs evenly across the requested number of runners. the resulting test pairs evenly across the requested number of runners.
Each pair is encoded as `scenario|platform_name|platform_image|platform_command`. Each pair is encoded as `scenario|platform_name|platform_image|platform_command`.
`scripts/ci/molecule_ci_guard.py` runs the actual molecule test for a `devx.molecule.molecule_ci_guard` runs the actual molecule test for a
given test pair, with CI context (Gitea URL, token, run ID) for given test pair, with CI context (Gitea URL, token, run ID) for
reporting results back to the commit status API. reporting results back to the commit status API.
### Commit Message Validation ### Commit Message Validation
`scripts/ci/validate_commit_msg.py` validates that commit messages `devx.ci.validate_commit_msg` validates that commit messages
follow the conventional commit format (`feat:`, `fix:`, `docs:`, etc.). follow the conventional commit format (`feat:`, `fix:`, `docs:`, etc.).
It is used by the pre-commit hook to enforce conventional commits on It is used by the pre-commit hook to enforce conventional commits on
feature branches. feature branches.
@@ -243,7 +243,7 @@ feature branches.
### Release Commit Detection ### Release Commit Detection
The `detect-type` job in the post-merge workflow runs The `detect-type` job in the post-merge workflow runs
`scripts/ci/detect_release_commit.py` to check whether the latest commit `devx.ci.detect_release_commit` to check whether the latest commit
is a release commit (format: `release: vX.Y.Z`). When a release commit is a release commit (format: `release: vX.Y.Z`). When a release commit
is detected, all post-merge jobs (release, sync-wiki, badges, vikunja) is detected, all post-merge jobs (release, sync-wiki, badges, vikunja)
are skipped — the tag push triggers the publish workflow instead. are skipped — the tag push triggers the publish workflow instead.
@@ -251,9 +251,9 @@ are skipped — the tag push triggers the publish workflow instead.
### Badge Generation and Push ### Badge Generation and Push
The `badges` job in the post-merge workflow runs The `badges` job in the post-merge workflow runs
`scripts/ci/push_badges.py` which: `devx.ci.push_badges` which:
1. Fetches the latest master and hard-resets to it (picks up release commits) 1. Fetches the latest master and hard-resets to it (picks up release commits)
2. Generates quality badge SVG files via `scripts/generate_badges.py` 2. Generates quality badge SVG files via `devx.tools.generate_badges`
3. Creates an orphan `badges` branch 3. Creates an orphan `badges` branch
4. Copies SVG files to the branch root 4. Copies SVG files to the branch root
5. Force-pushes the branch to the remote 5. Force-pushes the branch to the remote
+2 -2
View File
@@ -55,9 +55,9 @@ Every change to master goes through this workflow. No exceptions.
3. **Implement** — write code, tests (100% coverage), update docs 3. **Implement** — write code, tests (100% coverage), update docs
4. **Commit** — conventional commits (no `GRM-N:` prefix on branch) 4. **Commit** — conventional commits (no `GRM-N:` prefix on branch)
5. **Push & create PR** — title: `GRM-N: <vikunja task title>`, body: summary + `Closes GRM-N` 5. **Push & create PR** — title: `GRM-N: <vikunja task title>`, body: summary + `Closes GRM-N`
6. **Review** — review the full diff focusing on: functional completeness, edge cases, technical excellence (architecture, SRP, deduplication, code smells, best practices, code quality, reusability, clean code, readability, maintainability, extensibility), performance, security, UX, documentation completeness/relevance. Post review comments via `scripts/review_pr.py`. 6. **Review** — review the full diff focusing on: functional completeness, edge cases, technical excellence (architecture, SRP, deduplication, code smells, best practices, code quality, reusability, clean code, readability, maintainability, extensibility), performance, security, UX, documentation completeness/relevance. Post review comments via `devx.ci.review_pr`.
7. **Address comments** — fix each comment, commit, push, re-review 7. **Address comments** — fix each comment, commit, push, re-review
8. **Approve** — post an `APPROVE` review via `scripts/review_pr.py` 8. **Approve** — post an `APPROVE` review via `devx.ci.review_pr`
9. **Add `ready-to-merge` label** — auto-merge workflow squash-merges with title `GRM-N <conventional commit message>`, post-merge workflow marks the Vikunja task as done, release workflow automatically versions and tags 9. **Add `ready-to-merge` label** — auto-merge workflow squash-merges with title `GRM-N <conventional commit message>`, post-merge workflow marks the Vikunja task as done, release workflow automatically versions and tags
### Branch Protection (Required Gitea Settings) ### Branch Protection (Required Gitea Settings)
+3 -3
View File
@@ -10,7 +10,7 @@ Key technical decisions for the GRM project, extracted from `CHANGELOG.md` and `
**Decision:** Use `dynamic = ["version"]` in `pyproject.toml` with setuptools `attr` to source the version from `__version__` in `src/gitea_runner_manager/__init__.py`. **Decision:** Use `dynamic = ["version"]` in `pyproject.toml` with setuptools `attr` to source the version from `__version__` in `src/gitea_runner_manager/__init__.py`.
**Rationale:** `__init__.py` is the single source of truth for the version. The release script (`scripts/release.py`) only updates `__init__.py` — there is no need to touch `pyproject.toml`. `grm --version` reports this version directly. This eliminates version duplication across files and ensures the runtime version always matches the tagged release. **Rationale:** `__init__.py` is the single source of truth for the version. The release script (`devx.ci.release`) only updates `__init__.py` — there is no need to touch `pyproject.toml`. `grm --version` reports this version directly. This eliminates version duplication across files and ensures the runtime version always matches the tagged release.
**Source:** `CHANGELOG.md` (Unreleased — Added), `AGENTS.md` (Version Bumping Rules) **Source:** `CHANGELOG.md` (Unreleased — Added), `AGENTS.md` (Version Bumping Rules)
@@ -34,7 +34,7 @@ Key technical decisions for the GRM project, extracted from `CHANGELOG.md` and `
**Decision:** Use conventional commits on feature branches and git-cliff (`cliff.toml`) to calculate the next semver version from commit history, generate the changelog, and automate releases. **Decision:** Use conventional commits on feature branches and git-cliff (`cliff.toml`) to calculate the next semver version from commit history, generate the changelog, and automate releases.
**Rationale:** `scripts/release.py` uses git-cliff to calculate the next version from conventional commits since the last tag. Merge commits on master have the format `GRM-N <conventional commit>`, so `cliff.toml` includes a `commit_preprocessors` entry that strips the `GRM-N ` prefix before parsing. Version bumping rules: `feat:` → minor, `fix:` → patch, `feat!:`/`BREAKING CHANGE` → minor (pre-1.0), `chore:`/`ci:`/`docs:` → no bump. This fully automates versioning and changelog generation. **Rationale:** `devx.ci.release` uses git-cliff to calculate the next version from conventional commits since the last tag. Merge commits on master have the format `GRM-N <conventional commit>`, so `cliff.toml` includes a `commit_preprocessors` entry that strips the `GRM-N ` prefix before parsing. Version bumping rules: `feat:` → minor, `fix:` → patch, `feat!:`/`BREAKING CHANGE` → minor (pre-1.0), `chore:`/`ci:`/`docs:` → no bump. This fully automates versioning and changelog generation.
**Source:** `CHANGELOG.md` (Unreleased — Added), `AGENTS.md` (Automated Release Pipeline, git-cliff Commit Preprocessing, Version Bumping Rules), `cliff.toml` **Source:** `CHANGELOG.md` (Unreleased — Added), `AGENTS.md` (Automated Release Pipeline, git-cliff Commit Preprocessing, Version Bumping Rules), `cliff.toml`
@@ -58,7 +58,7 @@ Key technical decisions for the GRM project, extracted from `CHANGELOG.md` and `
**Decision:** Require branch protection on `master` (require pull request, require approval review, require status checks, block force pushes) and use an auto-merge workflow that programmatically enforces the APPROVE review check. **Decision:** Require branch protection on `master` (require pull request, require approval review, require status checks, block force pushes) and use an auto-merge workflow that programmatically enforces the APPROVE review check.
**Rationale:** Branch protection is the primary gate — no direct pushes to master, at least 1 APPROVE review before merge, CI quality + molecule tests must pass, and no history rewriting. The auto-merge workflow (`scripts/auto_merge.py`) enforces the APPROVE review check programmatically as a defense-in-depth measure. When the `ready-to-merge` label is added, the workflow validates PR title format, checks for APPROVE review, waits for CI, and squash-merges with title `GRM-N <conventional commit message>`. The post-merge workflow then marks the Vikunja task as done. **Rationale:** Branch protection is the primary gate — no direct pushes to master, at least 1 APPROVE review before merge, CI quality + molecule tests must pass, and no history rewriting. The auto-merge workflow (`devx.ci.auto_merge`) enforces the APPROVE review check programmatically as a defense-in-depth measure. When the `ready-to-merge` label is added, the workflow validates PR title format, checks for APPROVE review, waits for CI, and squash-merges with title `GRM-N <conventional commit message>`. The post-merge workflow then marks the Vikunja task as done.
**Source:** `CHANGELOG.md` (Unreleased — Added: mandatory PR review step, auto_merge.py), `AGENTS.md` (Branch Protection, PR Workflow step 8) **Source:** `CHANGELOG.md` (Unreleased — Added: mandatory PR review step, auto_merge.py), `AGENTS.md` (Branch Protection, PR Workflow step 8)
+3 -3
View File
@@ -46,8 +46,8 @@ The `make setup` target (from the `Makefile`):
- Installs/updates `pip`, `setuptools`, and `wheel` - Installs/updates `pip`, `setuptools`, and `wheel`
- Creates `.env` from `.env.example` if not present - Creates `.env` from `.env.example` if not present
- Generates shell activation scripts (`activate.sh`, `activate.fish`, `activate.zsh`) - Generates shell activation scripts (`activate.sh`, `activate.fish`, `activate.zsh`)
- Installs `checkmake` via `scripts/install_checkmake.py` - Installs `checkmake` via `devx.tools.install_checkmake`
- Runs `scripts/setup.sh` to install dependencies and hooks - Runs `python -m devx.tools.setup` to install dependencies and hooks
### Developer Quick Start ### Developer Quick Start
@@ -101,7 +101,7 @@ Individual lint targets from the `Makefile`:
| `lint-ruff` | `ruff check src/ tests/` | | `lint-ruff` | `ruff check src/ tests/` |
| `lint-format` | `ruff format --check src/ tests/` | | `lint-format` | `ruff format --check src/ tests/` |
| `typecheck` | `pyright` | | `typecheck` | `pyright` |
| `lint-bandit` | `bandit -r src/ scripts/` | | `lint-bandit` | `bandit -r src/` |
| `ansible-lint` | `ansible-lint ansible/` | | `ansible-lint` | `ansible-lint ansible/` |
| `makefile-lint` | `checkmake Makefile` | | `makefile-lint` | `checkmake Makefile` |
| `lint` | ruff + format check + pyright + bandit | | `lint` | ruff + format check + pyright + bandit |
+2 -2
View File
@@ -37,13 +37,13 @@ All scenarios test idempotence (second run produces zero changes).
4 platforms are tested: `ubuntu-2204`, `ubuntu-2404`, `debian-12`, `archlinux`. 4 platforms are tested: `ubuntu-2204`, `ubuntu-2404`, `debian-12`, `archlinux`.
The platform list is defined in `scripts/distribute_molecule.py` (single source of truth). The platform list is defined in `devx.molecule.distribute_molecule` (single source of truth).
### CI Test Distribution ### CI Test Distribution
CI runs all 6 scenarios × 4 platforms (24 test pairs) distributed across 3 parallel runners. CI runs all 6 scenarios × 4 platforms (24 test pairs) distributed across 3 parallel runners.
From `.gitea/workflows/ci.yml`, the `molecule-tests` job uses a matrix of `runner-index: [0, 1, 2]` and calls `scripts/distribute_molecule.py --runner-index <index> --max-runners 3` to discover assigned test pairs, then runs `scripts/molecule_ci_guard.py` with those pairs. From `.gitea/workflows/ci.yml`, the `molecule-tests` job uses a matrix of `runner-index: [0, 1, 2]` and calls `python -m devx.molecule.distribute_molecule --runner-index <index> --max-runners 3` to discover assigned test pairs, then runs `python -m devx.molecule.molecule_ci_guard` with those pairs.
## Integration Tests ## Integration Tests
+2 -2
View File
@@ -44,8 +44,8 @@ The test checks two things:
| Vikunja task not updated after merge | VIKUNJA_TOKEN expired or task ID missing from commit | Regenerate token; verify merge commit has `GRM-N:` prefix | | Vikunja task not updated after merge | VIKUNJA_TOKEN expired or task ID missing from commit | Regenerate token; verify merge commit has `GRM-N:` prefix |
| Post-merge can't find Vikunja task | Task not in project 6 or identifier mismatch | Verify task exists in Vikunja project 6 with correct identifier | | Post-merge can't find Vikunja task | Task not in project 6 or identifier mismatch | Verify task exists in Vikunja project 6 with correct identifier |
| `make pytest-cov` fails | Coverage below 100% | Add tests for new code paths | | `make pytest-cov` fails | Coverage below 100% | Add tests for new code paths |
| `scripts/configure_repo.py` fails | REPO_TOKEN missing or invalid | Set token with repo admin scope and re-run | | `devx.tools.configure_repo` fails | REPO_TOKEN missing or invalid | Set token with repo admin scope and re-run |
| `configure_repo.py` sets wrong status checks | Stale `BRANCH_PROTECTION_CONFIG` | Updated to include `(pull_request)` suffix; re-run `configure_repo.py` | | `configure_repo` sets wrong status checks | Stale `BRANCH_PROTECTION_CONFIG` | Updated to include `(pull_request)` suffix; re-run `configure_repo` |
| Token visible in `ps aux` during install | Old version passed tokens via command line | Fixed: tokens now passed via temp file with `0600` permissions | | Token visible in `ps aux` during install | Old version passed tokens via command line | Fixed: tokens now passed via temp file with `0600` permissions |
| `remove-runner.yml` leaves lingering enabled | Old version didn't disable lingering | Fixed: now runs `loginctl disable-linger` and removes subuid/subgid | | `remove-runner.yml` leaves lingering enabled | Old version didn't disable lingering | Fixed: now runs `loginctl disable-linger` and removes subuid/subgid |
| apt cache update always reports `changed` | `cache_valid_time: 0` forced update every run | Fixed: changed to `cache_valid_time: 3600` | | apt cache update always reports `changed` | `cache_valid_time: 0` forced update every run | Fixed: changed to `cache_valid_time: 3600` |
+1 -1
View File
@@ -2,4 +2,4 @@
# pre-commit hook: fail if unit tests take longer than 10 seconds. # pre-commit hook: fail if unit tests take longer than 10 seconds.
# Aligned with CI timeout (ci.yml uses --max-seconds 10). # Aligned with CI timeout (ci.yml uses --max-seconds 10).
set -e set -e
python3 scripts/check_test_speed.py --max-seconds 10 python3 -m devx.tools.check_test_speed --max-seconds 10
+1 -1
View File
@@ -2,4 +2,4 @@
# pre-push hook: fail if unit tests take longer than 10 seconds. # pre-push hook: fail if unit tests take longer than 10 seconds.
# Aligned with CI timeout (ci.yml uses --max-seconds 10). # Aligned with CI timeout (ci.yml uses --max-seconds 10).
set -e set -e
python3 scripts/check_test_speed.py --max-seconds 10 python3 -m devx.tools.check_test_speed --max-seconds 10
+34 -3
View File
@@ -62,8 +62,8 @@ gitea_runner_manager = ["translations.json"]
[tool.pytest.ini_options] [tool.pytest.ini_options]
testpaths = ["tests"] testpaths = ["tests"]
pythonpath = ["src", "."] 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-report=term-missing --cov-fail-under=100"
markers = [ markers = [
"integration: marks tests as integration tests (not counted in coverage)", "integration: marks tests as integration tests (not counted in coverage)",
] ]
@@ -81,6 +81,37 @@ quote-style = "double"
indent-style = "space" indent-style = "space"
[tool.pyright] [tool.pyright]
include = ["src", "scripts"] include = ["src"]
pythonVersion = "3.12" pythonVersion = "3.12"
strict = ["src/gitea_runner_manager"] strict = ["src/gitea_runner_manager"]
# ---------------------------------------------------------------------------
# Change classification — determines which changes trigger a release
# ---------------------------------------------------------------------------
# The framework provides DEFAULT_INFRASTRUCTURE (CI workflows, tests, docs,
# lint config, etc.) that applies to any Python project. We only specify
# what's different about GRM.
[tool.devx.classify]
# use_defaults = true # (default) merge with DEFAULT_INFRASTRUCTURE
# Project-specific infrastructure paths (merged with defaults).
# GRM has no additional infrastructure paths beyond the defaults.
infrastructure = []
# Infrastructure overrides — files that would default to user-facing
# but are actually infrastructure:
# - __init__.py: only contains __version__ (set by release.py, not user code)
# - api_clients.py: used only by tests and legacy CI scripts (now in devx),
# not by the grm CLI tool itself
infrastructure_overrides = [
"src/gitea_runner_manager/__init__.py",
"src/gitea_runner_manager/api_clients.py",
]
# User-facing overrides — safety override for broad infrastructure patterns
user_facing_overrides = []
# Tag patterns — additional categories for CI conditional execution
# Orthogonal to release impact (user-facing vs infrastructure)
[tool.devx.classify.tags]
ansible = ["ansible/**", ".ansible-lint"]
View File
-90
View File
@@ -1,90 +0,0 @@
#!/usr/bin/env python3
"""Run unit tests and enforce a maximum execution-time budget.
Usage:
python3 scripts/check_test_speed.py [--max-seconds N]
"""
from __future__ import annotations
import re
import subprocess # nosec B404
import click
from scripts.i18n import _
DEFAULT_MAX_SECONDS = 2.0
TEST_COMMAND = ["make", "test-unit"]
_TIMING_RE = re.compile(r"(\d+) passed.* in ([0-9.]+)s")
def run_tests() -> tuple[str, str]:
"""Execute the unit-test suite and return (stdout, stderr)."""
result = subprocess.run( # nosec B603
TEST_COMMAND,
capture_output=True,
text=True,
check=False,
)
return result.stdout, result.stderr
def parse_duration(output: str) -> float:
"""Extract elapsed seconds from pytest summary line.
Raises:
click.ClickException: when the timing line cannot be found.
"""
for line in output.splitlines():
match = _TIMING_RE.search(line)
if match:
return float(match.group(2))
raise click.ClickException(_("Could not parse test execution time from output."))
def check_speed(duration: float, max_seconds: float) -> None:
"""Validate duration is within budget; raise on violation."""
if duration > max_seconds:
raise click.ClickException(
_(
"Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n"
" Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n"
" Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.",
duration=duration,
max=max_seconds,
)
)
def main(max_seconds: float) -> None:
"""Run tests, parse timing, and enforce the budget."""
stdout, stderr = run_tests()
combined = stdout + "\n" + stderr
click.echo(combined, err=False)
duration = parse_duration(combined)
check_speed(duration, max_seconds)
click.echo(
_(
"Unit tests passed in {duration:.2f}s (under {max}s limit).",
duration=duration,
max=max_seconds,
)
)
@click.command()
@click.option(
"--max-seconds",
type=float,
default=DEFAULT_MAX_SECONDS,
show_default=True,
help="Maximum allowed execution time in seconds.",
)
def cli(max_seconds: float) -> None:
main(max_seconds)
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
View File
-253
View File
@@ -1,253 +0,0 @@
#!/usr/bin/env python3
"""Auto-merge PR when all CI checks pass.
Runs as the final job in ci.yml. Reads the task ID from ``.taskid`` file
(falling back to branch name extraction for backwards compatibility),
validates the PR title, and squash-merges with a conventional commit
message prefixed by the task ID.
PR title format: ``GRM-N: <vikunja task title>``
Merge commit format: ``GRM-N: <conventional commit message>``
The conventional commit message is extracted from the PR commits.
This allows the PR title to be a human-friendly Vikunja task title
while the squashed commit follows conventional commits.
Usage:
REPO_TOKEN=<token> python3 scripts/ci/auto_merge.py <branch> <pr_title> <repo> <pr_number>
"""
import os
import re
import subprocess # nosec B404
from pathlib import Path
from typing import Any
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from gitea_runner_manager.api_clients import GiteaClient, VikunjaClient
from gitea_runner_manager.config import (
CONVENTIONAL_RE,
DEFAULT_PER_PAGE,
GITEA_API_URL,
TASK_ID_RE,
VIKUNJA_API_URL,
VIKUNJA_PROJECT_ID,
)
from gitea_runner_manager.exceptions import APIError
from scripts.i18n import _
TASKID_FILE = ".taskid"
PR_TITLE_RE = re.compile(r"^GRM-\d+:\s+.+")
load_dotenv(override=True)
def run_cmd(args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]:
"""Run a command and return the completed process."""
result = subprocess.run(args, capture_output=True, text=True, check=False) # nosec B603
if check and result.returncode != 0:
raise click.ClickException(
_(
"Command failed ({cmd}): {stderr}",
cmd=" ".join(args),
stderr=result.stderr.strip() or result.stdout.strip(),
)
)
return result
def read_taskid(branch: str) -> str:
"""Read task ID from .taskid file, falling back to branch name extraction.
The .taskid file is a simple text file containing just the task ID
(e.g., ``GRM-60``). If the file doesn't exist, extract from the
branch name as a backwards-compatibility fallback.
"""
path = Path(TASKID_FILE)
if path.exists():
task_id = path.read_text(encoding="utf-8").strip()
if task_id:
return task_id
# Fallback: extract from branch name
match = TASK_ID_RE.search(branch)
return match.group(0) if match else ""
def extract_task_id(branch: str) -> str:
"""Extract GRM-N task identifier from branch name (legacy fallback)."""
match = TASK_ID_RE.search(branch)
return match.group(0) if match else ""
def validate_pr_title(pr_title: str, task_id: str) -> None:
"""Raise ClickException if PR title does not follow the required format.
Expected: ``GRM-N: <vikunja task title>``
"""
if not PR_TITLE_RE.match(pr_title):
raise click.ClickException(
_(
"Oops! PR title must follow format 'GRM-N: <task title>'.\n"
" Expected: {task_id}: <task title>\n"
" Got: {pr_title}",
task_id=task_id,
pr_title=pr_title,
)
)
if not pr_title.startswith(f"{task_id}:"):
raise click.ClickException(
_(
"Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
task_id=task_id,
pr_title=pr_title,
)
)
def get_vikunja_task_title(task_id: str) -> str:
"""Fetch the Vikunja task title for the given GRM-N identifier.
Returns empty string if VIKUNJA_TOKEN is not set (local dev without token).
Raises ClickException if the token is set but the task is not found.
"""
token = os.environ.get("VIKUNJA_TOKEN", "")
if not token:
return ""
client = VikunjaClient(VIKUNJA_API_URL, token)
page = 1
while True:
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
if not tasks:
break
matches = [t for t in tasks if t.get("identifier") == task_id]
if matches:
return str(matches[0].get("title", ""))
if len(tasks) < DEFAULT_PER_PAGE:
break
page += 1
raise click.ClickException(
_(
"Could not find Vikunja task {task_id} in project {project_id}. "
"Every PR must have a corresponding Vikunja task.",
task_id=task_id,
project_id=VIKUNJA_PROJECT_ID,
)
)
def validate_pr_title_matches_vikunja(pr_title: str, task_id: str) -> None:
"""Validate that PR title matches the Vikunja task title.
Skips validation if VIKUNJA_TOKEN is not set (local dev).
Raises ClickException if the task is not found or the title doesn't match.
"""
vikunja_title = get_vikunja_task_title(task_id)
if not vikunja_title:
# VIKUNJA_TOKEN not set — skip validation (local dev)
click.echo(_("Warning: VIKUNJA_TOKEN not set, skipping title match validation."))
return
expected = f"{task_id}: {vikunja_title}"
if pr_title != expected:
raise click.ClickException(
_(
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
expected=expected,
pr_title=pr_title,
)
)
def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
"""Extract the conventional commit message from PR commits.
Iterates commits in reverse order (newest first) to find the first
message matching the conventional commit format. Falls back to the
newest commit message if none match.
"""
for commit in reversed(commits):
commit_info = commit.get("commit", {})
message = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
if CONVENTIONAL_RE.match(message):
return message
# Fallback: use the newest commit's first line
if commits:
commit_info = commits[-1].get("commit", {})
return str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
return ""
@click.command()
@click.argument("branch")
@click.argument("pr_title")
@click.argument("repo")
@click.argument("pr_number")
def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
token = os.environ.get("REPO_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
owner, repo_name = repo.split("/")
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
task_id = read_taskid(branch)
if not task_id:
raise click.ClickException(
_(
"Oops! No task ID found in .taskid file or branch name '{branch}'.",
branch=branch,
)
)
click.echo(_("Task ID: {task_id}", task_id=task_id))
validate_pr_title(pr_title, task_id)
validate_pr_title_matches_vikunja(pr_title, task_id)
# Build merge title: GRM-N: <conventional commit message>
commits = client.get_pr_commits(pr_number)
conv_msg = extract_conventional_msg(commits)
if not conv_msg:
raise click.ClickException(_("Could not extract conventional commit message from PR commits."))
merge_title = f"{task_id}: {conv_msg}"
try:
client.merge_pr(pr_number, merge_title)
except APIError as e:
if e.status == 405 and "behind" in e.message.lower():
# Head branch is behind master — pull master and rebase, then retry
click.echo(_("Head branch is behind master. Pulling and rebasing..."))
try:
run_cmd(["git", "fetch", "origin", "master"])
run_cmd(["git", "rebase", "origin/master"])
run_cmd(["git", "push", "--force-with-lease"])
click.echo(_("Rebased and pushed. Retrying merge..."))
client.merge_pr(pr_number, merge_title)
except (APIError, Exception) as retry_err:
raise click.ClickException(
_(
"Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
error=str(retry_err),
)
) from None
else:
raise click.ClickException(
_(
"Merge failed with HTTP {status}: {message}\n"
"Please check the PR is ready and you have merge rights.",
status=e.status,
message=e.message,
)
) from None
click.echo(
_(
"Nice! PR #{pr_number} squash-merged with title: {merge_title}",
pr_number=pr_number,
merge_title=merge_title,
)
)
if __name__ == "__main__": # pragma: no cover
main()
-198
View File
@@ -1,198 +0,0 @@
#!/usr/bin/env python3
"""Check translation files for gaps, dead keys, and missing languages.
Validates two separate translation sets:
1. GRM tool translations: ``src/gitea_runner_manager/translations.json``
— keys used by ``src/gitea_runner_manager/*.py``
2. CI/dev tool translations: ``scripts/translations.json``
— keys used by ``scripts/**/*.py``
Checks performed (all fail with exit code 1 on error):
- **Missing keys**: a ``_()`` call in code has no entry in the corresponding
translations file.
- **Dead keys**: a key in a translations file is not used in any code.
- **Missing languages**: a key exists but is missing one of the 5 supported
languages (en, bg, de, ru, zh). Reported as a warning, not an error.
Usage::
python3 scripts/ci/check_translations.py
python3 scripts/ci/check_translations.py --strict # warnings are errors
"""
from __future__ import annotations
import ast
import json
import sys
from dataclasses import dataclass, field
from pathlib import Path
import click
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
SUPPORTED_LANGS = ("en", "bg", "de", "ru", "zh")
GRM_SRC_DIR = REPO_ROOT / "src" / "gitea_runner_manager"
GRM_TRANS_FILE = GRM_SRC_DIR / "translations.json"
CI_SRC_DIR = REPO_ROOT / "scripts"
CI_TRANS_FILE = CI_SRC_DIR / "translations.json"
# Functions that wrap _() and receive a translation key as first arg.
# Their string-literal arguments should be treated as translation keys.
_I18N_WRAPPERS = {"_handle_errors"}
# Known dynamic keys used via _(variable) that can't be detected by AST.
# These are status strings set as variable values and passed to _().
DYNAMIC_KEYS = {"completed", "pending", "in_progress", "failed", "active", "inactive", "unknown"}
@dataclass
class TranslationCheckResult:
"""Result of a translation check for one translation set."""
name: str
src_dir: Path
trans_file: Path
used_keys: set[str] = field(default_factory=set)
defined_keys: set[str] = field(default_factory=set)
missing_keys: set[str] = field(default_factory=set)
dead_keys: set[str] = field(default_factory=set)
missing_langs: dict[str, list[str]] = field(default_factory=dict)
errors: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
def extract_keys(filepath: Path) -> set[str]:
"""Extract translation keys from a Python file using AST.
Detects:
- ``_("key")`` calls with string-literal first argument
- ``_handle_errors("key")`` and other wrapper calls (see ``_I18N_WRAPPERS``)
"""
try:
tree = ast.parse(filepath.read_text(encoding="utf-8"), filename=str(filepath))
except SyntaxError:
return set()
keys: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Call):
func = node.func
if (
isinstance(func, ast.Name)
and func.id in ("_", *_I18N_WRAPPERS)
and node.args
and isinstance(node.args[0], ast.Constant)
and isinstance(node.args[0].value, str)
):
keys.add(node.args[0].value)
return keys
def collect_keys(src_dir: Path) -> set[str]:
"""Collect all translation keys from .py files in a directory tree.
Also includes known dynamic keys (see ``DYNAMIC_KEYS``) that are used
via ``_(variable)`` and can't be detected by AST scanning.
"""
keys: set[str] = set()
for pyfile in src_dir.rglob("*.py"):
if pyfile.name == "i18n.py":
continue
keys |= extract_keys(pyfile)
# Add dynamic keys for the GRM source directory
if src_dir == GRM_SRC_DIR:
keys |= DYNAMIC_KEYS
return keys
def check_translation_set(name: str, src_dir: Path, trans_file: Path) -> TranslationCheckResult:
"""Check one translation set for gaps and dead keys."""
result = TranslationCheckResult(name=name, src_dir=src_dir, trans_file=trans_file)
# Collect used keys from source code
result.used_keys = collect_keys(src_dir)
# Load defined keys from translations file
if not trans_file.exists():
result.errors.append(f"Translations file not found: {trans_file}")
return result
translations = json.loads(trans_file.read_text(encoding="utf-8"))
result.defined_keys = set(translations.keys())
# Check for missing keys (used in code but not in translations)
result.missing_keys = result.used_keys - result.defined_keys
for key in sorted(result.missing_keys):
result.errors.append(f"Missing key in {name}: {key!r}")
# Check for dead keys (in translations but not used in code)
result.dead_keys = result.defined_keys - result.used_keys
for key in sorted(result.dead_keys):
result.warnings.append(f"Dead key in {name}: {key!r}")
# Check for missing languages
for key, langs in translations.items():
missing = [lang for lang in SUPPORTED_LANGS if lang not in langs]
if missing:
result.missing_langs[key] = missing
result.warnings.append(f"Missing languages {missing} for key {key!r} in {name}")
return result
def print_result(result: TranslationCheckResult) -> None:
"""Print check results in a human-readable format."""
click.echo(f"\n=== {result.name} ===")
click.echo(f" Source dir: {result.src_dir}")
click.echo(f" Translations: {result.trans_file}")
click.echo(f" Used keys: {len(result.used_keys)}")
click.echo(f" Defined keys: {len(result.defined_keys)}")
click.echo(f" Missing keys: {len(result.missing_keys)}")
click.echo(f" Dead keys: {len(result.dead_keys)}")
click.echo(f" Missing langs: {len(result.missing_langs)} keys")
for err in result.errors:
click.echo(f" ERROR: {err}", err=True)
for warn in result.warnings:
click.echo(f" WARN: {warn}", err=True)
if not result.errors and not result.warnings:
click.echo(" All good!")
@click.command()
@click.option("--strict", is_flag=True, default=False, help="Treat warnings as errors.")
def main(strict: bool) -> None:
"""Check translation files for gaps, dead keys, and missing languages."""
results = [
check_translation_set("GRM tool", GRM_SRC_DIR, GRM_TRANS_FILE),
check_translation_set("CI/dev tools", CI_SRC_DIR, CI_TRANS_FILE),
]
has_errors = False
has_warnings = False
for result in results:
print_result(result)
if result.errors:
has_errors = True
if result.warnings:
has_warnings = True
click.echo()
if has_errors:
click.echo("FAIL: Translation check found errors.", err=True)
sys.exit(1)
if strict and has_warnings:
click.echo("FAIL: Translation check found warnings (--strict mode).", err=True)
sys.exit(1)
if has_warnings:
click.echo("PASS with warnings: Translation check passed (warnings present).")
else:
click.echo("PASS: All translations are complete and up to date.")
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
-295
View File
@@ -1,295 +0,0 @@
#!/usr/bin/env python3
"""Classify git changes as user-facing or workflow-only.
Determines whether changes between two git refs (e.g., last tag and HEAD)
affect the GRM tool itself (user-facing) or only the CI/CD infrastructure
(workflow-only). This is used by:
- **release.py** — skips release when only workflow files changed
- **CI workflow** — skips molecule tests and release dry-run when only
workflow files changed
Classification strategy (safe-by-default):
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/** — All scripts (CI/CD, dev tools, setup)
- src/gitea_runner_manager/__init__.py — Version file (release artifact)
- src/gitea_runner_manager/api_clients.py — Gitea API client (CI/CD only, not used by CLI)
- docs/** — Documentation
- tests/** — Test files
- hooks/** — Git hooks
- AGENTS.md — Agent conventions
- README.md — README (lean, links to wiki)
- CHANGELOG.md — Changelog (generated)
- TROUBLESHOOTING.md — Troubleshooting guide
- cliff.toml — git-cliff config
- Makefile — Build automation
- .pre-commit-config.yaml — Pre-commit config
- .ansible-lint — Ansible lint config
- .env.example — Environment template
- .gitignore — Git ignore rules
- .ruff.toml — Ruff config (if separate)
- .github/** — GitHub config (if present)
Everything else is user-facing (tool changes → release needed),
including but not limited to:
- src/gitea_runner_manager/*.py — Python CLI source (except __init__.py)
- ansible/** — Ansible role
- pyproject.toml — Package metadata
- Any new file type not in the allowlist
Usage:
python3 scripts/ci/classify_changes.py [--base <ref>] [--head <ref>]
python3 scripts/ci/classify_changes.py --base v0.3.0 --head HEAD
"""
from __future__ import annotations
import subprocess # nosec B404
import sys
import click
from scripts.i18n import _
# Explicit allowlist of workflow-only path patterns.
# Anything NOT matching these is treated as user-facing (safe default).
WORKFLOW_ONLY_PATTERNS = frozenset(
[
# CI/CD infrastructure
".gitea/",
# All scripts are infrastructure (CI/CD, dev tools, setup)
# User-facing code lives in src/gitea_runner_manager/
"scripts/",
# Version file — only contains __version__, not user-facing code.
# Version bumps are a release artifact, not a feature.
"src/gitea_runner_manager/__init__.py",
# Gitea API client — used only by CI/CD scripts, not by the GRM CLI.
"src/gitea_runner_manager/api_clients.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/",
]
)
def run_git(args: list[str]) -> str:
"""Run a git command and return stdout."""
result = subprocess.run( # nosec B603
args,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise click.ClickException(
_("git command failed ({cmd}): {stderr}", cmd=" ".join(args), stderr=result.stderr.strip())
)
return result.stdout.strip()
def get_changed_files(base: str, head: str) -> list[str]:
"""Get list of files changed between base and head refs."""
output = run_git(["git", "diff", "--name-only", base, head])
if not output:
return []
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).
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]]:
"""Classify changed files into user-facing and workflow-only.
Returns a dict with keys "user_facing" and "workflow_only".
"""
user_facing: list[str] = []
workflow_only: list[str] = []
for f in files:
if is_user_facing(f):
user_facing.append(f)
else:
workflow_only.append(f)
return {"user_facing": user_facing, "workflow_only": workflow_only}
def has_user_facing_changes(base: str, head: str) -> bool:
"""Check if any user-facing files changed between base and head.
Imported by ``scripts/ci/release.py`` to decide whether a release
is needed. This is a cross-CI import that requires ``PYTHONPATH=.``.
"""
files = get_changed_files(base, head)
return any(is_user_facing(f) for f in files)
def get_latest_tag() -> str:
"""Get the latest git tag, or empty string if none exists."""
result = subprocess.run( # nosec B603 B607
["git", "describe", "--tags", "--abbrev=0"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return ""
return result.stdout.strip()
def _write_github_output(key: str, value: str) -> None:
"""Append a key=value line to the $GITHUB_OUTPUT file."""
import os
gh_output = os.environ.get("GITHUB_OUTPUT")
if not gh_output:
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
with open(gh_output, "a") as f: # noqa: PTH123
f.write(f"{key}={value}\n")
@click.command()
@click.option("--base", default=None, help="Base ref (default: latest tag).")
@click.option("--head", default="HEAD", help="Head ref (default: HEAD).")
@click.option("--quiet", is_flag=True, default=False, help="Only output true/false.")
@click.option(
"--check",
type=click.Choice(["all", "ansible", "user-facing"]),
default="all",
help="Check specific category: all (default), ansible, or user-facing.",
)
@click.option(
"--github-output",
"github_output",
is_flag=True,
default=False,
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps).",
)
def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool) -> None:
if base is None:
base = get_latest_tag()
if not base:
if github_output:
_write_github_output("ansible-changed", "true")
_write_github_output("user-facing-changed", "true")
click.echo("No tags found — treating all changes as user-facing.")
return
if quiet:
click.echo("true")
else:
click.echo(_("No tags found — treating all changes as user-facing."))
return
files = get_changed_files(base, head)
if not files:
if github_output:
_write_github_output("ansible-changed", "false")
_write_github_output("user-facing-changed", "false")
click.echo(f"No changes between {base} and {head}.")
return
if quiet:
click.echo("false")
else:
click.echo(_("No changes between {base} and {head}.", base=base, head=head))
return
if github_output:
ansible_files = [f for f in files if f.startswith("ansible/") or f == ".ansible-lint"]
user_files = [f for f in files if is_user_facing(f)]
_write_github_output("ansible-changed", "true" if ansible_files else "false")
_write_github_output("user-facing-changed", "true" if user_files else "false")
click.echo(f"Ansible files changed: {bool(ansible_files)}")
click.echo(f"User-facing files changed: {bool(user_files)}")
return
if check == "ansible":
# Check only for Ansible-related file changes
ansible_files = [f for f in files if f.startswith("ansible/") or f == ".ansible-lint"]
has_ansible = bool(ansible_files)
if quiet:
click.echo("true" if has_ansible else "false")
return
click.echo(_("\nAnsible files changed ({count}):", count=len(ansible_files)))
for f in ansible_files:
click.echo(f" {f}")
click.echo(_("\nResult: {status}", status="Ansible changes detected" if has_ansible else "No Ansible changes"))
return
if check == "user-facing":
# Check only for user-facing file changes (inverse of workflow-only)
user_files = [f for f in files if is_user_facing(f)]
has_user = bool(user_files)
if quiet:
click.echo("true" if has_user else "false")
return
click.echo(_("\nUser-facing files changed ({count}):", count=len(user_files)))
for f in user_files:
click.echo(f" {f}")
click.echo(
_("\nResult: {status}", status="User-facing changes detected" if has_user else "No user-facing changes")
)
return
result = classify_changes(files)
has_user = bool(result["user_facing"])
if quiet:
click.echo("true" if has_user else "false")
return
click.echo(_("Comparing {base}..{head} ({count} files changed)", base=base, head=head, count=len(files)))
click.echo(_("\nUser-facing changes ({count}):", count=len(result["user_facing"])))
for f in result["user_facing"]:
click.echo(f" {f}")
click.echo(_("\nWorkflow-only changes ({count}):", count=len(result["workflow_only"])))
for f in result["workflow_only"]:
click.echo(f" {f}")
if has_user:
status = "USER-FACING changes detected — release needed"
else:
status = "Workflow-only changes — no release needed"
click.echo(_("\nResult: {status}", status=status))
if not has_user:
sys.exit(2) # Exit code 2 = workflow-only (used by CI to skip release)
if __name__ == "__main__": # pragma: no cover
main()
-65
View File
@@ -1,65 +0,0 @@
#!/usr/bin/env python3
"""Detect whether the latest git commit is a release commit.
Release commits have the format ``release: vX.Y.Z [skip ci]``.
This script writes ``is-release=true`` or ``is-release=false`` to
``$GITHUB_OUTPUT`` for use in CI workflow conditionals.
Usage::
python3 scripts/ci/detect_release_commit.py
"""
from __future__ import annotations
import os
import re
import subprocess # nosec B404
import click
RELEASE_RE = re.compile(r"^release: v\d+\.\d+\.\d+")
def get_commit_message() -> str:
"""Get the subject of the latest git commit."""
result = subprocess.run( # nosec B603 B607
["git", "log", "-1", "--pretty=%s"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise click.ClickException(f"git log failed: {result.stderr.strip()}")
return result.stdout.strip()
def is_release_commit(message: str) -> bool:
"""Check if a commit message matches the release commit format."""
return bool(RELEASE_RE.match(message))
def write_github_output(key: str, value: str) -> None:
"""Append a key=value line to the $GITHUB_OUTPUT file."""
gh_output = os.environ.get("GITHUB_OUTPUT")
if not gh_output:
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
with open(gh_output, "a") as f: # noqa: PTH123
f.write(f"{key}={value}\n")
@click.command()
def main() -> None:
"""Detect if the latest commit is a release commit and set GITHUB_OUTPUT."""
msg = get_commit_message()
click.echo(f"Commit message: {msg}")
is_release = is_release_commit(msg)
write_github_output("is-release", "true" if is_release else "false")
if is_release:
click.echo("Release commit — skipping all post-merge jobs.")
else:
click.echo("Regular merge commit — running all post-merge jobs.")
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
-180
View File
@@ -1,180 +0,0 @@
#!/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[str]:
"""Generate a list of runner indices ["1", "2", ..., "N"].
Uses 1-based string indices because Gitea Actions renders
integer 0 and string "0" as empty in ${{ matrix.runner-index }}
expressions, causing --runner-index to be passed without a value.
The distribute_molecule.py script converts these back to 0-based
internally.
"""
return [str(i + 1) for i in 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.")
@click.option(
"--github-output",
"github_output",
is_flag=True,
default=False,
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps).",
)
def main(
owner: str | None,
repo: str | None,
output_count: bool,
output_indices: bool,
github_output: 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 github_output:
gh_output = os.environ.get("GITHUB_OUTPUT")
if not gh_output:
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
with open(gh_output, "a") as f: # noqa: PTH123
f.write(f"runner-count={count}\n")
f.write(f"runner-indices={json.dumps(indices)}\n")
click.echo(f"Runner count: {count}")
click.echo(f"Runner indices: {indices}")
return
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()
-192
View File
@@ -1,192 +0,0 @@
#!/usr/bin/env python3
"""Distribute molecule (scenario, platform) pairs across N parallel runners.
Discovers all molecule scenarios under ansible/roles/*/molecule/ and
crosses them with the supported OS platform matrix, then splits the
resulting test pairs evenly across the requested number of runners.
Each pair is printed as ``scenario|platform_name|platform_image|platform_command``
so the CI workflow can set the appropriate environment variables.
Usage:
python3 scripts/distribute_molecule.py --runner-index 1 --max-runners 3
# prints: default|ubuntu-2204|ubuntu:22.04| lifecycle|ubuntu-2204|ubuntu:22.04| ...
python3 scripts/distribute_molecule.py --list
# prints all scenarios, one per line
python3 scripts/distribute_molecule.py --list-platforms
# prints all platforms, one per line
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
import click
from scripts.ci.platforms import PLATFORMS
from scripts.i18n import _
DEFAULT_MAX_RUNNERS = 3
MOLECULE_ROOT = Path("ansible/roles/gitea-runner/molecule")
@dataclass(frozen=True)
class TestPair:
"""A (scenario, platform) combination to test."""
scenario: str
platform: dict[str, str]
def encode(self) -> str:
"""Serialize to a pipe-delimited string for CI consumption."""
return f"{self.scenario}|{self.platform['name']}|{self.platform['image']}|{self.platform['command']}"
@staticmethod
def decode(encoded: str) -> TestPair:
"""Deserialize from a pipe-delimited string."""
parts = encoded.split("|")
return TestPair(
scenario=parts[0],
platform={"name": parts[1], "image": parts[2], "command": parts[3]},
)
def discover_scenarios(root: Path | None = None) -> list[str]:
"""Return sorted list of molecule scenario directory names."""
if root is None:
root = MOLECULE_ROOT
if not root.is_dir():
raise click.ClickException(_("Molecule directory not found: {path}", path=str(root)))
scenarios = [d.name for d in root.iterdir() if d.is_dir() and not d.name.startswith("_") and d.name != "common"]
return sorted(scenarios)
def build_pairs(scenarios: list[str], platforms: list[dict[str, str]] | None = None) -> list[TestPair]:
"""Build the full cross-product of scenarios and platforms."""
if platforms is None:
platforms = PLATFORMS
return [TestPair(s, p) for s in scenarios for p in platforms]
def distribute(pairs: list[TestPair], max_runners: int) -> list[list[TestPair]]:
"""Split *pairs* into *max_runners* balanced groups (round-robin)."""
groups: list[list[TestPair]] = [[] for _ in range(max_runners)]
for i, pair in enumerate(pairs):
groups[i % max_runners].append(pair)
return groups
def pairs_for_runner(pairs: list[TestPair], runner_index: int, max_runners: int) -> list[TestPair]:
"""Return the subset of pairs assigned to *runner_index*."""
groups = distribute(pairs, max_runners)
if runner_index < 0 or runner_index >= len(groups):
raise click.ClickException(
_(
"Runner index {index} out of range (0..{max})",
index=runner_index,
max=max_runners - 1,
)
)
return groups[runner_index]
def _write_github_env(key: str, value: str) -> None:
"""Append a key=value line to the $GITHUB_ENV file."""
import os
gh_env = os.environ.get("GITHUB_ENV")
if not gh_env:
raise click.ClickException("GITHUB_ENV environment variable is not set")
with open(gh_env, "a") as f: # noqa: PTH123
f.write(f"{key}={value}\n")
@click.command()
@click.option(
"--runner-index",
type=int,
default=None,
help="One-based runner index (Gitea Actions renders 0 as empty). "
"Converted to zero-based internally. If omitted, prints all groups.",
)
@click.option(
"--max-runners",
type=int,
default=DEFAULT_MAX_RUNNERS,
show_default=True,
help="Total number of parallel runners.",
)
@click.option(
"--list",
"list_all",
is_flag=True,
help="List all discovered scenarios, one per line.",
)
@click.option(
"--list-platforms",
"list_platforms",
is_flag=True,
help="List all supported platforms, one per line.",
)
@click.option(
"--github-env",
"github_env",
is_flag=True,
default=False,
help="Write TEST_PAIRS and SKIP to $GITHUB_ENV (for CI workflow steps).",
)
@click.option(
"--skip-if-excess",
is_flag=True,
default=False,
help="With --github-env: write SKIP=true when runner-index exceeds max-runners.",
)
def cli(
runner_index: int | None,
max_runners: int,
list_all: bool,
list_platforms: bool,
github_env: bool,
skip_if_excess: bool,
) -> None:
scenarios = discover_scenarios()
if list_all:
for s in scenarios:
click.echo(s)
return
if list_platforms:
for p in PLATFORMS:
click.echo(f"{p['name']}|{p['image']}|{p['command']}")
return
pairs = build_pairs(scenarios)
if runner_index is None:
groups = distribute(pairs, max_runners)
for i, group in enumerate(groups):
labels = " ".join(p.encode() for p in group) if group else "(none)"
click.echo(f"Runner {i}: {labels}")
return
# Skip if runner index exceeds available runners (CI static matrix has 3 slots)
if skip_if_excess and github_env and runner_index > max_runners:
click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}")
_write_github_env("TEST_PAIRS", "")
_write_github_env("SKIP", "true")
return
# Convert 1-based CLI index to 0-based internal index
zero_based = runner_index - 1
assigned = pairs_for_runner(pairs, zero_based, max_runners)
encoded = " ".join(p.encode() for p in assigned)
if github_env:
_write_github_env("TEST_PAIRS", encoded)
_write_github_env("SKIP", "false")
click.echo(f"Assigned pairs: {encoded}")
return
click.echo(encoded)
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
-171
View File
@@ -1,171 +0,0 @@
#!/usr/bin/env python3
"""Check documentation coverage for CLI commands and major modules.
Parses Click commands from the CLI source code and checks if each command
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/ci/doc_coverage.py [--docs-dir docs/] [--fail-on-missing]
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
import click
from scripts.i18n import _
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 = [
"cli.py",
"runner_manager.py",
"executor.py",
"registry.py",
"i18n.py",
"exceptions.py",
"api_clients.py",
"config.py",
]
# CI scripts that should be documented in tech/ci-cd-workflow.md
REQUIRED_SCRIPTS = [
"auto_merge.py",
"release.py",
"publish.py",
"review_pr.py",
"notify_failure.py",
"post_merge.py",
"classify_changes.py",
"discover_runners.py",
"detect_release_commit.py",
"push_badges.py",
"distribute_molecule.py",
"molecule_ci_guard.py",
"validate_commit_msg.py",
]
def extract_cli_commands() -> list[str]:
"""Extract command names from the CLI source file."""
content = CLI_FILE.read_text()
commands: list[str] = []
# Find all @cli.command(...) occurrences, then the next def statement
for match in re.finditer(r"@cli\.command\b", content):
# Check for explicit name="..." in the decorator arguments
decorator_end = content.find(")", match.start())
decorator_text = content[match.start() : decorator_end + 1]
name_match = re.search(r'name\s*=\s*"([^"]+)"', decorator_text)
if name_match:
commands.append(name_match.group(1))
continue
# Find the next def statement after this decorator
after = content[decorator_end:]
def_match = re.search(r"def\s+(\w+)\s*\(", after)
if def_match:
commands.append(def_match.group(1))
return commands
def check_command_documented(command: str, docs_content: str) -> bool:
"""Check if a CLI command is documented in the docs content."""
# Look for the command name as a heading or in code blocks
patterns = [
rf"##.*\b{re.escape(command)}\b",
rf"`grm\s+{re.escape(command)}\b",
rf"\bgrm\s+{re.escape(command)}\b",
rf"###.*\b{re.escape(command)}\b",
]
return any(re.search(p, docs_content, re.IGNORECASE) for p in patterns)
def check_module_documented(module: str, docs_content: str) -> bool:
"""Check if a module is mentioned in the docs content."""
return module in docs_content
@click.command()
@click.option("--docs-dir", default=str(DOCS_DIR), help="Path to the docs directory.")
@click.option(
"--fail-on-missing",
is_flag=True,
default=False,
help="Exit with non-zero status if any documentation is missing.",
)
def main(docs_dir: str, fail_on_missing: bool) -> None:
docs_path = Path(docs_dir)
cli_commands_file = docs_path / "user" / "cli-commands.md"
architecture_file = docs_path / "tech" / "architecture.md"
ci_cd_file = docs_path / "tech" / "ci-cd-workflow.md"
missing: list[str] = []
total = 0
# Check CLI commands
click.echo(_("Checking CLI command documentation..."))
commands = extract_cli_commands()
total += len(commands)
cli_docs = cli_commands_file.read_text() if cli_commands_file.exists() else ""
for cmd in commands:
if check_command_documented(cmd, cli_docs):
click.echo(_(" OK: grm {cmd}", cmd=cmd))
else:
click.echo(_(" MISSING: grm {cmd}", cmd=cmd))
missing.append(f"CLI command: grm {cmd}")
# Check modules in architecture.md
click.echo(_("\nChecking module documentation in architecture.md..."))
total += len(REQUIRED_MODULES)
arch_docs = architecture_file.read_text() if architecture_file.exists() else ""
for module in REQUIRED_MODULES:
if check_module_documented(module, arch_docs):
click.echo(_(" OK: {module}", module=module))
else:
click.echo(_(" MISSING: {module}", module=module))
missing.append(f"Module: {module}")
# Check CI scripts in ci-cd-workflow.md
click.echo(_("\nChecking CI script documentation in ci-cd-workflow.md..."))
total += len(REQUIRED_SCRIPTS)
ci_docs = ci_cd_file.read_text() if ci_cd_file.exists() else ""
for script in REQUIRED_SCRIPTS:
if check_module_documented(script, ci_docs):
click.echo(_(" OK: {script}", script=script))
else:
click.echo(_(" MISSING: {script}", script=script))
missing.append(f"CI script: {script}")
# Report
covered = total - len(missing)
percentage = (covered / total * 100) if total > 0 else 100.0
click.echo(
_(
"\nDoc coverage: {covered}/{total} ({pct}%)",
covered=covered,
total=total,
pct=f"{percentage:.0f}",
)
)
if missing:
click.echo(_("\nMissing documentation:"))
for item in missing:
click.echo(f" - {item}")
if missing and fail_on_missing:
click.echo(_("\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce."))
sys.exit(1)
if not missing:
click.echo(_("\nAll documentation coverage checks passed!"))
if __name__ == "__main__": # pragma: no cover
main()
-210
View File
@@ -1,210 +0,0 @@
#!/usr/bin/env python3
"""Run molecule tests sequentially while polling Gitea for other runner failures.
Each pair is encoded as ``scenario|platform_name|platform_image|platform_command``.
Pairs are executed one at a time (molecule scenarios share temp directories and
Docker networks, so parallel execution within a single runner is unsafe).
A background thread polls the Gitea API. If any other molecule matrix runner
reports failure, the current molecule subprocess is killed and this runner
exits early with code 1.
Usage:
python3 scripts/molecule_ci_guard.py <pair1> <pair2> ...
Environment variables:
GITEA_URL Base URL of the Gitea instance.
REPO_TOKEN API token with repo access.
RUN_ID Workflow run ID (GITHUB_RUN_ID).
JOB_NAME Base job name (GITHUB_JOB), e.g. "molecule-tests".
MATRIX_INDEX Current matrix index (runner-index).
GITEA_REPOSITORY Repository in "owner/repo" format.
"""
from __future__ import annotations
import contextlib
import os
import signal
import subprocess # nosec B404
import sys
import threading
import time
from pathlib import Path
import click
import requests
from scripts.i18n import _
POLL_INTERVAL = 10
def get_running_jobs(gitea_url: str, owner: str, repo: str, token: str, run_id: int) -> list[dict]:
"""Return jobs for the given workflow run."""
url = f"{gitea_url}/api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/jobs"
headers = {"Authorization": f"token {token}"}
response = requests.get(url, headers=headers, timeout=30)
response.raise_for_status()
data = response.json()
return data.get("jobs", [])
def any_other_runner_failed(jobs: list[dict], current_job_name: str, current_index: int) -> bool:
"""Return True if any other molecule matrix job has failed."""
for job in jobs:
name = job.get("name", "")
if not name.startswith(current_job_name):
continue
if name == f"{current_job_name} ({current_index})" or name == current_job_name:
continue
if job.get("conclusion") == "failure":
return True
return False
def poll_for_other_failures(
gitea_url: str,
owner: str,
repo: str,
token: str,
run_id: int,
job_name: str,
current_index: int,
stop_event: threading.Event,
failed_event: threading.Event,
) -> None:
"""Background thread: poll API and signal if another runner fails."""
while not stop_event.is_set():
try:
jobs = get_running_jobs(gitea_url, owner, repo, token, run_id)
if any_other_runner_failed(jobs, job_name, current_index):
click.echo(_("Another molecule runner failed. Stopping this runner early."))
failed_event.set()
return
except requests.RequestException as exc:
click.echo(_("API poll warning: {exc}", exc=exc))
stop_event.wait(POLL_INTERVAL)
def build_molecule_cmd(scenario: str) -> list[str]:
"""Build the molecule command for a scenario."""
cmd = ["molecule", "test"]
if scenario != "default":
cmd.extend(["-s", scenario])
return cmd
def build_env_for_pair(pair: str, base_env: dict[str, str]) -> dict[str, str]:
"""Build environment for a single molecule pair."""
scenario, platform_name, platform_image, platform_command = pair.split("|")
env = base_env.copy()
env["MOLECULE_PLATFORM_NAME"] = platform_name
env["MOLECULE_PLATFORM_IMAGE"] = platform_image
if platform_command:
env["MOLECULE_PLATFORM_COMMAND"] = platform_command
elif "MOLECULE_PLATFORM_COMMAND" in env:
del env["MOLECULE_PLATFORM_COMMAND"]
env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] = "true"
return env
@click.command()
@click.argument("pairs", nargs=-1, required=True)
def cli(pairs: tuple[str, ...]) -> None:
"""Run molecule pairs sequentially, stop if another CI runner fails."""
gitea_url = os.environ.get("GITEA_URL", "")
token = os.environ.get("REPO_TOKEN", "")
run_id = int(os.environ.get("RUN_ID", "0"))
job_name = os.environ.get("JOB_NAME", "molecule-tests")
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
repository = os.environ.get("GITEA_REPOSITORY", "oblachno-oss/grm")
owner, sep, repo = repository.partition("/")
if not owner or not repo:
owner, repo = "oblachno-oss", "grm"
if not all([gitea_url, token, run_id]):
click.echo(_("GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
repo_root = Path(__file__).resolve().parent.parent.parent
role_dir = repo_root / "ansible" / "roles" / "gitea-runner"
base_env = os.environ.copy()
base_env.setdefault("DOCKER_HOST", f"unix:///run/user/{os.getuid()}/docker.sock")
base_env.setdefault("ANSIBLE_INJECT_INVOCATION", "1")
stop_event = threading.Event()
failed_event = threading.Event()
if gitea_url and token and run_id:
poller = threading.Thread(
target=poll_for_other_failures,
args=(
gitea_url,
owner,
repo,
token,
run_id,
job_name,
current_index,
stop_event,
failed_event,
),
daemon=True,
)
poller.start()
try:
for pair in pairs:
if failed_event.is_set():
sys.exit(1)
scenario = pair.split("|")[0]
platform_name = pair.split("|")[1]
click.echo(_("Running: {scenario} on {platform}", scenario=scenario, platform=platform_name))
cmd = build_molecule_cmd(scenario)
env = build_env_for_pair(pair, base_env)
process = subprocess.Popen( # nosec B603
cmd,
cwd=str(role_dir),
env=env,
preexec_fn=os.setsid,
)
try:
while process.poll() is None:
if failed_event.is_set():
with contextlib.suppress(ProcessLookupError):
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
with contextlib.suppress(ProcessLookupError):
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
process.wait()
sys.exit(1)
time.sleep(1)
except KeyboardInterrupt:
with contextlib.suppress(ProcessLookupError):
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
process.wait()
sys.exit(1)
rc = process.returncode
if rc != 0:
click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc))
sys.exit(rc)
click.echo(_("PASSED: {pair}", pair=pair))
click.echo(_("All molecule tests passed."))
finally:
stop_event.set()
sys.exit(0)
if __name__ == "__main__": # pragma: no cover
cli()
-87
View File
@@ -1,87 +0,0 @@
#!/usr/bin/env python3
"""Create a Gitea issue when a CI workflow fails.
Used by the release and publish workflows to alert on failures that would
otherwise go unnoticed in the Actions tab. Uses the ``tea`` Gitea CLI
for issue creation — tea must be installed and configured.
Usage:
REPO_TOKEN=<token> python3 scripts/notify_failure.py \
--repo <owner/repo> \
--run-id <run_id> \
--workflow <workflow_name> \
--commit <commit_sha>
"""
from __future__ import annotations
import contextlib
import os
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from gitea_runner_manager.config import GITEA_API_URL
from scripts.gitea_cli import TeaCLI, TeaCLIError
from scripts.i18n import _
load_dotenv(override=True)
def _create_issue_via_tea(repo: str, title: str, body: str) -> int:
"""Create issue via tea CLI. Returns issue index.
Raises TeaCLIError if tea is not installed or the command fails.
"""
tea = TeaCLI(repo=repo)
# Check if "bug" label exists
labels: list[str] = []
with contextlib.suppress(TeaCLIError):
existing_labels = tea.list_labels(repo)
if any(label.get("name") == "bug" for label in existing_labels):
labels = ["bug"]
issue = tea.create_issue(repo, title=title, body=body, labels=labels if labels else None)
if labels:
with contextlib.suppress(TeaCLIError):
tea.add_label(repo, issue["index"], labels)
return int(issue.get("index", 0))
@click.command()
@click.option("--repo", required=True, help="Repository in owner/name format.")
@click.option("--run-id", required=True, help="CI run ID.")
@click.option("--workflow", required=True, help="Workflow name.")
@click.option("--commit", required=True, help="Commit SHA.")
def main(repo: str, run_id: str, workflow: str, commit: str) -> None:
token = os.environ.get("REPO_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
title = f"[CI] {workflow} workflow failed (run #{run_id})"
body = (
f"The **{workflow}** workflow failed.\n\n"
f"- **Run ID**: #{run_id}\n"
f"- **Commit**: `{commit[:8]}`\n"
f"- **Check the logs**: {GITEA_API_URL.replace('/api/v1', '')}/"
f"{repo}/actions/runs/{run_id}\n\n"
f"Please investigate and fix the issue."
)
try:
issue_id = _create_issue_via_tea(repo, title, body)
except TeaCLIError as e:
raise click.ClickException(_("Failed to create issue via tea: {error}", error=str(e))) from None
click.echo(
_(
"Created issue #{issue_id}: {title}",
issue_id=issue_id or "?",
title=title,
)
)
if __name__ == "__main__": # pragma: no cover
main()
-22
View File
@@ -1,22 +0,0 @@
"""Supported OS platform matrix for molecule tests.
Single source of truth for the platform list used by both:
- ``scripts/ci/distribute_molecule.py`` (CI parallel matrix)
- ``scripts/molecule_all.py`` (local sequential runner)
Keeping this in a dedicated module avoids cross-imports between
dev tools and CI scripts.
"""
from __future__ import annotations
#: Supported OS platform matrix.
#: Each entry maps a short name to (image, command).
#: The command must be systemd since rootless Docker requires
#: loginctl/systemctl --user.
PLATFORMS: list[dict[str, str]] = [
{"name": "ubuntu-2204", "image": "geerlingguy/docker-ubuntu2204-ansible:latest", "command": "/lib/systemd/systemd"},
{"name": "ubuntu-2404", "image": "geerlingguy/docker-ubuntu2404-ansible:latest", "command": "/lib/systemd/systemd"},
{"name": "debian-12", "image": "geerlingguy/docker-debian12-ansible:latest", "command": "/lib/systemd/systemd"},
{"name": "archlinux", "image": "marcstraube/archlinux-ansible:latest", "command": "/usr/lib/systemd/systemd"},
]
-197
View File
@@ -1,197 +0,0 @@
#!/usr/bin/env python3
"""Update Vikunja task after a merge to master.
Usage:
VIKUNJA_TOKEN=<token> python3 scripts/post_merge.py <commit_msg> [--commit-sha <sha>]
"""
import os
import re
import subprocess # nosec B404
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from gitea_runner_manager.api_clients import VikunjaClient
from gitea_runner_manager.config import DEFAULT_PER_PAGE, TASK_ID_RE, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
from gitea_runner_manager.exceptions import APIError
from scripts.i18n import _
load_dotenv(override=True)
def _get_git_commit_message() -> str:
"""Get the full commit message of the latest commit."""
result = subprocess.run( # nosec B603 B607
["git", "log", "-1", "--pretty=%B"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise click.ClickException(f"git log failed: {result.stderr.strip()}")
return result.stdout.strip()
def _get_git_commit_sha() -> str:
"""Get the SHA of the latest commit."""
result = subprocess.run( # nosec B603 B607
["git", "rev-parse", "HEAD"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise click.ClickException(f"git rev-parse failed: {result.stderr.strip()}")
return result.stdout.strip()
def extract_task_id(commit_msg: str) -> str:
"""Extract GRM-N task identifier from the first line of commit message."""
first_line = commit_msg.split("\n")[0]
match = TASK_ID_RE.search(first_line)
return match.group(0) if match else ""
def extract_conventional_msg(commit_msg: str) -> str:
"""Strip the GRM-N prefix from the commit subject.
Handles both formats:
- ``GRM-N: <message>`` (legacy, colon-separated)
- ``GRM-N <message>`` (current, space-separated)
"""
first_line = commit_msg.split("\n")[0]
return re.sub(r"^GRM-\d+[:\s]\s*", "", first_line)
def resolve_task_id(client: VikunjaClient, task_id: str) -> int:
"""Resolve GRM-N identifier to Vikunja numeric task ID.
Paginates through the project's tasks to handle projects with more
than 50 tasks. Raises ClickException if the task is not found.
"""
page = 1
while True:
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
if not tasks:
break
matches = [t for t in tasks if t.get("identifier") == task_id]
if matches:
return int(matches[0]["id"])
if len(tasks) < DEFAULT_PER_PAGE:
break
page += 1
raise click.ClickException(
_(
"Could not find Vikunja task {task_id} in project {project_id}. "
"Every PR must have a corresponding Vikunja task.",
task_id=task_id,
project_id=VIKUNJA_PROJECT_ID,
)
)
def build_comment(task_id: str, conv_msg: str, commit_sha: str) -> str:
"""Build HTML comment body for Vikunja."""
return f"<p><strong>{task_id}</strong>: {conv_msg}</p><p>Commit: <code>{commit_sha}</code></p>"
@click.command()
@click.argument("commit_msg", required=False)
@click.option("--commit-sha", default="", help="Commit SHA")
@click.option("--from-git", is_flag=True, default=False, help="Read commit message and SHA from git.")
@click.option(
"--git-sha",
default="",
help="Read commit message from a specific git SHA (avoids race condition with parallel jobs).",
)
def main(commit_msg: str | None, commit_sha: str, from_git: bool, git_sha: str) -> None:
if git_sha:
# Read commit message from a specific SHA — this avoids the race
# condition where a parallel job (e.g., release) pushes a new commit
# to master before this job reads HEAD.
result = subprocess.run( # nosec B603 B607
["git", "log", "-1", "--pretty=%B", git_sha],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise click.ClickException(f"git log failed for SHA {git_sha}: {result.stderr.strip()}")
commit_msg = result.stdout.strip()
if not commit_sha:
commit_sha = git_sha
elif from_git:
commit_msg = _get_git_commit_message()
if not commit_sha:
commit_sha = _get_git_commit_sha()
if not commit_msg:
raise click.ClickException("commit_msg argument is required (or use --from-git or --git-sha)")
token = os.environ.get("VIKUNJA_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: VIKUNJA_TOKEN is not set."))
task_id = extract_task_id(commit_msg)
if not task_id:
first_line = commit_msg.split("\n")[0]
# Skip gracefully for infrastructure commits that don't follow
# the GRM-N convention: release commits, reverts, bot commits, etc.
infra_patterns = [
r"^release: v\d+\.\d+\.\d+", # release commits
r"^revert: ", # git revert commits
r"^Merge ", # merge commits
r"^\[skip ci\]", # skip-ci commits
]
for pattern in infra_patterns:
if re.match(pattern, first_line):
click.echo(
_(
"Infrastructure commit (no GRM-N task ID), skipping Vikunja update: {msg}",
msg=first_line,
)
)
return
# Non-infrastructure commits without GRM-N prefix — warn but don't fail
click.echo(
_(
"Warning: No task ID (GRM-N) found in commit message: {msg}. Skipping Vikunja update.",
msg=first_line,
)
)
return
client = VikunjaClient(VIKUNJA_API_URL, token)
vikunja_task_id = resolve_task_id(client, task_id)
conv_msg = extract_conventional_msg(commit_msg)
sha = commit_sha or "unknown"
html = build_comment(task_id, conv_msg, sha)
try:
client.post_comment(vikunja_task_id, html)
client.update_task(vikunja_task_id, done=True)
except APIError as e:
# Vikunja is a project management tool — if it's down, the merge
# still succeeded. Warn but don't fail the post-merge workflow.
click.echo(
_(
"Warning: Vikunja API error (HTTP {status}): {message}. "
"Task {task_id} was NOT updated. The merge succeeded — "
"please update the Vikunja task manually.",
status=e.status,
message=e.message,
task_id=task_id,
)
)
return
click.echo(
_(
"Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.",
task_id=task_id,
vikunja_id=vikunja_task_id,
)
)
if __name__ == "__main__": # pragma: no cover
main()
-571
View File
@@ -1,571 +0,0 @@
#!/usr/bin/env python3
"""Automated PR review: check architecture compliance, best practices, and quality.
Fetches the PR diff via the Gitea API, runs a series of automated checks,
and posts a structured review using GiteaClient.create_review.
Checks performed:
1. Architecture compliance — no business logic in CLI, no direct subprocess
calls outside executor, no hardcoded config that should be in config.py
2. Best practices — no bare except, no print() (use click.echo), no TODO/FIXME
left in merged code, no functions > 50 lines
3. Security — no secrets in code, no shell=True, no eval/exec
4. i18n — no raw English strings in click.echo() without _() wrapper
5. Resource management — no open() without with statement, no subprocess without cleanup
6. Documentation — new CLI commands documented, new modules in architecture.md
7. Test coverage — 100% enforced by pytest-cov (checked in quality job)
8. Commit conventions — conventional commit format on branch commits
Usage:
REPO_TOKEN=<token> python3 scripts/ci/pr_review.py <pr_number> <owner/repo>
"""
from __future__ import annotations
import os
import re
from dataclasses import dataclass, field
from typing import Any
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from gitea_runner_manager.api_clients import GiteaClient
from gitea_runner_manager.config import GITEA_API_URL
from gitea_runner_manager.exceptions import APIError
from scripts.i18n import _
load_dotenv(override=True)
# Files that are exempt from certain checks
WORKFLOW_ONLY_SUFFIXES = (".yml", ".yaml", ".md", ".json", ".toml", ".cfg", ".ini", ".txt")
PYTHON_SUFFIX = ".py"
# Architecture rules
CLI_FILE = "src/gitea_runner_manager/cli.py"
EXECUTOR_FILE = "src/gitea_runner_manager/executor.py"
CONFIG_FILE = "src/gitea_runner_manager/config.py"
# Patterns that indicate business logic in CLI (should be in runner_manager.py)
BUSINESS_LOGIC_IN_CLI = [
(r"subprocess\.(run|call|Popen|check_output|check_call)", "subprocess call in CLI — delegate to executor.py"),
(r"\bos\.system\b", "os.system call in CLI — delegate to executor.py"),
(r"\bansible-playbook\b", "ansible-playbook reference in CLI — delegate to executor.py"),
]
# Patterns that indicate bad practices
BAD_PRACTICES = [
(r"\bprint\s*\(", "print() found — use click.echo() for user output"),
(r"\beval\s*\(", "eval() found — security risk, avoid dynamic code execution"),
(r"\bexec\s*\(", "exec() found — security risk, avoid dynamic code execution"),
(r"shell\s*=\s*True", "shell=True found — security risk, use shell=False with list args"),
(r"except\s*:", "bare except found — catch specific exceptions"),
(r"except\s+Exception\s*:", "broad Exception catch — catch specific exceptions"),
(r"#\s*(TODO|FIXME|HACK|XXX)", "TODO/FIXME found — resolve before merging"),
]
# Patterns for hardcoded config values that should be in config.py
HARDCODED_CONFIG = [
(r"https?://[a-z]+\.[a-z]+\.[a-z]+", "hardcoded URL — move to config.py with env var override"),
]
@dataclass
class ReviewResult:
"""Result of automated review checks."""
issues: list[dict[str, Any]] = field(default_factory=list)
summary: list[str] = field(default_factory=list)
@property
def has_issues(self) -> bool:
return bool(self.issues)
def add_issue(self, file_path: str, line: int, message: str, severity: str = "warning") -> None:
self.issues.append(
{
"path": file_path,
"body": f"[{severity}] {message}",
"new_position": line,
}
)
def add_summary(self, text: str) -> None:
self.summary.append(text)
def is_python_file(path: str) -> bool:
"""Check if a file is a Python source file."""
return path.endswith(PYTHON_SUFFIX) and not path.startswith("tests/")
def is_workflow_only(path: str) -> bool:
"""Check if a file is workflow/config/docs only (not Python source)."""
return path.endswith(WORKFLOW_ONLY_SUFFIXES) or path.startswith((".gitea/", "docs/", "ansible/"))
def check_architecture_compliance(files: list[dict[str, Any]], result: ReviewResult) -> None:
"""Check that changes follow the documented architecture."""
for f in files:
path = f.get("filename", "")
if not is_python_file(path):
continue
patch = f.get("patch", "")
if not patch:
continue
lines = patch.split("\n")
current_line = 0
for line in lines:
if line.startswith("@@"):
match = re.search(r"\+(\d+)", line)
if match:
current_line = int(match.group(1)) - 1
continue
if line.startswith("+") and not line.startswith("+++"):
current_line += 1
content = line[1:]
# Check for business logic in CLI
if path == CLI_FILE:
for pattern, msg in BUSINESS_LOGIC_IN_CLI:
if re.search(pattern, content):
result.add_issue(path, current_line, msg, "error")
if not result.issues:
result.add_summary("- Architecture compliance: OK")
def check_best_practices(files: list[dict[str, Any]], result: ReviewResult) -> None:
"""Check for common code quality issues."""
for f in files:
path = f.get("filename", "")
if not is_python_file(path):
continue
patch = f.get("patch", "")
if not patch:
continue
lines = patch.split("\n")
current_line = 0
for line in lines:
if line.startswith("@@"):
match = re.search(r"\+(\d+)", line)
if match:
current_line = int(match.group(1)) - 1
continue
if line.startswith("+") and not line.startswith("+++"):
current_line += 1
content = line[1:]
for pattern, msg in BAD_PRACTICES:
if re.search(pattern, content):
result.add_issue(path, current_line, msg, "warning")
if not any(i["body"].startswith("[warning]") for i in result.issues):
result.add_summary("- Best practices: OK")
def check_security(files: list[dict[str, Any]], result: ReviewResult) -> None:
"""Check for security issues in changed files."""
for f in files:
path = f.get("filename", "")
if not is_python_file(path):
continue
patch = f.get("patch", "")
if not patch:
continue
lines = patch.split("\n")
current_line = 0
for line in lines:
if line.startswith("@@"):
match = re.search(r"\+(\d+)", line)
if match:
current_line = int(match.group(1)) - 1
continue
if line.startswith("+") and not line.startswith("+++"):
current_line += 1
content = line[1:]
# Check for hardcoded secrets
secret_re = r'(token|password|secret|key)\s*=\s*["\'][^"\']{8,}["\']' # nosec B105
is_secret = re.search(secret_re, content, re.IGNORECASE)
is_comment = content.strip().startswith("#")
is_example = "your-" in content or "example" in content
if is_secret and not is_comment and not is_example:
result.add_issue(
path,
current_line,
"potential hardcoded secret — use environment variable",
"error",
)
if not any(i["body"].startswith("[error]") and "secret" in i["body"] for i in result.issues):
result.add_summary("- Security: OK")
def check_i18n(files: list[dict[str, Any]], result: ReviewResult) -> None:
"""Check that user-facing strings are wrapped in _().
Detects ``click.echo()`` calls with raw string literals that are not
wrapped in ``_()``. Only checks ``src/`` files, not tests or scripts.
"""
# Pattern: click.echo("...") or click.echo(f"...") without _() wrapper
raw_echo_re = re.compile(r'click\.echo\s*\(\s*["\']([^"\']+)["\']')
raw_fstring_re = re.compile(r'click\.echo\s*\(\s*f["\']')
# Also check click.ClickException and raise with string
raw_exception_re = re.compile(r'click\.ClickException\s*\(\s*["\']([^"\']+)["\']')
for f in files:
path = f.get("filename", "")
if not is_python_file(path) or not path.startswith("src/"):
continue
patch = f.get("patch", "")
if not patch:
continue
lines = patch.split("\n")
current_line = 0
for line in lines:
if line.startswith("@@"):
match = re.search(r"\+(\d+)", line)
if match:
current_line = int(match.group(1)) - 1
continue
if line.startswith("+") and not line.startswith("+++"):
current_line += 1
content = line[1:]
# Skip comments and docstrings
stripped = content.strip()
if stripped.startswith("#") or stripped.startswith('"""') or stripped.startswith("'''"):
continue
# Check for raw strings in click.echo without _()
for regex, msg in [
(raw_echo_re, "click.echo() with raw string — wrap in _() for i18n"),
(raw_fstring_re, "click.echo() with f-string — wrap in _() for i18n"),
(raw_exception_re, "ClickException with raw string — wrap in _() for i18n"),
]:
if regex.search(content):
result.add_issue(path, current_line, msg, "warning")
if not any("i18n" in i["body"] for i in result.issues):
result.add_summary("- i18n: OK")
def check_resource_management(files: list[dict[str, Any]], result: ReviewResult) -> None:
"""Check for resource leaks: open() without with, subprocess without cleanup.
Detects:
- ``open()`` calls not in a ``with`` statement
- ``subprocess.Popen()`` without ``.wait()`` or ``.communicate()``
"""
# Pattern: open("...") not preceded by "with" on the same line
open_re = re.compile(r"(?<!with\s)\bopen\s*\(")
popen_re = re.compile(r"subprocess\.Popen\s*\(")
for f in files:
path = f.get("filename", "")
if not is_python_file(path):
continue
patch = f.get("patch", "")
if not patch:
continue
lines = patch.split("\n")
current_line = 0
for line in lines:
if line.startswith("@@"):
match = re.search(r"\+(\d+)", line)
if match:
current_line = int(match.group(1)) - 1
continue
if line.startswith("+") and not line.startswith("+++"):
current_line += 1
content = line[1:]
# Skip comments
if content.strip().startswith("#"):
continue
# Check for open() without with
if open_re.search(content) and "with " not in content:
result.add_issue(
path, current_line, "open() without with statement — potential resource leak", "warning"
)
# Check for Popen without communicate/wait on same line
if popen_re.search(content) and ".communicate" not in content and ".wait" not in content:
result.add_issue(
path,
current_line,
"subprocess.Popen() without immediate .communicate() or .wait() — ensure cleanup",
"warning",
)
if not any("resource" in i["body"].lower() for i in result.issues):
result.add_summary("- Resource management: OK")
def check_function_length(files: list[dict[str, Any]], result: ReviewResult) -> None:
"""Check that no new function is excessively long (> 50 lines)."""
for f in files:
path = f.get("filename", "")
if not is_python_file(path):
continue
patch = f.get("patch", "")
if not patch:
continue
# Count consecutive added lines within a function
lines = patch.split("\n")
current_line = 0
func_start = 0
func_name = ""
added_in_func = 0
for line in lines:
if line.startswith("@@"):
if func_name and added_in_func > 50:
result.add_issue(
path,
func_start,
f"function '{func_name}' adds {added_in_func} lines — consider splitting (> 50 lines)",
"warning",
)
match = re.search(r"\+(\d+)", line)
if match:
current_line = int(match.group(1)) - 1
func_name = ""
added_in_func = 0
continue
if line.startswith("+") and not line.startswith("+++"):
current_line += 1
content = line[1:]
func_match = re.match(r"\s*def\s+(\w+)\s*\(", content)
if func_match:
if func_name and added_in_func > 50:
result.add_issue(
path,
func_start,
f"function '{func_name}' adds {added_in_func} lines — consider splitting (> 50 lines)",
"warning",
)
func_name = func_match.group(1)
func_start = current_line
added_in_func = 0
else:
added_in_func += 1
elif line.startswith(" ") or line.startswith("-"):
pass # context or removed line
# Check last function
if func_name and added_in_func > 50:
result.add_issue(
path,
func_start,
f"function '{func_name}' adds {added_in_func} lines — consider splitting (> 50 lines)",
"warning",
)
def check_documentation(files: list[dict[str, Any]], result: ReviewResult) -> None:
"""Check that documentation is updated for relevant changes."""
has_src_changes = any(
is_python_file(f.get("filename", "")) and f.get("filename", "").startswith("src/") for f in files
)
has_doc_changes = any(
f.get("filename", "").startswith("docs/") or f.get("filename", "") in ("README.md", "AGENTS.md", "CHANGELOG.md")
for f in files
)
has_ansible_changes = any(f.get("filename", "").startswith("ansible/") for f in files)
if has_src_changes and not has_doc_changes:
result.add_summary("- Documentation: WARNING — source files changed but no docs updated")
elif has_ansible_changes and not has_doc_changes:
result.add_summary("- Documentation: WARNING — Ansible role changed but no docs updated")
else:
result.add_summary("- Documentation: OK")
def check_test_coverage(files: list[dict[str, Any]], result: ReviewResult) -> None:
"""Check that tests are updated for source changes."""
has_src_changes = any(
is_python_file(f.get("filename", "")) and f.get("filename", "").startswith("src/") for f in files
)
has_test_changes = any(f.get("filename", "").startswith("tests/") for f in files)
if has_src_changes and not has_test_changes:
result.add_summary("- Tests: WARNING — source files changed but no test files updated")
else:
result.add_summary("- Tests: OK")
def check_commit_conventions(client: GiteaClient, pr_number: str, result: ReviewResult) -> None:
"""Check that PR commits follow conventional commit format.
Verifies that at least one commit on the PR branch matches the
conventional commit pattern (type: description). Merge commits
and revert commits are exempt.
"""
try:
commits = client.get_pr_commits(pr_number)
except APIError as e:
result.add_summary(f"- Commit conventions: ERROR — could not fetch commits: {e.message}")
return
if not commits:
result.add_summary("- Commit conventions: OK (no commits to check)")
return
from gitea_runner_manager.config import CONVENTIONAL_RE
has_conventional = False
non_conventional: list[str] = []
for commit in commits:
commit_info = commit.get("commit", {})
message = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
# Skip merge commits and revert commits
if message.startswith(("Merge", "Revert")):
continue
if CONVENTIONAL_RE.match(message):
has_conventional = True
else:
non_conventional.append(message[:60])
if has_conventional:
result.add_summary("- Commit conventions: OK")
elif non_conventional:
result.add_summary(
f"- Commit conventions: WARNING — no conventional commit found. "
f"Non-conventional commits: {', '.join(non_conventional[:3])}"
)
else:
result.add_summary("- Commit conventions: OK (all commits are merges/reverts)")
def run_review(client: GiteaClient, pr_number: str) -> ReviewResult:
"""Run all review checks and return the result."""
result = ReviewResult()
try:
files = client.get_pr_files(pr_number)
except APIError as e:
result.add_summary(f"- ERROR: Could not fetch PR files: {e.message}")
return result
if not files:
result.add_summary("- No files changed in this PR")
return result
# Run all checks
check_architecture_compliance(files, result)
check_best_practices(files, result)
check_security(files, result)
check_i18n(files, result)
check_resource_management(files, result)
check_function_length(files, result)
check_documentation(files, result)
check_test_coverage(files, result)
check_commit_conventions(client, pr_number, result)
return result
def build_review_body(result: ReviewResult) -> str:
"""Build the review body text from the review result."""
lines = ["## Automated PR Review", ""]
for item in result.summary:
lines.append(item)
if result.issues:
lines.append("")
lines.append(f"**{len(result.issues)} issue(s) found:**")
lines.append("")
for issue in result.issues:
lines.append(f"- `{issue['path']}:{issue['new_position']}` — {issue['body']}")
else:
lines.append("")
lines.append("No issues found by automated checks.")
lines.append("")
lines.append("---")
lines.append("**Auto-merge:** If all CI checks pass, this PR will be merged automatically.")
return "\n".join(lines)
def post_review(client: GiteaClient, pr_number: str, result: ReviewResult) -> dict[str, Any]:
"""Post the review to the PR.
Uses REQUEST_CHANGES when issues are found, COMMENT otherwise.
Never uses APPROVE — the bot shares the PR author's token, so
Gitea rejects self-approval. The actual APPROVE must come from
the manual review step.
"""
body = build_review_body(result)
event = "REQUEST_CHANGES" if result.has_issues else "COMMENT"
comments = result.issues if result.has_issues else []
return client.create_review(pr_number, event=event, body=body, comments=comments)
@click.command()
@click.argument("pr_number")
@click.argument("repo")
@click.option("--dry-run", is_flag=True, default=False, help="Print review without posting.")
def main(pr_number: str, repo: str, dry_run: bool) -> None:
"""Run automated PR review and post results to Gitea."""
token = os.environ.get("REPO_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
owner, repo_name = repo.split("/")
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
result = run_review(client, pr_number)
body = build_review_body(result)
event = "REQUEST_CHANGES" if result.has_issues else "COMMENT"
click.echo(f"Review event: {event}")
click.echo(f"Issues found: {len(result.issues)}")
click.echo("")
click.echo(body)
if dry_run:
click.echo("\n[dry-run] Review not posted.")
return
try:
review = post_review(client, pr_number, result)
except APIError as e:
if "approve" in e.message.lower() or "422" in str(e.status):
click.echo(_("Note: Self-approval not allowed. Posting COMMENT instead."))
review = client.create_review(pr_number, event="COMMENT", body=body)
else:
raise
review_id = review.get("id", "?")
click.echo(
_(
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
review_id=review_id,
pr_number=pr_number,
event=event,
num_comments=len(result.issues),
)
)
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
-128
View File
@@ -1,128 +0,0 @@
#!/usr/bin/env python3
"""Build package, optionally publish to PyPI, and create Gitea release.
Uses git-cliff to generate the release notes from conventional commits.
Uses the ``tea`` Gitea CLI for release creation.
Usage:
REPO_TOKEN=<token> [PYPI_TOKEN=<token>] python3 scripts/publish.py <tag> <repo>
"""
import os
import shutil
import subprocess # nosec B404
import sys
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from scripts.gitea_cli import TeaCLI, TeaCLIError
from scripts.i18n import _
load_dotenv(override=True)
CLIFF_CONFIG = "cliff.toml"
def generate_release_notes(tag: str) -> str:
"""Generate release notes for the given tag using git-cliff.
Falls back to a generic message if git-cliff is not available.
"""
cliff_bin = shutil.which("git-cliff")
if not cliff_bin:
return f"Release {tag}\n\nSee CHANGELOG.md for details."
try:
result = subprocess.run( # nosec B603
[cliff_bin, "--config", CLIFF_CONFIG, "--latest", "--strip", "header"],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
except FileNotFoundError:
pass
return f"Release {tag}\n\nSee CHANGELOG.md for details."
def build_package() -> None:
"""Build the Python package using python -m build."""
result = subprocess.run( # nosec B603
[sys.executable, "-m", "build"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise click.ClickException(
_(
"Oops! Package build failed:\n{stderr}",
stderr=result.stderr.strip(),
)
)
def publish_to_pypi(token: str) -> None:
"""Publish built packages to PyPI using twine."""
result = subprocess.run( # nosec B603
[
sys.executable,
"-m",
"twine",
"upload",
"dist/*",
"-u",
"__token__",
"-p",
token,
],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise click.ClickException(
_(
"Oops! PyPI publish failed:\n{stderr}",
stderr=result.stderr.strip(),
)
)
click.echo(_("Published to PyPI."))
@click.command()
@click.argument("tag")
@click.argument("repo")
def main(tag: str, repo: str) -> None:
gitea_token = os.environ.get("REPO_TOKEN", "")
if not gitea_token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
pypi_token = os.environ.get("PYPI_TOKEN", "")
build_package()
if pypi_token:
publish_to_pypi(pypi_token)
else:
click.echo(_("PYPI_TOKEN not set — skipping PyPI publish. No worries, we'll just create the Gitea release."))
tea = TeaCLI(repo=repo)
release_body = generate_release_notes(tag)
try:
tea.create_release(repo, tag=tag, title=tag, body=release_body)
except TeaCLIError as e:
raise click.ClickException(_("Release creation failed: {error}", error=str(e))) from None
click.echo(
_(
"Nice! Gitea release {tag} created.",
tag=tag,
)
)
if __name__ == "__main__": # pragma: no cover
main()
-173
View File
@@ -1,173 +0,0 @@
#!/usr/bin/env python3
"""Generate badge SVG files and push them to the ``badges`` branch.
Also updates README.md and docs/index.md on master with cache-busting
``raw/commit/<sha>/badge.svg`` URLs so that browsers always fetch the
latest badge version (Gitea caches ``raw/branch/`` URLs for 6 hours).
The script fetches the latest master before generating badges so that
the version badge always reflects the current state of the repository
(even if a release commit was pushed moments before by the parallel
release job).
Usage::
python3 scripts/ci/push_badges.py
"""
from __future__ import annotations
import re
import subprocess # nosec B404
import sys
from pathlib import Path
from typing import Any
import click
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
# Badge filenames that get pushed to the badges branch
BADGE_FILES = ["coverage.svg", "tests.svg", "docs.svg", "quality.svg", "version.svg", "python.svg"]
# Files that contain badge URLs and need to be updated
FILES_WITH_BADGE_URLS = ["README.md", "docs/index.md"]
# Pattern to match raw/branch/badges/<name>.svg URLs
_BADGE_URL_RE = re.compile(r"(https://[^/]+/[^/]+/[^/]+/raw/)(?:branch/badges|commit/[0-9a-f]{40})/([a-z_]+\.svg)")
def _run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
"""Run a command and return the result."""
return subprocess.run(cmd, check=True, text=True, **kwargs) # nosec B603
def _run_capture(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
"""Run a command and capture stdout."""
return subprocess.run(cmd, check=True, text=True, capture_output=True, **kwargs) # nosec B603
def fetch_latest_master(branch: str = "master") -> None:
"""Fetch and hard-reset to the latest remote branch.
Ensures the working tree reflects the absolute latest state of the
remote, which is critical when the release job may have just pushed
a new version commit.
"""
_run(["git", "fetch", "origin", branch]) # nosec B607
_run(["git", "reset", "--hard", f"origin/{branch}"]) # nosec B607
click.echo(f"Synced to latest origin/{branch}")
def generate_badges(output_dir: str) -> None:
"""Generate badge SVG files using generate_badges.py."""
_run([sys.executable, "scripts/generate_badges.py", "--output-dir", output_dir])
badges = list(Path(output_dir).glob("*.svg"))
if not badges:
raise click.ClickException("No badge SVG files generated")
click.echo(f"Generated {len(badges)} badge files")
def push_to_badges_branch(badges_dir: str) -> str:
"""Push generated badges to the orphan ``badges`` branch.
Returns the commit SHA of the pushed badges branch.
"""
_run(["git", "config", "user.name", "gitea-actions-bot"]) # nosec B607
_run(["git", "config", "user.email", "actions@oblachno.fyi"]) # nosec B607
_run(["git", "checkout", "--orphan", "badges"]) # nosec B607
_run(["git", "rm", "-rf", "."]) # nosec B607
# Copy badge files to root
import shutil
for svg in Path(badges_dir).glob("*.svg"):
shutil.copy2(svg, Path.cwd() / svg.name)
_run(["git", "add", "./*.svg"]) # nosec B607
_run(["git", "commit", "--no-verify", "-m", "Update badges [skip ci]"]) # nosec B607
_run(["git", "push", "origin", "badges", "--force"]) # nosec B607
click.echo("Badges pushed to badges branch")
# Get the commit SHA of the badges branch
result = _run_capture(["git", "rev-parse", "HEAD"]) # nosec B607
sha = result.stdout.strip()
click.echo(f"Badges commit SHA: {sha}")
return sha
def update_badge_urls(content: str, badges_sha: str) -> str:
"""Replace raw/branch/badges/<name>.svg URLs with raw/commit/<sha>/<name>.svg.
This bypasses Gitea's 6-hour cache on raw/branch/ URLs by using a
URL that changes each time the badges branch is updated.
"""
return _BADGE_URL_RE.sub(
lambda m: f"{m.group(1)}commit/{badges_sha}/{m.group(2)}",
content,
)
def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None) -> None:
"""Update README.md and docs/index.md with cache-busting badge URLs.
Switches back to master, replaces ``raw/branch/badges/`` URLs with
``raw/commit/<sha>/`` URLs, commits and pushes.
"""
root = repo_root or REPO_ROOT
# Switch back to master
_run(["git", "checkout", "master"]) # nosec B607
_run(["git", "fetch", "origin", "master"]) # nosec B607
_run(["git", "reset", "--hard", "origin/master"]) # nosec B607
updated_any = False
for filename in FILES_WITH_BADGE_URLS:
filepath = root / filename
if not filepath.exists():
continue
content = filepath.read_text()
new_content = update_badge_urls(content, badges_sha)
if new_content != content:
filepath.write_text(new_content)
click.echo(f"Updated badge URLs in {filename}")
updated_any = True
if not updated_any:
click.echo("No badge URLs found to update — README already up to date")
return
_run(["git", "add", "README.md", "docs/index.md"]) # nosec B607
_run(
[
"git",
"commit",
"--no-verify",
"-m",
f"chore: update badge URLs to commit {badges_sha[:8]} [skip ci]",
]
) # nosec B607
_run(["git", "push", "origin", "master"]) # nosec B607
click.echo(f"Pushed README update with badge SHA {badges_sha[:8]}")
@click.command()
@click.option("--output-dir", default=".badges/", help="Temporary directory for badge files.")
@click.option("--branch", default="master", help="Branch to sync before generating badges.")
@click.option(
"--no-readme-update",
is_flag=True,
default=False,
help="Skip updating README with cache-busting URLs (for local testing).",
)
def main(output_dir: str, branch: str, no_readme_update: bool) -> None:
"""Generate badges and push them to the badges branch."""
fetch_latest_master(branch)
generate_badges(output_dir)
badges_sha = push_to_badges_branch(output_dir)
if not no_readme_update:
update_readme_with_badge_sha(badges_sha)
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
-369
View File
@@ -1,369 +0,0 @@
#!/usr/bin/env python3
"""Automated release: calculate next version, update files, tag, and push.
Uses git-cliff to determine the next semver version from conventional commits
since the last tag. Updates ``__version__`` in ``__init__.py`` (the single
source of truth, read by setuptools via ``dynamic = ["version"]``) and
``CHANGELOG.md``, commits them with a ``release:`` prefix, tags the commit
with the changelog as the tag message, and pushes both to trigger the publish
workflow.
**Test enforcement**: Before committing or tagging, the script runs
``make lint-ruff`` and ``make pytest-cov`` to verify the release is healthy.
If either fails, the release is aborted no commit, no tag. This ensures
we never release a version that fails tests. Use ``--skip-tests`` only for
emergency releases (not recommended).
The ``release:`` prefix (instead of ``chore(release):``) keeps the history
clean while still being descriptive. Loops are prevented by the
``has_unreleased_changes`` check after a release commit is tagged, the next
run finds no unreleased changes and exits.
This script is idempotent: if there are no new conventional commits since the
last tag, it exits with a message and does nothing. If the tag already exists
(e.g., from a partial previous run), it skips tag creation and only pushes.
Usage:
REPO_TOKEN=<token> python3 scripts/release.py [--dry-run] [--skip-tests]
"""
from __future__ import annotations
import re
import subprocess # nosec B404
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from scripts.ci.classify_changes import has_user_facing_changes # cross-CI import, needs PYTHONPATH=.
from scripts.i18n import _
load_dotenv(override=True)
INIT_FILE = "src/gitea_runner_manager/__init__.py"
CHANGELOG_FILE = "CHANGELOG.md"
CLIFF_CONFIG = "cliff.toml"
def run_cmd(args: list[str], check: bool = True, capture: bool = True) -> subprocess.CompletedProcess[str]:
"""Run a command and return the completed process."""
result = subprocess.run( # nosec B603
args,
capture_output=capture,
text=True,
check=False,
)
if check and result.returncode != 0:
raise click.ClickException(
_(
"Command failed ({cmd}): {stderr}",
cmd=" ".join(args),
stderr=result.stderr.strip() if result.stderr else result.stdout.strip(),
)
)
return result
def get_latest_tag() -> str:
"""Get the latest git tag, or empty string if none exists."""
result = run_cmd(["git", "describe", "--tags", "--abbrev=0"], check=False)
if result.returncode != 0:
return ""
return result.stdout.strip()
def tag_exists(tag: str) -> bool:
"""Check if a git tag already exists."""
result = run_cmd(["git", "tag", "-l", tag], check=False)
return bool(result.stdout.strip())
def get_bumped_version() -> str:
"""Use git-cliff to calculate the next version from conventional commits."""
result = run_cmd(["git-cliff", "--bumped-version", "--config", CLIFF_CONFIG])
version = result.stdout.strip()
if not version:
raise click.ClickException(_("git-cliff returned empty version."))
# git-cliff may return with or without 'v' prefix
return version.lstrip("v")
def get_changelog(new_version: str) -> str:
"""Generate changelog content for the new version using git-cliff."""
result = run_cmd(
[
"git-cliff",
"--config",
CLIFF_CONFIG,
"--tag",
f"v{new_version}",
"--unreleased",
"--bump",
]
)
return result.stdout.strip()
def has_unreleased_changes(bumped_version: str | None = None) -> bool:
"""Check if there are unreleased conventional commits since the last tag.
Uses ``git log`` to check for commits between the last tag and HEAD.
This is more reliable than comparing version strings if git-cliff
bumps to the same version (e.g., two fix commits between tags), the
version comparison would incorrectly report "no unreleased changes"
even though there are commits that haven't been released yet.
"""
latest = get_latest_tag()
if not latest:
return True
# Check for any commits since the last tag
result = run_cmd(
["git", "log", f"{latest}..HEAD", "--oneline"],
check=False,
)
if result.returncode != 0:
return False
return bool(result.stdout.strip())
def update_init_version(new_version: str) -> None:
"""Update __version__ in __init__.py."""
with open(INIT_FILE) as f:
content = f.read()
if not re.search(r'^__version__\s*=\s*"[^"]*"', content, flags=re.MULTILINE):
raise click.ClickException(_("Could not find __version__ in {file}", file=INIT_FILE))
updated = re.sub(
r'^__version__\s*=\s*"[^"]*"',
f'__version__ = "{new_version}"',
content,
count=1,
flags=re.MULTILINE,
)
with open(INIT_FILE, "w") as f:
f.write(updated)
def update_changelog(changelog: str) -> None:
"""Prepend the new changelog section to CHANGELOG.md.
The changelog from git-cliff may include a header (e.g., "# Changelog").
This function strips everything before the first ``## [`` version section
before inserting, to avoid duplicating the header.
"""
# Strip git-cliff header — keep only from the first version section
section_match = re.search(r"^## \[", changelog, flags=re.MULTILINE)
if section_match:
changelog = changelog[section_match.start() :]
try:
with open(CHANGELOG_FILE) as f:
existing = f.read()
except FileNotFoundError:
with open(CHANGELOG_FILE, "w") as f:
f.write(changelog + "\n")
return
# Find the first version section header (## [...] or ## [unreleased])
match = re.search(r"^## \[", existing, flags=re.MULTILINE)
if match:
# Insert before the first version section
pos = match.start()
updated = existing[:pos] + changelog + "\n\n" + existing[pos:]
else:
# No version sections found — append
updated = existing.rstrip() + "\n\n" + changelog + "\n"
with open(CHANGELOG_FILE, "w") as f:
f.write(updated)
def commit_release_changes(new_version: str) -> bool:
"""Stage version file and changelog, then create a release commit.
Uses ``release:`` prefix (not ``chore(release):``) for clarity.
The commit is created with ``--no-verify`` to bypass the commit-msg hook
(which requires ``GRM-N:`` prefix for master commits) since release
commits are a special case generated by the release script.
Returns True if a commit was created, False if there were no staged changes.
"""
run_cmd(["git", "add", INIT_FILE, CHANGELOG_FILE])
status = run_cmd(["git", "diff", "--cached", "--quiet"], check=False)
if status.returncode == 0:
click.echo(_("No staged changes — version and changelog already up to date."))
return False
run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version} [skip ci]"])
return True
def run_tests() -> None:
"""Run lint and tests to verify the release is healthy.
This is called *after* version files are updated but *before* the tag is
created, ensuring we never tag a release that fails tests.
"""
click.echo(_("Running lint checks..."))
lint = run_cmd(["make", "lint-ruff"], check=False)
if lint.returncode != 0:
raise click.ClickException(
_(
"Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
stderr=lint.stderr.strip() if lint.stderr else lint.stdout.strip(),
)
)
click.echo(_("Lint passed."))
click.echo(_("Running tests..."))
tests = run_cmd(["make", "pytest-cov"], check=False)
if tests.returncode != 0:
raise click.ClickException(
_(
"Tests failed — refusing to release. Fix test failures first.\n{stderr}",
stderr=tests.stderr.strip() if tests.stderr else tests.stdout.strip(),
)
)
click.echo(_("Tests passed."))
def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool:
"""Create an annotated tag with the changelog as message and push it.
Returns True if the tag was created/pushed, False if it already existed.
"""
tag = f"v{new_version}"
if tag_exists(tag):
click.echo(_("Tag {tag} already exists, skipping creation.", tag=tag))
if not dry_run:
# Ensure the existing tag is pushed
run_cmd(["git", "push", "origin", tag], check=False)
return False
tag_msg = f"Release v{new_version}\n\n{changelog}"
if dry_run:
click.echo(_("[dry-run] Would create tag: {tag}", tag=tag))
return True
run_cmd(["git", "tag", "-a", tag, "-m", tag_msg])
run_cmd(["git", "push", "origin", tag])
return True
@click.command()
@click.option("--dry-run", is_flag=True, default=False, help="Show what would happen without making changes.")
@click.option(
"--skip-tests",
is_flag=True,
default=False,
help="Skip lint and test verification (NOT recommended — only for emergency releases).",
)
def main(dry_run: bool, skip_tests: bool) -> None:
# Ensure we're on master (skip this check in dry-run mode for PR validation)
branch = run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip()
if branch != "master" and not dry_run:
raise click.ClickException(_("Release must be run on master, currently on '{branch}'.", branch=branch))
if branch != "master" and dry_run:
click.echo(
_(
"Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
branch=branch,
)
)
# Release lock: if HEAD is already a release commit, another release
# run is in progress (or already completed). Skip to prevent duplicate tags.
head_msg = run_cmd(["git", "log", "-1", "--pretty=%s"]).stdout.strip()
if re.match(r"^release: v\d+\.\d+\.\d+", head_msg):
click.echo(
_(
"HEAD is already a release commit ('{msg}'). Another release may have just completed. Skipping.",
msg=head_msg,
)
)
return
# Check if any user-facing files changed since the last tag.
# If only workflow/infra files changed, skip the release entirely.
latest_tag = get_latest_tag()
if latest_tag and not has_user_facing_changes(latest_tag, "HEAD"):
click.echo(
_(
"No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
tag=latest_tag,
)
)
return
# Calculate next version (single git-cliff call — Gap 7 fix)
new_version = get_bumped_version()
# Check for unreleased changes (reuses the version we just calculated)
if not has_unreleased_changes(bumped_version=new_version):
click.echo(_("No unreleased changes found. Nothing to release."))
return
current_tag = get_latest_tag()
click.echo(
_(
"Bumping version: {current} -> v{new_version}",
current=current_tag or "(none)",
new_version=new_version,
)
)
# Generate changelog
changelog = get_changelog(new_version)
if not changelog:
click.echo(_("Warning: git-cliff generated empty changelog."))
if dry_run:
click.echo(_("\n[dry-run] Changelog:\n{changelog}", changelog=changelog))
click.echo(_("[dry-run] Would update {init}", init=INIT_FILE))
click.echo(_("[dry-run] Would update {changelog_file}", changelog_file=CHANGELOG_FILE))
click.echo(_("[dry-run] Would commit: release: v{version}", version=new_version))
click.echo(_("[dry-run] Would push commit to master"))
click.echo(_("[dry-run] Would create tag: v{version}", version=new_version))
return
# Update version file
update_init_version(new_version)
click.echo(_("Updated version in {init}", init=INIT_FILE))
# Update CHANGELOG.md (Gap 3 fix)
update_changelog(changelog)
click.echo(_("Updated {changelog_file}", changelog_file=CHANGELOG_FILE))
# Verify tests pass BEFORE committing or tagging.
# This ensures we never release a version that fails tests.
if skip_tests:
click.echo(_("WARNING: --skip-tests passed — skipping test verification."))
else:
run_tests()
# Commit version + changelog (Gap 11: use 'release:' prefix, not 'chore(release):')
committed = commit_release_changes(new_version)
if committed:
click.echo(_("Created release commit."))
# Pull --rebase before push to handle the case where master
# advanced between checkout and commit (e.g., another merge).
run_cmd(["git", "pull", "--rebase", "origin", "master"], check=False)
run_cmd(["git", "push", "origin", "master"])
click.echo(_("Pushed release commit to master."))
else:
click.echo(_("Skipping commit push — no staged changes."))
# Create and push tag (Gap 4: handles existing tag)
created = create_and_push_tag(new_version, changelog, dry_run)
if created:
click.echo(
_(
"Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
version=new_version,
)
)
else:
click.echo(
_(
"Tag v{version} already existed. Publish workflow should already have been triggered.",
version=new_version,
)
)
if __name__ == "__main__": # pragma: no cover
main()
-311
View File
@@ -1,311 +0,0 @@
#!/usr/bin/env python3
"""Sync documentation from /docs/ to the Gitea wiki via API.
Reads markdown files from the ``docs/`` directory, uses ``mapping.json`` to
map file paths to wiki page titles, and creates/updates wiki pages via the
Gitea API. Pages that exist in the wiki but not in the mapping are left
untouched (not deleted).
Gitea 1.26 wiki API endpoints (all use content_base64, NOT content):
- Create: POST /repos/{owner}/{repo}/wiki/new {title, content_base64, message}
- Update: PATCH /repos/{owner}/{repo}/wiki/page/{sub_url} {title, content_base64, message}
- List: GET /repos/{owner}/{repo}/wiki/pages [{title, sub_url, ...}]
- Fetch: GET /repos/{owner}/{repo}/wiki/page/{sub_url} {title, content_base64, ...}
- Delete: DELETE /repos/{owner}/{repo}/wiki/page/{sub_url}
Usage:
REPO_TOKEN=<token> python3 scripts/ci/sync_wiki.py [--dry-run] [--repo owner/repo]
"""
from __future__ import annotations
import base64
import json
import os
from pathlib import Path
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from gitea_runner_manager.api_clients import GiteaClient
from gitea_runner_manager.config import GITEA_API_URL
from gitea_runner_manager.exceptions import APIError
from scripts.i18n import _
load_dotenv(override=True)
DOCS_DIR = Path(__file__).resolve().parent.parent.parent / "docs"
MAPPING_FILE = DOCS_DIR / "mapping.json"
def load_mapping() -> dict[str, str]:
"""Load the file-to-wiki-page mapping from mapping.json."""
with open(MAPPING_FILE) as f:
return json.load(f)
def read_doc_content(file_path: str) -> str:
"""Read markdown content from a docs file."""
full_path = DOCS_DIR / file_path
with open(full_path) as f:
return f.read()
def encode_content(content: str) -> str:
"""Encode content as base64 for the Gitea wiki API.
The Gitea wiki API requires content_base64, not plain content.
Sending plain content silently fails (pages are created/updated
but with empty content).
"""
return base64.b64encode(content.encode("utf-8")).decode("ascii")
def decode_content(content_b64: str) -> str:
"""Decode base64 content from the Gitea wiki API."""
if not content_b64:
return ""
return base64.b64decode(content_b64).decode("utf-8")
def list_wiki_pages(client: GiteaClient) -> dict[str, str]:
"""List existing wiki pages, returning {title: sub_url}."""
try:
pages = client._request("GET", "/wiki/pages").json()
except APIError:
return {}
return {page.get("title", ""): page.get("sub_url", page.get("title", "")) for page in pages}
def fetch_page_content(client: GiteaClient, sub_url: str) -> str:
"""Fetch a wiki page's content by sub_url, decoded from base64."""
try:
page = client._request("GET", f"/wiki/page/{sub_url}").json()
return decode_content(page.get("content_base64", ""))
except APIError:
return ""
def sync_page(
client: GiteaClient,
page_title: str,
content: str,
existing_pages: dict[str, str],
dry_run: bool,
) -> str:
"""Create or update a single wiki page.
Returns "created", "updated", or "skipped" (if dry-run).
"""
if dry_run:
click.echo(_("[dry-run] Would sync page: {title} ({chars} chars)", title=page_title, chars=len(content)))
return "skipped"
content_b64 = encode_content(content)
if page_title in existing_pages:
# Update existing page via PATCH
sub_url = existing_pages[page_title]
client._request(
"PATCH",
f"/wiki/page/{sub_url}",
json={
"title": page_title,
"content_base64": content_b64,
"message": f"Sync from docs/ — update {page_title}",
},
)
return "updated"
# Create new page via POST /wiki/new
client._request(
"POST",
"/wiki/new",
json={
"title": page_title,
"content_base64": content_b64,
"message": f"Sync from docs/ — create {page_title}",
},
)
return "created"
def verify_wiki_page(
client: GiteaClient, page_title: str, expected_content: str, existing_pages: dict[str, str]
) -> bool:
"""Verify that a wiki page has non-empty content matching the docs.
Returns True if the page content matches, False otherwise.
"""
if page_title not in existing_pages:
return False
sub_url = existing_pages[page_title]
actual = fetch_page_content(client, sub_url)
return actual.strip() == expected_content.strip()
def verify_wiki_integrity(
client: GiteaClient,
mapping: dict[str, str],
synced: dict[str, str],
) -> list[str]:
"""Comprehensive wiki verification.
Checks:
1. Every mapped page exists in the wiki
2. Every mapped page has non-empty content
3. Every mapped page's content matches the docs
4. No stale pages exist in the wiki (pages not in mapping)
5. Page count matches
Returns a list of failure messages (empty if all checks pass).
"""
failures: list[str] = []
existing_pages = list_wiki_pages(client)
expected_titles = set(mapping.values())
# Check 1: Page count
if len(existing_pages) != len(expected_titles):
failures.append(f"Page count mismatch: wiki has {len(existing_pages)}, mapping has {len(expected_titles)}")
# Check 2: Missing pages (in mapping but not in wiki)
missing = expected_titles - set(existing_pages.keys())
for title in sorted(missing):
failures.append(f"Missing page: {title}")
# Check 3: Stale pages (in wiki but not in mapping)
stale = set(existing_pages.keys()) - expected_titles
for title in sorted(stale):
failures.append(f"Stale page (not in mapping): {title}")
# Check 4: Content verification
for page_title, expected_content in sorted(synced.items()):
ok = verify_wiki_page(client, page_title, expected_content, existing_pages)
if not ok:
sub_url = existing_pages.get(page_title, "?")
actual = fetch_page_content(client, sub_url)
if not actual.strip():
failures.append(f"Empty content: {page_title}")
else:
failures.append(f"Content mismatch: {page_title}")
return failures
@click.command()
@click.option("--dry-run", is_flag=True, default=False, help="Show what would happen without making changes.")
@click.option("--repo", default=None, help="Repository in owner/name format (auto-detected if omitted).")
@click.option(
"--verify",
is_flag=True,
default=False,
help="After syncing, verify each page has non-empty content. Exit 1 if any page is empty or mismatched.",
)
@click.option(
"--strict",
is_flag=True,
default=False,
help="Full integrity check: verify page count, missing pages, stale pages, and content. Implies --verify.",
)
def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
token = os.environ.get("REPO_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
if repo is None:
owner = os.environ.get("GRM_REPO_OWNER", "oblachno-oss")
repo_name = os.environ.get("GRM_REPO_NAME", "grm")
else:
owner, repo_name = repo.split("/")
if not MAPPING_FILE.exists():
raise click.ClickException(_("ERROR: mapping.json not found at {path}", path=MAPPING_FILE))
mapping = load_mapping()
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
click.echo(_("Syncing {count} documentation pages to wiki...", count=len(mapping)))
existing_pages = list_wiki_pages(client)
if existing_pages:
click.echo(_("Found {count} existing wiki pages.", count=len(existing_pages)))
created = 0
updated = 0
skipped = 0
synced: dict[str, str] = {} # title -> content, for verification
for file_path, page_title in sorted(mapping.items()):
try:
content = read_doc_content(file_path)
except FileNotFoundError:
click.echo(_("WARNING: File {file} not found — skipping.", file=file_path))
skipped += 1
continue
if not content.strip():
click.echo(_("WARNING: File {file} is empty — skipping.", file=file_path))
skipped += 1
continue
result = sync_page(client, page_title, content, existing_pages, dry_run)
if result == "created":
created += 1
click.echo(_(" Created: {title}", title=page_title))
elif result == "updated":
updated += 1
click.echo(_(" Updated: {title}", title=page_title))
else:
skipped += 1
synced[page_title] = content
click.echo(
_(
"\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
created=created,
updated=updated,
skipped=skipped,
)
)
# --strict implies --verify
do_verify = verify or strict
if do_verify and not dry_run:
if strict:
click.echo(_("\nRunning full wiki integrity check..."))
failures = verify_wiki_integrity(client, mapping, synced)
if failures:
click.echo(_("\nIntegrity check FAILED ({count} issues):", count=len(failures)))
for f in failures:
click.echo(f" - {f}")
raise click.ClickException(_("Wiki integrity check failed — {count} issue(s)", count=len(failures)))
click.echo(_("\nIntegrity check passed — all {count} pages verified.", count=len(synced)))
else:
click.echo(_("\nVerifying wiki pages have content..."))
# Re-fetch the page list to get updated sub_urls
existing_pages = list_wiki_pages(client)
failures = 0
for page_title, expected_content in sorted(synced.items()):
ok = verify_wiki_page(client, page_title, expected_content, existing_pages)
if ok:
click.echo(_(" OK: {title} ({chars} chars)", title=page_title, chars=len(expected_content)))
else:
click.echo(_(" FAIL: {title} — content mismatch or empty!", title=page_title))
failures += 1
if failures > 0:
click.echo(
_(
"\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
failures=failures,
)
)
raise click.ClickException(
_("Wiki verification failed — {failures} page(s) empty or mismatched", failures=failures)
)
click.echo(_("\nVerification passed — all wiki pages have correct content."))
if __name__ == "__main__": # pragma: no cover
main()
-93
View File
@@ -1,93 +0,0 @@
#!/usr/bin/env python3
"""Validate commit messages for GRM.
Rules:
- On feature branches: conventional commits ONLY, must NOT include GRM-N prefix.
- On master branch: must follow '<task-id>: <conventional commit>' pattern,
e.g. 'GRM-24: fix: resolve timeout'.
"""
import re
import subprocess # nosec B404
import click
from gitea_runner_manager.config import CONVENTIONAL_RE
from scripts.i18n import _
MASTER_TASK_ID_RE = re.compile(r"^GRM-\d+:")
def first_line(text: str) -> str:
return text.split("\n")[0]
def get_branch() -> str:
try:
result = subprocess.run( # nosec
["git", "symbolic-ref", "--short", "HEAD"],
capture_output=True,
text=True,
check=True,
)
return result.stdout.strip()
except subprocess.CalledProcessError:
return ""
@click.command()
@click.argument("commit_msg_file")
@click.option("--branch", default=None, help="Override branch detection (for CI use).")
def main(commit_msg_file: str, branch: str | None) -> None:
with open(commit_msg_file) as f:
msg = f.read().strip()
if branch is None:
branch = get_branch()
subject = first_line(msg)
if branch == "master":
if not MASTER_TASK_ID_RE.match(subject):
raise click.ClickException(
_(
"Oops! Master branch commits must start with a task ID.\n"
" Expected: GRM-N: <conventional commit message>\n"
" Got: {subject}",
subject=subject,
)
)
remainder = MASTER_TASK_ID_RE.sub("", subject).strip()
if not CONVENTIONAL_RE.match(remainder):
raise click.ClickException(
_(
"Oops! Master branch commit must follow conventional format after task ID.\n"
" Expected: GRM-N: <type>: <description>\n"
" Got: {subject}",
subject=subject,
)
)
return
if MASTER_TASK_ID_RE.match(subject):
raise click.ClickException(
_(
"Oops! Do not include task ID (GRM-N) in feature branch commits.\n"
" The task ID will be added automatically on merge via CI."
)
)
if not CONVENTIONAL_RE.match(subject):
raise click.ClickException(
_(
"Oops! Commit message must follow conventional commit format.\n"
" Expected: <type>: <description>\n"
" Got: {subject}\n"
" Allowed types: feat, fix, chore, docs, style, refactor,\n"
" perf, test, ci, build, revert, BREAKING CHANGE",
subject=subject,
)
)
if __name__ == "__main__": # pragma: no cover
main()
-82
View File
@@ -1,82 +0,0 @@
#!/usr/bin/env python3
"""Configure GRM repository: branch protection + repo settings via Gitea REST API.
Uses ``GiteaClient`` for branch protection and repo settings.
The ``tea`` CLI is used for label creation if available, with a
fallback to ``GiteaClient`` if tea is not installed.
Usage:
REPO_TOKEN=<token> python3 scripts/configure_repo.py
"""
import http
import os
from typing import cast
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from gitea_runner_manager.api_clients import GiteaClient
from gitea_runner_manager.config import (
BRANCH_PROTECTION_CONFIG,
GITEA_API_URL,
REPO_NAME,
REPO_OWNER,
REPO_SETTINGS_CONFIG,
)
from gitea_runner_manager.exceptions import APIError
from scripts.i18n import _
load_dotenv(override=True)
def _handle_http_error(e: APIError) -> None:
"""Raise a user-friendly Click exception for HTTP errors."""
if e.status == http.HTTPStatus.FORBIDDEN:
raise click.ClickException(
_(
"HTTP {status} Forbidden — your token lacks admin rights.\n"
"Make sure the token belongs to a repo owner or organisation admin.\n"
"Alternatively, configure branch protection manually in Settings → Branches.",
status=e.status,
)
)
raise click.ClickException(_("HTTP error: {status}{message}", status=e.status, message=e.message))
def main() -> None:
token = os.environ.get("REPO_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
client = GiteaClient(GITEA_API_URL, token, REPO_OWNER, REPO_NAME)
try:
click.echo(_("Configuring branch protection for {branch}...", branch="master"))
client.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG)
click.echo(_(" - Direct pushes: BLOCKED (require PR, whitelisted users can push)"))
click.echo(
_(
" - Required approvals: {count}",
count=BRANCH_PROTECTION_CONFIG["required_approvals"],
)
)
click.echo(_(" - Dismiss stale approvals: yes"))
click.echo(_(" - Block outdated branches: yes"))
click.echo(_(" - Block rejected reviews: yes"))
checks = ", ".join(cast(list[str], BRANCH_PROTECTION_CONFIG["status_check_contexts"]))
click.echo(_(" - Required status checks: {checks}", checks=checks))
click.echo("")
click.echo(_("Configuring repository settings..."))
client.update_repo_settings(cast(dict[str, object], REPO_SETTINGS_CONFIG))
click.echo(_(" - Auto-delete branch after merge: yes"))
click.echo("")
click.echo(_("Repository configuration complete."))
except APIError as e:
_handle_http_error(e)
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
-251
View File
@@ -1,251 +0,0 @@
#!/usr/bin/env python3
"""Generate self-contained SVG badge files from project metrics.
Runs pytest-cov, doc-coverage, lint checks, and version extraction,
then writes SVG badge files that can be served as static files from
the Gitea raw file API.
Usage:
python3 scripts/generate_badges.py --output-dir .badges/
"""
from __future__ import annotations
import re
import subprocess # nosec B404
from pathlib import Path
import click
REPO_ROOT = Path(__file__).resolve().parent.parent
INIT_FILE = REPO_ROOT / "src" / "gitea_runner_manager" / "__init__.py"
_COVERAGE_RE = re.compile(r"TOTAL.*?(\d+(?:\.\d+)?)%")
_PASSED_RE = re.compile(r"(\d+) passed")
_DOC_COVERAGE_RE = re.compile(r"Doc coverage:\s+\d+/\d+\s+\((\d+)%")
# shields.io color names to hex values
COLOR_HEX: dict[str, str] = {
"brightgreen": "#4c1",
"green": "#97ca00",
"yellowgreen": "#a4a61d",
"yellow": "#dfb317",
"orange": "#fe7d37",
"red": "#e05d44",
"blue": "#007ec6",
"lightgrey": "#9f9f9f",
}
def _xml_escape(text: str) -> str:
"""Escape XML special characters."""
return text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
def run_command(cmd: list[str]) -> tuple[int, str, str]:
"""Run a command and return (returncode, stdout, stderr)."""
result = subprocess.run( # nosec B603
cmd,
capture_output=True,
text=True,
check=False,
cwd=str(REPO_ROOT),
)
return result.returncode, result.stdout, result.stderr
def make_badge(label: str, message: str, color: str) -> dict[str, str | int]:
"""Build a badge data dict."""
return {
"schemaVersion": 1,
"label": label,
"message": message,
"color": color,
}
def render_svg(label: str, message: str, color: str) -> str:
"""Render a shields.io-style SVG badge."""
color_hex = COLOR_HEX.get(color, color if color.startswith("#") else "#9f9f9f")
# Approximate text width: 7px per character + 10px padding
label_text = _xml_escape(label)
message_text = _xml_escape(message)
label_w = max(len(label) * 7 + 10, 30)
message_w = max(len(message) * 7 + 10, 30)
total_w = label_w + message_w
return f'''<svg xmlns="http://www.w3.org/2000/svg" width="{total_w}" height="20" role="img"
aria-label="{label_text}: {message_text}">
<title>{label_text}: {message_text}</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#fff" stop-opacity=".7"/>
<stop offset=".1" stop-color="#bbb" stop-opacity=".1"/>
<stop offset=".9" stop-color="#000" stop-opacity=".3"/>
<stop offset="1" stop-color="#bbb" stop-opacity=".1"/>
</linearGradient>
<clipPath id="r"><rect width="{total_w}" height="20" rx="3" fill="#fff"/></clipPath>
<g clip-path="url(#r)">
<rect width="{label_w}" height="20" fill="#555"/>
<rect x="{label_w}" width="{message_w}" height="20" fill="{color_hex}"/>
<rect width="{total_w}" height="20" fill="url(#s)"/>
</g>
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
<text x="{label_w // 2}" y="14">{label_text}</text>
<text x="{label_w + message_w // 2}" y="14">{message_text}</text>
</g>
</svg>
'''
def extract_coverage(output: str) -> float | None:
"""Extract total coverage percentage from pytest-cov output."""
for line in output.splitlines():
match = _COVERAGE_RE.search(line)
if match:
return float(match.group(1))
return None
def extract_test_count(output: str) -> int | None:
"""Extract number of passed tests from pytest output."""
for line in output.splitlines():
match = _PASSED_RE.search(line)
if match:
return int(match.group(1))
return None
def extract_doc_coverage(output: str) -> int | None:
"""Extract doc coverage percentage from doc_coverage.py output."""
match = _DOC_COVERAGE_RE.search(output)
if match:
return int(match.group(1))
return None
def read_version() -> str:
"""Read __version__ from the package __init__.py."""
content = INIT_FILE.read_text()
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
if match:
return match.group(1)
return "unknown"
def coverage_color(pct: float) -> str:
"""Map coverage percentage to a color."""
if pct >= 100:
return "brightgreen"
if pct >= 90:
return "green"
if pct >= 80:
return "yellowgreen"
if pct >= 70:
return "yellow"
if pct >= 60:
return "orange"
return "red"
def doc_coverage_color(pct: int) -> str:
"""Map doc coverage percentage to a color."""
if pct >= 100:
return "brightgreen"
if pct >= 90:
return "green"
if pct >= 80:
return "yellowgreen"
if pct >= 70:
return "yellow"
return "orange"
def generate_badges(output_dir: Path) -> dict[str, dict[str, str | int]]:
"""Generate all badge SVG files and return badge data as a dict."""
badges: dict[str, dict[str, str | int]] = {}
# 1. Code coverage + test count (single pytest-cov run)
rc, stdout, stderr = run_command(
[
".venv/bin/pytest",
"tests/",
"-v",
"--cov=src/gitea_runner_manager",
"--cov=scripts",
"--cov-report=term-missing",
"--cov-fail-under=0",
]
)
combined = stdout + "\n" + stderr
coverage = extract_coverage(combined)
if coverage is not None:
badges["coverage"] = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage))
else:
badges["coverage"] = make_badge("coverage", "unknown", "red")
test_count = extract_test_count(combined)
if test_count is not None:
badges["tests"] = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red")
else:
badges["tests"] = make_badge("tests", "unknown", "red")
# 2. Documentation coverage
rc, stdout, _ = run_command(
[
".venv/bin/python3",
"scripts/ci/doc_coverage.py",
]
)
doc_pct = extract_doc_coverage(stdout)
if doc_pct is not None:
badges["docs"] = make_badge("docs", f"{doc_pct}%", doc_coverage_color(doc_pct))
else:
badges["docs"] = make_badge("docs", "unknown", "red")
# 3. Code quality (ruff + pyright + bandit all pass)
lint_rc, _, _ = run_command([".venv/bin/ruff", "check", "src/", "tests/", "scripts/"])
format_rc, _, _ = run_command([".venv/bin/ruff", "format", "--check", "src/", "tests/", "scripts/"])
type_rc, _, _ = run_command([".venv/bin/pyright"])
bandit_rc, _, _ = run_command([".venv/bin/bandit", "-r", "src/", "scripts/"])
all_pass = all(rc == 0 for rc in [lint_rc, format_rc, type_rc, bandit_rc])
badges["quality"] = make_badge("code quality", "A" if all_pass else "F", "brightgreen" if all_pass else "red")
# 4. Version
version = read_version()
badges["version"] = make_badge("version", f"v{version}", "blue")
# 5. Python version (static but nice)
badges["python"] = make_badge("python", "3.12", "blue")
# Write SVG files
output_dir.mkdir(parents=True, exist_ok=True)
for name, badge in badges.items():
svg = render_svg(str(badge["label"]), str(badge["message"]), str(badge["color"]))
path = output_dir / f"{name}.svg"
path.write_text(svg)
click.echo(f" Generated: {path}")
return badges
@click.command()
@click.option(
"--output-dir",
default=str(REPO_ROOT / ".badges"),
help="Directory to write badge SVG files.",
)
def cli(output_dir: str) -> None:
"""Generate self-contained SVG badge files from project metrics."""
out = Path(output_dir)
click.echo(f"Generating badges in {out}...")
badges = generate_badges(out)
click.echo(f"\nGenerated {len(badges)} badges:")
for name, badge in badges.items():
click.echo(f" {name}: {badge['label']}={badge['message']} ({badge['color']})")
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
-323
View File
@@ -1,323 +0,0 @@
#!/usr/bin/env python3
"""Thin Python wrapper around the ``tea`` Gitea CLI for CI/CD scripts.
This module provides a programmatic interface to the ``tea`` CLI tool,
parsing JSON output for structured data. It is used by CI scripts to
avoid hand-rolling HTTP requests and to leverage the official Gitea CLI
for reliability.
The wrapper requires ``tea`` to be installed and configured (run
``make setup`` which calls ``scripts/install_tools.py`` and
``scripts/setup.py``).
Operations supported via tea:
- Creating pull requests
- Creating issues
- Adding labels to issues/PRs
- Creating labels
- Merging pull requests
- Creating releases
- Posting reviews on PRs
- Listing branches
Operations NOT supported via tea (still use GiteaClient):
- Wiki page management
- Commit status checks
- Runner discovery
- PR file/commit listing (tea has limited support)
- Branch protection with detailed config (tea only has basic protect/unprotect)
Usage::
from scripts.gitea_cli import TeaCLI
tea = TeaCLI()
tea.create_issue("owner/repo", title="Bug", body="Description", labels=["bug"])
tea.add_label("owner/repo", 42, ["ready-to-merge"])
tea.create_release("owner/repo", tag="v1.0.0", title="Release 1.0.0", body="Notes")
"""
from __future__ import annotations
import json
import shutil
import subprocess # nosec B404
from typing import Any
class TeaCLIError(Exception):
"""Raised when a tea CLI command fails."""
class TeaCLI:
"""Wrapper around the ``tea`` Gitea CLI tool.
All methods parse JSON output from tea for structured access.
Commands are run with ``--output json`` where structured data is expected.
"""
def __init__(self, tea_bin: str | None = None, repo: str | None = None) -> None:
"""Initialize the tea CLI wrapper.
Args:
tea_bin: Path to the tea binary. If None, auto-detect via shutil.which.
repo: Default repo in ``owner/name`` format for commands that need it.
"""
self._tea = tea_bin or shutil.which("tea") or "tea"
self._repo = repo
def _run(self, args: list[str], json_output: bool = True) -> str:
"""Run a tea command and return stdout.
Args:
args: Command arguments (without the leading ``tea``).
json_output: If True, append ``--output json`` to the command.
Returns:
stdout as a string.
Raises:
TeaCLIError: If the command fails.
"""
cmd = [self._tea, *args]
if json_output:
cmd.extend(["--output", "json"])
result = subprocess.run( # nosec B603
cmd,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise TeaCLIError(
f"tea command failed (rc={result.returncode}): {' '.join(args)}\nstderr: {result.stderr.strip()}"
)
return result.stdout.strip()
def _run_raw(self, args: list[str]) -> str:
"""Run a tea command without JSON output and return stdout."""
return self._run(args, json_output=False)
def _repo_arg(self, repo: str | None = None) -> list[str]:
"""Build the --repo argument list."""
target = repo or self._repo
if target:
return ["--repo", target]
return []
# -- Issues --
def create_issue(
self,
repo: str,
title: str,
body: str = "",
labels: list[str] | None = None,
) -> dict[str, Any]:
"""Create an issue and return the issue dict.
Args:
repo: Repository in ``owner/name`` format.
title: Issue title.
body: Issue body (markdown).
labels: List of label names to attach.
Returns:
The created issue as a dict (parsed from tea JSON output).
"""
args = ["issues", "create", "--title", title, "--body", body, *self._repo_arg(repo)]
output = self._run(args, json_output=False)
# tea issues create doesn't output JSON; extract issue number from output
# Format: "Created issue #42: <title>"
issue_index = _extract_issue_number(output)
return {"title": title, "body": body, "index": issue_index, "url": output.strip()}
# -- Labels --
def list_labels(self, repo: str) -> list[dict[str, Any]]:
"""List all labels for a repository."""
output = self._run(["labels", "list", *self._repo_arg(repo)])
if not output:
return []
return json.loads(output)
def create_label(
self,
repo: str,
name: str,
color: str = "",
description: str = "",
) -> dict[str, Any]:
"""Create a label. Returns the label dict.
Args:
repo: Repository in ``owner/name`` format.
name: Label name.
color: Hex color (without #), e.g. ``2ecc71``.
description: Label description.
"""
args = ["labels", "create", name, *self._repo_arg(repo)]
if color:
args.extend(["--color", f"#{color}"])
if description:
args.extend(["--description", description])
output = self._run(args, json_output=False)
return {"name": name, "color": color, "description": description, "output": output}
def add_label(self, repo: str, issue_index: int, labels: list[str]) -> None:
"""Add labels to an issue or PR.
Args:
repo: Repository in ``owner/name`` format.
issue_index: Issue or PR number.
labels: List of label names to add.
"""
for _label in labels:
self._run_raw(["issues", "edit", "--add-labels", ",".join(labels), str(issue_index), *self._repo_arg(repo)])
return # tea edit handles all labels at once
# No labels to add — nothing to do
# -- Pull Requests --
def create_pr(
self,
repo: str,
title: str,
head: str,
base: str,
body: str = "",
) -> dict[str, Any]:
"""Create a pull request and return the PR dict.
Args:
repo: Repository in ``owner/name`` format.
title: PR title.
head: Head branch name.
base: Base branch name.
body: PR description (markdown).
"""
args = [
"pulls",
"create",
"--title",
title,
"--base",
base,
"--head",
head,
*self._repo_arg(repo),
]
if body:
args.extend(["--body", body])
output = self._run(args, json_output=False)
pr_index = _extract_pr_number(output)
return {"title": title, "index": pr_index, "url": output.strip()}
def merge_pr(self, repo: str, pr_index: int, style: str = "squash") -> None:
"""Merge a pull request.
Args:
repo: Repository in ``owner/name`` format.
pr_index: PR number.
style: Merge style: ``squash``, ``merge``, ``rebase``, ``rebase-edit``.
"""
self._run_raw(["pulls", "merge", "--style", style, str(pr_index), *self._repo_arg(repo)])
def review_pr(
self,
repo: str,
pr_index: int,
event: str = "COMMENT",
body: str = "",
) -> None:
"""Post a review on a pull request.
Args:
repo: Repository in ``owner/name`` format.
pr_index: PR number.
event: Review event: ``APPROVE``, ``REQUEST_CHANGES``, ``COMMENT``.
body: Review body text.
"""
args = ["pulls", "review", str(pr_index), *self._repo_arg(repo)]
if event == "APPROVE":
args.append("--approve")
elif event == "REQUEST_CHANGES":
args.extend(["--reject"])
if body:
args.extend(["--comment", body])
self._run_raw(args)
# -- Releases --
def create_release(
self,
repo: str,
tag: str,
title: str = "",
body: str = "",
target: str = "",
draft: bool = False,
prerelease: bool = False,
) -> dict[str, Any]:
"""Create a release and return the release dict.
Args:
repo: Repository in ``owner/name`` format.
tag: Tag name (e.g. ``v1.0.0``).
title: Release title.
body: Release notes (markdown).
target: Target branch/commit for the tag.
draft: If True, create as draft.
prerelease: If True, mark as prerelease.
"""
args = ["releases", "create", tag, *self._repo_arg(repo)]
if title:
args.extend(["--title", title])
if body:
args.extend(["--note", body])
if target:
args.extend(["--target", target])
if draft:
args.append("--draft")
if prerelease:
args.append("--prerelease")
output = self._run(args, json_output=False)
return {"tag": tag, "title": title, "url": output.strip()}
def list_releases(self, repo: str) -> list[dict[str, Any]]:
"""List all releases for a repository."""
output = self._run(["releases", "list", *self._repo_arg(repo)])
if not output:
return []
return json.loads(output)
# -- Branches --
def list_branches(self, repo: str) -> list[dict[str, Any]]:
"""List all branches for a repository."""
output = self._run(["branches", "list", *self._repo_arg(repo)])
if not output:
return []
return json.loads(output)
# -- Utility --
def whoami(self) -> str:
"""Return the current authenticated user."""
return self._run_raw(["whoami"])
def _extract_issue_number(output: str) -> int:
"""Extract the issue number from tea output like 'Created issue #42: ...'."""
for part in output.split():
if part.startswith("#"):
try:
return int(part[1:].rstrip(":"))
except ValueError:
continue
return 0
def _extract_pr_number(output: str) -> int:
"""Extract the PR number from tea output like 'Created PR #42: ...'."""
return _extract_issue_number(output)
-31
View File
@@ -1,31 +0,0 @@
"""i18n for CI/dev scripts.
Separate from the GRM tool's i18n (``gitea_runner_manager.i18n``) so that
CI-only translation keys don't bloat the packaged CLI.
Set GRM_LANG environment variable to override the default English.
Supported: en, bg, de, ru, zh.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
TRANSLATIONS: dict[str, dict[str, str]] = json.loads(
(Path(__file__).parent / "translations.json").read_text(encoding="utf-8")
)
def _(key: str, **kwargs: object) -> str:
"""Return a translated string for the given key.
Translation is opt-in via the ``GRM_LANG`` environment variable.
If unset, English is always returned regardless of system locale.
"""
lang = os.getenv("GRM_LANG", "en")
if lang not in ("en", "bg", "de", "ru", "zh"):
lang = "en"
template = TRANSLATIONS.get(key, {}).get(lang, key)
return template.format(**kwargs)
-69
View File
@@ -1,69 +0,0 @@
#!/usr/bin/env python3
"""Install checkmake if it is not already present.
Tries to install via Go if available, otherwise downloads the latest
pre-built Linux binary from the official GitHub releases.
"""
from __future__ import annotations
import platform
import shutil
import subprocess # nosec B404
import urllib.request
from pathlib import Path
import click
CHECKMAKE_VERSION = "0.3.2"
RELEASE_URL_TEMPLATE = (
"https://github.com/checkmake/checkmake/releases/download/"
f"v{CHECKMAKE_VERSION}/checkmake-v{CHECKMAKE_VERSION}.linux.{{arch}}"
)
TARGET_PATH = Path("/usr/local/bin/checkmake")
def _arch() -> str:
"""Return the architecture string used by checkmake releases."""
machine = platform.machine().lower()
if machine in {"x86_64", "amd64"}:
return "amd64"
if machine in {"aarch64", "arm64"}:
return "arm64"
raise click.ClickException(f"Unsupported architecture: {machine}")
def _install_with_go() -> bool:
"""Install checkmake using go install if Go is available."""
go_bin = shutil.which("go")
if go_bin is None:
return False
subprocess.run( # nosec B603
[
go_bin,
"install",
"github.com/checkmake/checkmake/cmd/checkmake@latest",
],
check=True,
)
return True
def _download_binary() -> None:
"""Download the prebuilt checkmake binary for the current architecture."""
url = RELEASE_URL_TEMPLATE.format(arch=_arch())
urllib.request.urlretrieve(url, TARGET_PATH) # nosec B310
TARGET_PATH.chmod(0o755)
def main() -> None:
"""Install checkmake if not already present."""
if shutil.which("checkmake") is not None:
return
if not _install_with_go():
_download_binary()
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
-219
View File
@@ -1,219 +0,0 @@
#!/usr/bin/env python3
"""Install CI/CD development tools that are not Python packages.
Handles installation of:
- actionlint (workflow YAML linter)
- git-cliff (changelog generator)
- act_runner (Gitea Actions local runner, optional)
- tea (Gitea CLI official command-line tool for Gitea API operations)
Each tool is installed to ``~/.local/bin`` if not already on PATH.
Idempotent: skips tools that are already available.
Usage::
python3 scripts/install_tools.py # install all
python3 scripts/install_tools.py --tool actionlint # install one
python3 scripts/install_tools.py --list # list status
"""
from __future__ import annotations
import os
import platform
import shutil
import tarfile
import tempfile
import urllib.request
from pathlib import Path
import click
TARGET_DIR = Path.home() / ".local" / "bin"
ACTIONLINT_VERSION = "1.7.12"
GIT_CLIFF_VERSION = "2.13.0"
ACT_RUNNER_VERSION = "0.2.11"
TEA_VERSION = "0.14.1"
def _arch() -> str:
"""Return the architecture string used by release assets."""
machine = platform.machine().lower()
if machine in {"x86_64", "amd64"}:
return "amd64"
if machine in {"aarch64", "arm64"}:
return "arm64"
raise click.ClickException(f"Unsupported architecture: {machine}")
def _ensure_target_dir() -> Path:
"""Ensure the target directory exists and return it."""
TARGET_DIR.mkdir(parents=True, exist_ok=True)
return TARGET_DIR
def _download(url: str, dest: Path) -> None:
"""Download a file from ``url`` to ``dest``."""
urllib.request.urlretrieve(url, dest) # nosec B310
def _download_and_extract_tarball(url: str, binary_name: str) -> Path:
"""Download a tarball, extract the binary, and install it to TARGET_DIR.
Returns the path to the installed binary.
"""
target_dir = _ensure_target_dir()
dest = target_dir / binary_name
with tempfile.TemporaryDirectory() as tmpdir:
tarball = Path(tmpdir) / "archive.tar.gz"
_download(url, tarball)
with tarfile.open(tarball, "r:gz") as tar:
tar.extractall(tmpdir) # nosec B202
# Find the binary in the extracted tree
extracted = Path(tmpdir).rglob(binary_name)
found = next(extracted, None)
if found is None:
raise click.ClickException(f"Binary {binary_name} not found in archive from {url}")
shutil.copy2(found, dest)
dest.chmod(0o755)
return dest
def _download_binary(url: str, binary_name: str) -> Path:
"""Download a standalone binary and install it to TARGET_DIR.
Returns the path to the installed binary.
"""
target_dir = _ensure_target_dir()
dest = target_dir / binary_name
_download(url, dest)
dest.chmod(0o755)
return dest
def _is_installed(name: str) -> bool:
"""Check if a tool is already on PATH or in TARGET_DIR."""
if shutil.which(name) is not None:
return True
return (TARGET_DIR / name).exists()
def install_actionlint() -> bool:
"""Install actionlint if not already present. Returns True if installed/skipped."""
if _is_installed("actionlint"):
click.echo("actionlint: already installed")
return True
arch = _arch()
url = (
f"https://github.com/rhysd/actionlint/releases/download/"
f"v{ACTIONLINT_VERSION}/actionlint_{ACTIONLINT_VERSION}_linux_{arch}.tar.gz"
)
dest = _download_and_extract_tarball(url, "actionlint")
click.echo(f"actionlint: installed to {dest}")
return True
def install_git_cliff() -> bool:
"""Install git-cliff if not already present. Returns True if installed/skipped."""
if _is_installed("git-cliff"):
click.echo("git-cliff: already installed")
return True
arch = _arch()
url = (
f"https://github.com/orhun/git-cliff/releases/download/"
f"v{GIT_CLIFF_VERSION}/git-cliff-{GIT_CLIFF_VERSION}-{arch}-unknown-linux-gnu.tar.gz"
)
dest = _download_and_extract_tarball(url, "git-cliff")
click.echo(f"git-cliff: installed to {dest}")
return True
def install_act_runner() -> bool:
"""Install act_runner if not already present. Returns True if installed/skipped."""
if _is_installed("act_runner"):
click.echo("act_runner: already installed")
return True
arch = _arch()
url = (
f"https://gitea.com/gitea/act_runner/releases/download/"
f"v{ACT_RUNNER_VERSION}/act_runner-{ACT_RUNNER_VERSION}-linux-{arch}"
)
dest = _download_binary(url, "act_runner")
click.echo(f"act_runner: installed to {dest}")
return True
def install_tea() -> bool:
"""Install tea (Gitea CLI) if not already present. Returns True if installed/skipped."""
if _is_installed("tea"):
click.echo("tea: already installed")
return True
arch = _arch()
url = f"https://dl.gitea.com/tea/{TEA_VERSION}/tea-{TEA_VERSION}-linux-{arch}"
dest = _download_binary(url, "tea")
click.echo(f"tea: installed to {dest}")
return True
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea"]
def _install_tool(name: str) -> bool:
"""Install a single tool by name."""
if name == "actionlint":
return install_actionlint()
if name == "git-cliff":
return install_git_cliff()
if name == "act_runner":
return install_act_runner()
if name == "tea":
return install_tea()
raise click.ClickException(f"Unknown tool: {name}")
def list_tools() -> None:
"""Print the installation status of all tools."""
for name in TOOL_NAMES:
status = "installed" if _is_installed(name) else "not installed"
click.echo(f" {name}: {status}")
@click.command()
@click.option(
"--tool",
"tools",
multiple=True,
type=click.Choice(TOOL_NAMES),
help="Install specific tool(s). Can be repeated.",
)
@click.option("--list", "list_status", is_flag=True, help="List tool installation status.")
def main(tools: tuple[str, ...], list_status: bool) -> None:
"""Install CI/CD development tools to ~/.local/bin."""
if list_status:
list_tools()
return
tools_to_install = list(tools) if tools else TOOL_NAMES
failed: list[str] = []
for name in tools_to_install:
try:
_install_tool(name)
except Exception as exc:
click.echo(f" {name}: FAILED — {exc}", err=True)
failed.append(name)
if failed:
raise click.ClickException(f"Failed to install: {', '.join(failed)}")
# Remind user to add ~/.local/bin to PATH if not already there
path_env = os.environ.get("PATH", "")
if str(TARGET_DIR) not in path_env:
click.echo(f"\nAdd {TARGET_DIR} to your PATH to use these tools.")
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
-92
View File
@@ -1,92 +0,0 @@
#!/usr/bin/env python3
"""Run all molecule scenarios on all supported OS platforms.
Replaces the previous ``scripts/molecule_all.sh`` with a tested Python equivalent.
Sequential execution CI uses the parallel matrix instead.
Usage::
python3 scripts/molecule_all.py
python3 scripts/molecule_all.py --bin .venv/bin
"""
from __future__ import annotations
import os
import subprocess # nosec B404
import sys
from pathlib import Path
import click
from scripts.ci.platforms import PLATFORMS
ROLE_DIR = Path("ansible/roles/gitea-runner")
SCENARIOS = ["default", "multi-instance", "lifecycle", "template-content", "deregister", "update"]
def _run_molecule(molecule_bin: str, scenario: str, role_dir: Path, env: dict[str, str]) -> int:
"""Run a single molecule scenario. Returns the exit code."""
cmd = [molecule_bin, "test"]
if scenario != "default":
cmd.extend(["-s", scenario])
click.echo(f"--- Scenario: {scenario} ---")
result = subprocess.run( # nosec B603
cmd,
cwd=str(role_dir),
env=env,
)
return result.returncode
def _run_platform(
molecule_bin: str,
platform: dict[str, str],
role_dir: Path,
scenarios: list[str],
base_env: dict[str, str],
) -> int:
"""Run all scenarios for a single platform. Returns the first non-zero exit code."""
env = dict(base_env)
env["MOLECULE_PLATFORM_NAME"] = platform["name"]
env["MOLECULE_PLATFORM_IMAGE"] = platform["image"]
if platform.get("command"):
env["MOLECULE_PLATFORM_COMMAND"] = platform["command"]
else:
env.pop("MOLECULE_PLATFORM_COMMAND", None)
click.echo(f"=== Platform: {platform['name']} ===")
for scenario in scenarios:
rc = _run_molecule(molecule_bin, scenario, role_dir, env)
if rc != 0:
return rc
return 0
@click.command()
@click.option("--bin", "bin_dir", default=".venv/bin", help="Path to the virtualenv bin directory.")
def main(bin_dir: str) -> None:
"""Run all molecule scenarios on all supported OS platforms sequentially."""
molecule_bin = str(Path(bin_dir) / "molecule")
if not Path(molecule_bin).exists():
raise click.ClickException(f"molecule not found at {molecule_bin}. Run 'make setup' first.")
if not ROLE_DIR.exists():
raise click.ClickException(f"Role directory not found: {ROLE_DIR}")
base_env = dict(os.environ)
base_env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] = "true"
base_env["ANSIBLE_INJECT_INVOCATION"] = "1"
for platform in PLATFORMS:
rc = _run_platform(molecule_bin, platform, ROLE_DIR, SCENARIOS, base_env)
if rc != 0:
click.echo(f"FAILED on platform {platform['name']}", err=True)
sys.exit(rc)
click.echo("All molecule scenarios passed on all platforms.")
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
-184
View File
@@ -1,184 +0,0 @@
#!/usr/bin/env python3
"""Project setup: install Python deps, Ansible collections, and pre-commit hooks.
Also configures the ``tea`` Gitea CLI login profile from ``.env`` so that
CI scripts and dev tools can use ``tea`` for Gitea API operations.
Usage::
python3 scripts/setup.py --bin .venv/bin
"""
from __future__ import annotations
import os
import shutil
import subprocess # nosec B404
from pathlib import Path
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
load_dotenv(override=True)
def _run(cmd: list[str], bin_dir: str) -> None:
"""Run a command, streaming output to stdout/stderr."""
click.echo(f" $ {' '.join(cmd)}")
subprocess.run(cmd, check=True) # nosec B603
def _install_python_deps(bin_dir: str, extras: str = "dev") -> None:
"""Install the project with the specified extras in editable mode."""
pip = str(Path(bin_dir) / "pip")
_run([pip, "install", "-e", f".[{extras}]"], bin_dir)
def _install_ansible_collections(bin_dir: str) -> None:
"""Install required Ansible Galaxy collections."""
galaxy = str(Path(bin_dir) / "ansible-galaxy")
requirements = Path("ansible/requirements.yml")
if not requirements.exists():
click.echo(" ansible/requirements.yml not found — skipping collections.")
return
_run([galaxy, "collection", "install", "-r", str(requirements)], bin_dir)
def _install_pre_commit_hooks(bin_dir: str) -> None:
"""Install pre-commit hooks for commit-msg, pre-commit, and pre-push."""
pre_commit = str(Path(bin_dir) / "pre-commit")
for hook_type in ["pre-commit", "commit-msg", "pre-push"]:
_run([pre_commit, "install", "--hook-type", hook_type], bin_dir)
def _configure_tea_login() -> None:
"""Configure tea CLI login from .env if REPO_TOKEN is set.
Idempotent: if a login with the same name already exists, it is not re-added.
Fails if tea is not installed (run ``make install-tools`` first).
Skips if REPO_TOKEN is not set (local dev without token).
"""
tea_bin = shutil.which("tea")
if tea_bin is None:
raise click.ClickException("tea: not installed — run 'make install-tools' to install it.")
token = os.environ.get("REPO_TOKEN", "")
if not token:
click.echo("tea: REPO_TOKEN not set — skipping login configuration.")
return
# Derive the Gitea URL from the API URL (strip /api/v1 suffix)
api_url = os.environ.get("GRM_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
gitea_url = api_url.replace("/api/v1", "")
login_name = "grm"
# Check if login already exists
result = subprocess.run( # nosec B603
[tea_bin, "login", "list", "--output", "simple"],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0 and login_name in result.stdout:
click.echo(f"tea: login '{login_name}' already configured.")
return
# Add login profile
click.echo(f"tea: configuring login '{login_name}' for {gitea_url}...")
add_result = subprocess.run( # nosec B603
[tea_bin, "login", "add", "--name", login_name, "--url", gitea_url, "--token", token],
capture_output=True,
text=True,
check=False,
)
if add_result.returncode != 0:
click.echo(f"tea: login configuration failed: {add_result.stderr.strip()}", err=True)
else:
# Set as default login
subprocess.run( # nosec B603
[tea_bin, "login", "default", login_name],
capture_output=True,
text=True,
check=False,
)
click.echo(f"tea: login '{login_name}' configured and set as default.")
def _verify(bin_dir: str) -> None:
"""Print versions of installed tools for verification."""
grm = str(Path(bin_dir) / "grm")
pre_commit = str(Path(bin_dir) / "pre-commit")
for tool in [grm, pre_commit]:
try:
result = subprocess.run([tool, "--version"], capture_output=True, text=True, timeout=10) # nosec B603
if result.returncode == 0:
click.echo(f" {result.stdout.strip()}")
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
@click.command()
@click.option("--bin", "bin_dir", default=".venv/bin", help="Path to the virtualenv bin directory.")
@click.option(
"--extras",
default="dev",
help="Dependency group to install: ci, lint, molecule, or dev (default: dev).",
)
@click.option(
"--no-ansible-collections",
is_flag=True,
default=False,
help="Skip Ansible Galaxy collections installation.",
)
@click.option(
"--no-pre-commit",
is_flag=True,
default=False,
help="Skip pre-commit hook installation.",
)
@click.option(
"--no-tea-login",
is_flag=True,
default=False,
help="Skip tea CLI login configuration.",
)
def main(
bin_dir: str,
extras: str,
no_ansible_collections: bool,
no_pre_commit: bool,
no_tea_login: bool,
) -> None:
"""Install Python deps, Ansible collections, and pre-commit hooks."""
if not Path(bin_dir).exists():
raise click.ClickException(f"Bin directory not found: {bin_dir}. Run 'python3 -m venv .venv' first.")
click.echo(f"Installing Python dependencies (extras: {extras})...")
_install_python_deps(bin_dir, extras)
if not no_ansible_collections:
click.echo("Installing Ansible collections...")
_install_ansible_collections(bin_dir)
if not no_pre_commit:
click.echo("Installing pre-commit hooks...")
_install_pre_commit_hooks(bin_dir)
if not no_tea_login:
click.echo("Configuring tea CLI login...")
_configure_tea_login()
click.echo("")
click.echo("Setup complete.")
click.echo("Activate the virtual environment with one of:")
click.echo(" source .venv/bin/activate (generic)")
click.echo(" source activate.sh (bash)")
click.echo(" source activate.fish (fish)")
click.echo(" source activate.zsh (zsh)")
click.echo("")
_verify(bin_dir)
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
-503
View File
@@ -1,503 +0,0 @@
{
"\nAll documentation coverage checks passed!": {
"en": "\nAll documentation coverage checks passed!"
},
"\nAnsible files changed ({count}):": {
"en": "\nAnsible files changed ({count}):"
},
"\nChecking CI script documentation in ci-cd-workflow.md...": {
"en": "\nChecking CI script documentation in ci-cd-workflow.md..."
},
"\nChecking module documentation in architecture.md...": {
"en": "\nChecking module documentation in architecture.md..."
},
"\nDoc coverage: {covered}/{total} ({pct}%)": {
"en": "\nDoc coverage: {covered}/{total} ({pct}%)"
},
"\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}": {
"en": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}"
},
"\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.": {
"en": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce."
},
"\nIntegrity check FAILED ({count} issues):": {
"en": "\nIntegrity check FAILED ({count} issues):"
},
"\nIntegrity check passed — all {count} pages verified.": {
"en": "\nIntegrity check passed — all {count} pages verified."
},
"\nMissing documentation:": {
"en": "\nMissing documentation:"
},
"\nResult: {status}": {
"en": "\nResult: {status}"
},
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).": {
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)."
},
"\nRunning full wiki integrity check...": {
"en": "\nRunning full wiki integrity check..."
},
"\nUser-facing changes ({count}):": {
"en": "\nUser-facing changes ({count}):"
},
"\nUser-facing files changed ({count}):": {
"en": "\nUser-facing files changed ({count}):"
},
"\nVerification FAILED: {failures} page(s) have empty or mismatched content!": {
"en": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!"
},
"\nVerification passed — all wiki pages have correct content.": {
"en": "\nVerification passed — all wiki pages have correct content."
},
"\nVerifying wiki pages have content...": {
"en": "\nVerifying wiki pages have content..."
},
"\nWorkflow-only changes ({count}):": {
"en": "\nWorkflow-only changes ({count}):"
},
"\n[dry-run] Changelog:\n{changelog}": {
"en": "\n[dry-run] Changelog:\n{changelog}"
},
" - Auto-delete branch after merge: yes": {
"en": " - Auto-delete branch after merge: yes",
"bg": " - Автоматично изтриване на клон след сливане: да",
"de": " - Branch nach Merge automatisch löschen: ja",
"ru": " - Автоудаление ветки после слияния: да",
"zh": " - 合并后自动删除分支: 是"
},
" - Block outdated branches: yes": {
"en": " - Block outdated branches: yes",
"bg": " - Блокиране на остарели клонове: да",
"de": " - Veraltete Branches blockieren: ja",
"ru": " - Блокировать устаревшие ветки: да",
"zh": " - 阻止过时分支: 是"
},
" - Block rejected reviews: yes": {
"en": " - Block rejected reviews: yes",
"bg": " - Блокиране на отхвърлени рецензии: да",
"de": " - Abgelehnte Reviews blockieren: ja",
"ru": " - Блокировать отклонённые ревью: да",
"zh": " - 阻止被拒绝的审查: 是"
},
" - Direct pushes: BLOCKED (require PR, whitelisted users can push)": {
"en": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)"
},
" - Dismiss stale approvals: yes": {
"en": " - Dismiss stale approvals: yes",
"bg": " - Анулиране на остарели одобрения: да",
"de": " - Veraltete Genehmigungen ablehnen: ja",
"ru": " - Отклонять устаревшие одобрения: да",
"zh": " - 忽略过时审批: 是"
},
" - Required approvals: {count}": {
"en": " - Required approvals: {count}",
"bg": " - Необходими одобрения: {count}",
"de": " - Erforderliche Genehmigungen: {count}",
"ru": " - Требуемые одобрения: {count}",
"zh": " - 必需审批数: {count}"
},
" - Required status checks: {checks}": {
"en": " - Required status checks: {checks}",
"bg": " - Необходими проверки на състоянието: {checks}",
"de": " - Erforderliche Status-Checks: {checks}",
"ru": " - Требуемые проверки статуса: {checks}",
"zh": " - 必需状态检查: {checks}"
},
" Created: {title}": {
"en": " Created: {title}"
},
" FAIL: {title} — content mismatch or empty!": {
"en": " FAIL: {title} — content mismatch or empty!"
},
" MISSING: grm {cmd}": {
"en": " MISSING: grm {cmd}"
},
" MISSING: {module}": {
"en": " MISSING: {module}"
},
" MISSING: {script}": {
"en": " MISSING: {script}"
},
" OK: grm {cmd}": {
"en": " OK: grm {cmd}"
},
" OK: {module}": {
"en": " OK: {module}"
},
" OK: {script}": {
"en": " OK: {script}"
},
" OK: {title} ({chars} chars)": {
"en": " OK: {title} ({chars} chars)"
},
" Updated: {title}": {
"en": " Updated: {title}"
},
"API poll warning: {exc}": {
"en": "API poll warning: {exc}"
},
"All molecule tests passed.": {
"en": "All molecule tests passed."
},
"Another molecule runner failed. Stopping this runner early.": {
"en": "Another molecule runner failed. Stopping this runner early."
},
"Bumping version: {current} -> v{new_version}": {
"en": "Bumping version: {current} -> v{new_version}"
},
"Checking CLI command documentation...": {
"en": "Checking CLI command documentation..."
},
"Command failed ({cmd}): {stderr}": {
"en": "Command failed ({cmd}): {stderr}"
},
"Comparing {base}..{head} ({count} files changed)": {
"en": "Comparing {base}..{head} ({count} files changed)"
},
"Configuring branch protection for {branch}...": {
"en": "Configuring branch protection for {branch}...",
"bg": "Конфигуриране на защита на клона {branch}...",
"de": "Konfiguriere Branch-Schutz für {branch}...",
"ru": "Настройка защиты ветки {branch}...",
"zh": "正在配置 {branch} 的分支保护..."
},
"Configuring repository settings...": {
"en": "Configuring repository settings...",
"bg": "Конфигуриране на настройките на хранилището...",
"de": "Repository-Einstellungen konfigurieren...",
"ru": "Настройка параметров репозитория...",
"zh": "正在配置仓库设置..."
},
"Could not extract conventional commit message from PR commits.": {
"en": "Could not extract conventional commit message from PR commits."
},
"Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.": {
"en": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task."
},
"Could not find __version__ in {file}": {
"en": "Could not find __version__ in {file}"
},
"Could not parse test execution time from output.": {
"en": "Could not parse test execution time from output."
},
"Created issue #{issue_id}: {title}": {
"en": "Created issue #{issue_id}: {title}"
},
"Created release commit.": {
"en": "Created release commit."
},
"Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.": {
"en": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently."
},
"ERROR: REPO_TOKEN is not set.": {
"en": "ERROR: REPO_TOKEN is not set.",
"bg": "ГРЕШКА: REPO_TOKEN не е зададен.",
"de": "FEHLER: REPO_TOKEN ist nicht gesetzt.",
"ru": "ОШИБКА: REPO_TOKEN не задан.",
"zh": "错误:未设置 REPO_TOKEN。"
},
"ERROR: VIKUNJA_TOKEN is not set.": {
"en": "ERROR: VIKUNJA_TOKEN is not set.",
"bg": "ГРЕШКА: VIKUNJA_TOKEN не е зададен.",
"de": "FEHLER: VIKUNJA_TOKEN ist nicht gesetzt.",
"ru": "ОШИБКА: VIKUNJA_TOKEN не задан.",
"zh": "错误:未设置 VIKUNJA_TOKEN。"
},
"ERROR: mapping.json not found at {path}": {
"en": "ERROR: mapping.json not found at {path}"
},
"FAILED: {pair} exited with code {code}": {
"en": "FAILED: {pair} exited with code {code}"
},
"Failed to create issue via tea: {error}": {
"en": "Failed to create issue via tea: {error}"
},
"Found {count} existing wiki pages.": {
"en": "Found {count} existing wiki pages."
},
"GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.": {
"en": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."
},
"HEAD is already a release commit ('{msg}'). Another release may have just completed. Skipping.": {
"en": "HEAD is already a release commit ('{msg}'). Another release may have just completed. Skipping."
},
"HTTP error: {status} — {message}": {
"en": "HTTP error: {status} — {message}",
"bg": "HTTP грешка: {status} — {message}",
"de": "HTTP-Fehler: {status} — {message}",
"ru": "Ошибка HTTP: {status} — {message}",
"zh": "HTTP 错误: {status} — {message}"
},
"HTTP {status} Forbidden — your token lacks admin rights.\nMake sure the token belongs to a repo owner or organisation admin.\nAlternatively, configure branch protection manually in Settings → Branches.": {
"en": "HTTP {status} Forbidden — your token lacks admin rights.\nMake sure the token belongs to a repo owner or organisation admin.\nAlternatively, configure branch protection manually in Settings → Branches.",
"bg": "HTTP {status} Забранено — вашият токен няма администраторски права.\nУверете се, че токенът принадлежи на собственик на хранилище или администратор на организация.\nАлтернативно, конфигурирайте защитата на клона ръчно в Настройки → Клонове.",
"de": "HTTP {status} Verboten — Ihr Token hat keine Admin-Rechte.\nStellen Sie sicher, dass das Token einem Repository-Besitzer oder Organisations-Admin gehört.\nAlternativ können Sie den Branch-Schutz manuell unter Einstellungen → Branches konfigurieren.",
"ru": "HTTP {status} Запрещено — у вашего токена нет прав администратора.\nУбедитесь, что токен принадлежит владельцу репозитория или администратору организации.\nЛибо настройте защиту ветки вручную в разделе Настройки → Ветки.",
"zh": "HTTP {status} 禁止访问 — 您的令牌缺少管理员权限。\n请确保令牌属于仓库所有者或组织管理员。\n或者,您可以在 设置 → 分支 中手动配置分支保护。"
},
"Head branch is behind master. Pulling and rebasing...": {
"en": "Head branch is behind master. Pulling and rebasing..."
},
"Infrastructure commit (no GRM-N task ID), skipping Vikunja update: {msg}": {
"en": "Infrastructure commit (no GRM-N task ID), skipping Vikunja update: {msg}"
},
"Lint failed — refusing to release. Fix lint errors first.\n{stderr}": {
"en": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}"
},
"Lint passed.": {
"en": "Lint passed."
},
"Merge failed after rebase retry: {error}\nPlease rebase the PR manually.": {
"en": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually."
},
"Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.": {
"en": "Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.",
"bg": "Сливането неуспешно с HTTP {status}: {message}\nПроверете дали PR е готов и имате права за сливане.",
"de": "Merge fehlgeschlagen mit HTTP {status}: {message}\nBitte prüfen Sie, ob der PR bereit ist und Sie Merge-Rechte haben.",
"ru": "Слияние не удалось: HTTP {status}: {message}\nПроверьте, что PR готов и у вас есть права на слияние.",
"zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。"
},
"Molecule directory not found: {path}": {
"en": "Molecule directory not found: {path}",
"bg": "Директорията на molecule не е намерена: {path}",
"de": "Molecule-Verzeichnis nicht gefunden: {path}",
"ru": "Директория molecule не найдена: {path}",
"zh": "未找到 molecule 目录: {path}"
},
"Nice! Gitea release {tag} created.": {
"en": "Nice! Gitea release {tag} created.",
"bg": "Отлично! Gitea release {tag} е създаден.",
"de": "Prima! Gitea-Release {tag} erstellt.",
"ru": "Отлично! Gitea release {tag} создан.",
"zh": "不错!Gitea release {tag} 已创建。"
},
"Nice! PR #{pr_number} squash-merged with title: {merge_title}": {
"en": "Nice! PR #{pr_number} squash-merged with title: {merge_title}",
"bg": "Отлично! PR #{pr_number} е squash-merge-нат със заглавие: {merge_title}",
"de": "Prima! PR #{pr_number} wurde mit Titel {merge_title} squash-gemergt.",
"ru": "Отлично! PR #{pr_number} squash-merge с заголовком: {merge_title}",
"zh": "不错!PR #{pr_number} 已 squash 合并,标题: {merge_title}"
},
"Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.": {
"en": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered."
},
"Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.": {
"en": "Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.",
"bg": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) е обновена и маркирана като готова.",
"de": "Prima! Vikunja-Aufgabe {task_id} (ID {vikunja_id}) aktualisiert und als erledigt markiert.",
"ru": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) обновлена и отмечена как выполненная.",
"zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。"
},
"No changes between {base} and {head}.": {
"en": "No changes between {base} and {head}."
},
"No staged changes — version and changelog already up to date.": {
"en": "No staged changes — version and changelog already up to date."
},
"No tags found — treating all changes as user-facing.": {
"en": "No tags found — treating all changes as user-facing."
},
"No unreleased changes found. Nothing to release.": {
"en": "No unreleased changes found. Nothing to release."
},
"No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.": {
"en": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release."
},
"Note: Self-approval not allowed. Posting COMMENT instead.": {
"en": "Note: Self-approval not allowed. Posting COMMENT instead."
},
"Oops! Commit message must follow conventional commit format.\n Expected: <type>: <description>\n Got: {subject}\n Allowed types: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE": {
"en": "Oops! Commit message must follow conventional commit format.\n Expected: <type>: <description>\n Got: {subject}\n Allowed types: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
"bg": "Опа! Съобщението за commit трябва да следва конвенционален формат.\n Очаква се: <type>: <description>\n Получено: {subject}\n Разрешени типове: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
"de": "Ups! Commit-Nachricht muss dem konventionellen Commit-Format folgen.\n Erwartet: <type>: <description>\n Erhalten: {subject}\n Erlaubte Typen: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
"ru": "Ой! Сообщение коммита должно соответствовать формату conventional commit.\n Ожидается: <type>: <description>\n Получено: {subject}\n Допустимые типы: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
"zh": "哎呀!提交消息必须遵循 conventional commit 格式。\n 预期格式: <type>: <description>\n 实际: {subject}\n 允许的类型: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE"
},
"Oops! Do not include task ID (GRM-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.": {
"en": "Oops! Do not include task ID (GRM-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.",
"bg": "Опа! Не включвайте идентификатор на задача (GRM-N) в commit-и от feature клонове.\n Идентификаторът ще бъде добавен автоматично при сливане чрез CI.",
"de": "Ups! Keine Task-ID (GRM-N) in Feature-Branch-Commits einfügen.\n Die Task-ID wird beim Merge automatisch über CI hinzugefügt.",
"ru": "Ой! Не включайте ID задачи (GRM-N) в коммиты feature-веток.\n ID задачи будет добавлен автоматически при слиянии через CI.",
"zh": "哎呀!不要在 feature 分支的提交中包含任务 ID (GRM-N)。\n 任务 ID 将在通过 CI 合并时自动添加。"
},
"Oops! Master branch commit must follow conventional format after task ID.\n Expected: GRM-N: <type>: <description>\n Got: {subject}": {
"en": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: GRM-N: <type>: <description>\n Got: {subject}",
"bg": "Опа! Commit-ът в клона master трябва да следва конвенционален формат след идентификатора.\n Очаква се: GRM-N: <type>: <description>\n Получено: {subject}",
"de": "Ups! Master-Branch-Commit muss nach der Task-ID dem konventionellen Format folgen.\n Erwartet: GRM-N: <type>: <description>\n Erhalten: {subject}",
"ru": "Ой! Коммит в ветку master после ID задачи должен соответствовать conventional формату.\n Ожидается: GRM-N: <type>: <description>\n Получено: {subject}",
"zh": "哎呀!master 分支提交在任务 ID 后必须遵循 conventional commit 格式。\n 预期格式: GRM-N: <type>: <description>\n 实际: {subject}"
},
"Oops! Master branch commits must start with a task ID.\n Expected: GRM-N: <conventional commit message>\n Got: {subject}": {
"en": "Oops! Master branch commits must start with a task ID.\n Expected: GRM-N: <conventional commit message>\n Got: {subject}",
"bg": "Опа! Commit-ите в клона master трябва да започват с идентификатор на задача.\n Очаква се: GRM-N: <conventional commit message>\n Получено: {subject}",
"de": "Ups! Master-Branch-Commits müssen mit einer Task-ID beginnen.\n Erwartet: GRM-N: <conventional commit message>\n Erhalten: {subject}",
"ru": "Ой! Коммиты в ветку master должны начинаться с ID задачи.\n Ожидается: GRM-N: <conventional commit message>\n Получено: {subject}",
"zh": "哎呀!master 分支的提交必须以任务 ID 开头。\n 预期格式: GRM-N: <conventional commit message>\n 实际: {subject}"
},
"Oops! No task ID found in .taskid file or branch name '{branch}'.": {
"en": "Oops! No task ID found in .taskid file or branch name '{branch}'."
},
"Oops! PR title must follow format 'GRM-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}": {
"en": "Oops! PR title must follow format 'GRM-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}"
},
"Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}": {
"en": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}"
},
"Oops! Package build failed:\n{stderr}": {
"en": "Oops! Package build failed:\n{stderr}",
"bg": "Опа! Сборката на пакета неуспешна:\n{stderr}",
"de": "Ups! Paket-Build fehlgeschlagen:\n{stderr}",
"ru": "Ой! Сборка пакета не удалась:\n{stderr}",
"zh": "哎呀!包构建失败:\n{stderr}"
},
"Oops! PyPI publish failed:\n{stderr}": {
"en": "Oops! PyPI publish failed:\n{stderr}",
"bg": "Опа! Публикуването в PyPI неуспешно:\n{stderr}",
"de": "Ups! PyPI-Veröffentlichung fehlgeschlagen:\n{stderr}",
"ru": "Ой! Публикация в PyPI не удалась:\n{stderr}",
"zh": "哎呀!PyPI 发布失败:\n{stderr}"
},
"PASSED: {pair}": {
"en": "PASSED: {pair}"
},
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}": {
"en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}"
},
"PYPI_TOKEN not set — skipping PyPI publish. No worries, we'll just create the Gitea release.": {
"en": "PYPI_TOKEN not set — skipping PyPI publish. No worries, we'll just create the Gitea release.",
"bg": "PYPI_TOKEN не е зададен — пропускаме публикуването в PyPI. Без притеснения, просто ще създадем Gitea release.",
"de": "PYPI_TOKEN nicht gesetzt — PyPI-Veröffentlichung wird übersprungen. Keine Sorge, wir erstellen einfach das Gitea-Release.",
"ru": "PYPI_TOKEN не задан — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.",
"zh": "未设置 PYPI_TOKEN — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。"
},
"Published to PyPI.": {
"en": "Published to PyPI.",
"bg": "Публикувано в PyPI.",
"de": "In PyPI veröffentlicht.",
"ru": "Опубликовано в PyPI.",
"zh": "已发布到 PyPI。"
},
"Pushed release commit to master.": {
"en": "Pushed release commit to master."
},
"Rebased and pushed. Retrying merge...": {
"en": "Rebased and pushed. Retrying merge..."
},
"Release creation failed: {error}": {
"en": "Release creation failed: {error}"
},
"Release must be run on master, currently on '{branch}'.": {
"en": "Release must be run on master, currently on '{branch}'."
},
"Repository configuration complete.": {
"en": "Repository configuration complete.",
"bg": "Конфигурирането на хранилището е завършено.",
"de": "Repository-Konfiguration abgeschlossen.",
"ru": "Конфигурация репозитория завершена.",
"zh": "仓库配置完成。"
},
"Runner index {index} out of range (0..{max})": {
"en": "Runner index {index} out of range (0..{max})",
"bg": "Индексът на runner {index} е извън диапазона (0..{max})",
"de": "Runner-Index {index} außerhalb des Bereichs (0..{max})",
"ru": "Индекс runner {index} вне диапазона (0..{max})",
"zh": "Runner 索引 {index} 超出范围 (0..{max})"
},
"Running lint checks...": {
"en": "Running lint checks..."
},
"Running tests...": {
"en": "Running tests..."
},
"Running: {scenario} on {platform}": {
"en": "Running: {scenario} on {platform}"
},
"Skipping commit push — no staged changes.": {
"en": "Skipping commit push — no staged changes."
},
"Syncing {count} documentation pages to wiki...": {
"en": "Syncing {count} documentation pages to wiki..."
},
"Tag v{version} already existed. Publish workflow should already have been triggered.": {
"en": "Tag v{version} already existed. Publish workflow should already have been triggered."
},
"Tag {tag} already exists, skipping creation.": {
"en": "Tag {tag} already exists, skipping creation."
},
"Task ID: {task_id}": {
"en": "Task ID: {task_id}"
},
"Tests failed — refusing to release. Fix test failures first.\n{stderr}": {
"en": "Tests failed — refusing to release. Fix test failures first.\n{stderr}"
},
"Tests passed.": {
"en": "Tests passed."
},
"Unit tests passed in {duration:.2f}s (under {max}s limit).": {
"en": "Unit tests passed in {duration:.2f}s (under {max}s limit)."
},
"Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.": {
"en": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures."
},
"Updated version in {init}": {
"en": "Updated version in {init}"
},
"Updated {changelog_file}": {
"en": "Updated {changelog_file}"
},
"WARNING: --skip-tests passed — skipping test verification.": {
"en": "WARNING: --skip-tests passed — skipping test verification."
},
"WARNING: File {file} is empty — skipping.": {
"en": "WARNING: File {file} is empty — skipping."
},
"WARNING: File {file} not found — skipping.": {
"en": "WARNING: File {file} not found — skipping."
},
"Warning: No task ID (GRM-N) found in commit message: {msg}. Skipping Vikunja update.": {
"en": "Warning: No task ID (GRM-N) found in commit message: {msg}. Skipping Vikunja update."
},
"Warning: VIKUNJA_TOKEN not set, skipping title match validation.": {
"en": "Warning: VIKUNJA_TOKEN not set, skipping title match validation."
},
"Warning: Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded — please update the Vikunja task manually.": {
"en": "Warning: Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded — please update the Vikunja task manually."
},
"Warning: git-cliff generated empty changelog.": {
"en": "Warning: git-cliff generated empty changelog."
},
"Wiki integrity check failed — {count} issue(s)": {
"en": "Wiki integrity check failed — {count} issue(s)"
},
"Wiki verification failed — {failures} page(s) empty or mismatched": {
"en": "Wiki verification failed — {failures} page(s) empty or mismatched"
},
"[dry-run] Would commit: release: v{version}": {
"en": "[dry-run] Would commit: release: v{version}"
},
"[dry-run] Would create tag: v{version}": {
"en": "[dry-run] Would create tag: v{version}"
},
"[dry-run] Would create tag: {tag}": {
"en": "[dry-run] Would create tag: {tag}"
},
"[dry-run] Would push commit to master": {
"en": "[dry-run] Would push commit to master"
},
"[dry-run] Would sync page: {title} ({chars} chars)": {
"en": "[dry-run] Would sync page: {title} ({chars} chars)"
},
"[dry-run] Would update {changelog_file}": {
"en": "[dry-run] Would update {changelog_file}"
},
"[dry-run] Would update {init}": {
"en": "[dry-run] Would update {init}"
},
"git command failed ({cmd}): {stderr}": {
"en": "git command failed ({cmd}): {stderr}"
},
"git-cliff returned empty version.": {
"en": "git-cliff returned empty version."
}
}
-345
View File
@@ -1,345 +0,0 @@
"""Unit tests for scripts/ci/auto_merge.py."""
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from gitea_runner_manager.exceptions import APIError
from scripts.ci.auto_merge import (
extract_conventional_msg,
extract_task_id,
main,
read_taskid,
run_cmd,
validate_pr_title,
validate_pr_title_matches_vikunja,
)
# -- read_taskid --
class TestReadTaskid:
def test_reads_from_file(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("GRM-60\n")
assert read_taskid("some-branch") == "GRM-60"
def test_falls_back_to_branch_name(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
assert read_taskid("GRM-19-fix-bug") == "GRM-19"
def test_returns_empty_when_no_file_no_match(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
assert read_taskid("feature-branch") == ""
def test_empty_file_falls_back_to_branch(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("\n")
assert read_taskid("GRM-42-test") == "GRM-42"
# -- extract_task_id (legacy fallback) --
class TestExtractTaskId:
def test_extracts_from_branch(self) -> None:
assert extract_task_id("GRM-19-fix-bug") == "GRM-19"
assert extract_task_id("GRM-123") == "GRM-123"
def test_returns_empty_when_no_match(self) -> None:
assert extract_task_id("feature-branch") == ""
# -- validate_pr_title --
class TestValidatePrTitle:
def test_valid_title(self) -> None:
validate_pr_title("GRM-19: Add new feature", "GRM-19")
def test_missing_colon(self) -> None:
with pytest.raises(click.ClickException, match="format"):
validate_pr_title("GRM-19 Add new feature", "GRM-19")
def test_task_id_mismatch(self) -> None:
with pytest.raises(click.ClickException, match="mismatch"):
validate_pr_title("GRM-20: Add feature", "GRM-19")
def test_no_task_id_in_title(self) -> None:
with pytest.raises(click.ClickException, match="format"):
validate_pr_title("Add new feature", "GRM-19")
# -- validate_pr_title_matches_vikunja --
class TestValidatePrTitleMatchesVikunja:
@patch.dict("os.environ", {}, clear=True)
def test_skips_when_no_token(self) -> None:
# Should not raise — just warn
validate_pr_title_matches_vikunja("GRM-19: test", "GRM-19")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("scripts.ci.auto_merge.VikunjaClient")
def test_matches(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 1, "identifier": "GRM-19", "title": "Add new feature"},
]
mock_client_cls.return_value = mock_client
validate_pr_title_matches_vikunja("GRM-19: Add new feature", "GRM-19")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("scripts.ci.auto_merge.VikunjaClient")
def test_mismatch_raises(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 1, "identifier": "GRM-19", "title": "Different title"},
]
mock_client_cls.return_value = mock_client
with pytest.raises(click.ClickException, match="does not match"):
validate_pr_title_matches_vikunja("GRM-19: Add new feature", "GRM-19")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("scripts.ci.auto_merge.VikunjaClient")
def test_task_not_found_raises(self, mock_client_cls: MagicMock) -> None:
"""When Vikunja task is not found and token is set, raises ClickException."""
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = []
mock_client_cls.return_value = mock_client
with pytest.raises(click.ClickException, match="Could not find"):
validate_pr_title_matches_vikunja("GRM-99: test", "GRM-99")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("scripts.ci.auto_merge.VikunjaClient")
def test_task_found_on_second_page(self, mock_client_cls: MagicMock) -> None:
"""Pagination: task found on page 2."""
mock_client = MagicMock()
page1 = [{"id": i, "identifier": f"GRM-{i}", "title": f"Title {i}"} for i in range(50)]
page2 = [{"id": 100, "identifier": "GRM-99", "title": "Found me"}]
mock_client.list_project_tasks.side_effect = [page1, page2]
mock_client_cls.return_value = mock_client
# Should not raise — title matches
validate_pr_title_matches_vikunja("GRM-99: Found me", "GRM-99")
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("scripts.ci.auto_merge.VikunjaClient")
def test_task_not_found_partial_page_raises(self, mock_client_cls: MagicMock) -> None:
"""Pagination stops when page has fewer than DEFAULT_PER_PAGE results. Task not found raises."""
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 1, "identifier": "GRM-1", "title": "Title 1"},
]
mock_client_cls.return_value = mock_client
with pytest.raises(click.ClickException, match="Could not find"):
validate_pr_title_matches_vikunja("GRM-99: test", "GRM-99")
# -- extract_conventional_msg --
class TestExtractConventionalMsg:
def test_finds_conventional(self) -> None:
commits = [
{"commit": {"message": "fix: resolve timeout"}},
{"commit": {"message": "merge branch"}},
]
assert extract_conventional_msg(commits) == "fix: resolve timeout"
def test_finds_latest_conventional(self) -> None:
commits = [
{"commit": {"message": "merge branch"}},
{"commit": {"message": "feat: add feature"}},
]
assert extract_conventional_msg(commits) == "feat: add feature"
def test_falls_back_to_newest(self) -> None:
commits = [
{"commit": {"message": "random message"}},
]
assert extract_conventional_msg(commits) == "random message"
def test_empty_commits(self) -> None:
assert extract_conventional_msg([]) == ""
def test_multiline_message(self) -> None:
commits = [
{"commit": {"message": "feat: add feature\n\nBody text."}},
]
assert extract_conventional_msg(commits) == "feat: add feature"
# -- run_cmd --
class TestRunCmd:
def test_success(self) -> None:
result = run_cmd(["echo", "hello"])
assert result.returncode == 0
def test_failure_raises(self) -> None:
with pytest.raises(click.ClickException, match="Command failed"):
run_cmd(["false"])
def test_failure_no_check(self) -> None:
result = run_cmd(["false"], check=False)
assert result.returncode != 0
# -- main (integration) --
class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": ""}, clear=True)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_full_merge_flow(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("GRM-19\n")
mock_client = MagicMock()
mock_client.get_pr_commits.return_value = [
{"commit": {"message": "fix: resolve timeout"}},
]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["GRM-19-fix-bug", "GRM-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code == 0, result.output
mock_client.merge_pr.assert_called_once_with("7", "GRM-19: fix: resolve timeout")
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
def test_no_token_raises(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: test", "owner/repo", "7"])
assert result.exit_code != 0
assert "REPO_TOKEN" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_no_task_id_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
# No .taskid file, no GRM-N in branch name
runner = CliRunner()
result = runner.invoke(main, ["feature-branch", "GRM-19: test", "owner/repo", "7"])
assert result.exit_code != 0
assert "No task ID" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_invalid_pr_title_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("GRM-19\n")
runner = CliRunner()
result = runner.invoke(main, ["GRM-19-fix", "Bad title", "owner/repo", "7"])
assert result.exit_code != 0
assert "format" in result.output.lower()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_merge_behind_master_rebases(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("GRM-19\n")
mock_client = MagicMock()
mock_client.get_pr_commits.return_value = [
{"commit": {"message": "fix: resolve timeout"}},
]
mock_client.merge_pr.side_effect = [
APIError(405, "HEAD branch is behind master"),
None, # Second call succeeds
]
mock_client_cls.return_value = mock_client
with patch("scripts.ci.auto_merge.run_cmd") as mock_run:
runner = CliRunner()
result = runner.invoke(
main,
["GRM-19-fix-bug", "GRM-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code == 0, result.output
assert mock_client.merge_pr.call_count == 2
# Should have fetched, rebased, and pushed
assert mock_run.call_count == 3
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_merge_failure_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("GRM-19\n")
mock_client = MagicMock()
mock_client.get_pr_commits.return_value = [
{"commit": {"message": "fix: resolve timeout"}},
]
mock_client.merge_pr.side_effect = APIError(409, "Conflict")
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["GRM-19-fix-bug", "GRM-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code != 0
assert "Merge failed" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_no_conventional_msg_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
"""When no conventional commit message is found in PR commits, raises."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("GRM-19\n")
mock_client = MagicMock()
mock_client.get_pr_commits.return_value = []
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["GRM-19-fix-bug", "GRM-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code != 0
assert "conventional commit" in result.output.lower()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.ci.auto_merge.GiteaClient")
def test_rebase_retry_failure_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
"""When rebase retry also fails, raises with helpful message."""
monkeypatch.chdir(tmp_path)
(tmp_path / ".taskid").write_text("GRM-19\n")
mock_client = MagicMock()
mock_client.get_pr_commits.return_value = [
{"commit": {"message": "fix: resolve timeout"}},
]
mock_client.merge_pr.side_effect = APIError(405, "HEAD branch is behind master")
mock_client_cls.return_value = mock_client
with patch("scripts.ci.auto_merge.run_cmd") as mock_run:
mock_run.side_effect = click.ClickException("git rebase failed")
runner = CliRunner()
result = runner.invoke(
main,
["GRM-19-fix-bug", "GRM-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code != 0
assert "rebase" in result.output.lower()
def test_main_module_block() -> None:
"""Test that the __main__ block can be executed."""
import scripts.ci.auto_merge as am
with open(am.__file__) as f:
source = f.read()
source = source.replace('if __name__ == "__main__":\n main()\n', "")
namespace = dict(am.__dict__)
exec(compile(source, am.__file__, "exec"), namespace)
# Verify main is callable
assert callable(namespace["main"])
-140
View File
@@ -1,140 +0,0 @@
"""Unit tests for scripts/check_test_speed.py."""
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from scripts.check_test_speed import (
DEFAULT_MAX_SECONDS,
TEST_COMMAND,
check_speed,
cli,
parse_duration,
run_tests,
)
class TestRunTests:
@patch("scripts.check_test_speed.subprocess.run")
def test_run_tests_returns_stdout_stderr(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(stdout="out", stderr="err", returncode=0)
stdout, stderr = run_tests()
assert stdout == "out"
assert stderr == "err"
mock_run.assert_called_once_with(
TEST_COMMAND,
capture_output=True,
text=True,
check=False,
)
class TestParseDuration:
def test_parses_valid_line(self) -> None:
assert parse_duration("234 passed in 0.70s") == 0.70
def test_parses_with_warnings(self) -> None:
assert parse_duration("293 passed, 1 warning in 0.45s") == 0.45
def test_parses_multiline_output(self) -> None:
output = "some header\n234 passed in 1.23s\nfooter"
assert parse_duration(output) == 1.23
def test_raises_when_no_timing_line(self) -> None:
with pytest.raises(click.ClickException) as exc:
parse_duration("no timing here")
assert "Could not parse" in str(exc.value)
class TestCheckSpeed:
def test_under_budget_passes(self) -> None:
check_speed(1.0, 2.0) # should not raise
def test_exact_budget_passes(self) -> None:
check_speed(2.0, 2.0) # should not raise
def test_over_budget_raises(self) -> None:
with pytest.raises(click.ClickException) as exc:
check_speed(2.1, 2.0)
msg = str(exc.value)
assert "too slow" in msg.lower()
assert "2.10s" in msg
assert "max allowed: 2.0s" in msg
def test_main_module_block() -> None:
import scripts.check_test_speed as cts
with patch.object(cts, "cli") as mock_cli:
with patch.object(cts, "__name__", "__main__"):
cts.cli([])
mock_cli.assert_called_once_with([])
class TestMain:
@patch("scripts.check_test_speed.run_tests")
@patch("scripts.check_test_speed.parse_duration")
@patch("scripts.check_test_speed.check_speed")
def test_successful_run(
self,
mock_check: MagicMock,
mock_parse: MagicMock,
mock_run: MagicMock,
) -> None:
mock_run.return_value = ("stdout\n", "stderr\n")
mock_parse.return_value = 1.5
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 0
assert "1.50s" in result.output
assert "under 2.0s limit" in result.output
mock_run.assert_called_once()
mock_parse.assert_called_once_with("stdout\n\nstderr\n")
mock_check.assert_called_once_with(1.5, DEFAULT_MAX_SECONDS)
@patch("scripts.check_test_speed.run_tests")
@patch("scripts.check_test_speed.parse_duration")
def test_slow_tests_exit(
self,
mock_parse: MagicMock,
mock_run: MagicMock,
) -> None:
mock_run.return_value = ("out\n", "err\n")
mock_parse.return_value = 3.0
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 1
assert "too slow" in result.output.lower()
@patch("scripts.check_test_speed.run_tests")
def test_parse_failure_exits(
self,
mock_run: MagicMock,
) -> None:
mock_run.return_value = ("bad output\n", "")
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 1
assert "Could not parse" in result.output
@patch("scripts.check_test_speed.run_tests")
@patch("scripts.check_test_speed.parse_duration")
@patch("scripts.check_test_speed.check_speed")
def test_custom_max_seconds(
self,
mock_check: MagicMock,
mock_parse: MagicMock,
mock_run: MagicMock,
) -> None:
mock_run.return_value = ("out\n", "err\n")
mock_parse.return_value = 0.5
runner = CliRunner()
result = runner.invoke(cli, ["--max-seconds", "1.5"])
assert result.exit_code == 0
mock_check.assert_called_once_with(0.5, 1.5)
-273
View File
@@ -1,273 +0,0 @@
from __future__ import annotations
import json
from pathlib import Path
import pytest
from click.testing import CliRunner
import scripts.ci.check_translations as check_translations
class TestExtractKeys:
def test_extracts_underscore_calls(self, tmp_path: Path) -> None:
f = tmp_path / "test.py"
f.write_text('from scripts.i18n import _\nprint(_("Hello world"))\n')
keys = check_translations.extract_keys(f)
assert "Hello world" in keys
def test_extracts_wrapper_calls(self, tmp_path: Path) -> None:
f = tmp_path / "test.py"
f.write_text('@_handle_errors("Update failed: {error}")\ndef foo(): pass\n')
keys = check_translations.extract_keys(f)
assert "Update failed: {error}" in keys
def test_ignores_non_string_args(self, tmp_path: Path) -> None:
f = tmp_path / "test.py"
f.write_text('x = "key"\n_(x)\n')
keys = check_translations.extract_keys(f)
assert keys == set()
def test_syntax_error_returns_empty(self, tmp_path: Path) -> None:
f = tmp_path / "test.py"
f.write_text("def broken(:\n")
keys = check_translations.extract_keys(f)
assert keys == set()
class TestCheckTranslationSet:
def test_all_good(self, tmp_path: Path) -> None:
src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "mod.py").write_text('_("Hello")\n')
trans_file = tmp_path / "translations.json"
trans_file.write_text(
json.dumps({"Hello": {"en": "Hello", "bg": "Здравей", "de": "Hallo", "ru": "Привет", "zh": "你好"}})
)
result = check_translations.check_translation_set("test", src_dir, trans_file)
assert not result.errors
assert not result.warnings
def test_missing_key(self, tmp_path: Path) -> None:
src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "mod.py").write_text('_("Missing")\n')
trans_file = tmp_path / "translations.json"
trans_file.write_text(json.dumps({"Other": {"en": "Other"}}))
result = check_translations.check_translation_set("test", src_dir, trans_file)
assert any("Missing key" in e for e in result.errors)
def test_dead_key(self, tmp_path: Path) -> None:
src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "mod.py").write_text('_("Used")\n')
trans_file = tmp_path / "translations.json"
trans_file.write_text(json.dumps({"Used": {"en": "Used"}, "Dead": {"en": "Dead"}}))
result = check_translations.check_translation_set("test", src_dir, trans_file)
assert any("Dead key" in w for w in result.warnings)
def test_missing_language(self, tmp_path: Path) -> None:
src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "mod.py").write_text('_("Hello")\n')
trans_file = tmp_path / "translations.json"
trans_file.write_text(json.dumps({"Hello": {"en": "Hello"}}))
result = check_translations.check_translation_set("test", src_dir, trans_file)
assert any("Missing languages" in w for w in result.warnings)
def test_missing_translations_file(self, tmp_path: Path) -> None:
src_dir = tmp_path / "src"
src_dir.mkdir()
(src_dir / "mod.py").write_text('_("Hello")\n')
trans_file = tmp_path / "nonexistent.json"
result = check_translations.check_translation_set("test", src_dir, trans_file)
assert any("not found" in e for e in result.errors)
class TestMain:
def test_passes_on_clean_repo(self) -> None:
"""The actual repo should pass (with warnings for missing langs)."""
runner = CliRunner()
result = runner.invoke(check_translations.main, [])
assert result.exit_code == 0
def test_strict_fails_on_warnings(self) -> None:
"""--strict should fail if there are missing language warnings."""
runner = CliRunner()
result = runner.invoke(check_translations.main, ["--strict"])
# The repo has missing language warnings, so --strict should fail
assert result.exit_code == 1
def test_fails_on_errors(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Should fail with exit code 1 when errors are found."""
error_result = check_translations.TranslationCheckResult(
name="mock",
src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"),
errors=["Missing key: 'foo'"],
)
ok_result = check_translations.TranslationCheckResult(
name="mock2",
src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"),
used_keys={"a"},
defined_keys={"a"},
)
monkeypatch.setattr(
check_translations,
"check_translation_set",
lambda name, src, trans: error_result if name == "GRM tool" else ok_result,
)
runner = CliRunner()
result = runner.invoke(check_translations.main, [])
assert result.exit_code == 1
assert "FAIL" in result.output
def test_passes_no_warnings(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Should pass with exit code 0 and 'PASS:' message when no warnings."""
ok_result = check_translations.TranslationCheckResult(
name="mock",
src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"),
used_keys={"a"},
defined_keys={"a"},
)
monkeypatch.setattr(
check_translations,
"check_translation_set",
lambda name, src, trans: ok_result,
)
runner = CliRunner()
result = runner.invoke(check_translations.main, [])
assert result.exit_code == 0
assert "PASS: All translations" in result.output
class TestPrintResult:
def test_prints_all_good(self, capsys: pytest.CaptureFixture[str]) -> None:
result = check_translations.TranslationCheckResult(
name="test",
src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"),
used_keys={"a"},
defined_keys={"a"},
)
check_translations.print_result(result)
captured = capsys.readouterr()
assert "All good!" in captured.out
def test_prints_errors(self, capsys: pytest.CaptureFixture[str]) -> None:
result = check_translations.TranslationCheckResult(
name="test",
src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"),
errors=["Missing key: 'foo'"],
)
check_translations.print_result(result)
captured = capsys.readouterr()
assert "ERROR" in captured.err
def test_prints_warnings(self, capsys: pytest.CaptureFixture[str]) -> None:
result = check_translations.TranslationCheckResult(
name="test",
src_dir=Path("/tmp"),
trans_file=Path("/tmp/t.json"),
warnings=["Dead key: 'bar'"],
)
check_translations.print_result(result)
captured = capsys.readouterr()
assert "WARN" in captured.err
class TestScriptsI18n:
"""Test the scripts/i18n.py module."""
def test_english_default(self) -> None:
from scripts.i18n import _
assert _("Running tests...") == "Running tests..."
def test_format_kwargs(self) -> None:
from scripts.i18n import _
result = _("Comparing {base}..{head} ({count} files changed)", base="a", head="b", count=5)
assert "a..b" in result
assert "5 files" in result
def test_unknown_key_returns_key(self) -> None:
from scripts.i18n import _
assert _("Nonexistent key 12345") == "Nonexistent key 12345"
def test_grm_lang_override(self, monkeypatch: pytest.MonkeyPatch) -> None:
import importlib
monkeypatch.setenv("GRM_LANG", "de")
import scripts.i18n
importlib.reload(scripts.i18n)
# "ERROR: REPO_TOKEN is not set." has a German translation
result = scripts.i18n._("ERROR: REPO_TOKEN is not set.")
assert "FEHLER" in result
# Restore
monkeypatch.delenv("GRM_LANG", raising=False)
importlib.reload(scripts.i18n)
def test_unsupported_lang_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None:
import importlib
monkeypatch.setenv("GRM_LANG", "xx")
import scripts.i18n
importlib.reload(scripts.i18n)
result = scripts.i18n._("Running tests...")
assert result == "Running tests..." # Falls back to English
monkeypatch.delenv("GRM_LANG", raising=False)
importlib.reload(scripts.i18n)
class TestGrmI18nSeparation:
"""Test that GRM and CI translations are properly separated."""
def test_grm_translations_exist(self) -> None:
trans = Path("src/gitea_runner_manager/translations.json")
assert trans.exists()
data = json.loads(trans.read_text())
assert len(data) > 0
def test_ci_translations_exist(self) -> None:
trans = Path("scripts/translations.json")
assert trans.exists()
data = json.loads(trans.read_text())
assert len(data) > 0
def test_no_overlap_between_sets(self) -> None:
grm_data = json.loads(Path("src/gitea_runner_manager/translations.json").read_text())
ci_data = json.loads(Path("scripts/translations.json").read_text())
grm_keys = set(grm_data)
ci_keys = set(ci_data)
overlap = grm_keys & ci_keys
assert not overlap, f"Keys found in both translation sets: {overlap}"
def test_grm_translations_only_grm_keys(self) -> None:
"""GRM translations should not contain CI-only keys."""
grm_data = json.loads(Path("src/gitea_runner_manager/translations.json").read_text())
# These are CI-only keys that should NOT be in GRM translations
ci_only_keys = {"Running tests...", "Lint passed.", "Published to PyPI."}
for key in ci_only_keys:
assert key not in grm_data, f"CI key {key!r} found in GRM translations"
def test_ci_translations_only_ci_keys(self) -> None:
"""CI translations should not contain GRM-only keys."""
ci_data = json.loads(Path("scripts/translations.json").read_text())
# These are GRM-only keys that should NOT be in CI translations
grm_only_keys = {"Installing Gitea Runner on {host}", "SSH user", "NAME"}
for key in grm_only_keys:
assert key not in ci_data, f"GRM key {key!r} found in CI translations"
-369
View File
@@ -1,369 +0,0 @@
"""Unit tests for scripts/ci/classify_changes.py."""
from pathlib import Path
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
import scripts.ci.classify_changes as classify_changes_mod
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,
)
class TestIsUserFacing:
def test_src_is_user_facing(self) -> None:
assert is_user_facing("src/gitea_runner_manager/cli.py") is True
def test_ansible_is_user_facing(self) -> None:
assert is_user_facing("ansible/roles/gitea-runner/tasks/main.yml") is True
def test_pyproject_is_user_facing(self) -> None:
assert is_user_facing("pyproject.toml") is True
def test_workflow_is_not_user_facing(self) -> None:
assert is_user_facing(".gitea/workflows/ci.yml") 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_not_user_facing(self) -> None:
"""All scripts under scripts/ are infrastructure (CI/CD, dev tools).
User-facing code lives in src/gitea_runner_manager/."""
assert is_user_facing("scripts/check_test_speed.py") is False
assert is_user_facing("scripts/configure_repo.py") is False
assert is_user_facing("scripts/install_checkmake.py") is False
def test_shell_scripts_are_not_user_facing(self) -> None:
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_version_file_is_not_user_facing(self) -> None:
"""__init__.py only contains __version__ — a release artifact,
not user-facing code. Version bumps alone should not trigger releases."""
assert is_user_facing("src/gitea_runner_manager/__init__.py") is False
def test_api_clients_is_not_user_facing(self) -> None:
"""api_clients.py is used only by CI/CD scripts, not by the GRM CLI."""
assert is_user_facing("src/gitea_runner_manager/api_clients.py") is False
def test_docs_are_not_user_facing(self) -> None:
assert is_user_facing("docs/user/getting-started.md") is False
def test_tests_are_not_user_facing(self) -> None:
assert is_user_facing("tests/unit/test_cli.py") is False
def test_agents_md_is_not_user_facing(self) -> None:
assert is_user_facing("AGENTS.md") is False
def test_makefile_is_not_user_facing(self) -> None:
assert is_user_facing("Makefile") is False
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:
files = ["src/gitea_runner_manager/cli.py", "ansible/roles/gitea-runner/tasks/main.yml"]
result = classify_changes(files)
assert result["user_facing"] == files
assert result["workflow_only"] == []
def test_all_workflow_only(self) -> None:
files = [".gitea/workflows/ci.yml", "docs/index.md", "AGENTS.md"]
result = classify_changes(files)
assert result["user_facing"] == []
assert result["workflow_only"] == files
def test_mixed(self) -> None:
files = [
"src/gitea_runner_manager/cli.py",
".gitea/workflows/ci.yml",
"pyproject.toml",
"docs/index.md",
]
result = classify_changes(files)
assert "src/gitea_runner_manager/cli.py" in result["user_facing"]
assert "pyproject.toml" in result["user_facing"]
assert ".gitea/workflows/ci.yml" in result["workflow_only"]
assert "docs/index.md" in result["workflow_only"]
def test_empty(self) -> None:
result = classify_changes([])
assert result == {"user_facing": [], "workflow_only": []}
class TestGetChangedFiles:
@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.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")
assert result == []
class TestHasUserFacingChanges:
@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.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.ci.classify_changes.get_changed_files")
def test_false_when_no_changes(self, mock_get: MagicMock) -> None:
mock_get.return_value = []
assert has_user_facing_changes("v0.1.0", "HEAD") is False
class TestGetLatestTag:
@patch("subprocess.run")
def test_returns_tag(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="v0.3.0\n", stderr="")
assert get_latest_tag() == "v0.3.0"
@patch("subprocess.run")
def test_returns_empty_when_no_tags(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
assert get_latest_tag() == ""
class TestRunGit:
@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.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):
run_git(["git", "bad-command"])
class TestMain:
@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.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.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()
result = runner.invoke(main, [])
assert result.exit_code == 2
assert "no release needed" in result.output
@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()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "release needed" in result.output
@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()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "No tags found" in result.output
@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()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "No changes" in result.output
@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"]
runner = CliRunner()
result = runner.invoke(main, ["--quiet"])
assert result.exit_code == 0
assert "true" in result.output
@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"]
runner = CliRunner()
result = runner.invoke(main, ["--quiet"])
assert result.exit_code == 0
assert "false" in result.output
@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"]
runner = CliRunner()
result = runner.invoke(main, ["--base", "v0.2.0", "--head", "HEAD"])
assert result.exit_code == 0
assert "release needed" in result.output
@patch("scripts.ci.classify_changes.get_changed_files")
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_ansible_true(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""--check ansible with Ansible changes outputs true."""
mock_changes.return_value = ["ansible/tasks/main.yml", ".gitea/workflows/ci.yml"]
runner = CliRunner()
result = runner.invoke(main, ["--check", "ansible", "--quiet"])
assert result.exit_code == 0
assert "true" in result.output
@patch("scripts.ci.classify_changes.get_changed_files")
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_ansible_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""--check ansible with no Ansible changes outputs false."""
mock_changes.return_value = ["src/gitea_runner_manager/cli.py", ".gitea/workflows/ci.yml"]
runner = CliRunner()
result = runner.invoke(main, ["--check", "ansible", "--quiet"])
assert result.exit_code == 0
assert "false" in result.output
@patch("scripts.ci.classify_changes.get_changed_files")
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_user_facing_true(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""--check user-facing with user-facing changes outputs true."""
mock_changes.return_value = ["src/gitea_runner_manager/cli.py", ".gitea/workflows/ci.yml"]
runner = CliRunner()
result = runner.invoke(main, ["--check", "user-facing", "--quiet"])
assert result.exit_code == 0
assert "true" in result.output
@patch("scripts.ci.classify_changes.get_changed_files")
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_user_facing_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""--check user-facing with only workflow changes outputs false."""
mock_changes.return_value = [".gitea/workflows/ci.yml", "tests/test_foo.py"]
runner = CliRunner()
result = runner.invoke(main, ["--check", "user-facing", "--quiet"])
assert result.exit_code == 0
assert "false" in result.output
@patch("scripts.ci.classify_changes.get_changed_files")
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_ansible_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""--check ansible in non-quiet mode prints file list."""
mock_changes.return_value = ["ansible/tasks/main.yml"]
runner = CliRunner()
result = runner.invoke(main, ["--check", "ansible"])
assert result.exit_code == 0
assert "Ansible changes detected" in result.output
@patch("scripts.ci.classify_changes.get_changed_files")
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
def test_check_user_facing_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
"""--check user-facing in non-quiet mode prints file list."""
mock_changes.return_value = ["src/gitea_runner_manager/cli.py"]
runner = CliRunner()
result = runner.invoke(main, ["--check", "user-facing"])
assert result.exit_code == 0
assert "User-facing changes detected" in result.output
class TestGithubOutput:
def test_writes_outputs(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object(
classify_changes_mod, "get_changed_files", return_value=["src/cli.py", "ansible/tasks/main.yml"]
):
runner = CliRunner()
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "ansible-changed=true" in content
assert "user-facing-changed=true" in content
def test_no_changes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object(classify_changes_mod, "get_changed_files", return_value=[]):
runner = CliRunner()
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "ansible-changed=false" in content
assert "user-facing-changed=false" in content
def test_no_tags(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object(classify_changes_mod, "get_latest_tag", return_value=""):
runner = CliRunner()
result = runner.invoke(main, ["--github-output"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "ansible-changed=true" in content
assert "user-facing-changed=true" in content
def test_no_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
with patch.object(classify_changes_mod, "get_changed_files", return_value=["src/cli.py"]):
runner = CliRunner()
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
assert result.exit_code != 0
def test_workflow_only(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object(
classify_changes_mod, "get_changed_files", return_value=[".gitea/workflows/ci.yml", "AGENTS.md"]
):
runner = CliRunner()
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "ansible-changed=false" in content
assert "user-facing-changed=false" in content
-67
View File
@@ -1,67 +0,0 @@
"""Unit tests for scripts/configure_repo.py."""
from unittest.mock import MagicMock, patch
import click
import pytest
from gitea_runner_manager.config import BRANCH_PROTECTION_CONFIG, REPO_SETTINGS_CONFIG
from gitea_runner_manager.exceptions import APIError
from scripts.configure_repo import _handle_http_error, main
class TestHandleHttpError:
def test_forbidden_raises_click_exception(self) -> None:
with pytest.raises(click.ClickException, match="Forbidden"):
_handle_http_error(APIError(403, "Forbidden"))
def test_other_error_raises_click_exception(self) -> None:
with pytest.raises(click.ClickException, match="HTTP error"):
_handle_http_error(APIError(500, "Server error"))
class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.configure_repo.GiteaClient")
def test_main_success(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
with click.Context(click.Command("test")):
main()
mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
mock_client.update_repo_settings.assert_called_once_with(REPO_SETTINGS_CONFIG)
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.configure_repo.GiteaClient")
def test_main_api_error(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.ensure_branch_protection.side_effect = APIError(403, "Forbidden")
mock_client_cls.return_value = mock_client
with pytest.raises(click.ClickException, match="Forbidden"):
main()
@patch.dict("os.environ", {}, clear=True)
def test_main_no_token(self) -> None:
with pytest.raises(click.ClickException, match="REPO_TOKEN"):
main()
def test_main_module_block() -> None:
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
with patch("scripts.configure_repo.GiteaClient") as mock_client_cls:
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
import scripts.configure_repo as cr
with open(cr.__file__) as f:
source = f.read()
source = source.replace('if __name__ == "__main__":\n main() # pragma: no cover\n', "")
namespace = dict(cr.__dict__)
exec(compile(source, cr.__file__, "exec"), namespace)
namespace["GiteaClient"] = mock_client_cls
namespace["main"]()
mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
mock_client.update_repo_settings.assert_called_once_with(REPO_SETTINGS_CONFIG)
-76
View File
@@ -1,76 +0,0 @@
from __future__ import annotations
import subprocess
from pathlib import Path
from unittest.mock import patch
import pytest
from click import ClickException
from click.testing import CliRunner
import scripts.ci.detect_release_commit as detect_release_commit
class TestGetCommitMessage:
def test_success(self) -> None:
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="feat: add feature\n", stderr="")
with patch("subprocess.run", return_value=mock_result):
assert detect_release_commit.get_commit_message() == "feat: add feature"
def test_failure(self) -> None:
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="git error")
with patch("subprocess.run", return_value=mock_result):
with pytest.raises(ClickException, match="git log failed"):
detect_release_commit.get_commit_message()
class TestIsReleaseCommit:
def test_release_commit(self) -> None:
assert detect_release_commit.is_release_commit("release: v1.0.0 [skip ci]") is True
def test_release_commit_no_skip(self) -> None:
assert detect_release_commit.is_release_commit("release: v0.1.0") is True
def test_regular_commit(self) -> None:
assert detect_release_commit.is_release_commit("feat: add feature") is False
def test_empty(self) -> None:
assert detect_release_commit.is_release_commit("") is False
class TestWriteGithubOutput:
def test_write(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
detect_release_commit.write_github_output("is-release", "true")
with open(gh_file) as f:
assert f.read() == "is-release=true\n"
def test_no_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
with pytest.raises(ClickException, match="GITHUB_OUTPUT"):
detect_release_commit.write_github_output("is-release", "true")
class TestMain:
def test_release_commit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object(detect_release_commit, "get_commit_message", return_value="release: v1.0.0 [skip ci]"):
runner = CliRunner()
result = runner.invoke(detect_release_commit.main, [])
assert result.exit_code == 0
assert "Release commit" in result.output
with open(gh_file) as f:
assert "is-release=true" in f.read()
def test_regular_commit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object(detect_release_commit, "get_commit_message", return_value="feat: add feature"):
runner = CliRunner()
result = runner.invoke(detect_release_commit.main, [])
assert result.exit_code == 0
assert "Regular merge commit" in result.output
with open(gh_file) as f:
assert "is-release=false" in f.read()
-210
View File
@@ -1,210 +0,0 @@
"""Unit tests for scripts/ci/discover_runners.py."""
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
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) == ["1"]
def test_three(self) -> None:
assert generate_indices(3) == ["1", "2", "3"]
def test_five(self) -> None:
assert generate_indices(5) == ["1", "2", "3", "4", "5"]
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=["1", "2", "3"]' 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()) == ["1", "2", "3", "4"]
@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()) == ["1"]
@patch("scripts.ci.discover_runners.get_runner_count", return_value=3)
def test_github_output(self, mock_count: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
runner = CliRunner()
result = runner.invoke(main, ["--github-output"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "runner-count=3" in content
assert "runner-indices=" in content
@patch("scripts.ci.discover_runners.get_runner_count", return_value=3)
def test_github_output_no_env(self, mock_count: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
runner = CliRunner()
result = runner.invoke(main, ["--github-output"])
assert result.exit_code != 0
-250
View File
@@ -1,250 +0,0 @@
"""Unit tests for scripts/ci/distribute_molecule.py."""
from pathlib import Path
from unittest.mock import patch
import click
import pytest
from click.testing import CliRunner
from scripts.ci.distribute_molecule import (
MOLECULE_ROOT,
PLATFORMS,
TestPair,
build_pairs,
cli,
discover_scenarios,
distribute,
pairs_for_runner,
)
class TestDiscoverScenarios:
def test_discovers_scenarios(self, tmp_path: Path) -> None:
root = tmp_path / "molecule"
(root / "default").mkdir(parents=True)
(root / "binary").mkdir(parents=True)
(root / "common").mkdir(parents=True)
(root / "_shared").mkdir(parents=True)
result = discover_scenarios(root)
assert result == ["binary", "default"]
def test_raises_when_dir_missing(self, tmp_path: Path) -> None:
with pytest.raises(click.ClickException) as exc:
discover_scenarios(tmp_path / "nonexistent")
assert "not found" in str(exc.value)
def test_default_root_constant(self) -> None:
assert Path("ansible/roles/gitea-runner/molecule") == MOLECULE_ROOT
class TestPairEncoding:
def test_encode_roundtrip(self) -> None:
pair = TestPair("default", {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": ""})
encoded = pair.encode()
assert encoded == "default|ubuntu-2204|ubuntu:22.04|"
decoded = TestPair.decode(encoded)
assert decoded.scenario == "default"
assert decoded.platform["name"] == "ubuntu-2204"
assert decoded.platform["image"] == "ubuntu:22.04"
assert decoded.platform["command"] == ""
def test_encode_with_command(self) -> None:
pair = TestPair(
"default",
{"name": "archlinux", "image": "archlinux:latest", "command": "/usr/lib/systemd/systemd"},
)
encoded = pair.encode()
assert encoded == "default|archlinux|archlinux:latest|/usr/lib/systemd/systemd"
decoded = TestPair.decode(encoded)
assert decoded.platform["command"] == "/usr/lib/systemd/systemd"
class TestBuildPairs:
def test_cross_product(self) -> None:
scenarios = ["a", "b"]
platforms = [
{"name": "p1", "image": "img1", "command": ""},
{"name": "p2", "image": "img2", "command": ""},
]
pairs = build_pairs(scenarios, platforms)
assert len(pairs) == 4
assert pairs[0].scenario == "a"
assert pairs[0].platform["name"] == "p1"
assert pairs[1].scenario == "a"
assert pairs[1].platform["name"] == "p2"
assert pairs[2].scenario == "b"
assert pairs[2].platform["name"] == "p1"
assert pairs[3].scenario == "b"
assert pairs[3].platform["name"] == "p2"
def test_default_platforms(self) -> None:
pairs = build_pairs(["default"])
assert len(pairs) == len(PLATFORMS)
assert all(p.scenario == "default" for p in pairs)
class TestDistribute:
def test_even_split(self) -> None:
pairs = [TestPair(f"s{i}", {"name": "p", "image": "i", "command": ""}) for i in range(6)]
groups = distribute(pairs, 3)
assert len(groups) == 3
assert len(groups[0]) == 2
assert len(groups[1]) == 2
assert len(groups[2]) == 2
def test_uneven_split(self) -> None:
pairs = [TestPair(f"s{i}", {"name": "p", "image": "i", "command": ""}) for i in range(5)]
groups = distribute(pairs, 3)
assert len(groups[0]) == 2
assert len(groups[1]) == 2
assert len(groups[2]) == 1
def test_more_runners_than_pairs(self) -> None:
pairs = [TestPair("a", {"name": "p", "image": "i", "command": ""})]
groups = distribute(pairs, 5)
assert len(groups) == 5
assert len(groups[0]) == 1
assert all(len(g) == 0 for g in groups[1:])
def test_empty_pairs(self) -> None:
groups = distribute([], 3)
assert groups == [[], [], []]
class TestPairsForRunner:
def test_returns_correct_subset(self) -> None:
pairs = [TestPair(f"s{i}", {"name": "p", "image": "i", "command": ""}) for i in range(6)]
assert len(pairs_for_runner(pairs, 0, 3)) == 2
assert len(pairs_for_runner(pairs, 1, 3)) == 2
assert len(pairs_for_runner(pairs, 2, 3)) == 2
def test_out_of_range_raises(self) -> None:
pairs = [TestPair("a", {"name": "p", "image": "i", "command": ""})]
with pytest.raises(click.ClickException) as exc:
pairs_for_runner(pairs, 5, 3)
assert "out of range" in str(exc.value)
def test_negative_index_raises(self) -> None:
pairs = [TestPair("a", {"name": "p", "image": "i", "command": ""})]
with pytest.raises(click.ClickException) as exc:
pairs_for_runner(pairs, -1, 3)
assert "out of range" in str(exc.value)
class TestCli:
def test_list_flag(self, tmp_path: Path) -> None:
from click.testing import CliRunner
from scripts.ci.distribute_molecule import cli
root = tmp_path / "molecule"
(root / "alpha").mkdir(parents=True)
(root / "beta").mkdir(parents=True)
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
runner = CliRunner()
result = runner.invoke(cli, ["--list"])
assert result.exit_code == 0
assert "alpha" in result.output
assert "beta" in result.output
def test_list_platforms_flag(self) -> None:
from click.testing import CliRunner
from scripts.ci.distribute_molecule import cli
runner = CliRunner()
result = runner.invoke(cli, ["--list-platforms"])
assert result.exit_code == 0
assert "ubuntu-2204" in result.output
assert "ubuntu-2404" in result.output
assert "debian-12" in result.output
assert "archlinux" in result.output
def test_no_runner_index_prints_all_groups(self, tmp_path: Path) -> None:
from click.testing import CliRunner
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.ci.distribute_molecule.MOLECULE_ROOT", root):
runner = CliRunner()
result = runner.invoke(cli, ["--max-runners", "3"])
assert result.exit_code == 0
assert "Runner 0:" in result.output
assert "Runner 1:" in result.output
assert "Runner 2:" in result.output
def test_runner_index_prints_assigned(self, tmp_path: Path) -> None:
from click.testing import CliRunner
from scripts.ci.distribute_molecule import cli
root = tmp_path / "molecule"
(root / "alpha").mkdir(parents=True)
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
runner = CliRunner()
# 1-based index: "1" maps to internal 0
result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3"])
assert result.exit_code == 0
# Output should contain encoded pairs with platform info
assert "alpha|" in result.output
assert "ubuntu-2204" in result.output
class TestGithubEnv:
def test_writes_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "env.txt"
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
root = tmp_path / "molecule"
scenario = root / "alpha"
scenario.mkdir(parents=True)
(scenario / "molecule.yml").write_text("name: alpha\n")
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
runner = CliRunner()
result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3", "--github-env"])
assert result.exit_code == 0
content = gh_file.read_text()
assert "TEST_PAIRS=" in content
assert "SKIP=false" in content
def test_skip_if_excess(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "env.txt"
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
root = tmp_path / "molecule"
scenario = root / "alpha"
scenario.mkdir(parents=True)
(scenario / "molecule.yml").write_text("name: alpha\n")
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
runner = CliRunner()
result = runner.invoke(
cli, ["--runner-index", "5", "--max-runners", "3", "--github-env", "--skip-if-excess"]
)
assert result.exit_code == 0
content = gh_file.read_text()
assert "TEST_PAIRS=\n" in content
assert "SKIP=true" in content
def test_no_env_var(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("GITHUB_ENV", raising=False)
root = tmp_path / "molecule"
scenario = root / "alpha"
scenario.mkdir(parents=True)
(scenario / "molecule.yml").write_text("name: alpha\n")
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
runner = CliRunner()
result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3", "--github-env"])
assert result.exit_code != 0
def test_main_module_block() -> None:
import scripts.ci.distribute_molecule as dm
with open(dm.__file__) as f:
source = f.read()
source = source.replace('if __name__ == "__main__":\n cli()\n', "")
namespace = dict(dm.__dict__)
exec(compile(source, dm.__file__, "exec"), namespace)
assert callable(namespace["cli"])
-112
View File
@@ -1,112 +0,0 @@
"""Unit tests for scripts/ci/doc_coverage.py."""
from pathlib import Path
from click.testing import CliRunner
from scripts.ci.doc_coverage import (
check_command_documented,
check_module_documented,
extract_cli_commands,
main,
)
class TestExtractCliCommands:
def test_extracts_commands(self) -> None:
commands = extract_cli_commands()
# Should find all 9 CLI commands
assert "install" in commands
assert "update" in commands
assert "start" in commands
assert "stop" in commands
assert "enable" in commands
assert "disable" in commands
assert "status" in commands
assert "remove" in commands
assert "list" in commands
def test_returns_list(self) -> None:
commands = extract_cli_commands()
assert isinstance(commands, list)
assert len(commands) == 9
class TestCheckCommandDocumented:
def test_finds_command_in_heading(self) -> None:
content = "## install\n\nInstall a runner."
assert check_command_documented("install", content) is True
def test_finds_command_in_code_block(self) -> None:
content = "```bash\ngrm install 192.168.1.10\n```"
assert check_command_documented("install", content) is True
def test_finds_command_with_grm_prefix(self) -> None:
content = "Use `grm start prod-runner` to start."
assert check_command_documented("start", content) is True
def test_missing_command(self) -> None:
content = "## Other stuff\n\nNo commands here."
assert check_command_documented("install", content) is False
class TestCheckModuleDocumented:
def test_finds_module(self) -> None:
content = "The cli.py module handles..."
assert check_module_documented("cli.py", content) is True
def test_missing_module(self) -> None:
content = "No modules mentioned."
assert check_module_documented("cli.py", content) is False
class TestMain:
def test_all_present(self, tmp_path: Path) -> None:
"""When all docs exist and cover all commands/modules, exit 0."""
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
# Write cli-commands.md with all commands
(docs / "user" / "cli-commands.md").write_text(
"## install\n## update\n## start\n## stop\n## enable\n## disable\n## status\n## remove\n## list\n"
)
# Write architecture.md with all modules
(docs / "tech" / "architecture.md").write_text(
"cli.py runner_manager.py executor.py registry.py i18n.py exceptions.py api_clients.py config.py"
)
# 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 discover_runners.py "
"detect_release_commit.py push_badges.py "
"distribute_molecule.py molecule_ci_guard.py validate_commit_msg.py"
)
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs)])
assert result.exit_code == 0
assert "100%" in result.output
def test_missing_docs_fail(self, tmp_path: Path) -> None:
"""When docs are missing and --fail-on-missing is set, exit 1."""
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
(docs / "user" / "cli-commands.md").write_text("No commands here.")
(docs / "tech" / "architecture.md").write_text("No modules here.")
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs), "--fail-on-missing"])
assert result.exit_code == 1
def test_missing_docs_warn_only(self, tmp_path: Path) -> None:
"""Without --fail-on-missing, missing docs only warn (exit 0)."""
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
(docs / "user" / "cli-commands.md").write_text("No commands here.")
(docs / "tech" / "architecture.md").write_text("No modules here.")
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs)])
assert result.exit_code == 0
assert "MISSING" in result.output
-290
View File
@@ -1,290 +0,0 @@
"""Unit tests for scripts/generate_badges.py."""
from pathlib import Path
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from scripts.generate_badges import (
COLOR_HEX,
cli,
coverage_color,
doc_coverage_color,
extract_coverage,
extract_doc_coverage,
extract_test_count,
generate_badges,
make_badge,
read_version,
render_svg,
run_command,
)
class TestRunCommand:
@patch("scripts.generate_badges.subprocess.run")
def test_returns_returncode_stdout_stderr(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="out", stderr="err")
rc, out, err = run_command(["echo", "hello"])
assert rc == 0
assert out == "out"
assert err == "err"
mock_run.assert_called_once()
class TestMakeBadge:
def test_creates_valid_badge_dict(self) -> None:
badge = make_badge("coverage", "100%", "brightgreen")
assert badge == {
"schemaVersion": 1,
"label": "coverage",
"message": "100%",
"color": "brightgreen",
}
class TestRenderSvg:
def test_generates_valid_svg(self) -> None:
svg = render_svg("coverage", "100%", "brightgreen")
assert svg.startswith("<svg")
assert svg.endswith("</svg>\n")
assert "coverage" in svg
assert "100%" in svg
assert COLOR_HEX["brightgreen"] in svg
def test_uses_color_hex_for_known_colors(self) -> None:
svg = render_svg("tests", "573 passing", "brightgreen")
assert "#4c1" in svg
def test_uses_hex_directly_for_unknown_hex_color(self) -> None:
svg = render_svg("label", "msg", "#abc123")
assert "#abc123" in svg
def test_uses_lightgrey_for_unknown_named_color(self) -> None:
svg = render_svg("label", "msg", "nonexistent")
assert "#9f9f9f" in svg
def test_escapes_xml_special_chars(self) -> None:
svg = render_svg("label", "<script>", "red")
assert "<script>" not in svg
assert "&lt;script&gt;" in svg
def test_has_correct_dimensions(self) -> None:
svg = render_svg("coverage", "100%", "brightgreen")
assert 'width="' in svg
assert 'height="20"' in svg
class TestExtractCoverage:
def test_extracts_from_total_line(self) -> None:
output = (
"src/gitea_runner_manager/cli.py 118 0 100%\n"
"TOTAL 1798 0 100.00%\n"
)
assert extract_coverage(output) == 100.0
def test_extracts_partial_coverage(self) -> None:
output = "TOTAL 100 20 80.00%\n"
assert extract_coverage(output) == 80.0
def test_extracts_without_decimal(self) -> None:
output = "TOTAL 1900 0 100%\n"
assert extract_coverage(output) == 100.0
def test_returns_none_when_no_match(self) -> None:
assert extract_coverage("no coverage here") is None
class TestExtractTestCount:
def test_extracts_passed_count(self) -> None:
assert extract_test_count("543 passed in 2.32s") == 543
def test_extracts_with_warnings(self) -> None:
assert extract_test_count("543 passed, 1 warning in 2.32s") == 543
def test_returns_none_when_no_match(self) -> None:
assert extract_test_count("no tests here") is None
class TestExtractDocCoverage:
def test_extracts_percentage(self) -> None:
output = "\nDoc coverage: 20/20 (100%)"
assert extract_doc_coverage(output) == 100
def test_extracts_partial(self) -> None:
output = "\nDoc coverage: 18/20 (90%)"
assert extract_doc_coverage(output) == 90
def test_returns_none_when_no_match(self) -> None:
assert extract_doc_coverage("no doc coverage here") is None
class TestCoverageColor:
def test_100_is_brightgreen(self) -> None:
assert coverage_color(100.0) == "brightgreen"
def test_90_is_green(self) -> None:
assert coverage_color(90.0) == "green"
def test_80_is_yellowgreen(self) -> None:
assert coverage_color(80.0) == "yellowgreen"
def test_70_is_yellow(self) -> None:
assert coverage_color(70.0) == "yellow"
def test_60_is_orange(self) -> None:
assert coverage_color(60.0) == "orange"
def test_below_60_is_red(self) -> None:
assert coverage_color(50.0) == "red"
class TestDocCoverageColor:
def test_100_is_brightgreen(self) -> None:
assert doc_coverage_color(100) == "brightgreen"
def test_90_is_green(self) -> None:
assert doc_coverage_color(90) == "green"
def test_80_is_yellowgreen(self) -> None:
assert doc_coverage_color(80) == "yellowgreen"
def test_70_is_yellow(self) -> None:
assert doc_coverage_color(70) == "yellow"
def test_below_70_is_orange(self) -> None:
assert doc_coverage_color(60) == "orange"
class TestReadVersion:
def test_reads_version_from_init(self) -> None:
with patch("scripts.generate_badges.INIT_FILE") as mock_file:
mock_file.read_text.return_value = '__version__ = "0.5.0"\n'
assert read_version() == "0.5.0"
def test_returns_unknown_when_no_version(self) -> None:
with patch("scripts.generate_badges.INIT_FILE") as mock_file:
mock_file.read_text.return_value = "no version here\n"
assert read_version() == "unknown"
class TestGenerateBadges:
@patch("scripts.generate_badges.run_command")
@patch("scripts.generate_badges.read_version", return_value="0.5.0")
@patch("scripts.generate_badges.extract_coverage", return_value=100.0)
@patch("scripts.generate_badges.extract_test_count", return_value=573)
@patch("scripts.generate_badges.extract_doc_coverage", return_value=100)
def test_generates_all_badge_files(
self,
mock_doc_cov: MagicMock,
mock_test_count: MagicMock,
mock_cov: MagicMock,
mock_version: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
mock_run.return_value = (0, "output", "")
badges = generate_badges(tmp_path)
expected = {"coverage", "tests", "docs", "quality", "version", "python"}
assert set(badges.keys()) == expected
# Verify SVG files were written
for name in expected:
svg_file = tmp_path / f"{name}.svg"
assert svg_file.exists()
content = svg_file.read_text()
assert content.startswith("<svg")
assert "</svg>" in content
@patch("scripts.generate_badges.run_command")
@patch("scripts.generate_badges.read_version", return_value="0.5.0")
@patch("scripts.generate_badges.extract_coverage", return_value=100.0)
@patch("scripts.generate_badges.extract_test_count", return_value=573)
@patch("scripts.generate_badges.extract_doc_coverage", return_value=100)
def test_quality_badge_pass_when_all_lint_passes(
self,
mock_doc_cov: MagicMock,
mock_test_count: MagicMock,
mock_cov: MagicMock,
mock_version: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
mock_run.return_value = (0, "output", "")
badges = generate_badges(tmp_path)
assert badges["quality"]["message"] == "A"
assert badges["quality"]["color"] == "brightgreen"
@patch("scripts.generate_badges.run_command")
@patch("scripts.generate_badges.read_version", return_value="0.5.0")
@patch("scripts.generate_badges.extract_coverage", return_value=100.0)
@patch("scripts.generate_badges.extract_test_count", return_value=573)
@patch("scripts.generate_badges.extract_doc_coverage", return_value=100)
def test_quality_badge_fails_when_lint_fails(
self,
mock_doc_cov: MagicMock,
mock_test_count: MagicMock,
mock_cov: MagicMock,
mock_version: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
mock_run.side_effect = [
(0, "output", ""),
(0, "output", ""),
(1, "error", ""),
(0, "output", ""),
(0, "output", ""),
(0, "output", ""),
]
badges = generate_badges(tmp_path)
assert badges["quality"]["message"] == "F"
assert badges["quality"]["color"] == "red"
@patch("scripts.generate_badges.run_command")
@patch("scripts.generate_badges.read_version", return_value="0.5.0")
@patch("scripts.generate_badges.extract_coverage", return_value=None)
@patch("scripts.generate_badges.extract_test_count", return_value=None)
@patch("scripts.generate_badges.extract_doc_coverage", return_value=None)
def test_badges_show_unknown_when_extraction_fails(
self,
mock_doc_cov: MagicMock,
mock_test_count: MagicMock,
mock_cov: MagicMock,
mock_version: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
mock_run.return_value = (1, "garbled output", "")
badges = generate_badges(tmp_path)
assert badges["coverage"]["message"] == "unknown"
assert badges["coverage"]["color"] == "red"
assert badges["tests"]["message"] == "unknown"
assert badges["tests"]["color"] == "red"
assert badges["docs"]["message"] == "unknown"
assert badges["docs"]["color"] == "red"
class TestCli:
@patch("scripts.generate_badges.generate_badges")
def test_cli_generates_badges(self, mock_gen: MagicMock, tmp_path: Path) -> None:
mock_gen.return_value = {
"coverage": make_badge("coverage", "100%", "brightgreen"),
"tests": make_badge("tests", "573 passing", "brightgreen"),
}
runner = CliRunner()
result = runner.invoke(cli, ["--output-dir", str(tmp_path)])
assert result.exit_code == 0
assert "Generating badges" in result.output
assert "Generated 2 badges" in result.output
mock_gen.assert_called_once_with(tmp_path)
def test_main_module_block() -> None:
import scripts.generate_badges as gb
with patch.object(gb, "cli") as mock_cli:
with patch.object(gb, "__name__", "__main__"):
gb.cli([])
mock_cli.assert_called_once_with([])
-355
View File
@@ -1,355 +0,0 @@
"""Unit tests for scripts/gitea_cli.py."""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
import pytest
from scripts.gitea_cli import TeaCLI, TeaCLIError, _extract_issue_number, _extract_pr_number
class TestExtractIssueNumber:
def test_extract_from_created_issue(self) -> None:
assert _extract_issue_number("Created issue #42: Bug title") == 42
def test_extract_no_hash(self) -> None:
assert _extract_issue_number("No issue number here") == 0
def test_extract_multiple_hashes(self) -> None:
assert _extract_issue_number("Issue #5 and PR #10") == 5
def test_extract_with_colon(self) -> None:
assert _extract_issue_number("Created issue #7: title") == 7
def test_extract_invalid_number(self) -> None:
assert _extract_issue_number("Issue #abc: title") == 0
class TestExtractPrNumber:
def test_extract_from_created_pr(self) -> None:
assert _extract_pr_number("Created PR #128: Feature") == 128
def test_extract_no_number(self) -> None:
assert _extract_pr_number("No PR number") == 0
class TestTeaCLIInit:
def test_auto_detect_tea(self) -> None:
with patch("shutil.which", return_value="/usr/bin/tea"):
cli = TeaCLI()
assert cli._tea == "/usr/bin/tea"
def test_explicit_tea_bin(self) -> None:
cli = TeaCLI(tea_bin="/custom/tea")
assert cli._tea == "/custom/tea"
def test_fallback_to_tea(self) -> None:
with patch("shutil.which", return_value=None):
cli = TeaCLI()
assert cli._tea == "tea"
def test_with_repo(self) -> None:
cli = TeaCLI(repo="owner/repo")
assert cli._repo == "owner/repo"
class TestTeaCLIRun:
def test_run_success_json(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout='[{"id": 1}]', stderr="")
with patch("subprocess.run", return_value=mock_result):
output = cli._run(["labels", "list"])
assert output == '[{"id": 1}]'
def test_run_success_raw(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Created issue #42", stderr="")
with patch("subprocess.run", return_value=mock_result):
output = cli._run_raw(["issues", "create"])
assert output == "Created issue #42"
def test_run_failure_raises(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=1, stdout="", stderr="auth error")
with patch("subprocess.run", return_value=mock_result):
with pytest.raises(TeaCLIError, match="auth error"):
cli._run(["labels", "list"])
def test_run_includes_json_flag(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="[]", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli._run(["labels", "list"])
cmd = mock_run.call_args[0][0]
assert "--output" in cmd
assert "json" in cmd
def test_run_raw_no_json_flag(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="ok", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli._run_raw(["whoami"])
cmd = mock_run.call_args[0][0]
assert "--output" not in cmd
class TestRepoArg:
def test_with_repo_arg(self) -> None:
cli = TeaCLI(repo="owner/repo")
assert cli._repo_arg() == ["--repo", "owner/repo"]
def test_with_explicit_repo(self) -> None:
cli = TeaCLI()
assert cli._repo_arg("other/repo") == ["--repo", "other/repo"]
def test_without_repo(self) -> None:
cli = TeaCLI()
assert cli._repo_arg() == []
def test_explicit_overrides_default(self) -> None:
cli = TeaCLI(repo="default/repo")
assert cli._repo_arg("override/repo") == ["--repo", "override/repo"]
class TestCreateIssue:
def test_create_issue_basic(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea", repo="owner/repo")
mock_result = MagicMock(returncode=0, stdout="Created issue #42: Bug", stderr="")
with patch("subprocess.run", return_value=mock_result):
issue = cli.create_issue("owner/repo", title="Bug", body="Description")
assert issue["index"] == 42
assert issue["title"] == "Bug"
def test_create_issue_with_labels(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Created issue #5: Title", stderr="")
with patch("subprocess.run", return_value=mock_result):
issue = cli.create_issue("owner/repo", title="Title", body="Body", labels=["bug"])
assert issue["index"] == 5
class TestListLabels:
def test_list_labels_with_data(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
labels_json = json.dumps([{"id": 1, "name": "bug"}, {"id": 2, "name": "enhancement"}])
mock_result = MagicMock(returncode=0, stdout=labels_json, stderr="")
with patch("subprocess.run", return_value=mock_result):
labels = cli.list_labels("owner/repo")
assert len(labels) == 2
assert labels[0]["name"] == "bug"
def test_list_labels_empty(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="", stderr="")
with patch("subprocess.run", return_value=mock_result):
labels = cli.list_labels("owner/repo")
assert labels == []
class TestCreateLabel:
def test_create_label_full(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Label created", stderr="")
with patch("subprocess.run", return_value=mock_result):
label = cli.create_label("owner/repo", name="bug", color="ff0000", description="A bug")
assert label["name"] == "bug"
assert label["color"] == "ff0000"
def test_create_label_name_only(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Label created", stderr="")
with patch("subprocess.run", return_value=mock_result):
label = cli.create_label("owner/repo", name="wip")
assert label["name"] == "wip"
assert label["color"] == ""
class TestAddLabel:
def test_add_label_single(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="ok", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.add_label("owner/repo", 42, ["ready-to-merge"])
cmd = mock_run.call_args[0][0]
assert "--add-labels" in cmd
assert "ready-to-merge" in cmd
assert "42" in cmd
def test_add_label_multiple(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="ok", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.add_label("owner/repo", 42, ["bug", "urgent"])
cmd = mock_run.call_args[0][0]
assert "--add-labels" in cmd
def test_add_label_empty_list(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
with patch("subprocess.run") as mock_run:
cli.add_label("owner/repo", 42, [])
mock_run.assert_not_called()
class TestCreatePR:
def test_create_pr_basic(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Created PR #128: Feature", stderr="")
with patch("subprocess.run", return_value=mock_result):
pr = cli.create_pr("owner/repo", title="Feature", head="feature-branch", base="master")
assert pr["index"] == 128
def test_create_pr_with_body(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Created PR #10: Title", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.create_pr("owner/repo", title="Title", head="feat", base="master", body="Description")
cmd = mock_run.call_args[0][0]
assert "--body" in cmd
assert "Description" in cmd
class TestMergePR:
def test_merge_pr_squash(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Merged", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.merge_pr("owner/repo", 42, style="squash")
cmd = mock_run.call_args[0][0]
assert "--style" in cmd
assert "squash" in cmd
assert "42" in cmd
def test_merge_pr_default_style(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Merged", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.merge_pr("owner/repo", 42)
cmd = mock_run.call_args[0][0]
assert "squash" in cmd
class TestReviewPR:
def test_review_approve(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.review_pr("owner/repo", 42, event="APPROVE", body="LGTM")
cmd = mock_run.call_args[0][0]
assert "--approve" in cmd
assert "--comment" in cmd
def test_review_reject(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.review_pr("owner/repo", 42, event="REQUEST_CHANGES", body="Needs work")
cmd = mock_run.call_args[0][0]
assert "--reject" in cmd
def test_review_comment(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.review_pr("owner/repo", 42, event="COMMENT", body="Note")
cmd = mock_run.call_args[0][0]
assert "--approve" not in cmd
assert "--reject" not in cmd
assert "--comment" in cmd
def test_review_no_body(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.review_pr("owner/repo", 42, event="COMMENT")
cmd = mock_run.call_args[0][0]
assert "--comment" not in cmd
class TestCreateRelease:
def test_create_release_full(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Release created", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
release = cli.create_release(
"owner/repo",
tag="v1.0.0",
title="Release 1.0.0",
body="Notes",
target="master",
)
cmd = mock_run.call_args[0][0]
assert "v1.0.0" in cmd
assert "--title" in cmd
assert "--note" in cmd
assert "--target" in cmd
assert release["tag"] == "v1.0.0"
def test_create_release_draft(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Release created", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.create_release("owner/repo", tag="v0.1.0", draft=True)
cmd = mock_run.call_args[0][0]
assert "--draft" in cmd
def test_create_release_prerelease(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Release created", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
cli.create_release("owner/repo", tag="v0.1.0-rc1", prerelease=True)
cmd = mock_run.call_args[0][0]
assert "--prerelease" in cmd
def test_create_release_minimal(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="Release created", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
release = cli.create_release("owner/repo", tag="v1.0.0")
cmd = mock_run.call_args[0][0]
assert "--title" not in cmd
assert "--note" not in cmd
assert release["tag"] == "v1.0.0"
class TestListReleases:
def test_list_releases_with_data(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
releases_json = json.dumps([{"tag": "v1.0.0"}, {"tag": "v0.9.0"}])
mock_result = MagicMock(returncode=0, stdout=releases_json, stderr="")
with patch("subprocess.run", return_value=mock_result):
releases = cli.list_releases("owner/repo")
assert len(releases) == 2
def test_list_releases_empty(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="", stderr="")
with patch("subprocess.run", return_value=mock_result):
releases = cli.list_releases("owner/repo")
assert releases == []
class TestListBranches:
def test_list_branches_with_data(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
branches_json = json.dumps([{"name": "master"}, {"name": "develop"}])
mock_result = MagicMock(returncode=0, stdout=branches_json, stderr="")
with patch("subprocess.run", return_value=mock_result):
branches = cli.list_branches("owner/repo")
assert len(branches) == 2
def test_list_branches_empty(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="", stderr="")
with patch("subprocess.run", return_value=mock_result):
branches = cli.list_branches("owner/repo")
assert branches == []
class TestWhoami:
def test_whoami(self) -> None:
cli = TeaCLI(tea_bin="/fake/tea")
mock_result = MagicMock(returncode=0, stdout="emil", stderr="")
with patch("subprocess.run", return_value=mock_result):
assert cli.whoami() == "emil"
-94
View File
@@ -1,94 +0,0 @@
from __future__ import annotations
import platform
from pathlib import Path
from unittest.mock import patch
import pytest
from click import ClickException
import scripts.install_checkmake as install_checkmake
class TestArch:
def test_amd64(self) -> None:
with patch.object(platform, "machine", return_value="x86_64"):
assert install_checkmake._arch() == "amd64"
def test_arm64(self) -> None:
with patch.object(platform, "machine", return_value="aarch64"):
assert install_checkmake._arch() == "arm64"
def test_unsupported(self) -> None:
with patch.object(platform, "machine", return_value="riscv64"):
with pytest.raises(ClickException):
install_checkmake._arch()
class TestInstallWithGo:
def test_no_go(self) -> None:
with patch("shutil.which", return_value=None):
assert install_checkmake._install_with_go() is False
def test_with_go(self) -> None:
with patch("shutil.which", return_value="/usr/bin/go"):
with patch("subprocess.run") as mock_run:
assert install_checkmake._install_with_go() is True
mock_run.assert_called_once_with(
[
"/usr/bin/go",
"install",
"github.com/checkmake/checkmake/cmd/checkmake@latest",
],
check=True,
)
class TestDownloadBinary:
def test_download(self, tmp_path: Path) -> None:
target = tmp_path / "checkmake"
def _write_file(url: str, path: str) -> tuple[str, None]:
Path(path).write_bytes(b"binary")
return path, None
with patch.object(install_checkmake, "TARGET_PATH", target):
with patch.object(platform, "machine", return_value="x86_64"):
with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve:
install_checkmake._download_binary()
mock_retrieve.assert_called_once()
assert target.exists()
assert target.stat().st_mode & 0o111
class TestMain:
def test_already_installed(self) -> None:
with patch("shutil.which", return_value="/usr/bin/checkmake"):
install_checkmake.main()
def test_install_with_go(self) -> None:
with patch("shutil.which", side_effect=[None, "/usr/bin/go"]):
with patch("subprocess.run") as mock_run:
install_checkmake.main()
mock_run.assert_called_once_with(
[
"/usr/bin/go",
"install",
"github.com/checkmake/checkmake/cmd/checkmake@latest",
],
check=True,
)
def test_download_when_no_go(self, tmp_path: Path) -> None:
target = tmp_path / "checkmake"
def _write_file(url: str, path: str) -> tuple[str, None]:
Path(path).write_bytes(b"binary")
return path, None
with patch.object(install_checkmake, "TARGET_PATH", target):
with patch("shutil.which", side_effect=[None, None]):
with patch.object(platform, "machine", return_value="x86_64"):
with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve:
install_checkmake.main()
mock_retrieve.assert_called_once()
-312
View File
@@ -1,312 +0,0 @@
from __future__ import annotations
import platform
from pathlib import Path
from unittest.mock import patch
import pytest
from click import ClickException
from click.testing import CliRunner
import scripts.install_tools as install_tools
class TestArch:
def test_amd64(self) -> None:
with patch.object(platform, "machine", return_value="x86_64"):
assert install_tools._arch() == "amd64"
def test_arm64(self) -> None:
with patch.object(platform, "machine", return_value="aarch64"):
assert install_tools._arch() == "arm64"
def test_unsupported(self) -> None:
with patch.object(platform, "machine", return_value="riscv64"):
with pytest.raises(ClickException):
install_tools._arch()
class TestIsInstalled:
def test_on_path(self) -> None:
with patch("shutil.which", return_value="/usr/bin/actionlint"):
assert install_tools._is_installed("actionlint") is True
def test_in_target_dir(self, tmp_path: Path) -> None:
with patch.object(install_tools, "TARGET_DIR", tmp_path):
(tmp_path / "actionlint").touch()
with patch("shutil.which", return_value=None):
assert install_tools._is_installed("actionlint") is True
def test_not_installed(self, tmp_path: Path) -> None:
with patch.object(install_tools, "TARGET_DIR", tmp_path):
with patch("shutil.which", return_value=None):
assert install_tools._is_installed("actionlint") is False
class TestDownload:
def test_download(self, tmp_path: Path) -> None:
dest = tmp_path / "file.bin"
def _write_file(url: str, path: Path) -> tuple[str, None]:
Path(path).write_bytes(b"data")
return str(path), None
with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve:
install_tools._download("https://example.com/file", dest)
mock_retrieve.assert_called_once()
assert dest.read_bytes() == b"data"
class TestDownloadBinary:
def test_download(self, tmp_path: Path) -> None:
dest = tmp_path / "act_runner"
def _write_file(url: str, path: Path) -> tuple[str, None]:
Path(path).write_bytes(b"binary")
return str(path), None
with patch.object(install_tools, "TARGET_DIR", tmp_path):
with patch.object(install_tools, "_download", side_effect=_write_file):
result = install_tools._download_binary("https://example.com/act_runner", "act_runner")
assert result == dest
assert dest.exists()
assert dest.stat().st_mode & 0o111
class TestDownloadAndExtractTarball:
def test_extract(self, tmp_path: Path) -> None:
import tarfile
# Create a fake tarball with a binary
tarball_path = tmp_path / "archive.tar.gz"
binary_content = b"fake binary"
with tarfile.open(tarball_path, "w:gz") as tar:
import io
info = tarfile.TarInfo(name="actionlint")
info.size = len(binary_content)
tar.addfile(info, io.BytesIO(binary_content))
target_dir = tmp_path / "bin"
target_dir.mkdir()
with patch.object(install_tools, "TARGET_DIR", target_dir):
with patch.object(
install_tools,
"_download",
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
):
result = install_tools._download_and_extract_tarball(
"https://example.com/actionlint.tar.gz", "actionlint"
)
assert result == target_dir / "actionlint"
assert result.exists()
assert result.read_bytes() == binary_content
def test_binary_not_found(self, tmp_path: Path) -> None:
import tarfile
tarball_path = tmp_path / "archive.tar.gz"
with tarfile.open(tarball_path, "w:gz") as tar:
import io
info = tarfile.TarInfo(name="other_file")
info.size = 0
tar.addfile(info, io.BytesIO(b""))
target_dir = tmp_path / "bin"
target_dir.mkdir()
with patch.object(install_tools, "TARGET_DIR", target_dir):
with patch.object(
install_tools,
"_download",
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
):
with pytest.raises(ClickException, match="not found in archive"):
install_tools._download_and_extract_tarball("https://example.com/actionlint.tar.gz", "actionlint")
class TestInstallActionlint:
def test_already_installed(self) -> None:
with patch.object(install_tools, "_is_installed", return_value=True):
assert install_tools.install_actionlint() is True
def test_install(self, tmp_path: Path) -> None:
import tarfile
tarball_path = tmp_path / "archive.tar.gz"
binary_content = b"fake actionlint"
with tarfile.open(tarball_path, "w:gz") as tar:
import io
info = tarfile.TarInfo(name="actionlint")
info.size = len(binary_content)
tar.addfile(info, io.BytesIO(binary_content))
with patch.object(install_tools, "_is_installed", return_value=False):
with patch.object(install_tools, "TARGET_DIR", tmp_path):
with patch.object(platform, "machine", return_value="x86_64"):
with patch.object(
install_tools,
"_download",
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
):
assert install_tools.install_actionlint() is True
assert (tmp_path / "actionlint").exists()
class TestInstallGitCliff:
def test_already_installed(self) -> None:
with patch.object(install_tools, "_is_installed", return_value=True):
assert install_tools.install_git_cliff() is True
def test_install(self, tmp_path: Path) -> None:
import tarfile
tarball_path = tmp_path / "archive.tar.gz"
binary_content = b"fake git-cliff"
with tarfile.open(tarball_path, "w:gz") as tar:
import io
info = tarfile.TarInfo(name="git-cliff")
info.size = len(binary_content)
tar.addfile(info, io.BytesIO(binary_content))
with patch.object(install_tools, "_is_installed", return_value=False):
with patch.object(install_tools, "TARGET_DIR", tmp_path):
with patch.object(platform, "machine", return_value="x86_64"):
with patch.object(
install_tools,
"_download",
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
):
assert install_tools.install_git_cliff() is True
assert (tmp_path / "git-cliff").exists()
class TestInstallActRunner:
def test_already_installed(self) -> None:
with patch.object(install_tools, "_is_installed", return_value=True):
assert install_tools.install_act_runner() is True
def test_install(self, tmp_path: Path) -> None:
def _write_file(url: str, path: Path) -> tuple[str, None]:
Path(path).write_bytes(b"binary")
return str(path), None
with patch.object(install_tools, "_is_installed", return_value=False):
with patch.object(install_tools, "TARGET_DIR", tmp_path):
with patch.object(platform, "machine", return_value="x86_64"):
with patch.object(install_tools, "_download", side_effect=_write_file):
assert install_tools.install_act_runner() is True
assert (tmp_path / "act_runner").exists()
class TestInstallTea:
def test_already_installed(self) -> None:
with patch.object(install_tools, "_is_installed", return_value=True):
assert install_tools.install_tea() is True
def test_install(self, tmp_path: Path) -> None:
def _write_file(url: str, path: Path) -> tuple[str, None]:
Path(path).write_bytes(b"binary")
return str(path), None
with patch.object(install_tools, "_is_installed", return_value=False):
with patch.object(install_tools, "TARGET_DIR", tmp_path):
with patch.object(platform, "machine", return_value="x86_64"):
with patch.object(install_tools, "_download", side_effect=_write_file):
assert install_tools.install_tea() is True
assert (tmp_path / "tea").exists()
class TestListTools:
def test_list(self, tmp_path: Path) -> None:
with patch.object(install_tools, "TARGET_DIR", tmp_path):
with patch("shutil.which", return_value=None):
with patch.object(install_tools, "TOOL_NAMES", ["actionlint", "git-cliff", "act_runner"]):
install_tools.list_tools()
class TestInstallTool:
def test_actionlint(self) -> None:
with patch.object(install_tools, "install_actionlint", return_value=True) as mock:
assert install_tools._install_tool("actionlint") is True
mock.assert_called_once()
def test_git_cliff(self) -> None:
with patch.object(install_tools, "install_git_cliff", return_value=True) as mock:
assert install_tools._install_tool("git-cliff") is True
mock.assert_called_once()
def test_act_runner(self) -> None:
with patch.object(install_tools, "install_act_runner", return_value=True) as mock:
assert install_tools._install_tool("act_runner") is True
mock.assert_called_once()
def test_tea(self) -> None:
with patch.object(install_tools, "install_tea", return_value=True) as mock:
assert install_tools._install_tool("tea") is True
mock.assert_called_once()
def test_unknown_tool(self) -> None:
with pytest.raises(ClickException, match="Unknown tool"):
install_tools._install_tool("unknown")
class TestMain:
def test_list_status(self) -> None:
runner = CliRunner()
with patch.object(install_tools, "_is_installed", return_value=True):
result = runner.invoke(install_tools.main, ["--list"])
assert result.exit_code == 0
assert "actionlint" in result.output
def test_install_all(self) -> None:
runner = CliRunner()
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
result = runner.invoke(install_tools.main, [])
assert result.exit_code == 0
assert mock_install.call_count == 4
def test_install_specific_tool(self) -> None:
runner = CliRunner()
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
result = runner.invoke(install_tools.main, ["--tool", "actionlint"])
assert result.exit_code == 0
mock_install.assert_called_once_with("actionlint")
def test_install_multiple_specific_tools(self) -> None:
runner = CliRunner()
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
result = runner.invoke(install_tools.main, ["--tool", "git-cliff", "--tool", "tea"])
assert result.exit_code == 0
assert mock_install.call_count == 2
def test_install_failure(self) -> None:
runner = CliRunner()
with patch.object(install_tools, "_install_tool", side_effect=Exception("network error")):
result = runner.invoke(install_tools.main, ["--tool", "actionlint"])
assert result.exit_code != 0
def test_path_reminder(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""When TARGET_DIR is not in PATH, a reminder is printed."""
monkeypatch.setenv("PATH", "/usr/bin:/bin")
runner = CliRunner()
with patch.object(install_tools, "_install_tool", return_value=True):
result = runner.invoke(install_tools.main, [])
assert result.exit_code == 0
assert "Add" in result.output
assert "PATH" in result.output
def test_no_path_reminder_when_in_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""When TARGET_DIR is in PATH, no reminder is printed."""
target_dir = str(install_tools.TARGET_DIR)
monkeypatch.setenv("PATH", f"/usr/bin:{target_dir}:/bin")
runner = CliRunner()
with patch.object(install_tools, "_install_tool", return_value=True):
result = runner.invoke(install_tools.main, [])
assert result.exit_code == 0
assert "Add" not in result.output
-130
View File
@@ -1,130 +0,0 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from click.testing import CliRunner
import scripts.molecule_all as molecule_all
class TestRunMolecule:
def test_success(self) -> None:
import subprocess
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
rc = molecule_all._run_molecule("/bin/molecule", "default", Path("/tmp/role"), {})
assert rc == 0
mock_run.assert_called_once()
def test_failure(self) -> None:
import subprocess
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="")
with patch("subprocess.run", return_value=mock_result):
rc = molecule_all._run_molecule("/bin/molecule", "default", Path("/tmp/role"), {})
assert rc == 1
def test_non_default_scenario_adds_flag(self) -> None:
import subprocess
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
with patch("subprocess.run", return_value=mock_result) as mock_run:
molecule_all._run_molecule("/bin/molecule", "lifecycle", Path("/tmp/role"), {})
cmd = mock_run.call_args[0][0]
assert "-s" in cmd
assert "lifecycle" in cmd
class TestRunPlatform:
def test_all_scenarios_pass(self) -> None:
platform = {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": "/lib/systemd/systemd"}
with patch("scripts.molecule_all._run_molecule", return_value=0) as mock_run:
rc = molecule_all._run_platform("/bin/molecule", platform, Path("/tmp/role"), ["default", "lifecycle"], {})
assert rc == 0
assert mock_run.call_count == 2
def test_stops_on_failure(self) -> None:
platform = {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": "/lib/systemd/systemd"}
with patch("scripts.molecule_all._run_molecule", side_effect=[1, 0]) as mock_run:
rc = molecule_all._run_platform("/bin/molecule", platform, Path("/tmp/role"), ["default", "lifecycle"], {})
assert rc == 1
assert mock_run.call_count == 1
def test_sets_env_vars(self) -> None:
platform = {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": "/lib/systemd/systemd"}
captured_env: dict[str, str] = {}
def _capture_env(cmd, cwd, env):
captured_env.update(env)
import subprocess
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
with patch("subprocess.run", side_effect=_capture_env):
molecule_all._run_platform("/bin/molecule", platform, Path("/tmp/role"), ["default"], {"PATH": "/usr/bin"})
assert captured_env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2204"
assert captured_env["MOLECULE_PLATFORM_IMAGE"] == "ubuntu:22.04"
assert captured_env["MOLECULE_PLATFORM_COMMAND"] == "/lib/systemd/systemd"
def test_empty_command_removes_env(self) -> None:
platform = {"name": "custom", "image": "custom:latest", "command": ""}
captured_env: dict[str, str] = {}
def _capture_env(cmd, cwd, env):
captured_env.update(env)
import subprocess
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
with patch("subprocess.run", side_effect=_capture_env):
molecule_all._run_platform(
"/bin/molecule", platform, Path("/tmp/role"), ["default"], {"MOLECULE_PLATFORM_COMMAND": "old"}
)
assert "MOLECULE_PLATFORM_COMMAND" not in captured_env
class TestMain:
def test_molecule_not_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
runner = CliRunner()
result = runner.invoke(molecule_all.main, ["--bin", "nonexistent/bin"])
assert result.exit_code != 0
assert "molecule not found" in result.output
def test_role_dir_not_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
bin_dir = tmp_path / ".venv" / "bin"
bin_dir.mkdir(parents=True)
(bin_dir / "molecule").touch()
runner = CliRunner()
result = runner.invoke(molecule_all.main, ["--bin", str(bin_dir)])
assert result.exit_code != 0
assert "Role directory not found" in result.output
def test_all_pass(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
bin_dir = tmp_path / ".venv" / "bin"
bin_dir.mkdir(parents=True)
(bin_dir / "molecule").touch()
(tmp_path / "ansible" / "roles" / "gitea-runner").mkdir(parents=True)
runner = CliRunner()
with patch("scripts.molecule_all._run_platform", return_value=0):
result = runner.invoke(molecule_all.main, ["--bin", str(bin_dir)])
assert result.exit_code == 0
assert "All molecule scenarios passed" in result.output
def test_platform_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
bin_dir = tmp_path / ".venv" / "bin"
bin_dir.mkdir(parents=True)
(bin_dir / "molecule").touch()
(tmp_path / "ansible" / "roles" / "gitea-runner").mkdir(parents=True)
runner = CliRunner()
with patch("scripts.molecule_all._run_platform", return_value=1):
result = runner.invoke(molecule_all.main, ["--bin", str(bin_dir)])
assert result.exit_code != 0
-428
View File
@@ -1,428 +0,0 @@
"""Unit tests for scripts/ci/molecule_ci_guard.py."""
from __future__ import annotations
import os
import subprocess # nosec B404
import time
from unittest.mock import MagicMock, patch
import pytest
import requests
from scripts.ci.molecule_ci_guard import (
any_other_runner_failed,
build_env_for_pair,
build_molecule_cmd,
cli,
get_running_jobs,
poll_for_other_failures,
)
class TestGetRunningJobs:
def test_returns_jobs(self) -> None:
with patch("scripts.ci.molecule_ci_guard.requests.get") as mock_get:
mock_response = MagicMock()
mock_response.json.return_value = {
"jobs": [
{"name": "molecule-tests (0)", "conclusion": "success"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
]
}
mock_response.raise_for_status.return_value = None
mock_get.return_value = mock_response
jobs = get_running_jobs("https://gitea.example", "owner", "repo", "token", 123)
assert len(jobs) == 2
mock_get.assert_called_once()
def test_raises_on_request_error(self) -> None:
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)
class TestAnyOtherRunnerFailed:
def test_detects_other_failure(self) -> None:
jobs = [
{"name": "molecule-tests (0)", "conclusion": "success"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
{"name": "molecule-tests (2)", "conclusion": "running"},
]
assert any_other_runner_failed(jobs, "molecule-tests", 0) is True
def test_ignores_current_runner(self) -> None:
jobs = [
{"name": "molecule-tests (0)", "conclusion": "failure"},
{"name": "molecule-tests (1)", "conclusion": "success"},
]
assert any_other_runner_failed(jobs, "molecule-tests", 0) is False
def test_ignores_non_molecule_jobs(self) -> None:
jobs = [
{"name": "quality", "conclusion": "failure"},
{"name": "molecule-tests (1)", "conclusion": "success"},
]
assert any_other_runner_failed(jobs, "molecule-tests", 0) is False
class TestBuildMoleculeCmd:
def test_default_scenario(self) -> None:
assert build_molecule_cmd("default") == ["molecule", "test"]
def test_named_scenario(self) -> None:
assert build_molecule_cmd("lifecycle") == ["molecule", "test", "-s", "lifecycle"]
class TestBuildEnvForPair:
def test_with_command(self) -> None:
env = build_env_for_pair("default|ubuntu-2204|img:latest|/lib/systemd/systemd", {})
assert env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2204"
assert env["MOLECULE_PLATFORM_IMAGE"] == "img:latest"
assert env["MOLECULE_PLATFORM_COMMAND"] == "/lib/systemd/systemd"
assert env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] == "true"
def test_without_command(self) -> None:
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {})
assert env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2204"
assert "MOLECULE_PLATFORM_COMMAND" not in env
def test_without_command_removes_existing(self) -> None:
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {"MOLECULE_PLATFORM_COMMAND": "old"})
assert "MOLECULE_PLATFORM_COMMAND" not in env
class TestPollForOtherFailures:
def test_sets_failed_event_when_other_runner_fails(self) -> None:
stop_event = MagicMock()
failed_event = MagicMock()
def side_effect(*args, **kwargs):
if stop_event.wait.call_count < 1:
return [
{"name": "molecule-tests (0)", "conclusion": "success"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
]
return []
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
poll_for_other_failures(
"https://gitea.example",
"owner",
"repo",
"token",
123,
"molecule-tests",
0,
stop_event,
failed_event,
)
failed_event.set.assert_called_once()
def test_poll_warns_on_api_error(self) -> None:
stop_event = MagicMock()
failed_event = MagicMock()
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
poll_for_other_failures(
"https://gitea.example",
"owner",
"repo",
"token",
123,
"molecule-tests",
0,
stop_event,
failed_event,
)
failed_event.set.assert_not_called()
class TestCli:
def test_all_pass(self) -> None:
from click.testing import CliRunner
with (
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 0
assert "All molecule tests passed" in result.output
def test_failure_exits_nonzero(self) -> None:
from click.testing import CliRunner
with (
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 1
proc.returncode = 1
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 1
assert "FAILED" in result.output
def test_exits_before_starting_when_already_failed(self) -> None:
from click.testing import CliRunner
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/grm",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
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 = [
{"name": "molecule-tests (0)", "conclusion": "running"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
]
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 1
assert "Another molecule runner failed" in result.output
def test_keyboard_interrupt_kills_process(self) -> None:
from click.testing import CliRunner
with (
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,
):
mock_getpgid.return_value = 123
proc = MagicMock()
proc.poll.return_value = None
proc.wait.return_value = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 1
mock_killpg.assert_called()
def test_exits_when_other_runner_fails(self) -> None:
from click.testing import CliRunner
real_sleep = time.sleep
call_count = [0]
def get_jobs_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] < 2:
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
return [
{"name": "molecule-tests (0)", "conclusion": "running"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
]
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/grm",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
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)),
):
mock_getpgid.return_value = 123
proc = MagicMock()
proc.poll.return_value = None
proc.wait.return_value = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 1
mock_killpg.assert_called()
def test_default_owner_repo_fallback(self) -> None:
from click.testing import CliRunner
real_sleep = time.sleep
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "invalid",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
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"}]
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 0
def test_exits_when_other_runner_fails_with_process_lookup_error(self) -> None:
from click.testing import CliRunner
real_sleep = time.sleep
call_count = [0]
def get_jobs_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] < 2:
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
return [
{"name": "molecule-tests (0)", "conclusion": "running"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
]
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/grm",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
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)),
):
mock_getpgid.return_value = 123
mock_killpg.side_effect = ProcessLookupError("no such process")
proc = MagicMock()
proc.poll.return_value = None
proc.wait.return_value = 0
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 1
def test_exits_when_other_runner_fails_with_timeout(self) -> None:
from click.testing import CliRunner
real_sleep = time.sleep
call_count = [0]
def get_jobs_side_effect(*args, **kwargs):
call_count[0] += 1
if call_count[0] < 2:
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
return [
{"name": "molecule-tests (0)", "conclusion": "running"},
{"name": "molecule-tests (1)", "conclusion": "failure"},
]
with (
patch.dict(
os.environ,
{
"GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token",
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/grm",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
),
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)),
):
mock_getpgid.return_value = 123
mock_killpg.side_effect = [None, ProcessLookupError("no such process")]
proc = MagicMock()
proc.poll.return_value = None
proc.wait.side_effect = [subprocess.TimeoutExpired("cmd", 10)]
mock_popen.return_value = proc
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 1
def test_main_module_block() -> None:
import scripts.ci.molecule_ci_guard as mg
with open(mg.__file__) as f:
source = f.read()
source = source.replace('if __name__ == "__main__":\n cli()\n', "")
namespace = dict(mg.__dict__)
exec(compile(source, mg.__file__, "exec"), namespace)
assert callable(namespace["cli"])
-116
View File
@@ -1,116 +0,0 @@
"""Unit tests for scripts/ci/notify_failure.py."""
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from scripts.ci.notify_failure import main
from scripts.gitea_cli import TeaCLIError
class TestNotifyFailure:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.ci.notify_failure.TeaCLI")
def test_creates_issue_with_tea(self, mock_tea_cls: MagicMock) -> None:
mock_tea = MagicMock()
mock_tea.list_labels.return_value = [{"id": 5, "name": "bug"}]
mock_tea.create_issue.return_value = {"index": 42, "title": "test"}
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(
main,
[
"--repo",
"owner/repo",
"--run-id",
"123",
"--workflow",
"release",
"--commit",
"abc123def456",
],
)
assert result.exit_code == 0
assert "issue #42" in result.output
mock_tea.create_issue.assert_called_once()
mock_tea.add_label.assert_called_once_with("owner/repo", 42, ["bug"])
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.ci.notify_failure.TeaCLI")
def test_tea_creates_issue_without_bug_label(self, mock_tea_cls: MagicMock) -> None:
mock_tea = MagicMock()
mock_tea.list_labels.return_value = [{"id": 1, "name": "enhancement"}]
mock_tea.create_issue.return_value = {"index": 43, "title": "test"}
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(
main,
["--repo", "owner/repo", "--run-id", "124", "--workflow", "publish", "--commit", "def789"],
)
assert result.exit_code == 0
assert "issue #43" in result.output
mock_tea.add_label.assert_not_called()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.ci.notify_failure.TeaCLI")
def test_tea_error_raises(self, mock_tea_cls: MagicMock) -> None:
"""When tea fails, the workflow fails — no fallback."""
mock_tea = MagicMock()
mock_tea.list_labels.side_effect = TeaCLIError("network error")
mock_tea.create_issue.side_effect = TeaCLIError("network error")
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(
main,
["--repo", "owner/repo", "--run-id", "125", "--workflow", "release", "--commit", "abc"],
)
assert result.exit_code != 0
assert "tea" in result.output.lower()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.ci.notify_failure.TeaCLI")
def test_tea_list_labels_error_continues_without_labels(self, mock_tea_cls: MagicMock) -> None:
"""If listing labels fails via tea, issue is still created without labels."""
mock_tea = MagicMock()
mock_tea.list_labels.side_effect = TeaCLIError("network error")
mock_tea.create_issue.return_value = {"index": 50, "title": "test"}
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(
main,
["--repo", "owner/repo", "--run-id", "128", "--workflow", "release", "--commit", "abc"],
)
assert result.exit_code == 0
assert "issue #50" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@patch("scripts.ci.notify_failure.TeaCLI")
def test_tea_add_label_error_is_ignored(self, mock_tea_cls: MagicMock) -> None:
"""If adding label fails via tea, issue is still reported as created."""
mock_tea = MagicMock()
mock_tea.list_labels.return_value = [{"id": 5, "name": "bug"}]
mock_tea.create_issue.return_value = {"index": 51, "title": "test"}
mock_tea.add_label.side_effect = TeaCLIError("permission denied")
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(
main,
["--repo", "owner/repo", "--run-id", "129", "--workflow", "release", "--commit", "abc"],
)
assert result.exit_code == 0
assert "issue #51" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
def test_missing_token_exits(self) -> None:
runner = CliRunner()
result = runner.invoke(
main,
["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc"],
)
assert result.exit_code != 0
assert "REPO_TOKEN" in result.output
-25
View File
@@ -1,25 +0,0 @@
"""Unit tests for scripts/ci/platforms.py."""
from scripts.ci.platforms import PLATFORMS
class TestPlatforms:
def test_platforms_not_empty(self) -> None:
assert len(PLATFORMS) >= 4
def test_each_platform_has_required_keys(self) -> None:
for p in PLATFORMS:
assert "name" in p
assert "image" in p
assert "command" in p
def test_platform_names_unique(self) -> None:
names = [p["name"] for p in PLATFORMS]
assert len(names) == len(set(names))
def test_known_platforms_present(self) -> None:
names = {p["name"] for p in PLATFORMS}
assert "ubuntu-2204" in names
assert "ubuntu-2404" in names
assert "debian-12" in names
assert "archlinux" in names
-328
View File
@@ -1,328 +0,0 @@
"""Unit tests for scripts/ci/post_merge.py."""
import http
import subprocess
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from gitea_runner_manager.exceptions import APIError
from scripts.ci.post_merge import (
_get_git_commit_message,
_get_git_commit_sha,
build_comment,
extract_conventional_msg,
extract_task_id,
main,
resolve_task_id,
)
class TestExtractTaskId:
def test_extracts_from_first_line(self) -> None:
assert extract_task_id("GRM-19: fix: resolve bug\n\nBody") == "GRM-19"
def test_returns_empty_when_missing(self) -> None:
assert extract_task_id("fix: resolve bug") == ""
class TestExtractConventionalMsg:
def test_strips_colon_prefix(self) -> None:
"""Legacy format: GRM-N: <message>"""
assert extract_conventional_msg("GRM-19: fix: resolve bug") == "fix: resolve bug"
def test_strips_space_prefix(self) -> None:
"""Current format: GRM-N <message>"""
assert extract_conventional_msg("GRM-19 fix: resolve bug") == "fix: resolve bug"
def test_returns_unchanged_without_prefix(self) -> None:
assert extract_conventional_msg("fix: resolve bug") == "fix: resolve bug"
class TestBuildComment:
def test_html_format(self) -> None:
html = build_comment("GRM-19", "fix: bug", "abc123")
assert "<strong>GRM-19</strong>" in html
assert "fix: bug" in html
assert "<code>abc123</code>" in html
class TestResolveTaskId:
def test_found(self) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 42, "identifier": "GRM-19"},
]
assert resolve_task_id(mock_client, "GRM-19") == 42
mock_client.list_project_tasks.assert_called_once()
def test_not_found_raises(self) -> None:
"""Missing Vikunja task is a fatal error — every PR must have a task."""
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = []
with pytest.raises(click.ClickException, match="Could not find"):
resolve_task_id(mock_client, "GRM-99")
def test_found_on_second_page(self) -> None:
"""Task is on page 2 when project has more than 50 tasks."""
mock_client = MagicMock()
page1 = [{"id": i, "identifier": f"GRM-{i}"} for i in range(50)]
page2 = [{"id": 100, "identifier": "GRM-99"}]
mock_client.list_project_tasks.side_effect = [page1, page2]
assert resolve_task_id(mock_client, "GRM-99") == 100
assert mock_client.list_project_tasks.call_count == 2
def test_stops_when_page_is_partial(self) -> None:
"""Stops paginating when a page has fewer than DEFAULT_PER_PAGE results."""
mock_client = MagicMock()
page1 = [{"id": i, "identifier": f"GRM-{i}"} for i in range(10)]
mock_client.list_project_tasks.return_value = page1
with pytest.raises(click.ClickException, match="Could not find"):
resolve_task_id(mock_client, "GRM-99")
assert mock_client.list_project_tasks.call_count == 1
def test_http_error_propagates(self) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
with pytest.raises(APIError):
resolve_task_id(mock_client, "GRM-19")
class TestMain:
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@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 = [
{"id": 267, "identifier": "GRM-20"},
]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["GRM-20: fix: resolve bug\n\nBody", "--commit-sha", "abc123"],
)
assert result.exit_code == 0
assert "updated and marked done" in result.output
mock_client.post_comment.assert_called_once()
mock_client.update_task.assert_called_once_with(267, done=True)
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@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 = [
{"id": 267, "identifier": "GRM-20"},
]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["GRM-20: fix: resolve bug"])
assert result.exit_code == 0
mock_client.post_comment.assert_called_once()
args, _ = mock_client.post_comment.call_args
assert "unknown" in args[1]
@patch.dict("os.environ", {"VIKUNJA_TOKEN": ""}, clear=True)
def test_missing_token_exits(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["GRM-20: fix: bug"])
assert result.exit_code == 1
assert "VIKUNJA_TOKEN" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_no_task_id_non_release_warns(self) -> None:
"""Non-release commits without GRM-N prefix should warn, not fail."""
runner = CliRunner()
result = runner.invoke(main, ["fix: resolve bug"])
assert result.exit_code == 0
assert "No task ID" in result.output
assert "Skipping" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_release_commit_without_task_id_skips(self) -> None:
"""Release commits without GRM-N prefix should skip gracefully."""
runner = CliRunner()
result = runner.invoke(main, ["release: v0.3.2"])
assert result.exit_code == 0
assert "skipping" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_revert_commit_skips(self) -> None:
"""Revert commits without GRM-N prefix should skip gracefully."""
runner = CliRunner()
result = runner.invoke(main, ["revert: remove v0.6.0 release"])
assert result.exit_code == 0
assert "Infrastructure commit" in result.output
assert "skipping" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_merge_commit_skips(self) -> None:
"""Merge commits without GRM-N prefix should skip gracefully."""
runner = CliRunner()
result = runner.invoke(main, ["Merge pull request #42"])
assert result.exit_code == 0
assert "Infrastructure commit" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.ci.post_merge.VikunjaClient")
def test_resolve_failure_fails(self, mock_client_cls: MagicMock) -> None:
"""Missing Vikunja task is a fatal error — every PR must have a task."""
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = []
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["GRM-20: fix: bug"])
assert result.exit_code != 0
assert "Could not find" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.ci.post_merge.VikunjaClient")
def test_post_comment_failure_warns(self, mock_client_cls: MagicMock) -> None:
"""Vikunja API errors should warn, not fail — the merge already succeeded."""
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 267, "identifier": "GRM-20"},
]
mock_client.post_comment.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["GRM-20: fix: bug"])
assert result.exit_code == 0
assert "Warning" in result.output
assert "not updated" in result.output.lower()
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.ci.post_merge.VikunjaClient")
def test_mark_done_failure_warns(self, mock_client_cls: MagicMock) -> None:
"""Vikunja API errors should warn, not fail — the merge already succeeded."""
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 267, "identifier": "GRM-20"},
]
mock_client.post_comment.return_value = None
mock_client.update_task.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["GRM-20: fix: bug"])
assert result.exit_code == 0
assert "Warning" in result.output
assert "not updated" in result.output.lower()
class TestGetGitCommitMessage:
def test_success(self) -> None:
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="GRM-20: fix: bug\n", stderr="")
with patch("subprocess.run", return_value=mock_result):
assert _get_git_commit_message() == "GRM-20: fix: bug"
def test_failure(self) -> None:
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="git error")
with patch("subprocess.run", return_value=mock_result):
with pytest.raises(click.ClickException, match="git log failed"):
_get_git_commit_message()
class TestGetGitCommitSha:
def test_success(self) -> None:
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="abc123\n", stderr="")
with patch("subprocess.run", return_value=mock_result):
assert _get_git_commit_sha() == "abc123"
def test_failure(self) -> None:
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="git error")
with patch("subprocess.run", return_value=mock_result):
with pytest.raises(click.ClickException, match="git rev-parse failed"):
_get_git_commit_sha()
class TestFromGit:
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.ci.post_merge.VikunjaClient")
@patch("scripts.ci.post_merge._get_git_commit_sha", return_value="abc123")
@patch("scripts.ci.post_merge._get_git_commit_message", return_value="GRM-20: fix: bug")
def test_from_git(self, mock_msg: MagicMock, mock_sha: MagicMock, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 267, "identifier": "GRM-20"},
]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["--from-git"])
assert result.exit_code == 0
assert "updated and marked done" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.ci.post_merge.VikunjaClient")
@patch("scripts.ci.post_merge._get_git_commit_sha", return_value="abc123")
@patch("scripts.ci.post_merge._get_git_commit_message", return_value="GRM-20: fix: bug")
def test_from_git_with_explicit_sha(
self, mock_msg: MagicMock, mock_sha: MagicMock, mock_client_cls: MagicMock
) -> None:
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 267, "identifier": "GRM-20"},
]
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(main, ["--from-git", "--commit-sha", "explicit_sha"])
assert result.exit_code == 0
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_no_msg_and_no_from_git(self) -> None:
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code != 0
assert "commit_msg" in result.output
class TestGitSha:
"""Tests for the --git-sha option (race condition fix)."""
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.ci.post_merge.VikunjaClient")
def test_git_sha_reads_commit_from_specific_sha(self, mock_client_cls: MagicMock) -> None:
"""--git-sha reads commit message from a specific SHA, not HEAD."""
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 267, "identifier": "GRM-20"},
]
mock_client_cls.return_value = mock_client
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="GRM-20: fix: bug\n", stderr="")
with patch("subprocess.run", return_value=mock_result):
runner = CliRunner()
result = runner.invoke(main, ["--git-sha", "abc123"])
assert result.exit_code == 0
assert "updated and marked done" in result.output
mock_client.post_comment.assert_called_once()
# Verify the SHA was passed to the comment
args, _ = mock_client.post_comment.call_args
assert "abc123" in args[1]
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
def test_git_sha_failure_raises(self) -> None:
"""--git-sha with invalid SHA should raise."""
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="bad sha")
with patch("subprocess.run", return_value=mock_result):
runner = CliRunner()
result = runner.invoke(main, ["--git-sha", "badsha"])
assert result.exit_code != 0
assert "git log failed" in result.output
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
@patch("scripts.ci.post_merge.VikunjaClient")
def test_git_sha_with_explicit_commit_sha(self, mock_client_cls: MagicMock) -> None:
"""--git-sha with --commit-sha uses the explicit SHA for the comment."""
mock_client = MagicMock()
mock_client.list_project_tasks.return_value = [
{"id": 267, "identifier": "GRM-20"},
]
mock_client_cls.return_value = mock_client
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="GRM-20: fix: bug\n", stderr="")
with patch("subprocess.run", return_value=mock_result):
runner = CliRunner()
result = runner.invoke(main, ["--git-sha", "abc123", "--commit-sha", "explicit_sha"])
assert result.exit_code == 0
args, _ = mock_client.post_comment.call_args
assert "explicit_sha" in args[1]
-691
View File
@@ -1,691 +0,0 @@
"""Unit tests for scripts/ci/pr_review.py."""
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from gitea_runner_manager.exceptions import APIError
from scripts.ci.pr_review import (
ReviewResult,
build_review_body,
check_architecture_compliance,
check_best_practices,
check_commit_conventions,
check_documentation,
check_function_length,
check_i18n,
check_resource_management,
check_security,
check_test_coverage,
is_python_file,
is_workflow_only,
main,
post_review,
run_review,
)
class TestIsPythonFile:
def test_python_file_in_src(self) -> None:
assert is_python_file("src/gitea_runner_manager/cli.py") is True
def test_python_file_in_scripts(self) -> None:
assert is_python_file("scripts/ci/release.py") is True
def test_test_file_excluded(self) -> None:
assert is_python_file("tests/unit/test_cli.py") is False
def test_non_python_file(self) -> None:
assert is_python_file("README.md") is False
def test_yaml_file(self) -> None:
assert is_python_file(".gitea/workflows/ci.yml") is False
class TestIsWorkflowOnly:
def test_yaml_is_workflow(self) -> None:
assert is_workflow_only(".gitea/workflows/ci.yml") is True
def test_md_is_workflow(self) -> None:
assert is_workflow_only("README.md") is True
def test_python_is_not_workflow(self) -> None:
assert is_workflow_only("src/gitea_runner_manager/cli.py") is False
def test_ansible_is_workflow(self) -> None:
assert is_workflow_only("ansible/tasks/main.yml") is True
class TestReviewResult:
def test_empty_result_has_no_issues(self) -> None:
result = ReviewResult()
assert result.has_issues is False
def test_add_issue_makes_has_issues_true(self) -> None:
result = ReviewResult()
result.add_issue("src/foo.py", 10, "bad code")
assert result.has_issues is True
assert len(result.issues) == 1
assert result.issues[0]["path"] == "src/foo.py"
assert result.issues[0]["new_position"] == 10
def test_add_summary(self) -> None:
result = ReviewResult()
result.add_summary("all good")
assert "all good" in result.summary
class TestCheckArchitectureCompliance:
def test_subprocess_in_cli_triggers_issue(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/gitea_runner_manager/cli.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ subprocess.run(['ls'])\n",
}
]
check_architecture_compliance(files, result)
assert result.has_issues
assert "subprocess" in result.issues[0]["body"].lower()
def test_subprocess_in_other_file_ok(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/gitea_runner_manager/executor.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ subprocess.run(['ls'])\n",
}
]
check_architecture_compliance(files, result)
assert not result.has_issues
def test_no_changes_adds_ok_summary(self) -> None:
result = ReviewResult()
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": ""}]
check_architecture_compliance(files, result)
assert any("Architecture compliance: OK" in s for s in result.summary)
def test_non_python_file_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "README.md", "patch": "@@ -1,1 +1,2 @@\n+subprocess.run(['ls'])\n"}]
check_architecture_compliance(files, result)
assert not result.has_issues
def test_empty_patch_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": ""}]
check_architecture_compliance(files, result)
assert not result.has_issues
def test_os_system_in_cli_triggers_issue(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/gitea_runner_manager/cli.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ os.system('ls')\n",
}
]
check_architecture_compliance(files, result)
assert result.has_issues
assert "os.system" in result.issues[0]["body"]
class TestCheckBestPractices:
def test_print_triggers_warning(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/gitea_runner_manager/cli.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ print('hello')\n",
}
]
check_best_practices(files, result)
assert result.has_issues
assert "print()" in result.issues[0]["body"]
def test_bare_except_triggers_warning(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/gitea_runner_manager/runner_manager.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ except:\n pass\n",
}
]
check_best_practices(files, result)
assert result.has_issues
assert "bare except" in result.issues[0]["body"]
def test_todo_triggers_warning(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/gitea_runner_manager/cli.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ # TODO: fix this\n",
}
]
check_best_practices(files, result)
assert result.has_issues
assert "TODO" in result.issues[0]["body"]
def test_clean_code_no_issues(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/gitea_runner_manager/cli.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ click.echo('hello')\n",
}
]
check_best_practices(files, result)
assert not result.has_issues
def test_empty_patch_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": ""}]
check_best_practices(files, result)
assert not result.has_issues
def test_non_python_file_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "README.md", "patch": "@@ -1,1 +1,2 @@\n+print('hello')\n"}]
check_best_practices(files, result)
assert not result.has_issues
class TestCheckSecurity:
def test_hardcoded_secret_triggers_error(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/gitea_runner_manager/config.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ token = 'abc123secrettoken456'\n",
}
]
check_security(files, result)
assert result.has_issues
assert "secret" in result.issues[0]["body"].lower()
def test_example_token_not_flagged(self) -> None:
result = ReviewResult()
files = [
{
"filename": ".env.example",
"patch": "@@ -1,1 +1,2 @@\n+token = your-example-token\n",
}
]
check_security(files, result)
assert not result.has_issues
def test_shell_true_triggers_warning(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/gitea_runner_manager/executor.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ subprocess.run('ls', shell=True)\n",
}
]
check_best_practices(files, result)
assert result.has_issues
assert "shell=True" in result.issues[0]["body"]
def test_empty_patch_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "src/gitea_runner_manager/config.py", "patch": ""}]
check_security(files, result)
assert not result.has_issues
def test_non_python_file_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "docs/config.md", "patch": "@@ -1,1 +1,2 @@\n+token = 'abc123secrettoken456'\n"}]
check_security(files, result)
assert not result.has_issues
class TestCheckI18n:
def test_raw_string_in_echo_triggers_warning(self) -> None:
result = ReviewResult()
files = [
{"filename": "src/gitea_runner_manager/cli.py", "patch": '@@ -1,1 +1,1 @@\n+click.echo("Hello world")\n'}
]
check_i18n(files, result)
assert result.has_issues
assert any("i18n" in i["body"] for i in result.issues)
def test_translated_string_no_warning(self) -> None:
result = ReviewResult()
files = [
{"filename": "src/gitea_runner_manager/cli.py", "patch": '@@ -1,1 +1,1 @@\n+click.echo(_("Hello world"))\n'}
]
check_i18n(files, result)
assert not result.has_issues
def test_fstring_in_echo_triggers_warning(self) -> None:
result = ReviewResult()
files = [
{"filename": "src/gitea_runner_manager/cli.py", "patch": '@@ -1,1 +1,1 @@\n+click.echo(f"Hello {name}")\n'}
]
check_i18n(files, result)
assert result.has_issues
def test_raw_exception_triggers_warning(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/gitea_runner_manager/cli.py",
"patch": '@@ -1,1 +1,1 @@\n+raise click.ClickException("Something went wrong")\n',
}
]
check_i18n(files, result)
assert result.has_issues
def test_non_src_file_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "scripts/ci/test.py", "patch": '@@ -1,1 +1,1 @@\n+click.echo("Hello world")\n'}]
check_i18n(files, result)
assert not result.has_issues
def test_comment_skipped(self) -> None:
result = ReviewResult()
files = [
{"filename": "src/gitea_runner_manager/cli.py", "patch": '@@ -1,1 +1,1 @@\n+# click.echo("Hello world")\n'}
]
check_i18n(files, result)
assert not result.has_issues
def test_empty_patch_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": ""}]
check_i18n(files, result)
assert not result.has_issues
def test_clean_code_adds_ok_summary(self) -> None:
result = ReviewResult()
files = [
{"filename": "src/gitea_runner_manager/cli.py", "patch": '@@ -1,1 +1,1 @@\n+click.echo(_("Hello world"))\n'}
]
check_i18n(files, result)
assert any("i18n: OK" in s for s in result.summary)
class TestCheckResourceManagement:
def test_open_without_with_triggers_warning(self) -> None:
result = ReviewResult()
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": '@@ -1,1 +1,1 @@\n+f = open("file.txt")\n'}]
check_resource_management(files, result)
assert result.has_issues
assert any("resource" in i["body"].lower() for i in result.issues)
def test_open_with_with_no_warning(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/gitea_runner_manager/cli.py",
"patch": '@@ -1,1 +1,1 @@\n+with open("file.txt") as f:\n+ pass\n',
}
]
check_resource_management(files, result)
assert not result.has_issues
def test_popen_without_cleanup_triggers_warning(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/gitea_runner_manager/executor.py",
"patch": '@@ -1,1 +1,1 @@\n+proc = subprocess.Popen(["cmd"])\n',
}
]
check_resource_management(files, result)
assert result.has_issues
def test_popen_with_communicate_no_warning(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/gitea_runner_manager/executor.py",
"patch": '@@ -1,1 +1,1 @@\n+out, err = subprocess.Popen(["cmd"], stdout=PIPE).communicate()\n',
}
]
check_resource_management(files, result)
assert not result.has_issues
def test_comment_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": '@@ -1,1 +1,1 @@\n+# f = open("file.txt")\n'}]
check_resource_management(files, result)
assert not result.has_issues
def test_empty_patch_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": ""}]
check_resource_management(files, result)
assert not result.has_issues
def test_non_python_file_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "docs/config.md", "patch": '@@ -1,1 +1,1 @@\n+f = open("file.txt")\n'}]
check_resource_management(files, result)
assert not result.has_issues
def test_clean_code_adds_ok_summary(self) -> None:
result = ReviewResult()
files = [
{
"filename": "src/gitea_runner_manager/cli.py",
"patch": '@@ -1,1 +1,1 @@\n+with open("file.txt") as f:\n+ data = f.read()\n',
}
]
check_resource_management(files, result)
assert any("Resource management: OK" in s for s in result.summary)
class TestCheckFunctionLength:
def test_long_function_triggers_warning(self) -> None:
result = ReviewResult()
# Create a patch with a function that adds > 50 lines
added_lines = "\n".join(f"+ x = {i}" for i in range(55))
patch = f"@@ -10,3 +10,59 @@\n+def foo():\n+ pass\n{added_lines}\n"
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": patch}]
check_function_length(files, result)
assert result.has_issues
assert "foo" in result.issues[0]["body"]
def test_short_function_no_warning(self) -> None:
result = ReviewResult()
patch = "@@ -10,3 +10,8 @@\n def foo():\n pass\n+ x = 1\n+ y = 2\n+ z = 3\n"
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": patch}]
check_function_length(files, result)
assert not result.has_issues
def test_empty_patch_skipped(self) -> None:
result = ReviewResult()
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": ""}]
check_function_length(files, result)
assert not result.has_issues
def test_non_python_file_skipped(self) -> None:
result = ReviewResult()
added_lines = "\n".join(f"+ x = {i}" for i in range(55))
patch = f"@@ -10,3 +10,59 @@\n+def foo():\n+ pass\n{added_lines}\n"
files = [{"filename": "README.md", "patch": patch}]
check_function_length(files, result)
assert not result.has_issues
def test_multiple_functions_resets_count(self) -> None:
"""Two short functions back-to-back should not trigger the length warning."""
result = ReviewResult()
patch = "@@ -10,3 +10,15 @@\n+def foo():\n+ x = 1\n+def bar():\n+ y = 2\n"
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": patch}]
check_function_length(files, result)
assert not result.has_issues
def test_long_function_followed_by_new_hunk(self) -> None:
"""Long function followed by @@ header triggers the warning at hunk boundary."""
result = ReviewResult()
added_lines = "\n".join(f"+ x = {i}" for i in range(55))
patch = (
f"@@ -10,3 +10,59 @@\n+def foo():\n+ pass\n{added_lines}\n@@ -100,3 +100,5 @@\n+def bar():\n+ pass\n"
)
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": patch}]
check_function_length(files, result)
assert result.has_issues
assert "foo" in result.issues[0]["body"]
def test_long_function_followed_by_new_def(self) -> None:
"""Long function followed by another def triggers the warning at def boundary."""
result = ReviewResult()
added_lines = "\n".join(f"+ x = {i}" for i in range(55))
patch = f"@@ -10,3 +10,60 @@\n+def foo():\n+ pass\n{added_lines}\n+def bar():\n+ pass\n"
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": patch}]
check_function_length(files, result)
assert result.has_issues
assert "foo" in result.issues[0]["body"]
class TestCheckDocumentation:
def test_src_changes_without_docs_warns(self) -> None:
result = ReviewResult()
files = [{"filename": "src/gitea_runner_manager/cli.py"}]
check_documentation(files, result)
assert any("WARNING" in s for s in result.summary)
def test_src_changes_with_docs_ok(self) -> None:
result = ReviewResult()
files = [{"filename": "src/gitea_runner_manager/cli.py"}, {"filename": "docs/user/cli-commands.md"}]
check_documentation(files, result)
assert any("Documentation: OK" in s for s in result.summary)
def test_ansible_changes_without_docs_warns(self) -> None:
result = ReviewResult()
files = [{"filename": "ansible/roles/gitea-runner/tasks/main.yml"}]
check_documentation(files, result)
assert any("WARNING" in s for s in result.summary)
def test_only_doc_changes_ok(self) -> None:
result = ReviewResult()
files = [{"filename": "README.md"}]
check_documentation(files, result)
assert any("Documentation: OK" in s for s in result.summary)
class TestCheckTestCoverage:
def test_src_changes_without_tests_warns(self) -> None:
result = ReviewResult()
files = [{"filename": "src/gitea_runner_manager/cli.py"}]
check_test_coverage(files, result)
assert any("WARNING" in s for s in result.summary)
def test_src_changes_with_tests_ok(self) -> None:
result = ReviewResult()
files = [{"filename": "src/gitea_runner_manager/cli.py"}, {"filename": "tests/unit/test_cli.py"}]
check_test_coverage(files, result)
assert any("Tests: OK" in s for s in result.summary)
def test_only_test_changes_ok(self) -> None:
result = ReviewResult()
files = [{"filename": "tests/unit/test_cli.py"}]
check_test_coverage(files, result)
assert any("Tests: OK" in s for s in result.summary)
class TestBuildReviewBody:
def test_body_contains_summary(self) -> None:
result = ReviewResult()
result.add_summary("- Architecture compliance: OK")
body = build_review_body(result)
assert "Architecture compliance: OK" in body
assert "Automated PR Review" in body
def test_body_contains_issues(self) -> None:
result = ReviewResult()
result.add_issue("src/foo.py", 10, "bad code")
body = build_review_body(result)
assert "1 issue(s) found" in body
assert "src/foo.py:10" in body
assert "bad code" in body
def test_body_contains_no_issues_message(self) -> None:
result = ReviewResult()
body = build_review_body(result)
assert "No issues found" in body
def test_body_contains_auto_merge_note(self) -> None:
"""Review body must mention auto-merge."""
result = ReviewResult()
body = build_review_body(result)
assert "Auto-merge" in body
class TestRunReview:
@patch("scripts.ci.pr_review.GiteaClient")
def test_run_review_with_no_files(self, mock_client_class: MagicMock) -> None:
mock_client = mock_client_class.return_value
mock_client.get_pr_files.return_value = []
result = run_review(mock_client, "42")
assert "No files changed" in result.summary[0]
@patch("scripts.ci.pr_review.GiteaClient")
def test_run_review_finds_issues(self, mock_client_class: MagicMock) -> None:
mock_client = mock_client_class.return_value
mock_client.get_pr_files.return_value = [
{
"filename": "src/gitea_runner_manager/cli.py",
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ print('hello')\n",
}
]
mock_client.get_pr_commits.return_value = [{"commit": {"message": "fix: resolve print issue"}}]
result = run_review(mock_client, "42")
assert result.has_issues
def test_run_review_handles_api_error(self) -> None:
client = MagicMock()
client.get_pr_files.side_effect = APIError(404, "Not found")
result = run_review(client, "42")
assert any("ERROR" in s for s in result.summary)
class TestCheckCommitConventions:
def test_conventional_commit_found(self) -> None:
"""Should report OK when at least one commit is conventional."""
client = MagicMock()
client.get_pr_commits.return_value = [
{"commit": {"message": "fix: resolve bug\n\nDetails"}},
{"commit": {"message": "wip: testing"}},
]
result = ReviewResult()
check_commit_conventions(client, "42", result)
assert any("OK" in s for s in result.summary)
def test_no_conventional_commit(self) -> None:
"""Should warn when no commits are conventional."""
client = MagicMock()
client.get_pr_commits.return_value = [
{"commit": {"message": "updated stuff"}},
{"commit": {"message": "wip: testing"}},
]
result = ReviewResult()
check_commit_conventions(client, "42", result)
assert any("WARNING" in s for s in result.summary)
def test_merge_commits_excluded(self) -> None:
"""Merge commits should be excluded from the check."""
client = MagicMock()
client.get_pr_commits.return_value = [
{"commit": {"message": "Merge branch 'feature' into master"}},
{"commit": {"message": "fix: resolve bug"}},
]
result = ReviewResult()
check_commit_conventions(client, "42", result)
assert any("OK" in s for s in result.summary)
def test_all_merges_and_reverts(self) -> None:
"""Should report OK when all commits are merges/reverts."""
client = MagicMock()
client.get_pr_commits.return_value = [
{"commit": {"message": "Merge branch 'feature' into master"}},
{"commit": {"message": "Revert: bad commit"}},
]
result = ReviewResult()
check_commit_conventions(client, "42", result)
assert any("merges/reverts" in s for s in result.summary)
def test_no_commits(self) -> None:
"""Should report OK when there are no commits."""
client = MagicMock()
client.get_pr_commits.return_value = []
result = ReviewResult()
check_commit_conventions(client, "42", result)
assert any("no commits" in s for s in result.summary)
def test_api_error(self) -> None:
"""Should report ERROR when API call fails."""
client = MagicMock()
client.get_pr_commits.side_effect = APIError(500, "server error")
result = ReviewResult()
check_commit_conventions(client, "42", result)
assert any("ERROR" in s for s in result.summary)
class TestPostReview:
def test_post_review_with_issues(self) -> None:
client = MagicMock()
result = ReviewResult()
result.add_issue("src/foo.py", 10, "bad code")
post_review(client, "42", result)
client.create_review.assert_called_once()
call_args = client.create_review.call_args
assert call_args[1]["event"] == "REQUEST_CHANGES"
assert call_args[1]["comments"] == result.issues
def test_post_review_without_issues_uses_comment_not_approve(self) -> None:
"""Automated review posts COMMENT, not APPROVE (self-approval not allowed)."""
client = MagicMock()
result = ReviewResult()
post_review(client, "42", result)
client.create_review.assert_called_once()
call_args = client.create_review.call_args
assert call_args[1]["event"] == "COMMENT"
assert call_args[1]["comments"] == []
class TestMain:
@patch("scripts.ci.pr_review.run_review")
@patch("scripts.ci.pr_review.GiteaClient")
def test_dry_run_does_not_post(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = ReviewResult()
runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm", "--dry-run"], env={"REPO_TOKEN": "fake"})
assert result.exit_code == 0
assert "[dry-run]" in result.output
mock_client_class.return_value.create_review.assert_not_called()
@patch("scripts.ci.pr_review.run_review")
@patch("scripts.ci.pr_review.GiteaClient")
def test_post_review_on_success(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = ReviewResult()
mock_client_class.return_value.create_review.return_value = {"id": 123}
runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": "fake"})
assert result.exit_code == 0
assert "Review #123" in result.output
mock_client_class.return_value.create_review.assert_called_once()
@patch("scripts.ci.pr_review.run_review")
@patch("scripts.ci.pr_review.GiteaClient")
def test_self_approval_falls_back_to_comment(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
"""If REQUEST_CHANGES fails with 422 (self-approval), fall back to COMMENT."""
mock_run.return_value = ReviewResult()
client = mock_client_class.return_value
client.create_review.side_effect = [
APIError(422, "approve your own pull is not allowed"),
{"id": 124},
]
runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": "fake"})
assert result.exit_code == 0
assert "Review #124" in result.output
assert client.create_review.call_count == 2
@patch("scripts.ci.pr_review.run_review")
@patch("scripts.ci.pr_review.GiteaClient")
def test_other_api_error_re_raises(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
"""Non-approval API errors should re-raise, not fall back."""
mock_run.return_value = ReviewResult()
client = mock_client_class.return_value
client.create_review.side_effect = APIError(500, "Internal server error")
runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": "fake"})
assert result.exit_code != 0
def test_no_token_raises(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": ""})
assert result.exit_code != 0
assert "REPO_TOKEN" in result.output
def test_main_module_block() -> None:
import scripts.ci.pr_review as pr
with patch.object(pr, "main") as mock_main:
with patch.object(pr, "__name__", "__main__"):
pr.main([])
mock_main.assert_called_once_with([])
-190
View File
@@ -1,190 +0,0 @@
"""Unit tests for scripts/ci/publish.py."""
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from scripts.ci.publish import (
build_package,
generate_release_notes,
main,
publish_to_pypi,
)
from scripts.gitea_cli import TeaCLIError
class TestGenerateReleaseNotes:
@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.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.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.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.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.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
assert "CHANGELOG.md" in result
class TestBuildPackage:
@patch("scripts.ci.publish.subprocess.run")
def test_success(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stderr="")
build_package()
args, _ = mock_run.call_args
assert args[0][1] == "-m"
assert args[0][2] == "build"
@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:
build_package()
assert "build" in str(exc.value)
class TestPublishToPypi:
@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")
args, _ = mock_run.call_args
assert "twine" in args[0]
assert "pypi-tok" in args[0]
@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:
publish_to_pypi("pypi-tok")
assert "PyPI" in str(exc.value)
class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
@patch("scripts.ci.publish.TeaCLI")
@patch("scripts.ci.publish.publish_to_pypi")
@patch("scripts.ci.publish.build_package")
def test_full_flow_with_pypi(
self,
mock_build: MagicMock,
mock_publish: MagicMock,
mock_tea_cls: MagicMock,
mock_notes: MagicMock,
) -> None:
mock_tea = MagicMock()
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 0
assert "Gitea release v1.0.0 created" in result.output
mock_build.assert_called_once()
mock_publish.assert_called_once_with("pypi-tok")
mock_tea.create_release.assert_called_once_with(
"owner/repo", tag="v1.0.0", title="v1.0.0", body="Release notes"
)
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True)
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
@patch("scripts.ci.publish.TeaCLI")
@patch("scripts.ci.publish.build_package")
def test_without_pypi(
self,
mock_build: MagicMock,
mock_tea_cls: MagicMock,
mock_notes: MagicMock,
) -> None:
mock_tea = MagicMock()
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 0
mock_build.assert_called_once()
mock_tea.create_release.assert_called_once()
assert "PYPI_TOKEN not set" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
def test_missing_repo_token_exits(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 1
assert "REPO_TOKEN" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
@patch("scripts.ci.publish.TeaCLI")
@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_tea_cls: MagicMock, mock_notes: MagicMock
) -> None:
mock_build.side_effect = click.ClickException("build failed")
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 1
assert "build" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
@patch("scripts.ci.publish.TeaCLI")
@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_tea_cls: MagicMock, mock_notes: MagicMock
) -> None:
mock_publish.side_effect = click.ClickException("publish failed")
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 1
assert "publish" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
@patch("scripts.ci.publish.TeaCLI")
@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_tea_cls: MagicMock, mock_notes: MagicMock
) -> None:
mock_tea = MagicMock()
mock_tea.create_release.side_effect = TeaCLIError("server error")
mock_tea_cls.return_value = mock_tea
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 1
assert "Release creation failed" in result.output
-225
View File
@@ -1,225 +0,0 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from click import ClickException
from click.testing import CliRunner
import scripts.ci.push_badges as push_badges
class TestFetchLatestMaster:
def test_fetch_and_reset(self) -> None:
with patch("subprocess.run") as mock_run:
push_badges.fetch_latest_master("master")
# Should call git fetch and git reset --hard
calls = [str(c.args[0]) for c in mock_run.call_args_list]
assert any("fetch" in c for c in calls)
assert any("reset" in c for c in calls)
def test_custom_branch(self) -> None:
with patch("subprocess.run") as mock_run:
push_badges.fetch_latest_master("develop")
calls = [list(c.args[0]) for c in mock_run.call_args_list]
# fetch call should include the branch name
fetch_call = [c for c in calls if "fetch" in c][0]
assert "develop" in fetch_call
# reset call should include origin/develop
reset_call = [c for c in calls if "reset" in c][0]
assert "origin/develop" in reset_call
def test_fetch_failure_raises(self) -> None:
import subprocess
with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")):
with pytest.raises(subprocess.CalledProcessError):
push_badges.fetch_latest_master()
class TestGenerateBadges:
def test_success(self, tmp_path: Path) -> None:
output_dir = tmp_path / ".badges"
output_dir.mkdir()
(output_dir / "badge1.svg").touch()
with patch("subprocess.run") as mock_run:
push_badges.generate_badges(str(output_dir))
mock_run.assert_called_once()
def test_no_badges_generated(self, tmp_path: Path) -> None:
output_dir = tmp_path / ".badges"
output_dir.mkdir()
with patch("subprocess.run"):
with pytest.raises(ClickException, match="No badge SVG files generated"):
push_badges.generate_badges(str(output_dir))
class TestPushToBadgesBranch:
def test_success(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
badges_dir = tmp_path / ".badges"
badges_dir.mkdir()
svg = badges_dir / "badge1.svg"
svg.write_text("<svg></svg>")
sha_result = MagicMock()
sha_result.stdout = "abc123\n"
default_result = MagicMock()
with patch(
"subprocess.run",
side_effect=[default_result] * 7 + [sha_result],
) as mock_run:
sha = push_badges.push_to_badges_branch(str(badges_dir))
# Should have called git config x2, checkout, rm, add, commit, push, rev-parse
assert mock_run.call_count >= 8
# Verify the SVG was copied to cwd
assert (tmp_path / "badge1.svg").exists()
assert sha == "abc123"
class TestUpdateBadgeUrls:
def test_replaces_branch_url(self) -> None:
content = "[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/tests.svg)]"
result = push_badges.update_badge_urls(content, "abc123def456")
assert "raw/commit/abc123def456/tests.svg" in result
assert "raw/branch/badges" not in result
def test_replaces_commit_url(self) -> None:
"""Old commit SHA URLs should be replaced with the new one."""
old_sha = "aabb123456789012345678901234567890123456" # 40 hex chars
new_sha = "ccdd123456789012345678901234567890123456" # 40 hex chars
content = f"[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/{old_sha}/tests.svg)]"
result = push_badges.update_badge_urls(content, new_sha)
assert f"raw/commit/{new_sha}/tests.svg" in result
assert old_sha not in result
def test_no_badge_urls(self) -> None:
content = "# No badges here\nJust text."
result = push_badges.update_badge_urls(content, "abc123")
assert result == content
def test_multiple_badges(self) -> None:
content = (
"[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/coverage.svg)]\n"
"[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/tests.svg)]\n"
"[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/version.svg)]"
)
result = push_badges.update_badge_urls(content, "abc123def456")
assert result.count("raw/commit/abc123def456/") == 3
assert "raw/branch/badges" not in result
def test_preserves_non_badge_urls(self) -> None:
content = "[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions/workflows/ci.yml/badge.svg)]"
result = push_badges.update_badge_urls(content, "abc123")
assert result == content
class TestUpdateReadmeWithBadgeSha:
def test_updates_readme(self, tmp_path: Path) -> None:
readme = tmp_path / "README.md"
readme.write_text("[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/tests.svg)]")
with patch("subprocess.run"):
push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path)
content = readme.read_text()
assert "raw/commit/abc123def456/tests.svg" in content
def test_no_badge_urls_skips_commit(self, tmp_path: Path) -> None:
readme = tmp_path / "README.md"
readme.write_text("# No badges here")
with patch("subprocess.run") as mock_run:
push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path)
# Should checkout master but not commit/push
calls = [list(c.args[0]) for c in mock_run.call_args_list]
assert not any("commit" in c for c in calls)
assert not any("push" in c for c in calls)
def test_missing_readme_skips(self, tmp_path: Path) -> None:
with patch("subprocess.run"):
push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path)
# Should not raise
class TestMain:
def test_success(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
badges_dir = tmp_path / ".badges"
badges_dir.mkdir()
(badges_dir / "badge1.svg").touch()
runner = CliRunner()
with patch("subprocess.run"):
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update"])
assert result.exit_code == 0
def test_no_badges(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
badges_dir = tmp_path / ".badges"
badges_dir.mkdir()
runner = CliRunner()
with patch("subprocess.run"):
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update"])
assert result.exit_code != 0
def test_custom_branch(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
badges_dir = tmp_path / ".badges"
badges_dir.mkdir()
(badges_dir / "badge1.svg").touch()
runner = CliRunner()
with patch("subprocess.run") as mock_run:
result = runner.invoke(
push_badges.main,
["--output-dir", str(badges_dir), "--branch", "develop", "--no-readme-update"],
)
assert result.exit_code == 0
# Verify fetch was called with the custom branch
calls = [list(c.args[0]) for c in mock_run.call_args_list]
fetch_call = [c for c in calls if "fetch" in c][0]
assert "develop" in fetch_call
def test_fetch_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
import subprocess
runner = CliRunner()
with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")):
result = runner.invoke(push_badges.main, ["--no-readme-update"])
assert result.exit_code != 0
def test_readme_update_called_by_default(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Without --no-readme-update, update_readme_with_badge_sha is called."""
monkeypatch.chdir(tmp_path)
badges_dir = tmp_path / ".badges"
badges_dir.mkdir()
(badges_dir / "badge1.svg").touch()
runner = CliRunner()
with (
patch("subprocess.run"),
patch("scripts.ci.push_badges.update_readme_with_badge_sha") as mock_update,
):
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir)])
assert result.exit_code == 0
mock_update.assert_called_once()
def test_readme_update_skipped_with_flag(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""With --no-readme-update, update_readme_with_badge_sha is not called."""
monkeypatch.chdir(tmp_path)
badges_dir = tmp_path / ".badges"
badges_dir.mkdir()
(badges_dir / "badge1.svg").touch()
runner = CliRunner()
with (
patch("subprocess.run"),
patch("scripts.ci.push_badges.update_readme_with_badge_sha") as mock_update,
):
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update"])
assert result.exit_code == 0
mock_update.assert_not_called()
-606
View File
@@ -1,606 +0,0 @@
"""Unit tests for scripts/ci/release.py."""
from unittest.mock import MagicMock, patch
import click
import pytest
from click.testing import CliRunner
from scripts.ci.release import (
commit_release_changes,
create_and_push_tag,
get_bumped_version,
get_changelog,
get_latest_tag,
has_unreleased_changes,
main,
run_cmd,
run_tests,
tag_exists,
update_changelog,
update_init_version,
)
class TestRunCmd:
@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.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.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)
assert result.returncode == 1
class TestGetLatestTag:
@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.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.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.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.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.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.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):
get_bumped_version()
class TestGetChangelog:
@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.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.ci.release.get_latest_tag")
def test_no_tags_has_changes(self, mock_latest: MagicMock) -> None:
mock_latest.return_value = ""
assert has_unreleased_changes() is True
@patch("scripts.ci.release.get_latest_tag")
@patch("scripts.ci.release.run_cmd")
def test_no_commits_since_tag(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="")
mock_latest.return_value = "v0.2.0"
assert has_unreleased_changes() is False
@patch("scripts.ci.release.get_latest_tag")
@patch("scripts.ci.release.run_cmd")
def test_commits_since_tag(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="abc123 fix: bug\n")
mock_latest.return_value = "v0.2.0"
assert has_unreleased_changes() is True
@patch("scripts.ci.release.get_latest_tag")
@patch("scripts.ci.release.run_cmd")
def test_git_log_fails_returns_false(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None:
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="")
mock_latest.return_value = "v0.2.0"
assert has_unreleased_changes() is False
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.ci.release.INIT_FILE", str(init_file))
update_init_version("0.2.0")
assert '__version__ = "0.2.0"' in init_file.read_text()
def test_same_version_ok(self, tmp_path, monkeypatch) -> None:
"""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.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.ci.release.INIT_FILE", str(init_file))
with pytest.raises(click.ClickException):
update_init_version("0.2.0")
class TestUpdateChangelog:
def test_creates_new_file(self, tmp_path, monkeypatch) -> None:
changelog_file = tmp_path / "CHANGELOG.md"
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
assert "new thing" in content
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.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
# New version should be before old version
assert content.index("0.2.0") < content.index("0.1.0")
assert "new thing" in content
assert "old thing" in content
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.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
assert "## [0.2.0]" in content
def test_strips_git_cliff_header(self, tmp_path, monkeypatch) -> None:
"""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.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)
content = changelog_file.read_text()
# Header should appear only once (from the existing file)
assert content.count("# Changelog") == 1
assert "## [0.2.0]" in content
assert "new thing" in content
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.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()
assert "# Changelog" not in content
assert "## [0.2.0]" in content
class TestCommitReleaseChanges:
@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="")
result = commit_release_changes("0.2.0")
assert result is True
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
assert ["git", "add", "src/gitea_runner_manager/__init__.py", "CHANGELOG.md"] in calls
assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0 [skip ci]"] in calls
@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="")
result = commit_release_changes("0.1.0")
assert result is False
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
assert ["git", "commit", "--no-verify", "-m", "release: v0.1.0"] not in calls
class TestCreateAndPushTag:
@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.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
for call in mock_run_cmd.call_args_list:
assert call.args[0][0:2] != ["git", "push"]
assert call.args[0][0:2] != ["git", "tag"]
@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
# Should not create tag, but should ensure it's pushed
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
assert ["git", "tag", "-a"] not in [c[:3] for c in calls]
assert ["git", "push", "origin", "v0.1.0"] in calls
@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
# No git commands at all in dry-run when tag exists
mock_run_cmd.assert_not_called()
class TestRunTests:
@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.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.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
MagicMock(returncode=1, stdout="", stderr="test failure"), # tests fail
]
with pytest.raises(click.ClickException, match="Tests failed"):
run_tests()
class TestMain:
@patch.dict("os.environ", {})
@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()
result = runner.invoke(main, [])
assert result.exit_code != 0
assert "master" in result.output
@patch.dict("os.environ", {})
@patch("scripts.ci.release.has_user_facing_changes", return_value=False)
@patch("scripts.ci.release.run_cmd")
def test_dry_run_on_non_master_warns(self, mock_run_cmd: MagicMock, mock_uf: MagicMock) -> None:
"""Dry-run mode should not fail on non-master branches."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="feature-branch\n", stderr="")
runner = CliRunner()
result = runner.invoke(main, ["--dry-run"])
assert result.exit_code == 0
assert "Dry-run mode" in result.output
@patch.dict("os.environ", {})
@patch("scripts.ci.release.has_user_facing_changes", return_value=True)
@patch("scripts.ci.release.run_cmd")
def test_release_lock_skips_when_head_is_release_commit(self, mock_run_cmd: MagicMock, mock_uf: MagicMock) -> None:
"""If HEAD is already a release commit, should skip to prevent duplicate releases."""
# First call: git rev-parse (master), second: git log -1 (release commit)
mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout="master\n", stderr=""),
MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""),
]
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "already a release commit" in result.output
assert "Skipping" in result.output
@patch.dict("os.environ", {})
@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,
mock_bumped: MagicMock,
mock_has: MagicMock,
mock_user: MagicMock,
) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "No unreleased changes" in result.output
@patch.dict("os.environ", {})
@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,
mock_has: MagicMock,
mock_bumped: MagicMock,
mock_latest: MagicMock,
mock_changelog: MagicMock,
mock_update_init: MagicMock,
mock_update_changelog: MagicMock,
mock_commit: MagicMock,
mock_tag: MagicMock,
mock_user: MagicMock,
) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
runner = CliRunner()
result = runner.invoke(main, ["--dry-run"])
assert result.exit_code == 0
assert "empty changelog" in result.output
@patch.dict("os.environ", {})
@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,
mock_has: MagicMock,
mock_bumped: MagicMock,
mock_latest: MagicMock,
mock_changelog: MagicMock,
mock_update_init: MagicMock,
mock_update_changelog: MagicMock,
mock_commit: MagicMock,
mock_tag: MagicMock,
mock_user: MagicMock,
) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
runner = CliRunner()
result = runner.invoke(main, ["--dry-run"])
assert result.exit_code == 0
assert "[dry-run]" in result.output
mock_update_init.assert_not_called()
mock_update_changelog.assert_not_called()
mock_commit.assert_not_called()
mock_tag.assert_not_called()
@patch.dict("os.environ", {})
@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,
mock_user_facing: MagicMock,
mock_latest: MagicMock,
) -> None:
"""Release is skipped when only workflow/infra files changed."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "No user-facing changes" in result.output
assert "Skipping release" in result.output
@patch.dict("os.environ", {})
@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,
mock_has: MagicMock,
mock_bumped: MagicMock,
mock_latest: MagicMock,
mock_changelog: MagicMock,
mock_update_init: MagicMock,
mock_update_changelog: MagicMock,
mock_commit: MagicMock,
mock_tag: MagicMock,
mock_run_tests: MagicMock,
mock_user: MagicMock,
) -> None:
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "Bumping version" in result.output
mock_update_init.assert_called_once_with("0.2.0")
mock_update_changelog.assert_called_once_with("changelog")
mock_run_tests.assert_called_once()
mock_commit.assert_called_once_with("0.2.0")
mock_tag.assert_called_once_with("0.2.0", "changelog", False)
@patch.dict("os.environ", {})
@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,
mock_has: MagicMock,
mock_bumped: MagicMock,
mock_latest: MagicMock,
mock_changelog: MagicMock,
mock_update_init: MagicMock,
mock_update_changelog: MagicMock,
mock_commit: MagicMock,
mock_tag: MagicMock,
mock_run_tests: MagicMock,
mock_user: MagicMock,
) -> None:
"""When tag already exists, still update files but report existing tag."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "already existed" in result.output
mock_tag.assert_called_once_with("0.1.0", "changelog", False)
@patch.dict("os.environ", {})
@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,
mock_has: MagicMock,
mock_bumped: MagicMock,
mock_latest: MagicMock,
mock_changelog: MagicMock,
mock_update_init: MagicMock,
mock_update_changelog: MagicMock,
mock_commit: MagicMock,
mock_tag: MagicMock,
mock_user: MagicMock,
) -> None:
"""--skip-tests bypasses test verification."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
runner = CliRunner()
result = runner.invoke(main, ["--skip-tests"])
assert result.exit_code == 0
assert "WARNING: --skip-tests" in result.output
# run_tests should NOT be called — verify no "make lint-ruff" or "make pytest-cov" calls
make_calls = [c.args[0] for c in mock_run_cmd.call_args_list if c.args[0][:1] == ["make"]]
assert make_calls == []
@patch.dict("os.environ", {})
@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,
mock_has: MagicMock,
mock_bumped: MagicMock,
mock_latest: MagicMock,
mock_changelog: MagicMock,
mock_update_init: MagicMock,
mock_update_changelog: MagicMock,
mock_commit: MagicMock,
mock_tag: MagicMock,
mock_user: MagicMock,
) -> None:
"""If tests fail, release aborts — no commit, no tag."""
# Calls: git rev-parse (master), git log -1 (release lock check),
# make lint-ruff (success), make pytest-cov (failure)
mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout="master\n", stderr=""),
MagicMock(returncode=0, stdout="GRM-50 fix: something\n", stderr=""),
MagicMock(returncode=0, stdout="", stderr=""),
MagicMock(returncode=1, stdout="", stderr="test failure"),
]
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code != 0
assert "Tests failed" in result.output
mock_commit.assert_not_called()
mock_tag.assert_not_called()
@patch.dict("os.environ", {})
@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,
mock_has: MagicMock,
mock_bumped: MagicMock,
mock_latest: MagicMock,
mock_changelog: MagicMock,
mock_update_init: MagicMock,
mock_update_changelog: MagicMock,
mock_commit: MagicMock,
mock_tag: MagicMock,
mock_user: MagicMock,
) -> None:
"""If lint fails, release aborts — no commit, no tag."""
# Calls: git rev-parse (master), git log -1 (release lock check),
# make lint-ruff (failure)
mock_run_cmd.side_effect = [
MagicMock(returncode=0, stdout="master\n", stderr=""),
MagicMock(returncode=0, stdout="GRM-50 fix: something\n", stderr=""),
MagicMock(returncode=1, stdout="", stderr="lint error"),
]
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code != 0
assert "Lint failed" in result.output
mock_commit.assert_not_called()
mock_tag.assert_not_called()
-269
View File
@@ -1,269 +0,0 @@
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import click
import pytest
from click.testing import CliRunner
import scripts.setup as setup
class TestRun:
def test_run_success(self) -> None:
with patch("subprocess.run") as mock_run:
setup._run(["echo", "hello"], ".venv/bin")
mock_run.assert_called_once_with(["echo", "hello"], check=True)
def test_run_failure(self) -> None:
import subprocess
with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, ["echo"])):
with pytest.raises(subprocess.CalledProcessError):
setup._run(["echo", "hello"], ".venv/bin")
class TestInstallPythonDeps:
def test_install_default_dev(self) -> None:
with patch("scripts.setup._run") as mock_run:
setup._install_python_deps(".venv/bin")
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[dev]"], ".venv/bin")
def test_install_ci_extras(self) -> None:
with patch("scripts.setup._run") as mock_run:
setup._install_python_deps(".venv/bin", extras="ci")
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci]"], ".venv/bin")
def test_install_lint_extras(self) -> None:
with patch("scripts.setup._run") as mock_run:
setup._install_python_deps(".venv/bin", extras="ci,lint")
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci,lint]"], ".venv/bin")
class TestInstallAnsibleCollections:
def test_install(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
(tmp_path / "ansible").mkdir()
(tmp_path / "ansible/requirements.yml").write_text("collections: []")
with patch("scripts.setup._run") as mock_run:
setup._install_ansible_collections(".venv/bin")
mock_run.assert_called_once()
def test_no_requirements(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
with patch("scripts.setup._run") as mock_run:
setup._install_ansible_collections(".venv/bin")
mock_run.assert_not_called()
class TestInstallPreCommitHooks:
def test_install(self) -> None:
with patch("scripts.setup._run") as mock_run:
setup._install_pre_commit_hooks(".venv/bin")
assert mock_run.call_count == 3
calls = [c.args[0] for c in mock_run.call_args_list]
# Each call should have the pre-commit binary and --hook-type flag
for call in calls:
assert ".venv/bin/pre-commit" in call[0]
assert "--hook-type" in call
class TestVerify:
def test_verify_success(self) -> None:
import subprocess
mock_result = subprocess.CompletedProcess(
args=["grm", "--version"], returncode=0, stdout="grm 1.0.0", stderr=""
)
with patch("subprocess.run", return_value=mock_result):
setup._verify(".venv/bin")
def test_verify_not_found(self) -> None:
with patch("subprocess.run", side_effect=FileNotFoundError()):
setup._verify(".venv/bin")
def test_verify_timeout(self) -> None:
import subprocess
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd=["grm", "--version"], timeout=10)):
setup._verify(".venv/bin")
class TestConfigureTeaLogin:
def test_tea_not_installed_raises(self) -> None:
"""tea must be installed — setup fails if it's missing."""
with patch("shutil.which", return_value=None):
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
with pytest.raises(click.ClickException, match="not installed"):
setup._configure_tea_login()
def test_no_repo_token(self) -> None:
with patch("shutil.which", return_value="/usr/bin/tea"):
with patch.dict("os.environ", {}, clear=True):
setup._configure_tea_login()
def test_login_already_exists(self) -> None:
import subprocess
mock_result = subprocess.CompletedProcess(
args=["tea", "login", "list"], returncode=0, stdout="grm https://git.example.com", stderr=""
)
with patch("shutil.which", return_value="/usr/bin/tea"):
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
with patch("subprocess.run", return_value=mock_result):
setup._configure_tea_login()
def test_login_added_successfully(self) -> None:
import subprocess
list_result = subprocess.CompletedProcess(args=["tea", "login", "list"], returncode=0, stdout="", stderr="")
add_result = subprocess.CompletedProcess(
args=["tea", "login", "add"], returncode=0, stdout="Login added", stderr=""
)
default_result = subprocess.CompletedProcess(
args=["tea", "login", "default"], returncode=0, stdout="", stderr=""
)
with patch("shutil.which", return_value="/usr/bin/tea"):
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
with patch("subprocess.run", side_effect=[list_result, add_result, default_result]):
setup._configure_tea_login()
def test_login_add_failure(self) -> None:
import subprocess
list_result = subprocess.CompletedProcess(args=["tea", "login", "list"], returncode=0, stdout="", stderr="")
add_result = subprocess.CompletedProcess(
args=["tea", "login", "add"], returncode=1, stdout="", stderr="auth failed"
)
with patch("shutil.which", return_value="/usr/bin/tea"):
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
with patch("subprocess.run", side_effect=[list_result, add_result]):
setup._configure_tea_login()
class TestMain:
def test_bin_not_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
runner = CliRunner()
result = runner.invoke(setup.main, ["--bin", "nonexistent/bin"])
assert result.exit_code != 0
assert "Bin directory not found" in result.output
def test_success(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.chdir(tmp_path)
bin_dir = tmp_path / ".venv" / "bin"
bin_dir.mkdir(parents=True)
(bin_dir / "grm").touch()
(bin_dir / "pre-commit").touch()
(tmp_path / "ansible").mkdir()
(tmp_path / "ansible/requirements.yml").write_text("collections: []")
runner = CliRunner()
with patch("scripts.setup._install_python_deps"):
with patch("scripts.setup._install_ansible_collections"):
with patch("scripts.setup._install_pre_commit_hooks"):
with patch("scripts.setup._configure_tea_login"):
with patch("scripts.setup._verify"):
result = runner.invoke(setup.main, ["--bin", str(bin_dir)])
assert result.exit_code == 0
assert "Setup complete" in result.output
def test_extras_ci(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""--extras ci should pass 'ci' to _install_python_deps."""
monkeypatch.chdir(tmp_path)
bin_dir = tmp_path / ".venv" / "bin"
bin_dir.mkdir(parents=True)
runner = CliRunner()
with patch("scripts.setup._install_python_deps") as mock_deps:
with patch("scripts.setup._install_ansible_collections"):
with patch("scripts.setup._install_pre_commit_hooks"):
with patch("scripts.setup._configure_tea_login"):
with patch("scripts.setup._verify"):
result = runner.invoke(setup.main, ["--bin", str(bin_dir), "--extras", "ci"])
assert result.exit_code == 0
mock_deps.assert_called_once_with(str(bin_dir), "ci")
def test_no_ansible_collections(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""--no-ansible-collections should skip ansible collection install."""
monkeypatch.chdir(tmp_path)
bin_dir = tmp_path / ".venv" / "bin"
bin_dir.mkdir(parents=True)
runner = CliRunner()
with patch("scripts.setup._install_python_deps"):
with patch("scripts.setup._install_ansible_collections") as mock_ansible:
with patch("scripts.setup._install_pre_commit_hooks"):
with patch("scripts.setup._configure_tea_login"):
with patch("scripts.setup._verify"):
result = runner.invoke(
setup.main,
["--bin", str(bin_dir), "--no-ansible-collections"],
)
assert result.exit_code == 0
mock_ansible.assert_not_called()
def test_no_pre_commit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""--no-pre-commit should skip pre-commit hook install."""
monkeypatch.chdir(tmp_path)
bin_dir = tmp_path / ".venv" / "bin"
bin_dir.mkdir(parents=True)
runner = CliRunner()
with patch("scripts.setup._install_python_deps"):
with patch("scripts.setup._install_ansible_collections"):
with patch("scripts.setup._install_pre_commit_hooks") as mock_hooks:
with patch("scripts.setup._configure_tea_login"):
with patch("scripts.setup._verify"):
result = runner.invoke(
setup.main,
["--bin", str(bin_dir), "--no-pre-commit"],
)
assert result.exit_code == 0
mock_hooks.assert_not_called()
def test_no_tea_login(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""--no-tea-login should skip tea login configuration."""
monkeypatch.chdir(tmp_path)
bin_dir = tmp_path / ".venv" / "bin"
bin_dir.mkdir(parents=True)
runner = CliRunner()
with patch("scripts.setup._install_python_deps"):
with patch("scripts.setup._install_ansible_collections"):
with patch("scripts.setup._install_pre_commit_hooks"):
with patch("scripts.setup._configure_tea_login") as mock_tea:
with patch("scripts.setup._verify"):
result = runner.invoke(
setup.main,
["--bin", str(bin_dir), "--no-tea-login"],
)
assert result.exit_code == 0
mock_tea.assert_not_called()
def test_lean_ci_setup(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""Simulate the setup-ci make target: all skip flags together."""
monkeypatch.chdir(tmp_path)
bin_dir = tmp_path / ".venv" / "bin"
bin_dir.mkdir(parents=True)
runner = CliRunner()
with patch("scripts.setup._install_python_deps") as mock_deps:
with patch("scripts.setup._install_ansible_collections") as mock_ansible:
with patch("scripts.setup._install_pre_commit_hooks") as mock_hooks:
with patch("scripts.setup._configure_tea_login") as mock_tea:
with patch("scripts.setup._verify"):
result = runner.invoke(
setup.main,
[
"--bin",
str(bin_dir),
"--extras",
"ci",
"--no-ansible-collections",
"--no-pre-commit",
"--no-tea-login",
],
)
assert result.exit_code == 0
mock_deps.assert_called_once_with(str(bin_dir), "ci")
mock_ansible.assert_not_called()
mock_hooks.assert_not_called()
mock_tea.assert_not_called()
-483
View File
@@ -1,483 +0,0 @@
"""Unit tests for scripts/ci/sync_wiki.py."""
import base64
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from scripts.ci.sync_wiki import (
decode_content,
encode_content,
fetch_page_content,
list_wiki_pages,
load_mapping,
main,
read_doc_content,
sync_page,
verify_wiki_integrity,
verify_wiki_page,
)
class TestEncodeContent:
def test_encodes_utf8_to_base64(self) -> None:
result = encode_content("# Hello World")
assert result == base64.b64encode(b"# Hello World").decode("ascii")
def test_encodes_empty_string(self) -> None:
assert encode_content("") == ""
def test_encodes_unicode(self) -> None:
result = encode_content("# Café — résumé")
decoded = base64.b64decode(result).decode("utf-8")
assert decoded == "# Café — résumé"
class TestDecodeContent:
def test_decodes_base64_to_utf8(self) -> None:
encoded = base64.b64encode(b"# Hello").decode("ascii")
assert decode_content(encoded) == "# Hello"
def test_empty_string_returns_empty(self) -> None:
assert decode_content("") == ""
def test_roundtrip(self) -> None:
original = "# Wiki Page\n\nContent with **markdown**."
encoded = encode_content(original)
assert decode_content(encoded) == original
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.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.ci.sync_wiki.MAPPING_FILE", tmp_path / "nonexistent.json"):
with pytest.raises(FileNotFoundError):
load_mapping()
class TestReadDocContent:
def test_reads_file(self, tmp_path: Path) -> None:
docs_dir = tmp_path / "docs"
docs_dir.mkdir()
(docs_dir / "test.md").write_text("# Test\n\nContent")
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.ci.sync_wiki.DOCS_DIR", tmp_path):
with pytest.raises(FileNotFoundError):
read_doc_content("nonexistent.md")
class TestListWikiPages:
def test_returns_empty_on_api_error(self) -> None:
from gitea_runner_manager.exceptions import APIError
client = MagicMock()
client._request.side_effect = APIError(404, "not found")
result = list_wiki_pages(client)
assert result == {}
def test_returns_page_dict(self) -> None:
client = MagicMock()
client._request.return_value.json.return_value = [
{"title": "Home", "sub_url": "Home"},
{"title": "Getting-Started", "sub_url": "Getting-Started.-"},
]
result = list_wiki_pages(client)
assert result == {"Home": "Home", "Getting-Started": "Getting-Started.-"}
class TestFetchPageContent:
def test_fetches_and_decodes_content(self) -> None:
client = MagicMock()
encoded = base64.b64encode(b"# Hello Wiki").decode("ascii")
client._request.return_value.json.return_value = {"content_base64": encoded}
result = fetch_page_content(client, "Home")
assert result == "# Hello Wiki"
def test_returns_empty_on_api_error(self) -> None:
from gitea_runner_manager.exceptions import APIError
client = MagicMock()
client._request.side_effect = APIError(404, "not found")
assert fetch_page_content(client, "Missing") == ""
def test_returns_empty_for_empty_content(self) -> None:
client = MagicMock()
client._request.return_value.json.return_value = {"content_base64": ""}
assert fetch_page_content(client, "Home") == ""
class TestSyncPage:
def test_dry_run_skips(self) -> None:
client = MagicMock()
result = sync_page(client, "Test-Page", "# Content", {}, dry_run=True)
assert result == "skipped"
client._request.assert_not_called()
def test_creates_new_page_with_base64(self) -> None:
client = MagicMock()
result = sync_page(client, "New-Page", "# Content", {}, dry_run=False)
assert result == "created"
client._request.assert_called_once()
call_args = client._request.call_args
assert call_args.args[0] == "POST"
assert call_args.args[1] == "/wiki/new"
# Verify content_base64 is used, not content
payload = call_args.kwargs["json"]
assert "content_base64" in payload
assert "content" not in payload
assert base64.b64decode(payload["content_base64"]).decode("utf-8") == "# Content"
def test_updates_existing_page_with_base64(self) -> None:
client = MagicMock()
existing = {"Existing-Page": "Existing-Page.-"}
result = sync_page(client, "Existing-Page", "# Updated", existing, dry_run=False)
assert result == "updated"
client._request.assert_called_once()
call_args = client._request.call_args
assert call_args.args[0] == "PATCH"
assert "/wiki/page/Existing-Page.-" in call_args.args[1]
# Verify content_base64 is used
payload = call_args.kwargs["json"]
assert "content_base64" in payload
assert "content" not in payload
assert base64.b64decode(payload["content_base64"]).decode("utf-8") == "# Updated"
class TestVerifyWikiPage:
def test_verifies_matching_content(self) -> None:
client = MagicMock()
encoded = base64.b64encode(b"# Hello Wiki").decode("ascii")
client._request.return_value.json.return_value = {"content_base64": encoded}
existing = {"Home": "Home"}
assert verify_wiki_page(client, "Home", "# Hello Wiki", existing) is True
def test_fails_on_mismatch(self) -> None:
client = MagicMock()
encoded = base64.b64encode(b"# Old Content").decode("ascii")
client._request.return_value.json.return_value = {"content_base64": encoded}
existing = {"Home": "Home"}
assert verify_wiki_page(client, "Home", "# New Content", existing) is False
def test_fails_on_empty_wiki_content(self) -> None:
client = MagicMock()
client._request.return_value.json.return_value = {"content_base64": ""}
existing = {"Home": "Home"}
assert verify_wiki_page(client, "Home", "# Expected", existing) is False
def test_fails_when_page_not_in_existing(self) -> None:
client = MagicMock()
assert verify_wiki_page(client, "Missing", "# Content", {}) is False
class TestVerifyWikiIntegrity:
def _make_client(self, pages: dict[str, str], contents: dict[str, str]) -> MagicMock:
"""Create a mock client that returns the given pages and contents."""
client = MagicMock()
# list_wiki_pages calls GET /wiki/pages
page_list = [{"title": t, "sub_url": s} for t, s in pages.items()]
# fetch_page_content calls GET /wiki/page/{sub_url}
def mock_request(method, path, **kwargs):
resp = MagicMock()
if path == "/wiki/pages":
resp.json.return_value = page_list
elif path.startswith("/wiki/page/"):
sub_url = path.replace("/wiki/page/", "")
content = contents.get(sub_url, "")
encoded = base64.b64encode(content.encode()).decode("ascii") if content else ""
resp.json.return_value = {"content_base64": encoded}
return resp
client._request.side_effect = mock_request
return client
def test_all_good_no_failures(self) -> None:
pages = {"Home": "Home", "FAQ": "FAQ"}
contents = {"Home": "# Home", "FAQ": "# FAQ"}
client = self._make_client(pages, contents)
mapping = {"index.md": "Home", "faq.md": "FAQ"}
synced = {"Home": "# Home", "FAQ": "# FAQ"}
failures = verify_wiki_integrity(client, mapping, synced)
assert failures == []
def test_missing_page_detected(self) -> None:
pages = {"Home": "Home"} # FAQ missing from wiki
contents = {"Home": "# Home"}
client = self._make_client(pages, contents)
mapping = {"index.md": "Home", "faq.md": "FAQ"}
synced = {"Home": "# Home"}
failures = verify_wiki_integrity(client, mapping, synced)
assert any("Missing page: FAQ" in f for f in failures)
def test_stale_page_detected(self) -> None:
pages = {"Home": "Home", "Old-Page": "Old-Page"} # Old-Page not in mapping
contents = {"Home": "# Home", "Old-Page": "# Old"}
client = self._make_client(pages, contents)
mapping = {"index.md": "Home"}
synced = {"Home": "# Home"}
failures = verify_wiki_integrity(client, mapping, synced)
assert any("Stale page" in f and "Old-Page" in f for f in failures)
def test_page_count_mismatch_detected(self) -> None:
pages = {"Home": "Home", "Extra": "Extra"}
contents = {"Home": "# Home", "Extra": "# Extra"}
client = self._make_client(pages, contents)
mapping = {"index.md": "Home"}
synced = {"Home": "# Home"}
failures = verify_wiki_integrity(client, mapping, synced)
assert any("Page count mismatch" in f for f in failures)
def test_empty_content_detected(self) -> None:
pages = {"Home": "Home"}
contents = {"Home": ""} # Empty content
client = self._make_client(pages, contents)
mapping = {"index.md": "Home"}
synced = {"Home": "# Expected Content"}
failures = verify_wiki_integrity(client, mapping, synced)
assert any("Empty content: Home" in f for f in failures)
def test_content_mismatch_detected(self) -> None:
pages = {"Home": "Home"}
contents = {"Home": "# Wrong Content"}
client = self._make_client(pages, contents)
mapping = {"index.md": "Home"}
synced = {"Home": "# Correct Content"}
failures = verify_wiki_integrity(client, mapping, synced)
assert any("Content mismatch: Home" in f for f in failures)
def test_multiple_failures_all_reported(self) -> None:
pages = {"Home": "Home", "Stale": "Stale"}
contents = {"Home": "", "Stale": "# Stale"}
client = self._make_client(pages, contents)
mapping = {"index.md": "Home", "faq.md": "FAQ"} # FAQ missing
synced = {"Home": "# Home Content"}
failures = verify_wiki_integrity(client, mapping, synced)
assert len(failures) >= 3 # count mismatch, missing FAQ, stale Stale, empty Home
class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
@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.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
assert "dry-run" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
def test_missing_token_exits(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo"])
assert result.exit_code == 1
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.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.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
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.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.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = False
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo"])
assert result.exit_code == 1
assert "mapping.json" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@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.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
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.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.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
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
assert "not found" in result.output
assert "Skipped: 1" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.ci.sync_wiki.GiteaClient")
def test_empty_doc_file_skipped(self, mock_client_cls: MagicMock) -> None:
"""Test that empty doc files are skipped with a warning."""
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"empty.md": "Empty-Page"}):
with patch("scripts.ci.sync_wiki.read_doc_content", return_value=" \n "):
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
assert "empty" in result.output.lower()
assert "Skipped: 1" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@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.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.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
assert "Created: 1" in result.output
assert "Updated: 1" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.ci.sync_wiki.GiteaClient")
def test_verify_passes(self, mock_client_cls: MagicMock) -> None:
"""Test that --verify passes when content matches."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
encoded = base64.b64encode(b"# Home Content").decode("ascii")
# list_wiki_pages returns {"Home": "Home"}, fetch returns encoded content
mock_client._request.return_value.json.return_value = {"content_base64": encoded}
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
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 Content"):
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
with patch("scripts.ci.sync_wiki.verify_wiki_page", return_value=True):
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo", "--verify"])
assert result.exit_code == 0
assert "Verification passed" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.ci.sync_wiki.GiteaClient")
def test_verify_fails_on_empty_content(self, mock_client_cls: MagicMock) -> None:
"""Test that --verify fails when wiki pages have empty content."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
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 Content"):
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
with patch("scripts.ci.sync_wiki.verify_wiki_page", return_value=False):
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo", "--verify"])
assert result.exit_code == 1
assert "FAIL" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.ci.sync_wiki.GiteaClient")
def test_verify_skipped_in_dry_run(self, mock_client_cls: MagicMock) -> None:
"""Test that --verify is skipped during dry-run."""
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
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", "--verify", "--repo", "owner/repo"])
assert result.exit_code == 0
assert "Verification" not in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.ci.sync_wiki.GiteaClient")
def test_strict_passes(self, mock_client_cls: MagicMock) -> None:
"""Test that --strict passes when integrity check succeeds."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
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"}):
with patch("scripts.ci.sync_wiki.verify_wiki_integrity", return_value=[]):
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo", "--strict"])
assert result.exit_code == 0
assert "Integrity check passed" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.ci.sync_wiki.GiteaClient")
def test_strict_fails_on_integrity_issues(self, mock_client_cls: MagicMock) -> None:
"""Test that --strict fails when integrity check finds issues."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
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"}):
with patch(
"scripts.ci.sync_wiki.verify_wiki_integrity",
return_value=["Missing page: FAQ", "Stale page: Old-Page"],
):
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo", "--strict"])
assert result.exit_code == 1
assert "Integrity check FAILED" in result.output
assert "Missing page: FAQ" in result.output
assert "Stale page: Old-Page" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
@patch("scripts.ci.sync_wiki.GiteaClient")
def test_strict_skipped_in_dry_run(self, mock_client_cls: MagicMock) -> None:
"""Test that --strict verification is skipped during dry-run."""
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
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", "--strict", "--repo", "owner/repo"])
assert result.exit_code == 0
assert "Integrity check" not in result.output
-176
View File
@@ -1,176 +0,0 @@
"""Unit tests for scripts/ci/validate_commit_msg.py."""
import os
import subprocess
import tempfile
from unittest.mock import patch
from click.testing import CliRunner
from gitea_runner_manager.config import CONVENTIONAL_RE, TASK_ID_RE
from scripts.ci.validate_commit_msg import first_line, get_branch, main
class TestHelpers:
def test_first_line_single(self) -> None:
assert first_line("feat: add something") == "feat: add something"
def test_first_line_multiline(self) -> None:
msg = "feat: add something\n\nBody text here.\nMore body."
assert first_line(msg) == "feat: add something"
def test_conventional_re_matches_valid(self) -> None:
assert CONVENTIONAL_RE.match("feat: add feature")
assert CONVENTIONAL_RE.match("fix: bug fix")
assert CONVENTIONAL_RE.match("chore: update deps")
assert CONVENTIONAL_RE.match("docs: update readme")
assert CONVENTIONAL_RE.match("style: format code")
assert CONVENTIONAL_RE.match("refactor: simplify")
assert CONVENTIONAL_RE.match("perf: speed up")
assert CONVENTIONAL_RE.match("test: add tests")
assert CONVENTIONAL_RE.match("ci: update workflow")
assert CONVENTIONAL_RE.match("build: update deps")
assert CONVENTIONAL_RE.match("revert: undo change")
def test_conventional_re_allows_scope(self) -> None:
assert CONVENTIONAL_RE.match("feat(cli): add --url option")
assert CONVENTIONAL_RE.match("fix(api): handle timeout")
def test_conventional_re_rejects_invalid(self) -> None:
assert not CONVENTIONAL_RE.match("GRM-19: feat: something")
assert not CONVENTIONAL_RE.match("random message")
assert not CONVENTIONAL_RE.match("feat:")
assert not CONVENTIONAL_RE.match(": description")
def test_task_id_re_matches(self) -> None:
assert TASK_ID_RE.match("GRM-19: feat: something")
assert TASK_ID_RE.match("GRM-123: fix: bug")
def test_task_id_re_rejects(self) -> None:
assert not TASK_ID_RE.match("feat: something")
assert not TASK_ID_RE.match("GRM: something")
class TestGetBranch:
def test_returns_branch_name(self) -> None:
with patch("subprocess.run") as mock_run:
mock_run.return_value.stdout = "feature-branch\n"
mock_run.return_value.returncode = 0
assert get_branch() == "feature-branch"
def test_returns_empty_on_error(self) -> None:
with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git")):
assert get_branch() == ""
class TestMain:
def _write_msg(self, content: str) -> str:
fd, path = tempfile.mkstemp()
with os.fdopen(fd, "w") as f:
f.write(content)
return path
def test_rejects_task_id_on_feature_branch(self) -> None:
msg_path = self._write_msg("GRM-19: feat: add feature")
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
assert "task ID" in result.output
def test_accepts_conventional_on_feature_branch(self) -> None:
msg_path = self._write_msg("feat: add feature")
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.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.ci.validate_commit_msg.get_branch", return_value="master"):
runner = CliRunner()
result = runner.invoke(main, [msg_path])
assert result.exit_code == 1
assert "task ID" in result.output
def test_rejects_master_with_non_conventional_after_task_id(self) -> None:
msg_path = self._write_msg("GRM-19: random message")
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
assert "conventional" in result.output
def test_rejects_non_conventional_on_feature_branch(self) -> None:
msg_path = self._write_msg("random message")
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
assert "conventional" in result.output
def test_accepts_multiline_conventional(self) -> None:
msg_path = self._write_msg("feat: add feature\n\nBody text.\nMore text.")
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
def test_usage_message_without_args(self) -> None:
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 2
def test_branch_override_accepts_master_commit(self) -> None:
"""--branch master overrides branch detection (for CI use)."""
msg_path = self._write_msg("GRM-19: feat: add feature")
runner = CliRunner()
result = runner.invoke(main, [msg_path, "--branch", "master"])
assert result.exit_code == 0
def test_branch_override_rejects_missing_task_id(self) -> None:
"""--branch master still enforces GRM-N: prefix."""
msg_path = self._write_msg("feat: add feature")
runner = CliRunner()
result = runner.invoke(main, [msg_path, "--branch", "master"])
assert result.exit_code == 1
assert "task ID" in result.output
def test_branch_override_feature_accepts_conventional(self) -> None:
"""--branch feature still rejects GRM-N prefix."""
msg_path = self._write_msg("GRM-19: feat: add feature")
runner = CliRunner()
result = runner.invoke(main, [msg_path, "--branch", "feature"])
assert result.exit_code == 1
assert "task ID" in result.output
def test_main_module_block() -> None:
import tempfile
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
f.write("GRM-1: feat: test")
msg_path = f.name
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()
# Remove __main__ block so exec doesn't call main() before we control sys.argv
source = source.replace('if __name__ == "__main__":\n main()\n', "")
namespace = dict(vcm.__dict__)
exec(compile(source, vcm.__file__, "exec"), namespace)
# exec() redefines get_branch() from source, overwriting the mock.
# Restore the patched mock so main() uses it.
namespace["get_branch"] = vcm.get_branch
namespace["main"]([msg_path], standalone_mode=False)
os.unlink(msg_path)