Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb76ac91ef | ||
|
|
0c54efbf6b | ||
|
|
945344f960 | ||
|
|
713e752860 | ||
|
|
e0ae01e36e | ||
|
|
5511cba1b0 | ||
|
|
56eb241b77 | ||
|
|
6b3f2a5866 | ||
|
|
6f181e85e1 |
@@ -17,7 +17,7 @@ jobs:
|
||||
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
python3 scripts/auto_merge.py \
|
||||
python3 scripts/ci/auto_merge.py \
|
||||
"${{ github.head_ref }}" \
|
||||
"${{ github.event.pull_request.title }}" \
|
||||
"${{ github.repository }}" \
|
||||
|
||||
+67
-9
@@ -26,19 +26,41 @@ jobs:
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 scripts/check_test_speed.py --max-seconds 10
|
||||
- name: Release dry-run validation
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
PYTHONPATH=src python3 scripts/release.py --dry-run || true
|
||||
- name: Documentation coverage check
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
PYTHONPATH=src python3 scripts/doc_coverage.py
|
||||
PYTHONPATH=src python3 scripts/ci/doc_coverage.py
|
||||
|
||||
release-dry-run:
|
||||
needs: [quality, detect-changes]
|
||||
if: needs.detect-changes.outputs.user-facing-changed == 'true'
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
run: make setup
|
||||
- name: Install git-cliff
|
||||
run: |
|
||||
GIT_CLIFF_VERSION="2.13.0"
|
||||
URL="https://github.com/orhun/git-cliff/releases/download/v${GIT_CLIFF_VERSION}/git-cliff-${GIT_CLIFF_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
|
||||
TMPDIR="$(mktemp -d)"
|
||||
curl -sL "$URL" | tar xz -C "$TMPDIR"
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
mv "$TMPDIR/git-cliff-${GIT_CLIFF_VERSION}/git-cliff" "$HOME/.local/bin/git-cliff"
|
||||
chmod +x "$HOME/.local/bin/git-cliff"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
- name: Release dry-run validation
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
PYTHONPATH=. python3 scripts/ci/release.py --dry-run || true
|
||||
|
||||
detect-changes:
|
||||
runs-on: docker
|
||||
outputs:
|
||||
ansible-changed: ${{ steps.detect.outputs.ansible-changed }}
|
||||
user-facing-changed: ${{ steps.detect.outputs.user-facing-changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -62,14 +84,50 @@ jobs:
|
||||
echo "ansible-changed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No Ansible files changed — skipping molecule tests."
|
||||
fi
|
||||
# Check if any user-facing files changed (src/, ansible/, pyproject.toml)
|
||||
USER_FACING=$(git diff --name-only "$BASE" "$HEAD" -- src/gitea_runner_manager/ ansible/ pyproject.toml 2>/dev/null | head -1)
|
||||
if [ -n "$USER_FACING" ]; then
|
||||
echo "user-facing-changed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "User-facing files changed — release dry-run will run."
|
||||
else
|
||||
echo "user-facing-changed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No user-facing files changed — skipping release dry-run."
|
||||
fi
|
||||
|
||||
discover-runners:
|
||||
needs: [detect-changes]
|
||||
if: needs.detect-changes.outputs.ansible-changed == 'true'
|
||||
runs-on: docker
|
||||
outputs:
|
||||
runner-count: ${{ steps.discover.outputs.runner-count }}
|
||||
runner-indices: ${{ steps.discover.outputs.runner-indices }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
run: make setup
|
||||
- name: Discover available runners
|
||||
id: discover
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
MOLECULE_RUNNERS: ${{ vars.MOLECULE_RUNNERS }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
OUTPUT=$(python3 scripts/ci/discover_runners.py --owner "${{ github.repository_owner }}" --repo "${{ github.event.repository.name }}")
|
||||
echo "$OUTPUT"
|
||||
# Parse outputs
|
||||
RUNNER_COUNT=$(echo "$OUTPUT" | grep '^count=' | cut -d= -f2)
|
||||
RUNNER_INDICES=$(echo "$OUTPUT" | grep '^indices=' | cut -d= -f2)
|
||||
echo "runner-count=$RUNNER_COUNT" >> "$GITHUB_OUTPUT"
|
||||
echo "runner-indices=$RUNNER_INDICES" >> "$GITHUB_OUTPUT"
|
||||
|
||||
molecule-tests:
|
||||
needs: [quality, detect-changes]
|
||||
needs: [quality, detect-changes, discover-runners]
|
||||
if: needs.detect-changes.outputs.ansible-changed == 'true'
|
||||
runs-on: docker
|
||||
strategy:
|
||||
matrix:
|
||||
runner-index: [0, 1, 2]
|
||||
runner-index: ${{ fromJSON(needs.discover-runners.outputs.runner-indices) }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
@@ -77,14 +135,14 @@ jobs:
|
||||
- name: Discover assigned test pairs
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
PAIRS=$(python3 scripts/distribute_molecule.py --runner-index ${{ matrix.runner-index }} --max-runners 3)
|
||||
PAIRS=$(python3 scripts/ci/distribute_molecule.py --runner-index ${{ matrix.runner-index }} --max-runners ${{ needs.discover-runners.outputs.runner-count }})
|
||||
echo "Assigned pairs: $PAIRS"
|
||||
echo "TEST_PAIRS=$PAIRS" >> $GITHUB_ENV
|
||||
- name: Run molecule tests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. .venv/bin/activate
|
||||
python3 scripts/molecule_ci_guard.py $TEST_PAIRS
|
||||
python3 scripts/ci/molecule_ci_guard.py $TEST_PAIRS
|
||||
env:
|
||||
GITEA_URL: ${{ github.server_url }}
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
|
||||
@@ -18,6 +18,6 @@ jobs:
|
||||
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
python3 scripts/post_merge.py \
|
||||
python3 scripts/ci/post_merge.py \
|
||||
"$(git log -1 --pretty=%B)" \
|
||||
--commit-sha "$(git rev-parse HEAD)"
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
python3 scripts/publish.py \
|
||||
python3 scripts/ci/publish.py \
|
||||
"${{ github.ref_name }}" \
|
||||
"${{ github.repository }}"
|
||||
- name: Notify on failure
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
python3 scripts/notify_failure.py \
|
||||
python3 scripts/ci/notify_failure.py \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "publish" \
|
||||
|
||||
@@ -31,18 +31,18 @@ jobs:
|
||||
git config user.email "grm-ci-bot@oblachno.fyi"
|
||||
- name: Run release
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
PYTHONPATH: .
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 scripts/release.py
|
||||
python3 scripts/ci/release.py
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
PYTHONPATH: .
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 scripts/notify_failure.py \
|
||||
python3 scripts/ci/notify_failure.py \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "release" \
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 scripts/sync_wiki.py --repo "${{ github.repository }}"
|
||||
python3 scripts/ci/sync_wiki.py --repo "${{ github.repository }}" --strict
|
||||
- name: Tag wiki on release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
|
||||
@@ -3,7 +3,7 @@ repos:
|
||||
hooks:
|
||||
- id: validate-commit-msg
|
||||
name: validate commit message
|
||||
entry: .venv/bin/python scripts/validate_commit_msg.py
|
||||
entry: .venv/bin/python scripts/ci/validate_commit_msg.py
|
||||
language: system
|
||||
stages: [commit-msg]
|
||||
pass_filenames: true
|
||||
@@ -58,7 +58,7 @@ repos:
|
||||
|
||||
- id: commit-msg
|
||||
name: validate commit message
|
||||
entry: .venv/bin/python scripts/validate_commit_msg.py
|
||||
entry: .venv/bin/python scripts/ci/validate_commit_msg.py
|
||||
language: system
|
||||
stages: [commit-msg]
|
||||
pass_filenames: true
|
||||
|
||||
@@ -83,9 +83,9 @@ Review the full diff (`git diff master...HEAD`) focusing on:
|
||||
- **User experience**: Clear error messages, intuitive CLI flags, helpful output
|
||||
- **Documentation**: Completeness and relevance of docs, CHANGELOG entries, AGENTS.md updates
|
||||
|
||||
Post review comments using `scripts/review_pr.py`:
|
||||
Post review comments using `scripts/ci/review_pr.py`:
|
||||
```bash
|
||||
REPO_TOKEN=<token> python3 scripts/review_pr.py <pr_number> <owner/repo> \
|
||||
REPO_TOKEN=<token> python3 scripts/ci/review_pr.py <pr_number> <owner/repo> \
|
||||
--event REQUEST_CHANGES \
|
||||
--body "Review summary" \
|
||||
--comments-json comments.json
|
||||
@@ -97,7 +97,7 @@ Fix each comment one by one, commit, and push. Re-review until satisfied.
|
||||
### 8. Approve and Merge
|
||||
Once all comments are addressed:
|
||||
```bash
|
||||
REPO_TOKEN=<token> python3 scripts/review_pr.py <pr_number> <owner/repo> \
|
||||
REPO_TOKEN=<token> python3 scripts/ci/review_pr.py <pr_number> <owner/repo> \
|
||||
--event APPROVE \
|
||||
--body "All comments addressed. LGTM."
|
||||
```
|
||||
@@ -118,6 +118,20 @@ changed, molecule tests are skipped — this prevents non-Ansible changes
|
||||
(e.g., Python scripts, workflow YAML, docs) from being blocked by molecule
|
||||
test infrastructure flakiness.
|
||||
|
||||
### Dynamic Runner Discovery
|
||||
|
||||
Molecule tests are distributed across available Gitea Actions runners
|
||||
dynamically via `scripts/ci/discover_runners.py`. The `discover-runners`
|
||||
job queries the Gitea API for runners at all levels (repo, org, instance)
|
||||
and generates a dynamic matrix. If the API can't see instance-level runners
|
||||
(no admin scope), it falls back to the `MOLECULE_RUNNERS` repo variable,
|
||||
then to a default of 3.
|
||||
|
||||
**When adding/removing Gitea runners:**
|
||||
1. Repo/org-level runners are auto-detected via the API
|
||||
2. For instance-level runners, update the `MOLECULE_RUNNERS` repo variable
|
||||
3. The workflow automatically scales the matrix to match available runners
|
||||
|
||||
### Automated Release Pipeline
|
||||
|
||||
After a PR is merged to master, the release pipeline runs automatically:
|
||||
@@ -125,18 +139,65 @@ After a PR is merged to master, the release pipeline runs automatically:
|
||||
1. **Release workflow** (`.gitea/workflows/release.yml`):
|
||||
- Triggers on push to master
|
||||
- Sets up full dev environment (`make setup`) so lint and tests can run
|
||||
- Runs `scripts/release.py` which uses **git-cliff** to:
|
||||
- Calculate the next semver version from conventional commits since the last tag
|
||||
- Update `__version__` in `src/gitea_runner_manager/__init__.py` (single source of truth)
|
||||
- Update `CHANGELOG.md` with the new version section
|
||||
- **Run `make lint-ruff` and `make pytest-cov`** to verify the release is healthy
|
||||
- If lint or tests fail, **abort immediately** — no commit, no tag
|
||||
- Commit with `release: vX.Y.Z` prefix (cleaner than `chore(release):`)
|
||||
- Create an annotated tag `vX.Y.Z` on the release commit
|
||||
- Push both the commit and tag to master
|
||||
- Runs `scripts/ci/release.py` which:
|
||||
- **Checks for user-facing changes** via `scripts/ci/classify_changes.py` — if only
|
||||
workflow/infrastructure files changed (`.gitea/`, `scripts/`, `docs/`, `tests/`,
|
||||
`AGENTS.md`, `Makefile`, etc.), the release is **skipped entirely** — no version
|
||||
bump, no tag, no publish. This prevents unnecessary releases for CI/docs-only changes.
|
||||
- Uses **git-cliff** to calculate the next semver version from conventional commits
|
||||
- Updates `__version__` in `src/gitea_runner_manager/__init__.py` (single source of truth)
|
||||
- Updates `CHANGELOG.md` with the new version section
|
||||
- **Runs `make lint-ruff` and `make pytest-cov`** to verify the release is healthy
|
||||
- If lint or tests fail, **aborts immediately** — no commit, no tag
|
||||
- Commits with `release: vX.Y.Z` prefix (cleaner than `chore(release):`)
|
||||
- Creates an annotated tag `vX.Y.Z` on the release commit
|
||||
- Pushes both the commit and tag to master
|
||||
- `--skip-tests` flag bypasses test verification (emergency use only, not recommended)
|
||||
- Loops are prevented by `has_unreleased_changes` — after a release commit is tagged, the next run finds no unreleased changes and exits
|
||||
- On failure, creates a Gitea issue via `scripts/notify_failure.py`
|
||||
- On failure, creates a Gitea issue via `scripts/ci/notify_failure.py`
|
||||
|
||||
### Smart CI: User-Facing vs Workflow-Only Changes
|
||||
|
||||
Not all changes require the full CI pipeline or a new release. The project
|
||||
classifies changes into two categories using `scripts/ci/classify_changes.py`:
|
||||
|
||||
**Classification strategy (safe-by-default):** Any file NOT in the explicit
|
||||
workflow-only allowlist is treated as user-facing. This prevents new file
|
||||
types from accidentally skipping releases.
|
||||
|
||||
**Workflow-only paths** (infrastructure → no release needed):
|
||||
- `.gitea/**` — Gitea Actions workflows
|
||||
- `scripts/ci/**` — CI/CD automation scripts
|
||||
- `scripts/setup.sh`, `scripts/molecule_all.sh`, `scripts/__init__.py` — Shell scripts and package init
|
||||
- `docs/**` — Documentation
|
||||
- `tests/**` — Test files
|
||||
- `AGENTS.md`, `README.md`, `CHANGELOG.md`, `TROUBLESHOOTING.md` — Project docs
|
||||
- `Makefile`, `cliff.toml`, `.pre-commit-config.yaml`, `.ansible-lint` — Config
|
||||
- `.env.example`, `.gitignore`, `.ruff.toml` — Config
|
||||
- `hooks/**` — Git hooks
|
||||
|
||||
**User-facing paths** (tool changes → release needed) — everything else:
|
||||
- `src/gitea_runner_manager/**` — Python CLI source
|
||||
- `ansible/**` — Ansible role
|
||||
- `pyproject.toml` — Package metadata
|
||||
- `scripts/check_test_speed.py`, `scripts/configure_repo.py`, `scripts/install_checkmake.py` — Dev tools
|
||||
- Any new file type not in the allowlist
|
||||
|
||||
**Script directory structure:**
|
||||
- `scripts/` — Dev tools (run locally by developers): `check_test_speed.py`, `configure_repo.py`, `install_checkmake.py`, `setup.sh`, `molecule_all.sh`
|
||||
- `scripts/ci/` — CI/CD automation (run by workflows): `release.py`, `publish.py`, `auto_merge.py`, `classify_changes.py`, `doc_coverage.py`, `sync_wiki.py`, etc.
|
||||
|
||||
**CI behavior based on classification:**
|
||||
- **Molecule tests**: Only run when `ansible/` or `.ansible-lint` files change
|
||||
- **Release dry-run**: Only runs when user-facing files change
|
||||
- **Quality job** (lint, unit tests, coverage, doc-coverage): Always runs
|
||||
- **Release workflow**: Skips entirely when no user-facing files changed since last tag
|
||||
|
||||
**AI agents must follow these rules:**
|
||||
- When working on workflow/CI/docs-only changes, use `ci:` or `docs:` commit prefixes
|
||||
- Do NOT bump the version or create tags for workflow-only changes
|
||||
- The `classify_changes.py` script enforces this automatically — no manual intervention needed
|
||||
- When adding a new CI script, place it in `scripts/ci/`. Dev tools go in `scripts/`.
|
||||
|
||||
2. **Publish workflow** (`.gitea/workflows/publish.yml`):
|
||||
- Triggers on tag push (`v*`)
|
||||
@@ -144,7 +205,7 @@ After a PR is merged to master, the release pipeline runs automatically:
|
||||
- Builds the Python package
|
||||
- Optionally publishes to PyPI (if `PYPI_TOKEN` is set)
|
||||
- Creates a Gitea release with git-cliff-generated release notes
|
||||
- On failure, creates a Gitea issue via `scripts/notify_failure.py`
|
||||
- On failure, creates a Gitea issue via `scripts/ci/notify_failure.py`
|
||||
|
||||
### git-cliff Commit Preprocessing
|
||||
|
||||
@@ -198,7 +259,7 @@ main.yml → systemd_check → user_setup → rootless_docker → install_runner
|
||||
|
||||
6 scenarios: `default`, `multi-instance`, `lifecycle`, `template-content`, `deregister`, `update`
|
||||
4 platforms: `ubuntu-2204`, `ubuntu-2404`, `debian-12`, `archlinux`
|
||||
Platform list is defined in `scripts/distribute_molecule.py` (single source of truth)
|
||||
Platform list is defined in `scripts/ci/distribute_molecule.py` (single source of truth)
|
||||
|
||||
## Known Issues
|
||||
|
||||
@@ -232,14 +293,14 @@ docs/
|
||||
|
||||
### Wiki Sync
|
||||
|
||||
- **On merge to master**: `sync-wiki.yml` workflow runs `scripts/sync_wiki.py` which pushes all `/docs/` content to the Gitea wiki via API
|
||||
- **On merge to master**: `sync-wiki.yml` workflow runs `scripts/ci/sync_wiki.py` which pushes all `/docs/` content to the Gitea wiki via API
|
||||
- **On release tag**: Same sync runs, plus the wiki is tagged with the release version
|
||||
- `mapping.json` maps each file path to a wiki page title (e.g., `user/getting-started.md` → `Getting-Started`)
|
||||
- README.md is a lean entry point with links to the wiki — no detailed content
|
||||
|
||||
### Documentation Coverage
|
||||
|
||||
- `scripts/doc_coverage.py` checks that all CLI commands, Python modules, and CI scripts are documented
|
||||
- `scripts/ci/doc_coverage.py` checks that all CLI commands, Python modules, and CI scripts are documented
|
||||
- Runs as a CI step in the quality job
|
||||
- Goal: 100% coverage for public CLI commands and major architectural components
|
||||
|
||||
|
||||
@@ -2,6 +2,43 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.4.0] - 2026-06-21
|
||||
|
||||
### Features
|
||||
|
||||
- Smart CI and release skipping for workflow-only changes
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Set PYTHONPATH=. for release.py to find scripts.ci module (#32)
|
||||
- Use content_base64 for Gitea wiki API, add --verify flag (#33)
|
||||
- Wiki links, add --strict integrity check for wiki sync (#34)
|
||||
|
||||
### Other
|
||||
|
||||
- V0.3.2
|
||||
|
||||
### Refactor
|
||||
|
||||
- Split CI scripts, fix release PYTHONPATH, dynamic runner discovery
|
||||
|
||||
## [0.3.2] - 2026-06-21
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Set PYTHONPATH=. for release.py to find scripts.ci module (#32)
|
||||
|
||||
### Other
|
||||
|
||||
- Smart CI and release skipping for workflow-only changes
|
||||
- Split CI scripts, fix release PYTHONPATH, dynamic runner discovery
|
||||
|
||||
## [0.3.1] - 2026-06-21
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use correct Gitea 1.26 wiki API endpoints
|
||||
|
||||
## [0.3.0] - 2026-06-21
|
||||
|
||||
### Features
|
||||
|
||||
@@ -78,7 +78,7 @@ typecheck:
|
||||
lint: lint-ruff lint-format typecheck lint-bandit
|
||||
|
||||
lint-bandit:
|
||||
$(BIN)/bandit -r src/ scripts/
|
||||
$(BIN)/bandit -r src/ scripts/ scripts/ci/
|
||||
|
||||
ansible-lint:
|
||||
$(BIN)/ansible-lint ansible/
|
||||
@@ -95,7 +95,7 @@ test-integration:
|
||||
$(BIN)/pytest tests/integration/ -v --no-cov
|
||||
|
||||
pytest-cov:
|
||||
$(BIN)/pytest tests/unit/ -v --cov=src/gitea_runner_manager --cov=scripts --cov-report=term-missing --cov-fail-under=100
|
||||
$(BIN)/pytest tests/unit/ -v --cov=src/gitea_runner_manager --cov=scripts --cov=scripts/ci --cov-report=term-missing --cov-fail-under=100
|
||||
|
||||
MOLECULE := $(realpath $(BIN))/molecule
|
||||
MOLECULE_BASE := cd $(CURDIR)/ansible/roles/gitea-runner && ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true ANSIBLE_INJECT_INVOCATION=1 $(MOLECULE)
|
||||
|
||||
+7
-7
@@ -6,20 +6,20 @@ A lean command-line tool to automate the installation, configuration, and lifecy
|
||||
|
||||
## User Documentation
|
||||
|
||||
- [Getting Started](Getting-Started) — Installation, quick start, first run
|
||||
- [Getting Started](Getting-Started.-) — Installation, quick start, first run
|
||||
- [Installation](Installation) — Prerequisites, setup, multiple instances
|
||||
- [CLI Commands](CLI-Commands) — All commands with arguments and options
|
||||
- [CLI Commands](CLI-Commands.-) — All commands with arguments and options
|
||||
- [Troubleshooting](Troubleshooting) — Common issues and solutions
|
||||
- [FAQ](FAQ) — Frequently asked questions
|
||||
|
||||
## Technical Documentation
|
||||
|
||||
- [Architecture](Architecture) — High-level design, component interactions, data flow
|
||||
- [Development Setup](Development-Setup) — Environment setup, dependencies, local testing
|
||||
- [CI/CD Workflow](CI-CD-Workflow) — How CI works, release process, branch protection
|
||||
- [Testing Strategy](Testing-Strategy) — Unit, integration, and Molecule tests
|
||||
- [Decision Log](Decision-Log) — Key technical decisions and rationale
|
||||
- [Contributing Guide](Contributing-Guide) — Coding standards, PR workflow, commit rules
|
||||
- [Development Setup](Development-Setup.-) — Environment setup, dependencies, local testing
|
||||
- [CI/CD Workflow](CI-CD-Workflow.-) — How CI works, release process, branch protection
|
||||
- [Testing Strategy](Testing-Strategy.-) — Unit, integration, and Molecule tests
|
||||
- [Decision Log](Decision-Log.-) — Key technical decisions and rationale
|
||||
- [Contributing Guide](Contributing-Guide.-) — Coding standards, PR workflow, commit rules
|
||||
|
||||
## Quick Links
|
||||
|
||||
|
||||
+51
-11
@@ -60,10 +60,10 @@ Review the full diff (`git diff master...HEAD`) focusing on:
|
||||
- **User experience**: Clear error messages, intuitive CLI flags, helpful output
|
||||
- **Documentation**: Completeness and relevance of docs, CHANGELOG entries, AGENTS.md updates
|
||||
|
||||
Post review comments using `scripts/review_pr.py`:
|
||||
Post review comments using `scripts/ci/review_pr.py`:
|
||||
|
||||
```bash
|
||||
REPO_TOKEN=<token> python3 scripts/review_pr.py <pr_number> <owner/repo> \
|
||||
REPO_TOKEN=<token> python3 scripts/ci/review_pr.py <pr_number> <owner/repo> \
|
||||
--event REQUEST_CHANGES \
|
||||
--body "Review summary" \
|
||||
--comments-json comments.json
|
||||
@@ -78,7 +78,7 @@ Fix each comment one by one, commit, and push. Re-review until satisfied.
|
||||
Once all comments are addressed:
|
||||
|
||||
```bash
|
||||
REPO_TOKEN=<token> python3 scripts/review_pr.py <pr_number> <owner/repo> \
|
||||
REPO_TOKEN=<token> python3 scripts/ci/review_pr.py <pr_number> <owner/repo> \
|
||||
--event APPROVE \
|
||||
--body "All comments addressed. LGTM."
|
||||
```
|
||||
@@ -96,7 +96,7 @@ Then add the `ready-to-merge` label. The auto-merge workflow will:
|
||||
|
||||
After the squash-merge:
|
||||
|
||||
- The **post-merge workflow** (`.gitea/workflows/post-merge.yml`) triggers on push to `master` and runs `scripts/post_merge.py` to mark the Vikunja task as done, extracting the task ID from the merge commit message.
|
||||
- The **post-merge workflow** (`.gitea/workflows/post-merge.yml`) triggers on push to `master` and runs `scripts/ci/post_merge.py` to mark the Vikunja task as done, extracting the task ID from the merge commit message.
|
||||
- The **release workflow** (`.gitea/workflows/release.yml`) triggers on push to `master` and automatically versions, tags, and publishes (see below).
|
||||
|
||||
## Branch Protection (Required Gitea Settings)
|
||||
@@ -132,7 +132,7 @@ The `quality` job in `.gitea/workflows/ci.yml` runs:
|
||||
2. `make lint-all` — ruff + pyright + bandit + ansible-lint + checkmake
|
||||
3. `make pytest-cov` — unit tests with 100% coverage enforcement
|
||||
4. `python3 scripts/check_test_speed.py --max-seconds 10` — verify unit tests run fast
|
||||
5. `PYTHONPATH=src python3 scripts/release.py --dry-run` — release dry-run validation
|
||||
5. `PYTHONPATH=src python3 scripts/ci/release.py --dry-run` — release dry-run validation
|
||||
|
||||
## Automated Release Pipeline
|
||||
|
||||
@@ -144,7 +144,7 @@ After a PR is merged to master, the release pipeline runs automatically.
|
||||
- Sets up full dev environment (`make setup`) so lint and tests can run
|
||||
- Installs git-cliff (version 2.13.0)
|
||||
- Configures git as `grm-ci-bot`
|
||||
- Runs `scripts/release.py` which uses **git-cliff** to:
|
||||
- Runs `scripts/ci/release.py` which uses **git-cliff** to:
|
||||
- Calculate the next semver version from conventional commits since the last tag
|
||||
- Update `__version__` in `src/gitea_runner_manager/__init__.py` (single source of truth)
|
||||
- Update `CHANGELOG.md` with the new version section
|
||||
@@ -155,7 +155,7 @@ After a PR is merged to master, the release pipeline runs automatically.
|
||||
- Push both the commit and tag to master
|
||||
- `--skip-tests` flag bypasses test verification (emergency use only, not recommended)
|
||||
- Loops are prevented by `has_unreleased_changes` — after a release commit is tagged, the next run finds no unreleased changes and exits
|
||||
- On failure, creates a Gitea issue via `scripts/notify_failure.py`
|
||||
- On failure, creates a Gitea issue via `scripts/ci/notify_failure.py`
|
||||
|
||||
### Publish Workflow (`.gitea/workflows/publish.yml`)
|
||||
|
||||
@@ -166,21 +166,61 @@ After a PR is merged to master, the release pipeline runs automatically.
|
||||
- Builds the Python package
|
||||
- Optionally publishes to PyPI (if `PYPI_TOKEN` is set)
|
||||
- Creates a Gitea release with git-cliff-generated release notes
|
||||
- Uses `scripts/publish.py` for build and publish orchestration
|
||||
- On failure, creates a Gitea issue via `scripts/notify_failure.py`
|
||||
- Uses `scripts/ci/publish.py` for build and publish orchestration
|
||||
- On failure, creates a Gitea issue via `scripts/ci/notify_failure.py`
|
||||
|
||||
### Auto-Merge Workflow (`.gitea/workflows/auto-merge.yml`)
|
||||
|
||||
- Triggers on `pull_request` labeled events
|
||||
- Runs `scripts/auto_merge.py` with the branch name, PR title, repository, PR number, and label name
|
||||
- Runs `scripts/ci/auto_merge.py` with the branch name, PR title, repository, PR number, and label name
|
||||
- Validates PR title format, checks for APPROVE review, waits for CI, and squash-merges
|
||||
|
||||
### Post-Merge Workflow (`.gitea/workflows/post-merge.yml`)
|
||||
|
||||
- Triggers on push to `master`
|
||||
- Runs `scripts/post_merge.py` with the latest commit message and commit SHA
|
||||
- Runs `scripts/ci/post_merge.py` with the latest commit message and commit SHA
|
||||
- Marks the corresponding Vikunja task as done
|
||||
|
||||
### Smart CI: User-Facing vs Workflow-Only Changes
|
||||
|
||||
Not all changes require the full CI pipeline or a new release. The project uses
|
||||
`scripts/ci/classify_changes.py` to classify changed files into two categories:
|
||||
|
||||
**User-facing paths** (tool changes → release needed):
|
||||
- `src/gitea_runner_manager/**` — Python CLI source
|
||||
- `ansible/**` — Ansible role
|
||||
- `pyproject.toml` — Package metadata
|
||||
|
||||
**Workflow-only paths** (infrastructure → no release needed):
|
||||
- `.gitea/workflows/**`, `scripts/**`, `docs/**`, `tests/**`
|
||||
- `AGENTS.md`, `README.md`, `CHANGELOG.md`, `Makefile`, `cliff.toml`, etc.
|
||||
|
||||
**CI behavior based on classification:**
|
||||
- **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)
|
||||
- **Quality job** (lint, unit tests, coverage, doc-coverage): Always runs
|
||||
- **Release workflow**: `release.py` calls `classify_changes.py` to check if any
|
||||
user-facing files changed since the last tag. If not, the release is skipped
|
||||
entirely — no version bump, no tag, no publish.
|
||||
|
||||
### Dynamic Runner Discovery
|
||||
|
||||
Molecule tests are distributed across available Gitea Actions runners
|
||||
dynamically. The `discover-runners` job runs `scripts/ci/discover_runners.py` which queries the Gitea API for
|
||||
registered runners at three levels (repo, org, instance) and generates
|
||||
a matrix of runner indices. If the API query fails (e.g., no admin
|
||||
access for instance-level runners), it falls back to the
|
||||
`MOLECULE_RUNNERS` repo variable, then to a default of 3.
|
||||
|
||||
The `molecule-tests` job uses `fromJSON()` to consume the dynamic
|
||||
matrix, and passes the runner count to `distribute_molecule.py
|
||||
--max-runners` so test pairs are evenly distributed.
|
||||
|
||||
When adding or removing Gitea runners:
|
||||
1. If runners are registered at the repo/org level, they're auto-detected
|
||||
2. If runners are at the instance level, update the `MOLECULE_RUNNERS` repo variable
|
||||
3. The workflow automatically scales the matrix to match available runners
|
||||
|
||||
## git-cliff Commit Preprocessing
|
||||
|
||||
Merge commits on master have the format `GRM-N <conventional commit>`. The `GRM-N ` prefix is not a valid conventional commit prefix, so `cliff.toml` includes a `commit_preprocessors` entry that strips it before parsing:
|
||||
|
||||
+2
-2
@@ -50,7 +50,7 @@ gitea_runner_manager = ["translations.json"]
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src", "."]
|
||||
addopts = "--cov=src/gitea_runner_manager --cov=scripts --cov-report=term-missing --cov-fail-under=100"
|
||||
addopts = "--cov=src/gitea_runner_manager --cov=scripts --cov=scripts/ci --cov-report=term-missing --cov-fail-under=100"
|
||||
markers = [
|
||||
"integration: marks tests as integration tests (not counted in coverage)",
|
||||
]
|
||||
@@ -68,6 +68,6 @@ quote-style = "double"
|
||||
indent-style = "space"
|
||||
|
||||
[tool.pyright]
|
||||
include = ["src", "scripts"]
|
||||
include = ["src", "scripts", "scripts/ci"]
|
||||
pythonVersion = "3.12"
|
||||
strict = ["src/gitea_runner_manager"]
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
#!/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/ci/** — CI/CD automation scripts
|
||||
- scripts/*.sh — Shell scripts (setup, molecule runners)
|
||||
- scripts/__init__.py — Package init for scripts
|
||||
- docs/** — Documentation
|
||||
- tests/** — Test files
|
||||
- hooks/** — Git hooks
|
||||
- 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/** — Python CLI source
|
||||
- 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 gitea_runner_manager.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/",
|
||||
"scripts/ci/",
|
||||
"scripts/setup.sh",
|
||||
"scripts/molecule_all.sh",
|
||||
"scripts/__init__.py",
|
||||
# Documentation
|
||||
"docs/",
|
||||
"AGENTS.md",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"TROUBLESHOOTING.md",
|
||||
# Tests
|
||||
"tests/",
|
||||
# Config / build automation
|
||||
"cliff.toml",
|
||||
"Makefile",
|
||||
".pre-commit-config.yaml",
|
||||
".ansible-lint",
|
||||
".env.example",
|
||||
".gitignore",
|
||||
".ruff.toml",
|
||||
# Hooks
|
||||
"hooks/",
|
||||
# GitHub (if ever added)
|
||||
".github/",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
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."""
|
||||
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()
|
||||
|
||||
|
||||
@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.")
|
||||
def main(base: str | None, head: str, quiet: bool) -> None:
|
||||
if base is None:
|
||||
base = get_latest_tag()
|
||||
if not base:
|
||||
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 quiet:
|
||||
click.echo("false")
|
||||
else:
|
||||
click.echo(_("No changes between {base} and {head}.", base=base, head=head))
|
||||
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()
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Discover available Gitea Actions runners for dynamic job distribution.
|
||||
|
||||
Queries the Gitea API for registered runners at three levels:
|
||||
1. Repository level: GET /repos/{owner}/{repo}/actions/runners
|
||||
2. Organization level: GET /orgs/{org}/actions/runners
|
||||
3. Instance (admin) level: GET /admin/actions/runners
|
||||
|
||||
Falls back to the ``MOLECULE_RUNNERS`` repo variable or environment
|
||||
variable, then to ``DEFAULT_MAX_RUNNERS`` (3).
|
||||
|
||||
Outputs:
|
||||
- ``--count``: prints the number of available runners
|
||||
- ``--indices``: prints a JSON array [0, 1, ..., N-1] for use as a
|
||||
dynamic matrix in Gitea Actions
|
||||
- (default): prints both as ``count=N`` and ``indices=[0,1,...]``
|
||||
|
||||
Usage:
|
||||
python3 scripts/ci/discover_runners.py --owner oblachno-oss --repo grm
|
||||
python3 scripts/ci/discover_runners.py --indices
|
||||
python3 scripts/ci/discover_runners.py --count
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
import click
|
||||
import requests
|
||||
|
||||
from gitea_runner_manager.config import GITEA_API_URL
|
||||
|
||||
DEFAULT_MAX_RUNNERS = 3
|
||||
|
||||
|
||||
def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
"""Query the Gitea API for registered runners at all levels.
|
||||
|
||||
Returns the total count of active runners. If the API call fails
|
||||
(e.g., no admin access for instance-level runners), falls back to
|
||||
what we can see.
|
||||
"""
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
total = 0
|
||||
|
||||
# 1. Repository-level runners
|
||||
try:
|
||||
r = requests.get(
|
||||
f"{api_url}/repos/{owner}/{repo}/actions/runners",
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
total += data.get("total_count", 0)
|
||||
except (requests.RequestException, ValueError):
|
||||
pass
|
||||
|
||||
# 2. Organization-level runners
|
||||
try:
|
||||
r = requests.get(
|
||||
f"{api_url}/orgs/{owner}/actions/runners",
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
total += data.get("total_count", 0)
|
||||
except (requests.RequestException, ValueError):
|
||||
pass
|
||||
|
||||
# 3. Instance-level runners (requires admin scope)
|
||||
try:
|
||||
r = requests.get(
|
||||
f"{api_url}/admin/actions/runners",
|
||||
headers=headers,
|
||||
timeout=10,
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
total += data.get("total_count", 0)
|
||||
except (requests.RequestException, ValueError):
|
||||
pass
|
||||
|
||||
return total
|
||||
|
||||
|
||||
def get_runner_count(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
"""Determine the number of available runners.
|
||||
|
||||
Tries the Gitea API first, then falls back to env vars, then default.
|
||||
"""
|
||||
# Try API query if we have a token
|
||||
if token:
|
||||
api_count = query_runners(api_url, token, owner, repo)
|
||||
if api_count > 0:
|
||||
return api_count
|
||||
|
||||
# Fall back to MOLECULE_RUNNERS env var (set by CI from repo variable)
|
||||
env_count = os.environ.get("MOLECULE_RUNNERS")
|
||||
if env_count:
|
||||
try:
|
||||
count = int(env_count)
|
||||
if count > 0:
|
||||
return count
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Fall back to default
|
||||
return DEFAULT_MAX_RUNNERS
|
||||
|
||||
|
||||
def generate_indices(count: int) -> list[int]:
|
||||
"""Generate a list of runner indices [0, 1, ..., count-1]."""
|
||||
return list(range(count))
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--owner", default=None, help="Repository owner (for API query).")
|
||||
@click.option("--repo", default=None, help="Repository name (for API query).")
|
||||
@click.option("--count", "output_count", is_flag=True, help="Output only the count.")
|
||||
@click.option("--indices", "output_indices", is_flag=True, help="Output only the JSON indices array.")
|
||||
def main(owner: str | None, repo: str | None, output_count: bool, output_indices: bool) -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
|
||||
if owner is None:
|
||||
owner = os.environ.get("GRM_REPO_OWNER", "oblachno-oss")
|
||||
if repo is None:
|
||||
repo = os.environ.get("GRM_REPO_NAME", "grm")
|
||||
|
||||
count = get_runner_count(GITEA_API_URL, token, owner, repo)
|
||||
indices = generate_indices(count)
|
||||
|
||||
if output_count:
|
||||
click.echo(str(count))
|
||||
return
|
||||
|
||||
if output_indices:
|
||||
click.echo(json.dumps(indices))
|
||||
return
|
||||
|
||||
# Default: output both as key=value pairs for CI consumption
|
||||
click.echo(f"count={count}")
|
||||
click.echo(f"indices={json.dumps(indices)}")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -6,7 +6,7 @@ has corresponding documentation in the wiki/docs. Reports missing
|
||||
documentation as warnings and exits with non-zero if coverage is below 100%.
|
||||
|
||||
Usage:
|
||||
python3 scripts/doc_coverage.py [--docs-dir docs/] [--fail-on-missing]
|
||||
python3 scripts/ci/doc_coverage.py [--docs-dir docs/] [--fail-on-missing]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,8 +19,9 @@ import click
|
||||
|
||||
from gitea_runner_manager.i18n import _
|
||||
|
||||
DOCS_DIR = Path(__file__).resolve().parent.parent / "docs"
|
||||
CLI_FILE = Path(__file__).resolve().parent.parent / "src" / "gitea_runner_manager" / "cli.py"
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
DOCS_DIR = REPO_ROOT / "docs"
|
||||
CLI_FILE = REPO_ROOT / "src" / "gitea_runner_manager" / "cli.py"
|
||||
|
||||
# Major modules that should be documented in tech/architecture.md
|
||||
REQUIRED_MODULES = [
|
||||
@@ -42,6 +43,8 @@ REQUIRED_SCRIPTS = [
|
||||
"review_pr.py",
|
||||
"notify_failure.py",
|
||||
"post_merge.py",
|
||||
"classify_changes.py",
|
||||
"discover_runners.py",
|
||||
]
|
||||
|
||||
|
||||
@@ -36,6 +36,7 @@ import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from gitea_runner_manager.i18n import _
|
||||
from scripts.ci.classify_changes import has_user_facing_changes
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
@@ -256,6 +257,18 @@ def main(dry_run: bool, skip_tests: bool) -> None:
|
||||
if branch != "master":
|
||||
raise click.ClickException(_("Release must be run on master, currently on '{branch}'.", branch=branch))
|
||||
|
||||
# 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()
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
#!/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 gitea_runner_manager.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()
|
||||
@@ -1,148 +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).
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 scripts/sync_wiki.py [--dry-run] [--repo owner/repo]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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 gitea_runner_manager.i18n import _
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
DOCS_DIR = Path(__file__).resolve().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 list_wiki_pages(client: GiteaClient) -> dict[str, str]:
|
||||
"""List existing wiki pages, returning {title: page_name}."""
|
||||
try:
|
||||
pages = client._request("GET", "/wiki/pages").json()
|
||||
except APIError:
|
||||
return {}
|
||||
return {page.get("title", ""): page.get("page_name", page.get("title", "")) for page in pages}
|
||||
|
||||
|
||||
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"
|
||||
|
||||
if page_title in existing_pages:
|
||||
# Update existing page
|
||||
page_name = existing_pages[page_title]
|
||||
client._request(
|
||||
"PUT",
|
||||
f"/wiki/page/{page_name}",
|
||||
json={"content": content, "message": f"Sync from docs/ — update {page_title}"},
|
||||
)
|
||||
return "updated"
|
||||
|
||||
# Create new page
|
||||
client._request(
|
||||
"POST",
|
||||
"/wiki/page",
|
||||
json={"title": page_title, "content": content, "message": f"Sync from docs/ — create {page_title}"},
|
||||
)
|
||||
return "created"
|
||||
|
||||
|
||||
@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).")
|
||||
def main(dry_run: bool, repo: str | None) -> 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
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
"\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
|
||||
created=created,
|
||||
updated=updated,
|
||||
skipped=skipped,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Gitea Runner Manager — lean CLI for managing Gitea Actions runners."""
|
||||
|
||||
__version__ = "0.3.0"
|
||||
__version__ = "0.4.0"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for scripts/auto_merge.py."""
|
||||
"""Unit tests for scripts/ci/auto_merge.py."""
|
||||
|
||||
import http
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -9,7 +9,7 @@ from click.testing import CliRunner
|
||||
|
||||
from gitea_runner_manager.config import CONVENTIONAL_RE, TASK_ID_RE
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from scripts.auto_merge import (
|
||||
from scripts.ci.auto_merge import (
|
||||
PR_TITLE_RE,
|
||||
extract_conventional_msg,
|
||||
extract_task_id,
|
||||
@@ -162,25 +162,25 @@ class TestHasApprovalReview:
|
||||
|
||||
|
||||
class TestValidatePrTitleMatchesVikunja:
|
||||
@patch("scripts.auto_merge.get_vikunja_task_title", return_value="")
|
||||
@patch("scripts.ci.auto_merge.get_vikunja_task_title", return_value="")
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_no_vikunja_token_skips(self, mock_get: MagicMock) -> None:
|
||||
"""Should skip validation when VIKUNJA_TOKEN is not set."""
|
||||
validate_pr_title_matches_vikunja("GRM-19: Some title", "GRM-19")
|
||||
|
||||
@patch("scripts.auto_merge.get_vikunja_task_title", return_value="Some task title")
|
||||
@patch("scripts.ci.auto_merge.get_vikunja_task_title", return_value="Some task title")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
def test_matching_title_passes(self, mock_get: MagicMock) -> None:
|
||||
validate_pr_title_matches_vikunja("GRM-19: Some task title", "GRM-19")
|
||||
|
||||
@patch("scripts.auto_merge.get_vikunja_task_title", return_value="Some task title")
|
||||
@patch("scripts.ci.auto_merge.get_vikunja_task_title", return_value="Some task title")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
def test_mismatched_title_raises(self, mock_get: MagicMock) -> None:
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
validate_pr_title_matches_vikunja("GRM-19: Different title", "GRM-19")
|
||||
assert "does not match" in str(exc.value)
|
||||
|
||||
@patch("scripts.auto_merge.get_vikunja_task_title", return_value="")
|
||||
@patch("scripts.ci.auto_merge.get_vikunja_task_title", return_value="")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
def test_task_not_found_skips(self, mock_get: MagicMock) -> None:
|
||||
"""Should skip validation when Vikunja task is not found."""
|
||||
@@ -190,14 +190,14 @@ class TestValidatePrTitleMatchesVikunja:
|
||||
class TestGetVikunjaTaskTitle:
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_no_token_returns_empty(self) -> None:
|
||||
from scripts.auto_merge import get_vikunja_task_title
|
||||
from scripts.ci.auto_merge import get_vikunja_task_title
|
||||
|
||||
assert get_vikunja_task_title("GRM-19") == ""
|
||||
|
||||
@patch("scripts.auto_merge.VikunjaClient")
|
||||
@patch("scripts.ci.auto_merge.VikunjaClient")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
def test_finds_task(self, mock_client_cls: MagicMock) -> None:
|
||||
from scripts.auto_merge import get_vikunja_task_title
|
||||
from scripts.ci.auto_merge import get_vikunja_task_title
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
@@ -206,10 +206,10 @@ class TestGetVikunjaTaskTitle:
|
||||
mock_client_cls.return_value = mock_client
|
||||
assert get_vikunja_task_title("GRM-19") == "Some task title"
|
||||
|
||||
@patch("scripts.auto_merge.VikunjaClient")
|
||||
@patch("scripts.ci.auto_merge.VikunjaClient")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
def test_task_not_found_returns_empty(self, mock_client_cls: MagicMock) -> None:
|
||||
from scripts.auto_merge import get_vikunja_task_title
|
||||
from scripts.ci.auto_merge import get_vikunja_task_title
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
@@ -218,10 +218,10 @@ class TestGetVikunjaTaskTitle:
|
||||
mock_client_cls.return_value = mock_client
|
||||
assert get_vikunja_task_title("GRM-19") == ""
|
||||
|
||||
@patch("scripts.auto_merge.VikunjaClient")
|
||||
@patch("scripts.ci.auto_merge.VikunjaClient")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
def test_paginates_to_find_task(self, mock_client_cls: MagicMock) -> None:
|
||||
from scripts.auto_merge import get_vikunja_task_title
|
||||
from scripts.ci.auto_merge import get_vikunja_task_title
|
||||
|
||||
mock_client = MagicMock()
|
||||
# First page: full page of 50 tasks, no match; second page: match
|
||||
@@ -231,10 +231,10 @@ class TestGetVikunjaTaskTitle:
|
||||
mock_client_cls.return_value = mock_client
|
||||
assert get_vikunja_task_title("GRM-99") == "Found task"
|
||||
|
||||
@patch("scripts.auto_merge.VikunjaClient")
|
||||
@patch("scripts.ci.auto_merge.VikunjaClient")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
def test_empty_pages_returns_empty(self, mock_client_cls: MagicMock) -> None:
|
||||
from scripts.auto_merge import get_vikunja_task_title
|
||||
from scripts.ci.auto_merge import get_vikunja_task_title
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = []
|
||||
@@ -257,7 +257,7 @@ class TestWaitForCi:
|
||||
[_status("CI / quality (pull_request)", CI_PENDING)],
|
||||
[_status("CI / quality (pull_request)", CI_SUCCESS)],
|
||||
]
|
||||
with patch("scripts.auto_merge.time.sleep"):
|
||||
with patch("scripts.ci.auto_merge.time.sleep"):
|
||||
assert wait_for_ci(client, "abc123", max_wait=10, poll_interval=5) is True
|
||||
|
||||
def test_fails_on_failed_check(self) -> None:
|
||||
@@ -273,7 +273,7 @@ class TestWaitForCi:
|
||||
client.get_commit_status.return_value = [
|
||||
_status("CI / quality (pull_request)", CI_PENDING),
|
||||
]
|
||||
with patch("scripts.auto_merge.time.sleep"):
|
||||
with patch("scripts.ci.auto_merge.time.sleep"):
|
||||
assert wait_for_ci(client, "abc123", max_wait=5) is False
|
||||
|
||||
def test_no_statuses_waits(self) -> None:
|
||||
@@ -282,7 +282,7 @@ class TestWaitForCi:
|
||||
[],
|
||||
[_status("CI / quality (pull_request)", CI_SUCCESS)],
|
||||
]
|
||||
with patch("scripts.auto_merge.time.sleep"):
|
||||
with patch("scripts.ci.auto_merge.time.sleep"):
|
||||
assert wait_for_ci(client, "abc123", max_wait=10, poll_interval=5) is True
|
||||
|
||||
def test_ignores_non_ci_contexts(self) -> None:
|
||||
@@ -307,7 +307,7 @@ class TestWaitForCi:
|
||||
[_status("Auto-merge / merge (pull_request)", CI_PENDING)],
|
||||
[_status("CI / quality (pull_request)", CI_SUCCESS)],
|
||||
]
|
||||
with patch("scripts.auto_merge.time.sleep"):
|
||||
with patch("scripts.ci.auto_merge.time.sleep"):
|
||||
assert wait_for_ci(client, "abc123", max_wait=10, poll_interval=5) is True
|
||||
|
||||
|
||||
@@ -325,9 +325,9 @@ def _mock_commits() -> list[dict[str, dict[str, str]]]:
|
||||
|
||||
class TestMain:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.auto_merge.has_approval_review", return_value=True)
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
|
||||
@patch("scripts.ci.auto_merge.GiteaClient")
|
||||
def test_successful_flow_with_label_arg(
|
||||
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
|
||||
) -> None:
|
||||
@@ -346,9 +346,9 @@ class TestMain:
|
||||
mock_client.merge_pr.assert_called_once_with("7", "GRM-19 fix: resolve timeout")
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.auto_merge.has_approval_review", return_value=True)
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
|
||||
@patch("scripts.ci.auto_merge.GiteaClient")
|
||||
def test_successful_flow_label_fallback(
|
||||
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
|
||||
) -> None:
|
||||
@@ -369,9 +369,9 @@ class TestMain:
|
||||
mock_client.merge_pr.assert_called_once_with("7", "GRM-19 fix: resolve timeout")
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.auto_merge.has_approval_review", return_value=True)
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
|
||||
@patch("scripts.ci.auto_merge.GiteaClient")
|
||||
def test_wrong_label_skips_merge(
|
||||
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
|
||||
) -> None:
|
||||
@@ -389,9 +389,9 @@ class TestMain:
|
||||
mock_client.merge_pr.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.auto_merge.has_approval_review", return_value=True)
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
|
||||
@patch("scripts.ci.auto_merge.GiteaClient")
|
||||
def test_empty_label_falls_back_to_api(
|
||||
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
|
||||
) -> None:
|
||||
@@ -418,7 +418,7 @@ class TestMain:
|
||||
assert "REPO_TOKEN" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
@patch("scripts.ci.auto_merge.GiteaClient")
|
||||
def test_missing_task_id_exits(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
|
||||
@@ -429,7 +429,7 @@ class TestMain:
|
||||
assert "task ID" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
@patch("scripts.ci.auto_merge.GiteaClient")
|
||||
def test_invalid_pr_title_exits(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
|
||||
@@ -440,7 +440,7 @@ class TestMain:
|
||||
assert "GRM-N" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
@patch("scripts.ci.auto_merge.GiteaClient")
|
||||
def test_pr_title_task_id_mismatch_exits(self, mock_client_cls: MagicMock) -> None:
|
||||
"""PR title has a different task ID than the branch."""
|
||||
mock_client = MagicMock()
|
||||
@@ -452,9 +452,9 @@ class TestMain:
|
||||
assert "mismatch" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.auto_merge.has_approval_review", return_value=False)
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.ci.auto_merge.has_approval_review", return_value=False)
|
||||
@patch("scripts.ci.auto_merge.GiteaClient")
|
||||
def test_no_approval_review_blocks_merge(
|
||||
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
|
||||
) -> None:
|
||||
@@ -469,9 +469,9 @@ class TestMain:
|
||||
mock_client.merge_pr.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.auto_merge.has_approval_review", return_value=True)
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
|
||||
@patch("scripts.ci.auto_merge.GiteaClient")
|
||||
def test_empty_commits_exits(
|
||||
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
|
||||
) -> None:
|
||||
@@ -489,9 +489,9 @@ class TestMain:
|
||||
mock_client.merge_pr.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.auto_merge.has_approval_review", return_value=True)
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
|
||||
@patch("scripts.ci.auto_merge.GiteaClient")
|
||||
def test_merge_pr_failure_raises_click(
|
||||
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
|
||||
) -> None:
|
||||
@@ -508,9 +508,9 @@ class TestMain:
|
||||
assert "HTTP" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.auto_merge.has_approval_review", return_value=True)
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
|
||||
@patch("scripts.ci.auto_merge.GiteaClient")
|
||||
def test_merge_pr_json_parse_failure(
|
||||
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
|
||||
) -> None:
|
||||
@@ -527,9 +527,9 @@ class TestMain:
|
||||
assert str(http.HTTPStatus.BAD_GATEWAY) in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.auto_merge.has_approval_review", return_value=True)
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
|
||||
@patch("scripts.ci.auto_merge.GiteaClient")
|
||||
def test_ci_failure_blocks_merge(
|
||||
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
|
||||
) -> None:
|
||||
@@ -548,9 +548,9 @@ class TestMain:
|
||||
mock_client.merge_pr.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.auto_merge.has_approval_review", return_value=True)
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
@patch("scripts.ci.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.ci.auto_merge.has_approval_review", return_value=True)
|
||||
@patch("scripts.ci.auto_merge.GiteaClient")
|
||||
def test_no_sha_proceeds_without_wait(
|
||||
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
|
||||
) -> None:
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Unit tests for scripts/ci/classify_changes.py."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
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_user_facing(self) -> None:
|
||||
"""Dev scripts (check_test_speed, configure_repo) are NOT in the
|
||||
workflow-only allowlist, so they default to user-facing."""
|
||||
assert is_user_facing("scripts/check_test_speed.py") is True
|
||||
assert is_user_facing("scripts/configure_repo.py") is True
|
||||
assert is_user_facing("scripts/install_checkmake.py") is True
|
||||
|
||||
def test_shell_scripts_are_not_user_facing(self) -> None:
|
||||
assert is_user_facing("scripts/setup.sh") is False
|
||||
assert is_user_facing("scripts/molecule_all.sh") is False
|
||||
|
||||
def test_scripts_init_is_not_user_facing(self) -> None:
|
||||
assert is_user_facing("scripts/__init__.py") is False
|
||||
|
||||
def test_docs_are_not_user_facing(self) -> None:
|
||||
assert is_user_facing("docs/user/getting-started.md") is False
|
||||
|
||||
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
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Unit tests for scripts/ci/discover_runners.py."""
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from scripts.ci.discover_runners import (
|
||||
DEFAULT_MAX_RUNNERS,
|
||||
generate_indices,
|
||||
get_runner_count,
|
||||
main,
|
||||
query_runners,
|
||||
)
|
||||
|
||||
|
||||
class TestGenerateIndices:
|
||||
def test_zero(self) -> None:
|
||||
assert generate_indices(0) == []
|
||||
|
||||
def test_one(self) -> None:
|
||||
assert generate_indices(1) == [0]
|
||||
|
||||
def test_three(self) -> None:
|
||||
assert generate_indices(3) == [0, 1, 2]
|
||||
|
||||
def test_five(self) -> None:
|
||||
assert generate_indices(5) == [0, 1, 2, 3, 4]
|
||||
|
||||
|
||||
class TestQueryRunners:
|
||||
@patch("scripts.ci.discover_runners.requests.get")
|
||||
def test_returns_total_from_all_levels(self, mock_get: MagicMock) -> None:
|
||||
"""Runners from repo, org, and admin levels are summed."""
|
||||
responses = [
|
||||
MagicMock(status_code=200, json=lambda: {"runners": [], "total_count": 2}),
|
||||
MagicMock(status_code=200, json=lambda: {"runners": [], "total_count": 1}),
|
||||
MagicMock(status_code=200, json=lambda: {"runners": [], "total_count": 3}),
|
||||
]
|
||||
mock_get.side_effect = responses
|
||||
result = query_runners("https://api.example.com", "token", "owner", "repo")
|
||||
assert result == 6
|
||||
|
||||
@patch("scripts.ci.discover_runners.requests.get")
|
||||
def test_skips_non_200(self, mock_get: MagicMock) -> None:
|
||||
"""Non-200 responses (e.g., 403 for admin) are skipped."""
|
||||
responses = [
|
||||
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
|
||||
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
|
||||
MagicMock(status_code=403, json=lambda: {"message": "forbidden"}),
|
||||
]
|
||||
mock_get.side_effect = responses
|
||||
result = query_runners("https://api.example.com", "token", "owner", "repo")
|
||||
assert result == 3
|
||||
|
||||
@patch("scripts.ci.discover_runners.requests.get")
|
||||
def test_handles_request_exception(self, mock_get: MagicMock) -> None:
|
||||
"""Network errors are caught and don't crash."""
|
||||
mock_get.side_effect = [
|
||||
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
|
||||
MagicMock(side_effect=__import__("requests").RequestException("network error")),
|
||||
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
|
||||
]
|
||||
result = query_runners("https://api.example.com", "token", "owner", "repo")
|
||||
assert result == 3
|
||||
|
||||
@patch("scripts.ci.discover_runners.requests.get")
|
||||
def test_all_failures_return_zero(self, mock_get: MagicMock) -> None:
|
||||
"""When all API calls fail, returns 0."""
|
||||
mock_get.side_effect = [
|
||||
MagicMock(status_code=404),
|
||||
MagicMock(status_code=404),
|
||||
MagicMock(status_code=403),
|
||||
]
|
||||
result = query_runners("https://api.example.com", "token", "owner", "repo")
|
||||
assert result == 0
|
||||
|
||||
@patch("scripts.ci.discover_runners.requests.get")
|
||||
def test_value_error_on_repo_level(self, mock_get: MagicMock) -> None:
|
||||
"""JSON parse error on repo level is caught."""
|
||||
responses = [
|
||||
MagicMock(status_code=200, json=MagicMock(side_effect=ValueError("bad json"))),
|
||||
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
|
||||
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
|
||||
]
|
||||
mock_get.side_effect = responses
|
||||
result = query_runners("https://api.example.com", "token", "owner", "repo")
|
||||
assert result == 3
|
||||
|
||||
@patch("scripts.ci.discover_runners.requests.get")
|
||||
def test_value_error_on_org_level(self, mock_get: MagicMock) -> None:
|
||||
"""JSON parse error on org level is caught."""
|
||||
responses = [
|
||||
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
|
||||
MagicMock(status_code=200, json=MagicMock(side_effect=ValueError("bad json"))),
|
||||
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
|
||||
]
|
||||
mock_get.side_effect = responses
|
||||
result = query_runners("https://api.example.com", "token", "owner", "repo")
|
||||
assert result == 3
|
||||
|
||||
@patch("scripts.ci.discover_runners.requests.get")
|
||||
def test_value_error_on_admin_level(self, mock_get: MagicMock) -> None:
|
||||
"""JSON parse error on admin level is caught."""
|
||||
responses = [
|
||||
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
|
||||
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
|
||||
MagicMock(status_code=200, json=MagicMock(side_effect=ValueError("bad json"))),
|
||||
]
|
||||
mock_get.side_effect = responses
|
||||
result = query_runners("https://api.example.com", "token", "owner", "repo")
|
||||
assert result == 3
|
||||
|
||||
@patch("scripts.ci.discover_runners.requests.get")
|
||||
def test_request_exception_on_all_levels(self, mock_get: MagicMock) -> None:
|
||||
"""Network errors on all levels return 0."""
|
||||
mock_get.side_effect = __import__("requests").RequestException("network error")
|
||||
result = query_runners("https://api.example.com", "token", "owner", "repo")
|
||||
assert result == 0
|
||||
|
||||
|
||||
class TestGetRunnerCount:
|
||||
@patch("scripts.ci.discover_runners.query_runners", return_value=5)
|
||||
def test_uses_api_count_when_positive(self, mock_query: MagicMock) -> None:
|
||||
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
|
||||
assert result == 5
|
||||
|
||||
@patch("scripts.ci.discover_runners.query_runners", return_value=0)
|
||||
@patch.dict("os.environ", {"MOLECULE_RUNNERS": "4"})
|
||||
def test_falls_back_to_env_var(self, mock_query: MagicMock) -> None:
|
||||
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
|
||||
assert result == 4
|
||||
|
||||
@patch("scripts.ci.discover_runners.query_runners", return_value=0)
|
||||
@patch.dict("os.environ", {"MOLECULE_RUNNERS": "invalid"})
|
||||
def test_falls_back_to_default_on_invalid_env(self, mock_query: MagicMock) -> None:
|
||||
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
|
||||
assert result == DEFAULT_MAX_RUNNERS
|
||||
|
||||
@patch("scripts.ci.discover_runners.query_runners", return_value=0)
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_falls_back_to_default_when_no_env(self, mock_query: MagicMock) -> None:
|
||||
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
|
||||
assert result == DEFAULT_MAX_RUNNERS
|
||||
|
||||
@patch("scripts.ci.discover_runners.query_runners", return_value=0)
|
||||
@patch.dict("os.environ", {"MOLECULE_RUNNERS": "0"})
|
||||
def test_env_var_zero_falls_back_to_default(self, mock_query: MagicMock) -> None:
|
||||
"""MOLECULE_RUNNERS=0 is invalid, falls back to default."""
|
||||
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
|
||||
assert result == DEFAULT_MAX_RUNNERS
|
||||
|
||||
@patch("scripts.ci.discover_runners.query_runners", return_value=0)
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_no_token_uses_env_var(self, mock_query: MagicMock) -> None:
|
||||
"""When no token, skips API and uses env/default."""
|
||||
with patch.dict("os.environ", {"MOLECULE_RUNNERS": "2"}):
|
||||
result = get_runner_count("https://api.example.com", "", "owner", "repo")
|
||||
assert result == 2
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch("scripts.ci.discover_runners.get_runner_count", return_value=3)
|
||||
def test_default_output(self, mock_count: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "count=3" in result.output
|
||||
assert "indices=[0, 1, 2]" in result.output
|
||||
|
||||
@patch("scripts.ci.discover_runners.get_runner_count", return_value=5)
|
||||
def test_count_only(self, mock_count: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--count"])
|
||||
assert result.exit_code == 0
|
||||
assert result.output.strip() == "5"
|
||||
|
||||
@patch("scripts.ci.discover_runners.get_runner_count", return_value=4)
|
||||
def test_indices_only(self, mock_count: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--indices"])
|
||||
assert result.exit_code == 0
|
||||
assert json.loads(result.output.strip()) == [0, 1, 2, 3]
|
||||
|
||||
@patch("scripts.ci.discover_runners.get_runner_count", return_value=1)
|
||||
def test_single_runner(self, mock_count: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--indices"])
|
||||
assert result.exit_code == 0
|
||||
assert json.loads(result.output.strip()) == [0]
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for scripts/distribute_molecule.py."""
|
||||
"""Unit tests for scripts/ci/distribute_molecule.py."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
@@ -6,7 +6,7 @@ from unittest.mock import patch
|
||||
import click
|
||||
import pytest
|
||||
|
||||
from scripts.distribute_molecule import (
|
||||
from scripts.ci.distribute_molecule import (
|
||||
MOLECULE_ROOT,
|
||||
PLATFORMS,
|
||||
TestPair,
|
||||
@@ -134,12 +134,12 @@ class TestCli:
|
||||
def test_list_flag(self, tmp_path: Path) -> None:
|
||||
from click.testing import CliRunner
|
||||
|
||||
from scripts.distribute_molecule import cli
|
||||
from scripts.ci.distribute_molecule import cli
|
||||
|
||||
root = tmp_path / "molecule"
|
||||
(root / "alpha").mkdir(parents=True)
|
||||
(root / "beta").mkdir(parents=True)
|
||||
with patch("scripts.distribute_molecule.MOLECULE_ROOT", root):
|
||||
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--list"])
|
||||
assert result.exit_code == 0
|
||||
@@ -149,7 +149,7 @@ class TestCli:
|
||||
def test_list_platforms_flag(self) -> None:
|
||||
from click.testing import CliRunner
|
||||
|
||||
from scripts.distribute_molecule import cli
|
||||
from scripts.ci.distribute_molecule import cli
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--list-platforms"])
|
||||
@@ -162,12 +162,12 @@ class TestCli:
|
||||
def test_no_runner_index_prints_all_groups(self, tmp_path: Path) -> None:
|
||||
from click.testing import CliRunner
|
||||
|
||||
from scripts.distribute_molecule import cli
|
||||
from scripts.ci.distribute_molecule import cli
|
||||
|
||||
root = tmp_path / "molecule"
|
||||
for s in ["a", "b", "c"]:
|
||||
(root / s).mkdir(parents=True)
|
||||
with patch("scripts.distribute_molecule.MOLECULE_ROOT", root):
|
||||
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--max-runners", "3"])
|
||||
assert result.exit_code == 0
|
||||
@@ -178,11 +178,11 @@ class TestCli:
|
||||
def test_runner_index_prints_assigned(self, tmp_path: Path) -> None:
|
||||
from click.testing import CliRunner
|
||||
|
||||
from scripts.distribute_molecule import cli
|
||||
from scripts.ci.distribute_molecule import cli
|
||||
|
||||
root = tmp_path / "molecule"
|
||||
(root / "alpha").mkdir(parents=True)
|
||||
with patch("scripts.distribute_molecule.MOLECULE_ROOT", root):
|
||||
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--runner-index", "0", "--max-runners", "3"])
|
||||
assert result.exit_code == 0
|
||||
@@ -192,7 +192,7 @@ class TestCli:
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
import scripts.distribute_molecule as dm
|
||||
import scripts.ci.distribute_molecule as dm
|
||||
|
||||
with open(dm.__file__) as f:
|
||||
source = f.read()
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
"""Unit tests for scripts/doc_coverage.py."""
|
||||
"""Unit tests for scripts/ci/doc_coverage.py."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from scripts.doc_coverage import (
|
||||
from scripts.ci.doc_coverage import (
|
||||
check_command_documented,
|
||||
check_module_documented,
|
||||
extract_cli_commands,
|
||||
@@ -76,7 +76,8 @@ class TestMain:
|
||||
)
|
||||
# Write ci-cd-workflow.md with all scripts
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text(
|
||||
"auto_merge.py release.py publish.py review_pr.py notify_failure.py post_merge.py"
|
||||
"auto_merge.py release.py publish.py review_pr.py "
|
||||
"notify_failure.py post_merge.py classify_changes.py discover_runners.py"
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for scripts/molecule_ci_guard.py."""
|
||||
"""Unit tests for scripts/ci/molecule_ci_guard.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -10,7 +10,7 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from scripts.molecule_ci_guard import (
|
||||
from scripts.ci.molecule_ci_guard import (
|
||||
any_other_runner_failed,
|
||||
build_env_for_pair,
|
||||
build_molecule_cmd,
|
||||
@@ -22,7 +22,7 @@ from scripts.molecule_ci_guard import (
|
||||
|
||||
class TestGetRunningJobs:
|
||||
def test_returns_jobs(self) -> None:
|
||||
with patch("scripts.molecule_ci_guard.requests.get") as mock_get:
|
||||
with patch("scripts.ci.molecule_ci_guard.requests.get") as mock_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {
|
||||
"jobs": [
|
||||
@@ -38,7 +38,7 @@ class TestGetRunningJobs:
|
||||
mock_get.assert_called_once()
|
||||
|
||||
def test_raises_on_request_error(self) -> None:
|
||||
with patch("scripts.molecule_ci_guard.requests.get") as mock_get:
|
||||
with patch("scripts.ci.molecule_ci_guard.requests.get") as mock_get:
|
||||
mock_get.side_effect = requests.RequestException("boom")
|
||||
with pytest.raises(requests.RequestException):
|
||||
get_running_jobs("https://gitea.example", "owner", "repo", "token", 123)
|
||||
@@ -107,7 +107,7 @@ class TestPollForOtherFailures:
|
||||
]
|
||||
return []
|
||||
|
||||
with patch("scripts.molecule_ci_guard.get_running_jobs") as mock_get_jobs:
|
||||
with patch("scripts.ci.molecule_ci_guard.get_running_jobs") as mock_get_jobs:
|
||||
mock_get_jobs.side_effect = side_effect
|
||||
stop_event.is_set.side_effect = [False, False]
|
||||
stop_event.wait.return_value = True
|
||||
@@ -130,7 +130,7 @@ class TestPollForOtherFailures:
|
||||
stop_event = MagicMock()
|
||||
failed_event = MagicMock()
|
||||
|
||||
with patch("scripts.molecule_ci_guard.get_running_jobs") as mock_get_jobs:
|
||||
with patch("scripts.ci.molecule_ci_guard.get_running_jobs") as mock_get_jobs:
|
||||
mock_get_jobs.side_effect = requests.RequestException("boom")
|
||||
stop_event.is_set.side_effect = [False, True]
|
||||
stop_event.wait.return_value = True
|
||||
@@ -155,7 +155,7 @@ class TestCli:
|
||||
from click.testing import CliRunner
|
||||
|
||||
with (
|
||||
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("time.sleep"),
|
||||
):
|
||||
proc = MagicMock()
|
||||
@@ -172,7 +172,7 @@ class TestCli:
|
||||
from click.testing import CliRunner
|
||||
|
||||
with (
|
||||
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("time.sleep"),
|
||||
):
|
||||
proc = MagicMock()
|
||||
@@ -202,9 +202,9 @@ class TestCli:
|
||||
},
|
||||
clear=True,
|
||||
),
|
||||
patch("scripts.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("scripts.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
|
||||
patch("scripts.ci.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("scripts.ci.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
|
||||
patch("time.sleep"),
|
||||
):
|
||||
mock_get_jobs.return_value = [
|
||||
@@ -225,7 +225,7 @@ class TestCli:
|
||||
from click.testing import CliRunner
|
||||
|
||||
with (
|
||||
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("time.sleep", side_effect=KeyboardInterrupt),
|
||||
patch("os.killpg") as mock_killpg,
|
||||
patch("os.getpgid") as mock_getpgid,
|
||||
@@ -270,9 +270,9 @@ class TestCli:
|
||||
},
|
||||
clear=True,
|
||||
),
|
||||
patch("scripts.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("scripts.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("scripts.ci.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("scripts.ci.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("os.killpg") as mock_killpg,
|
||||
patch("os.getpgid") as mock_getpgid,
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
||||
@@ -307,9 +307,9 @@ class TestCli:
|
||||
},
|
||||
clear=True,
|
||||
),
|
||||
patch("scripts.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("scripts.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
|
||||
patch("scripts.ci.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("scripts.ci.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.05)),
|
||||
):
|
||||
mock_get_jobs.return_value = [{"name": "molecule-tests (1)", "conclusion": "success"}]
|
||||
@@ -351,9 +351,9 @@ class TestCli:
|
||||
},
|
||||
clear=True,
|
||||
),
|
||||
patch("scripts.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("scripts.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("scripts.ci.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("scripts.ci.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("os.killpg") as mock_killpg,
|
||||
patch("os.getpgid") as mock_getpgid,
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
||||
@@ -398,9 +398,9 @@ class TestCli:
|
||||
},
|
||||
clear=True,
|
||||
),
|
||||
patch("scripts.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("scripts.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("scripts.ci.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("scripts.ci.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("os.killpg") as mock_killpg,
|
||||
patch("os.getpgid") as mock_getpgid,
|
||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
||||
@@ -418,7 +418,7 @@ class TestCli:
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
import scripts.molecule_ci_guard as mg
|
||||
import scripts.ci.molecule_ci_guard as mg
|
||||
|
||||
with open(mg.__file__) as f:
|
||||
source = f.read()
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
"""Unit tests for scripts/notify_failure.py."""
|
||||
"""Unit tests for scripts/ci/notify_failure.py."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from scripts.notify_failure import main
|
||||
from scripts.ci.notify_failure import main
|
||||
|
||||
|
||||
class TestNotifyFailure:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.notify_failure.GiteaClient")
|
||||
@patch("scripts.ci.notify_failure.GiteaClient")
|
||||
def test_creates_issue_with_labels(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_labels.return_value = [{"id": 5, "name": "bug"}]
|
||||
@@ -38,7 +38,7 @@ class TestNotifyFailure:
|
||||
assert call_kwargs.kwargs["labels"] == [5]
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.notify_failure.GiteaClient")
|
||||
@patch("scripts.ci.notify_failure.GiteaClient")
|
||||
def test_creates_issue_without_bug_label(self, mock_client_cls: MagicMock) -> None:
|
||||
"""When 'bug' label doesn't exist, create issue without labels."""
|
||||
mock_client = MagicMock()
|
||||
@@ -67,7 +67,7 @@ class TestNotifyFailure:
|
||||
assert call_kwargs.kwargs.get("labels") is None
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.notify_failure.GiteaClient")
|
||||
@patch("scripts.ci.notify_failure.GiteaClient")
|
||||
def test_api_error_raises(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_labels.return_value = []
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for scripts/post_merge.py."""
|
||||
"""Unit tests for scripts/ci/post_merge.py."""
|
||||
|
||||
import http
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -8,7 +8,7 @@ import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from scripts.post_merge import (
|
||||
from scripts.ci.post_merge import (
|
||||
build_comment,
|
||||
extract_conventional_msg,
|
||||
extract_task_id,
|
||||
@@ -90,7 +90,7 @@ class TestResolveTaskId:
|
||||
|
||||
class TestMain:
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("scripts.post_merge.VikunjaClient")
|
||||
@patch("scripts.ci.post_merge.VikunjaClient")
|
||||
def test_full_flow(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
@@ -108,7 +108,7 @@ class TestMain:
|
||||
mock_client.update_task.assert_called_once_with(267, done=True)
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("scripts.post_merge.VikunjaClient")
|
||||
@patch("scripts.ci.post_merge.VikunjaClient")
|
||||
def test_no_commit_sha(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
@@ -137,7 +137,7 @@ class TestMain:
|
||||
assert "skipping" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("scripts.post_merge.VikunjaClient")
|
||||
@patch("scripts.ci.post_merge.VikunjaClient")
|
||||
def test_resolve_failure_propagates(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = []
|
||||
@@ -148,7 +148,7 @@ class TestMain:
|
||||
assert "Could not find" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("scripts.post_merge.VikunjaClient")
|
||||
@patch("scripts.ci.post_merge.VikunjaClient")
|
||||
def test_post_comment_failure_raises_click(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
@@ -162,7 +162,7 @@ class TestMain:
|
||||
assert "HTTP" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("scripts.post_merge.VikunjaClient")
|
||||
@patch("scripts.ci.post_merge.VikunjaClient")
|
||||
def test_mark_done_failure_raises_click(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
|
||||
+40
-40
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for scripts/publish.py."""
|
||||
"""Unit tests for scripts/ci/publish.py."""
|
||||
|
||||
import http
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -7,7 +7,7 @@ import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from scripts.publish import (
|
||||
from scripts.ci.publish import (
|
||||
build_package,
|
||||
generate_release_notes,
|
||||
main,
|
||||
@@ -16,44 +16,44 @@ from scripts.publish import (
|
||||
|
||||
|
||||
class TestGenerateReleaseNotes:
|
||||
@patch("scripts.publish.subprocess.run")
|
||||
@patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
||||
@patch("scripts.ci.publish.subprocess.run")
|
||||
@patch("scripts.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
||||
def test_generates_from_git_cliff(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="## v1.0.0\n- feat: x")
|
||||
result = generate_release_notes("v1.0.0")
|
||||
assert "## v1.0.0" in result
|
||||
assert "feat: x" in result
|
||||
|
||||
@patch("scripts.publish.subprocess.run")
|
||||
@patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
||||
@patch("scripts.ci.publish.subprocess.run")
|
||||
@patch("scripts.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
||||
def test_strips_whitespace(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout=" changelog \n")
|
||||
result = generate_release_notes("v1.0.0")
|
||||
assert result == "changelog"
|
||||
|
||||
@patch("scripts.publish.subprocess.run")
|
||||
@patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
||||
@patch("scripts.ci.publish.subprocess.run")
|
||||
@patch("scripts.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
||||
def test_falls_back_on_failure(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="")
|
||||
result = generate_release_notes("v1.0.0")
|
||||
assert "Release v1.0.0" in result
|
||||
assert "CHANGELOG.md" in result
|
||||
|
||||
@patch("scripts.publish.subprocess.run")
|
||||
@patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
||||
@patch("scripts.ci.publish.subprocess.run")
|
||||
@patch("scripts.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
||||
def test_falls_back_on_empty_output(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout=" ")
|
||||
result = generate_release_notes("v1.0.0")
|
||||
assert "Release v1.0.0" in result
|
||||
|
||||
@patch("scripts.publish.subprocess.run", side_effect=FileNotFoundError)
|
||||
@patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
||||
@patch("scripts.ci.publish.subprocess.run", side_effect=FileNotFoundError)
|
||||
@patch("scripts.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
||||
def test_falls_back_on_file_not_found(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
result = generate_release_notes("v1.0.0")
|
||||
assert "Release v1.0.0" in result
|
||||
assert "CHANGELOG.md" in result
|
||||
|
||||
@patch("scripts.publish.shutil.which", return_value=None)
|
||||
@patch("scripts.ci.publish.shutil.which", return_value=None)
|
||||
def test_falls_back_when_not_installed(self, mock_which: MagicMock) -> None:
|
||||
result = generate_release_notes("v1.0.0")
|
||||
assert "Release v1.0.0" in result
|
||||
@@ -61,7 +61,7 @@ class TestGenerateReleaseNotes:
|
||||
|
||||
|
||||
class TestBuildPackage:
|
||||
@patch("scripts.publish.subprocess.run")
|
||||
@patch("scripts.ci.publish.subprocess.run")
|
||||
def test_success(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr="")
|
||||
build_package()
|
||||
@@ -69,7 +69,7 @@ class TestBuildPackage:
|
||||
assert args[0][1] == "-m"
|
||||
assert args[0][2] == "build"
|
||||
|
||||
@patch("scripts.publish.subprocess.run")
|
||||
@patch("scripts.ci.publish.subprocess.run")
|
||||
def test_failure_raises(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr="build error")
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
@@ -78,7 +78,7 @@ class TestBuildPackage:
|
||||
|
||||
|
||||
class TestPublishToPypi:
|
||||
@patch("scripts.publish.subprocess.run")
|
||||
@patch("scripts.ci.publish.subprocess.run")
|
||||
def test_success(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr="")
|
||||
publish_to_pypi("pypi-tok")
|
||||
@@ -86,7 +86,7 @@ class TestPublishToPypi:
|
||||
assert "twine" in args[0]
|
||||
assert "pypi-tok" in args[0]
|
||||
|
||||
@patch("scripts.publish.subprocess.run")
|
||||
@patch("scripts.ci.publish.subprocess.run")
|
||||
def test_failure_raises(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr="upload failed")
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
@@ -96,10 +96,10 @@ class TestPublishToPypi:
|
||||
|
||||
class TestMain:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("scripts.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.publish.GiteaClient")
|
||||
@patch("scripts.publish.publish_to_pypi")
|
||||
@patch("scripts.publish.build_package")
|
||||
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.ci.publish.GiteaClient")
|
||||
@patch("scripts.ci.publish.publish_to_pypi")
|
||||
@patch("scripts.ci.publish.build_package")
|
||||
def test_full_flow_with_pypi(
|
||||
self,
|
||||
mock_build: MagicMock,
|
||||
@@ -119,9 +119,9 @@ class TestMain:
|
||||
assert call_args.kwargs["body"] == "Release notes"
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True)
|
||||
@patch("scripts.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.publish.GiteaClient")
|
||||
@patch("scripts.publish.build_package")
|
||||
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.ci.publish.GiteaClient")
|
||||
@patch("scripts.ci.publish.build_package")
|
||||
def test_without_pypi(
|
||||
self,
|
||||
mock_build: MagicMock,
|
||||
@@ -143,10 +143,10 @@ class TestMain:
|
||||
assert "REPO_TOKEN" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("scripts.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.publish.GiteaClient")
|
||||
@patch("scripts.publish.publish_to_pypi")
|
||||
@patch("scripts.publish.build_package")
|
||||
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.ci.publish.GiteaClient")
|
||||
@patch("scripts.ci.publish.publish_to_pypi")
|
||||
@patch("scripts.ci.publish.build_package")
|
||||
def test_build_failure_raises_click(
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock, mock_notes: MagicMock
|
||||
) -> None:
|
||||
@@ -157,10 +157,10 @@ class TestMain:
|
||||
assert "build" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("scripts.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.publish.GiteaClient")
|
||||
@patch("scripts.publish.publish_to_pypi")
|
||||
@patch("scripts.publish.build_package")
|
||||
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.ci.publish.GiteaClient")
|
||||
@patch("scripts.ci.publish.publish_to_pypi")
|
||||
@patch("scripts.ci.publish.build_package")
|
||||
def test_publish_failure_raises_click(
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock, mock_notes: MagicMock
|
||||
) -> None:
|
||||
@@ -171,10 +171,10 @@ class TestMain:
|
||||
assert "publish" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("scripts.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.publish.GiteaClient")
|
||||
@patch("scripts.publish.publish_to_pypi")
|
||||
@patch("scripts.publish.build_package")
|
||||
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.ci.publish.GiteaClient")
|
||||
@patch("scripts.ci.publish.publish_to_pypi")
|
||||
@patch("scripts.ci.publish.build_package")
|
||||
def test_release_failure_raises_click(
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock, mock_notes: MagicMock
|
||||
) -> None:
|
||||
@@ -189,10 +189,10 @@ class TestMain:
|
||||
assert "HTTP" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("scripts.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.publish.GiteaClient")
|
||||
@patch("scripts.publish.publish_to_pypi")
|
||||
@patch("scripts.publish.build_package")
|
||||
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.ci.publish.GiteaClient")
|
||||
@patch("scripts.ci.publish.publish_to_pypi")
|
||||
@patch("scripts.ci.publish.build_package")
|
||||
def test_release_json_parse_failure(
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock, mock_notes: MagicMock
|
||||
) -> None:
|
||||
|
||||
+152
-113
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for scripts/release.py."""
|
||||
"""Unit tests for scripts/ci/release.py."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -6,7 +6,7 @@ import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from scripts.release import (
|
||||
from scripts.ci.release import (
|
||||
commit_release_changes,
|
||||
create_and_push_tag,
|
||||
get_bumped_version,
|
||||
@@ -23,20 +23,20 @@ from scripts.release import (
|
||||
|
||||
|
||||
class TestRunCmd:
|
||||
@patch("scripts.release.subprocess.run")
|
||||
@patch("scripts.ci.release.subprocess.run")
|
||||
def test_success(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr="", stdout="")
|
||||
result = run_cmd(["echo", "hi"])
|
||||
assert result.returncode == 0
|
||||
mock_run.assert_called_once()
|
||||
|
||||
@patch("scripts.release.subprocess.run")
|
||||
@patch("scripts.ci.release.subprocess.run")
|
||||
def test_failure_raises(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr="err", stdout="")
|
||||
with pytest.raises(click.ClickException):
|
||||
run_cmd(["false"])
|
||||
|
||||
@patch("scripts.release.subprocess.run")
|
||||
@patch("scripts.ci.release.subprocess.run")
|
||||
def test_check_false_no_raise(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr="err", stdout="")
|
||||
result = run_cmd(["false"], check=False)
|
||||
@@ -44,41 +44,41 @@ class TestRunCmd:
|
||||
|
||||
|
||||
class TestGetLatestTag:
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_returns_tag(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.1.0\n")
|
||||
assert get_latest_tag() == "v0.1.0"
|
||||
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_no_tags_returns_empty(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="")
|
||||
assert get_latest_tag() == ""
|
||||
|
||||
|
||||
class TestTagExists:
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_exists(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.1.0\n")
|
||||
assert tag_exists("v0.1.0") is True
|
||||
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_not_exists(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="")
|
||||
assert tag_exists("v0.2.0") is False
|
||||
|
||||
|
||||
class TestGetBumpedVersion:
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_returns_version(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="0.2.0\n")
|
||||
assert get_bumped_version() == "0.2.0"
|
||||
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_strips_v_prefix(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.2.0\n")
|
||||
assert get_bumped_version() == "0.2.0"
|
||||
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_empty_raises(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="")
|
||||
with pytest.raises(click.ClickException):
|
||||
@@ -86,48 +86,48 @@ class TestGetBumpedVersion:
|
||||
|
||||
|
||||
class TestGetChangelog:
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_returns_changelog(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="## 0.2.0\n- fix\n")
|
||||
assert get_changelog("0.2.0") == "## 0.2.0\n- fix"
|
||||
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_strips_whitespace(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout=" text \n")
|
||||
assert get_changelog("0.2.0") == "text"
|
||||
|
||||
|
||||
class TestHasUnreleasedChanges:
|
||||
@patch("scripts.release.get_latest_tag")
|
||||
@patch("scripts.ci.release.get_latest_tag")
|
||||
def test_with_bumped_version_no_changes(self, mock_latest: MagicMock) -> None:
|
||||
mock_latest.return_value = "v0.2.0"
|
||||
assert has_unreleased_changes(bumped_version="0.2.0") is False
|
||||
|
||||
@patch("scripts.release.get_latest_tag")
|
||||
@patch("scripts.ci.release.get_latest_tag")
|
||||
def test_with_bumped_version_has_changes(self, mock_latest: MagicMock) -> None:
|
||||
mock_latest.return_value = "v0.2.0"
|
||||
assert has_unreleased_changes(bumped_version="0.3.0") is True
|
||||
|
||||
@patch("scripts.release.get_latest_tag")
|
||||
@patch("scripts.ci.release.get_latest_tag")
|
||||
def test_with_bumped_version_no_tags(self, mock_latest: MagicMock) -> None:
|
||||
mock_latest.return_value = ""
|
||||
assert has_unreleased_changes(bumped_version="0.1.0") is True
|
||||
|
||||
@patch("scripts.release.get_latest_tag")
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.get_latest_tag")
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_without_bumped_version_no_changes(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.2.0\n")
|
||||
mock_latest.return_value = "v0.2.0"
|
||||
assert has_unreleased_changes() is False
|
||||
|
||||
@patch("scripts.release.get_latest_tag")
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.get_latest_tag")
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_without_bumped_version_has_changes(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.3.0\n")
|
||||
mock_latest.return_value = "v0.2.0"
|
||||
assert has_unreleased_changes() is True
|
||||
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_cliff_fails_returns_false(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="")
|
||||
assert has_unreleased_changes() is False
|
||||
@@ -137,7 +137,7 @@ class TestUpdateInitVersion:
|
||||
def test_updates_version(self, tmp_path, monkeypatch) -> None:
|
||||
init_file = tmp_path / "__init__.py"
|
||||
init_file.write_text('__version__ = "0.1.0"\n')
|
||||
monkeypatch.setattr("scripts.release.INIT_FILE", str(init_file))
|
||||
monkeypatch.setattr("scripts.ci.release.INIT_FILE", str(init_file))
|
||||
update_init_version("0.2.0")
|
||||
assert '__version__ = "0.2.0"' in init_file.read_text()
|
||||
|
||||
@@ -145,14 +145,14 @@ class TestUpdateInitVersion:
|
||||
"""Updating to the same version should not raise."""
|
||||
init_file = tmp_path / "__init__.py"
|
||||
init_file.write_text('__version__ = "0.1.0"\n')
|
||||
monkeypatch.setattr("scripts.release.INIT_FILE", str(init_file))
|
||||
monkeypatch.setattr("scripts.ci.release.INIT_FILE", str(init_file))
|
||||
update_init_version("0.1.0")
|
||||
assert '__version__ = "0.1.0"' in init_file.read_text()
|
||||
|
||||
def test_no_version_raises(self, tmp_path, monkeypatch) -> None:
|
||||
init_file = tmp_path / "__init__.py"
|
||||
init_file.write_text('"""module"""\n')
|
||||
monkeypatch.setattr("scripts.release.INIT_FILE", str(init_file))
|
||||
monkeypatch.setattr("scripts.ci.release.INIT_FILE", str(init_file))
|
||||
with pytest.raises(click.ClickException):
|
||||
update_init_version("0.2.0")
|
||||
|
||||
@@ -160,7 +160,7 @@ class TestUpdateInitVersion:
|
||||
class TestUpdateChangelog:
|
||||
def test_creates_new_file(self, tmp_path, monkeypatch) -> None:
|
||||
changelog_file = tmp_path / "CHANGELOG.md"
|
||||
monkeypatch.setattr("scripts.release.CHANGELOG_FILE", str(changelog_file))
|
||||
monkeypatch.setattr("scripts.ci.release.CHANGELOG_FILE", str(changelog_file))
|
||||
update_changelog("## [0.2.0] - 2026-06-21\n\n### Features\n- new thing")
|
||||
content = changelog_file.read_text()
|
||||
assert "## [0.2.0]" in content
|
||||
@@ -169,7 +169,7 @@ class TestUpdateChangelog:
|
||||
def test_prepends_to_existing(self, tmp_path, monkeypatch) -> None:
|
||||
changelog_file = tmp_path / "CHANGELOG.md"
|
||||
changelog_file.write_text("# Changelog\n\n## [0.1.0] - 2026-06-20\n\n### Features\n- old thing\n")
|
||||
monkeypatch.setattr("scripts.release.CHANGELOG_FILE", str(changelog_file))
|
||||
monkeypatch.setattr("scripts.ci.release.CHANGELOG_FILE", str(changelog_file))
|
||||
update_changelog("## [0.2.0] - 2026-06-21\n\n### Features\n- new thing")
|
||||
content = changelog_file.read_text()
|
||||
assert "# Changelog" in content
|
||||
@@ -181,7 +181,7 @@ class TestUpdateChangelog:
|
||||
def test_appends_when_no_version_sections(self, tmp_path, monkeypatch) -> None:
|
||||
changelog_file = tmp_path / "CHANGELOG.md"
|
||||
changelog_file.write_text("# Changelog\n\nSome intro text.\n")
|
||||
monkeypatch.setattr("scripts.release.CHANGELOG_FILE", str(changelog_file))
|
||||
monkeypatch.setattr("scripts.ci.release.CHANGELOG_FILE", str(changelog_file))
|
||||
update_changelog("## [0.2.0] - 2026-06-21\n\n### Features\n- new thing")
|
||||
content = changelog_file.read_text()
|
||||
assert "Some intro text" in content
|
||||
@@ -191,7 +191,7 @@ class TestUpdateChangelog:
|
||||
"""git-cliff output includes a header — should be stripped before inserting."""
|
||||
changelog_file = tmp_path / "CHANGELOG.md"
|
||||
changelog_file.write_text("# Changelog\n\n## [0.1.0] - 2026-06-20\n\n### Features\n- old thing\n")
|
||||
monkeypatch.setattr("scripts.release.CHANGELOG_FILE", str(changelog_file))
|
||||
monkeypatch.setattr("scripts.ci.release.CHANGELOG_FILE", str(changelog_file))
|
||||
# Simulate git-cliff output with header
|
||||
cliff_output = "# Changelog\n\nAll notable changes...\n\n## [0.2.0] - 2026-06-21\n\n### Features\n- new thing"
|
||||
update_changelog(cliff_output)
|
||||
@@ -204,7 +204,7 @@ class TestUpdateChangelog:
|
||||
def test_strips_header_when_creating_new_file(self, tmp_path, monkeypatch) -> None:
|
||||
"""When creating a new file, strip the git-cliff header."""
|
||||
changelog_file = tmp_path / "CHANGELOG.md"
|
||||
monkeypatch.setattr("scripts.release.CHANGELOG_FILE", str(changelog_file))
|
||||
monkeypatch.setattr("scripts.ci.release.CHANGELOG_FILE", str(changelog_file))
|
||||
cliff_output = "# Changelog\n\nAll notable changes...\n\n## [0.2.0] - 2026-06-21\n\n### Features\n- new thing"
|
||||
update_changelog(cliff_output)
|
||||
content = changelog_file.read_text()
|
||||
@@ -213,7 +213,7 @@ class TestUpdateChangelog:
|
||||
|
||||
|
||||
class TestCommitReleaseChanges:
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_commits_when_changes(self, mock_run_cmd: MagicMock) -> None:
|
||||
# git diff --cached --quiet returns 1 (changes exist)
|
||||
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="")
|
||||
@@ -223,7 +223,7 @@ class TestCommitReleaseChanges:
|
||||
assert ["git", "add", "src/gitea_runner_manager/__init__.py", "CHANGELOG.md"] in calls
|
||||
assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0"] in calls
|
||||
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_skips_when_no_changes(self, mock_run_cmd: MagicMock) -> None:
|
||||
# git diff --cached --quiet returns 0 (no changes)
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
@@ -234,16 +234,16 @@ class TestCommitReleaseChanges:
|
||||
|
||||
|
||||
class TestCreateAndPushTag:
|
||||
@patch("scripts.release.tag_exists", return_value=False)
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.tag_exists", return_value=False)
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_creates_tag(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
|
||||
create_and_push_tag("0.2.0", "changelog", dry_run=False)
|
||||
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
|
||||
assert ["git", "tag", "-a", "v0.2.0", "-m", "Release v0.2.0\n\nchangelog"] in calls
|
||||
assert ["git", "push", "origin", "v0.2.0"] in calls
|
||||
|
||||
@patch("scripts.release.tag_exists", return_value=False)
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.tag_exists", return_value=False)
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_dry_run_no_push(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
|
||||
result = create_and_push_tag("0.2.0", "changelog", dry_run=True)
|
||||
assert result is True
|
||||
@@ -251,8 +251,8 @@ class TestCreateAndPushTag:
|
||||
assert call.args[0][0:2] != ["git", "push"]
|
||||
assert call.args[0][0:2] != ["git", "tag"]
|
||||
|
||||
@patch("scripts.release.tag_exists", return_value=True)
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.tag_exists", return_value=True)
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_tag_exists_skips_creation(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
|
||||
result = create_and_push_tag("0.1.0", "changelog", dry_run=False)
|
||||
assert result is False
|
||||
@@ -261,8 +261,8 @@ class TestCreateAndPushTag:
|
||||
assert ["git", "tag", "-a"] not in [c[:3] for c in calls]
|
||||
assert ["git", "push", "origin", "v0.1.0"] in calls
|
||||
|
||||
@patch("scripts.release.tag_exists", return_value=True)
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.tag_exists", return_value=True)
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_tag_exists_dry_run_no_push(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
|
||||
result = create_and_push_tag("0.1.0", "changelog", dry_run=True)
|
||||
assert result is False
|
||||
@@ -271,18 +271,18 @@ class TestCreateAndPushTag:
|
||||
|
||||
|
||||
class TestRunTests:
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_lint_and_tests_pass(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
run_tests() # should not raise
|
||||
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_lint_fails_raises(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="lint error")
|
||||
with pytest.raises(click.ClickException, match="Lint failed"):
|
||||
run_tests()
|
||||
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_tests_fail_raises(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.side_effect = [
|
||||
MagicMock(returncode=0, stdout="", stderr=""), # lint passes
|
||||
@@ -294,7 +294,7 @@ class TestRunTests:
|
||||
|
||||
class TestMain:
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_not_on_master_exits(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="feature-branch\n", stderr="")
|
||||
runner = CliRunner()
|
||||
@@ -303,10 +303,17 @@ class TestMain:
|
||||
assert "master" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("scripts.release.has_unreleased_changes", return_value=False)
|
||||
@patch("scripts.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("scripts.release.run_cmd")
|
||||
def test_no_unreleased_changes(self, mock_run_cmd: MagicMock, mock_bumped: MagicMock, mock_has: MagicMock) -> None:
|
||||
@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, [])
|
||||
@@ -314,15 +321,16 @@ class TestMain:
|
||||
assert "No unreleased changes" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("scripts.release.create_and_push_tag")
|
||||
@patch("scripts.release.commit_release_changes")
|
||||
@patch("scripts.release.update_changelog")
|
||||
@patch("scripts.release.update_init_version")
|
||||
@patch("scripts.release.get_changelog", return_value="")
|
||||
@patch("scripts.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("scripts.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("scripts.release.has_unreleased_changes", return_value=True)
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("scripts.ci.release.create_and_push_tag")
|
||||
@patch("scripts.ci.release.commit_release_changes")
|
||||
@patch("scripts.ci.release.update_changelog")
|
||||
@patch("scripts.ci.release.update_init_version")
|
||||
@patch("scripts.ci.release.get_changelog", return_value="")
|
||||
@patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("scripts.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("scripts.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_dry_run_empty_changelog(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
@@ -334,6 +342,7 @@ class TestMain:
|
||||
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()
|
||||
@@ -342,15 +351,16 @@ class TestMain:
|
||||
assert "empty changelog" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("scripts.release.create_and_push_tag")
|
||||
@patch("scripts.release.commit_release_changes")
|
||||
@patch("scripts.release.update_changelog")
|
||||
@patch("scripts.release.update_init_version")
|
||||
@patch("scripts.release.get_changelog", return_value="changelog")
|
||||
@patch("scripts.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("scripts.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("scripts.release.has_unreleased_changes", return_value=True)
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("scripts.ci.release.create_and_push_tag")
|
||||
@patch("scripts.ci.release.commit_release_changes")
|
||||
@patch("scripts.ci.release.update_changelog")
|
||||
@patch("scripts.ci.release.update_init_version")
|
||||
@patch("scripts.ci.release.get_changelog", return_value="changelog")
|
||||
@patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("scripts.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("scripts.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_dry_run(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
@@ -362,6 +372,7 @@ class TestMain:
|
||||
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()
|
||||
@@ -374,16 +385,35 @@ class TestMain:
|
||||
mock_tag.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("scripts.release.run_tests")
|
||||
@patch("scripts.release.create_and_push_tag", return_value=True)
|
||||
@patch("scripts.release.commit_release_changes", return_value=True)
|
||||
@patch("scripts.release.update_changelog")
|
||||
@patch("scripts.release.update_init_version")
|
||||
@patch("scripts.release.get_changelog", return_value="changelog")
|
||||
@patch("scripts.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("scripts.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("scripts.release.has_unreleased_changes", return_value=True)
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.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,
|
||||
@@ -396,6 +426,7 @@ class TestMain:
|
||||
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()
|
||||
@@ -409,16 +440,17 @@ class TestMain:
|
||||
mock_tag.assert_called_once_with("0.2.0", "changelog", False)
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("scripts.release.run_tests")
|
||||
@patch("scripts.release.create_and_push_tag", return_value=False)
|
||||
@patch("scripts.release.commit_release_changes", return_value=False)
|
||||
@patch("scripts.release.update_changelog")
|
||||
@patch("scripts.release.update_init_version")
|
||||
@patch("scripts.release.get_changelog", return_value="changelog")
|
||||
@patch("scripts.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("scripts.release.get_bumped_version", return_value="0.1.0")
|
||||
@patch("scripts.release.has_unreleased_changes", return_value=True)
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("scripts.ci.release.run_tests")
|
||||
@patch("scripts.ci.release.create_and_push_tag", return_value=False)
|
||||
@patch("scripts.ci.release.commit_release_changes", return_value=False)
|
||||
@patch("scripts.ci.release.update_changelog")
|
||||
@patch("scripts.ci.release.update_init_version")
|
||||
@patch("scripts.ci.release.get_changelog", return_value="changelog")
|
||||
@patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("scripts.ci.release.get_bumped_version", return_value="0.1.0")
|
||||
@patch("scripts.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_full_flow_tag_exists(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
@@ -431,6 +463,7 @@ class TestMain:
|
||||
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="")
|
||||
@@ -441,15 +474,16 @@ class TestMain:
|
||||
mock_tag.assert_called_once_with("0.1.0", "changelog", False)
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("scripts.release.create_and_push_tag", return_value=True)
|
||||
@patch("scripts.release.commit_release_changes", return_value=True)
|
||||
@patch("scripts.release.update_changelog")
|
||||
@patch("scripts.release.update_init_version")
|
||||
@patch("scripts.release.get_changelog", return_value="changelog")
|
||||
@patch("scripts.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("scripts.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("scripts.release.has_unreleased_changes", return_value=True)
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("scripts.ci.release.create_and_push_tag", return_value=True)
|
||||
@patch("scripts.ci.release.commit_release_changes", return_value=True)
|
||||
@patch("scripts.ci.release.update_changelog")
|
||||
@patch("scripts.ci.release.update_init_version")
|
||||
@patch("scripts.ci.release.get_changelog", return_value="changelog")
|
||||
@patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("scripts.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("scripts.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_full_flow_skip_tests(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
@@ -461,6 +495,7 @@ class TestMain:
|
||||
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="")
|
||||
@@ -473,15 +508,16 @@ class TestMain:
|
||||
assert make_calls == []
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("scripts.release.create_and_push_tag")
|
||||
@patch("scripts.release.commit_release_changes")
|
||||
@patch("scripts.release.update_changelog")
|
||||
@patch("scripts.release.update_init_version")
|
||||
@patch("scripts.release.get_changelog", return_value="changelog")
|
||||
@patch("scripts.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("scripts.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("scripts.release.has_unreleased_changes", return_value=True)
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("scripts.ci.release.create_and_push_tag")
|
||||
@patch("scripts.ci.release.commit_release_changes")
|
||||
@patch("scripts.ci.release.update_changelog")
|
||||
@patch("scripts.ci.release.update_init_version")
|
||||
@patch("scripts.ci.release.get_changelog", return_value="changelog")
|
||||
@patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("scripts.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("scripts.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_tests_fail_aborts_before_tag(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
@@ -493,6 +529,7 @@ class TestMain:
|
||||
mock_update_changelog: MagicMock,
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
) -> None:
|
||||
"""If tests fail, release aborts — no commit, no tag."""
|
||||
# First call: git rev-parse (master), then make lint-ruff (success),
|
||||
@@ -510,15 +547,16 @@ class TestMain:
|
||||
mock_tag.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("scripts.release.create_and_push_tag")
|
||||
@patch("scripts.release.commit_release_changes")
|
||||
@patch("scripts.release.update_changelog")
|
||||
@patch("scripts.release.update_init_version")
|
||||
@patch("scripts.release.get_changelog", return_value="changelog")
|
||||
@patch("scripts.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("scripts.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("scripts.release.has_unreleased_changes", return_value=True)
|
||||
@patch("scripts.release.run_cmd")
|
||||
@patch("scripts.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("scripts.ci.release.create_and_push_tag")
|
||||
@patch("scripts.ci.release.commit_release_changes")
|
||||
@patch("scripts.ci.release.update_changelog")
|
||||
@patch("scripts.ci.release.update_init_version")
|
||||
@patch("scripts.ci.release.get_changelog", return_value="changelog")
|
||||
@patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("scripts.ci.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("scripts.ci.release.has_unreleased_changes", return_value=True)
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_lint_fail_aborts_before_tag(
|
||||
self,
|
||||
mock_run_cmd: MagicMock,
|
||||
@@ -530,6 +568,7 @@ class TestMain:
|
||||
mock_update_changelog: MagicMock,
|
||||
mock_commit: MagicMock,
|
||||
mock_tag: MagicMock,
|
||||
mock_user: MagicMock,
|
||||
) -> None:
|
||||
"""If lint fails, release aborts — no commit, no tag."""
|
||||
mock_run_cmd.side_effect = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for scripts/review_pr.py."""
|
||||
"""Unit tests for scripts/ci/review_pr.py."""
|
||||
|
||||
import http
|
||||
import json
|
||||
@@ -9,7 +9,7 @@ import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from scripts.review_pr import main, parse_comments
|
||||
from scripts.ci.review_pr import main, parse_comments
|
||||
|
||||
|
||||
class TestParseComments:
|
||||
@@ -21,7 +21,7 @@ class TestParseComments:
|
||||
|
||||
def test_parse_from_stdin(self) -> None:
|
||||
comments = [{"path": "a.py", "body": "fix", "new_position": 1}]
|
||||
with patch("scripts.review_pr.sys.stdin") as mock_stdin:
|
||||
with patch("scripts.ci.review_pr.sys.stdin") as mock_stdin:
|
||||
mock_stdin.read.return_value = json.dumps(comments)
|
||||
assert parse_comments(None, True) == comments
|
||||
|
||||
@@ -41,26 +41,26 @@ class TestParseComments:
|
||||
parse_comments(str(f), False)
|
||||
|
||||
def test_stdin_non_list_raises(self) -> None:
|
||||
with patch("scripts.review_pr.sys.stdin") as mock_stdin:
|
||||
with patch("scripts.ci.review_pr.sys.stdin") as mock_stdin:
|
||||
mock_stdin.read.return_value = json.dumps({"path": "a.py"})
|
||||
with pytest.raises(click.ClickException):
|
||||
parse_comments(None, True)
|
||||
|
||||
def test_stdin_invalid_json_raises(self) -> None:
|
||||
with patch("scripts.review_pr.sys.stdin") as mock_stdin:
|
||||
with patch("scripts.ci.review_pr.sys.stdin") as mock_stdin:
|
||||
mock_stdin.read.return_value = "not json{"
|
||||
with pytest.raises(click.ClickException):
|
||||
parse_comments(None, True)
|
||||
|
||||
def test_stdin_empty_returns_empty(self) -> None:
|
||||
with patch("scripts.review_pr.sys.stdin") as mock_stdin:
|
||||
with patch("scripts.ci.review_pr.sys.stdin") as mock_stdin:
|
||||
mock_stdin.read.return_value = " "
|
||||
assert parse_comments(None, True) == []
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.review_pr.GiteaClient")
|
||||
@patch("scripts.ci.review_pr.GiteaClient")
|
||||
def test_successful_comment_review(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.create_review.return_value = {"id": 42}
|
||||
@@ -75,7 +75,7 @@ class TestMain:
|
||||
mock_client.create_review.assert_called_once_with("5", event="COMMENT", body="LGTM", comments=[])
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.review_pr.GiteaClient")
|
||||
@patch("scripts.ci.review_pr.GiteaClient")
|
||||
def test_successful_approve_review(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.create_review.return_value = {"id": 7}
|
||||
@@ -87,7 +87,7 @@ class TestMain:
|
||||
mock_client.create_review.assert_called_once_with("5", event="APPROVE", body="", comments=[])
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.review_pr.GiteaClient")
|
||||
@patch("scripts.ci.review_pr.GiteaClient")
|
||||
def test_successful_with_inline_comments(self, mock_client_cls: MagicMock, tmp_path) -> None:
|
||||
comments = [{"path": "a.py", "body": "fix", "new_position": 1}]
|
||||
f = tmp_path / "comments.json"
|
||||
@@ -104,7 +104,7 @@ class TestMain:
|
||||
mock_client.create_review.assert_called_once_with("5", event="COMMENT", body="", comments=comments)
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.review_pr.GiteaClient")
|
||||
@patch("scripts.ci.review_pr.GiteaClient")
|
||||
def test_successful_with_stdin_comments(self, mock_client_cls: MagicMock) -> None:
|
||||
comments = [{"path": "a.py", "body": "fix", "new_position": 1}]
|
||||
mock_client = MagicMock()
|
||||
@@ -127,7 +127,7 @@ class TestMain:
|
||||
assert "REPO_TOKEN" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.review_pr.GiteaClient")
|
||||
@patch("scripts.ci.review_pr.GiteaClient")
|
||||
def test_no_body_or_comments_for_comment_event(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
@@ -138,7 +138,7 @@ class TestMain:
|
||||
mock_client.create_review.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.review_pr.GiteaClient")
|
||||
@patch("scripts.ci.review_pr.GiteaClient")
|
||||
def test_no_body_or_comments_for_request_changes(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
@@ -149,7 +149,7 @@ class TestMain:
|
||||
mock_client.create_review.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.review_pr.GiteaClient")
|
||||
@patch("scripts.ci.review_pr.GiteaClient")
|
||||
def test_api_error_raises_click(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.create_review.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
|
||||
@@ -160,7 +160,7 @@ class TestMain:
|
||||
assert "HTTP" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.review_pr.GiteaClient")
|
||||
@patch("scripts.ci.review_pr.GiteaClient")
|
||||
def test_invalid_event_choice(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
+339
-42
@@ -1,5 +1,6 @@
|
||||
"""Unit tests for scripts/sync_wiki.py."""
|
||||
"""Unit tests for scripts/ci/sync_wiki.py."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -7,25 +8,58 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from scripts.sync_wiki import (
|
||||
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.sync_wiki.MAPPING_FILE", mapping_file):
|
||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE", mapping_file):
|
||||
result = load_mapping()
|
||||
assert result == {"user/getting-started.md": "Getting-Started"}
|
||||
|
||||
def test_missing_mapping_raises(self, tmp_path: Path) -> None:
|
||||
with patch("scripts.sync_wiki.MAPPING_FILE", tmp_path / "nonexistent.json"):
|
||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE", tmp_path / "nonexistent.json"):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_mapping()
|
||||
|
||||
@@ -35,12 +69,12 @@ class TestReadDocContent:
|
||||
docs_dir = tmp_path / "docs"
|
||||
docs_dir.mkdir()
|
||||
(docs_dir / "test.md").write_text("# Test\n\nContent")
|
||||
with patch("scripts.sync_wiki.DOCS_DIR", docs_dir):
|
||||
with patch("scripts.ci.sync_wiki.DOCS_DIR", docs_dir):
|
||||
content = read_doc_content("test.md")
|
||||
assert content == "# Test\n\nContent"
|
||||
|
||||
def test_missing_file_raises(self, tmp_path: Path) -> None:
|
||||
with patch("scripts.sync_wiki.DOCS_DIR", tmp_path):
|
||||
with patch("scripts.ci.sync_wiki.DOCS_DIR", tmp_path):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
read_doc_content("nonexistent.md")
|
||||
|
||||
@@ -57,11 +91,32 @@ class TestListWikiPages:
|
||||
def test_returns_page_dict(self) -> None:
|
||||
client = MagicMock()
|
||||
client._request.return_value.json.return_value = [
|
||||
{"title": "Home", "page_name": "Home"},
|
||||
{"title": "Getting-Started", "page_name": "Getting-Started"},
|
||||
{"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"}
|
||||
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:
|
||||
@@ -71,36 +126,159 @@ class TestSyncPage:
|
||||
assert result == "skipped"
|
||||
client._request.assert_not_called()
|
||||
|
||||
def test_creates_new_page(self) -> None:
|
||||
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/page"
|
||||
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(self) -> None:
|
||||
def test_updates_existing_page_with_base64(self) -> None:
|
||||
client = MagicMock()
|
||||
existing = {"Existing-Page": "Existing-Page"}
|
||||
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] == "PUT"
|
||||
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.sync_wiki.MAPPING_FILE")
|
||||
@patch("scripts.sync_wiki.DOCS_DIR")
|
||||
@patch("scripts.sync_wiki.GiteaClient")
|
||||
@patch("scripts.ci.sync_wiki.MAPPING_FILE")
|
||||
@patch("scripts.ci.sync_wiki.DOCS_DIR")
|
||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
||||
def test_dry_run(self, mock_client_cls: MagicMock, mock_docs_dir: Path, mock_mapping_file: Path) -> None:
|
||||
mock_mapping_file.exists.return_value = True
|
||||
mock_mapping_file.__str__ = lambda _: "/docs/mapping.json"
|
||||
with patch("scripts.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||
with patch("scripts.sync_wiki.read_doc_content", return_value="# Home"):
|
||||
with patch("scripts.sync_wiki.list_wiki_pages", return_value={}):
|
||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={}):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
@@ -114,24 +292,24 @@ class TestMain:
|
||||
assert "REPO_TOKEN" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "GRM_REPO_OWNER": "me", "GRM_REPO_NAME": "myrepo"}, clear=True)
|
||||
@patch("scripts.sync_wiki.GiteaClient")
|
||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
||||
def test_auto_detect_repo(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that repo is auto-detected from env vars when --repo is not passed."""
|
||||
with patch("scripts.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
with patch("scripts.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||
with patch("scripts.sync_wiki.read_doc_content", return_value="# Home"):
|
||||
with patch("scripts.sync_wiki.list_wiki_pages", return_value={}):
|
||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={}):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--dry-run"])
|
||||
assert result.exit_code == 0
|
||||
mock_client_cls.assert_called_once()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.sync_wiki.GiteaClient")
|
||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
||||
def test_missing_mapping_file(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that missing mapping.json exits with error."""
|
||||
with patch("scripts.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = False
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--repo", "owner/repo"])
|
||||
@@ -139,28 +317,28 @@ class TestMain:
|
||||
assert "mapping.json" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.sync_wiki.GiteaClient")
|
||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
||||
def test_existing_pages_message(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that existing wiki pages are reported."""
|
||||
with patch("scripts.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
with patch("scripts.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||
with patch("scripts.sync_wiki.read_doc_content", return_value="# Home"):
|
||||
with patch("scripts.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
|
||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
assert "existing wiki pages" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.sync_wiki.GiteaClient")
|
||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
||||
def test_file_not_found_warning(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that missing doc files are skipped with a warning."""
|
||||
with patch("scripts.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
with patch("scripts.sync_wiki.load_mapping", return_value={"missing.md": "Missing"}):
|
||||
with patch("scripts.sync_wiki.read_doc_content", side_effect=FileNotFoundError):
|
||||
with patch("scripts.sync_wiki.list_wiki_pages", return_value={}):
|
||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"missing.md": "Missing"}):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", side_effect=FileNotFoundError):
|
||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={}):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
@@ -168,19 +346,138 @@ class TestMain:
|
||||
assert "Skipped: 1" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.sync_wiki.GiteaClient")
|
||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
||||
def test_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.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
mapping = {"new.md": "New-Page", "existing.md": "Existing-Page"}
|
||||
with patch("scripts.sync_wiki.load_mapping", return_value=mapping):
|
||||
with patch("scripts.sync_wiki.read_doc_content", return_value="# Content"):
|
||||
with patch("scripts.sync_wiki.list_wiki_pages", return_value={"Existing-Page": "Existing-Page"}):
|
||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value=mapping):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Content"):
|
||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Existing-Page": "Existing-Page"}):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--repo", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Unit tests for scripts/validate_commit_msg.py."""
|
||||
"""Unit tests for scripts/ci/validate_commit_msg.py."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
@@ -8,7 +8,7 @@ from unittest.mock import patch
|
||||
from click.testing import CliRunner
|
||||
|
||||
from gitea_runner_manager.config import CONVENTIONAL_RE, TASK_ID_RE
|
||||
from scripts.validate_commit_msg import first_line, get_branch, main
|
||||
from scripts.ci.validate_commit_msg import first_line, get_branch, main
|
||||
|
||||
|
||||
class TestHelpers:
|
||||
@@ -72,7 +72,7 @@ class TestMain:
|
||||
|
||||
def test_rejects_task_id_on_feature_branch(self) -> None:
|
||||
msg_path = self._write_msg("GRM-19: feat: add feature")
|
||||
with patch("scripts.validate_commit_msg.get_branch", return_value="GRM-19"):
|
||||
with patch("scripts.ci.validate_commit_msg.get_branch", return_value="GRM-19"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [msg_path])
|
||||
assert result.exit_code == 1
|
||||
@@ -80,21 +80,21 @@ class TestMain:
|
||||
|
||||
def test_accepts_conventional_on_feature_branch(self) -> None:
|
||||
msg_path = self._write_msg("feat: add feature")
|
||||
with patch("scripts.validate_commit_msg.get_branch", return_value="GRM-19"):
|
||||
with patch("scripts.ci.validate_commit_msg.get_branch", return_value="GRM-19"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [msg_path])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_accepts_valid_master_commit(self) -> None:
|
||||
msg_path = self._write_msg("GRM-19: feat: add feature")
|
||||
with patch("scripts.validate_commit_msg.get_branch", return_value="master"):
|
||||
with patch("scripts.ci.validate_commit_msg.get_branch", return_value="master"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [msg_path])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_rejects_master_without_task_id(self) -> None:
|
||||
msg_path = self._write_msg("feat: add feature")
|
||||
with patch("scripts.validate_commit_msg.get_branch", return_value="master"):
|
||||
with patch("scripts.ci.validate_commit_msg.get_branch", return_value="master"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [msg_path])
|
||||
assert result.exit_code == 1
|
||||
@@ -102,7 +102,7 @@ class TestMain:
|
||||
|
||||
def test_rejects_master_with_non_conventional_after_task_id(self) -> None:
|
||||
msg_path = self._write_msg("GRM-19: random message")
|
||||
with patch("scripts.validate_commit_msg.get_branch", return_value="master"):
|
||||
with patch("scripts.ci.validate_commit_msg.get_branch", return_value="master"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [msg_path])
|
||||
assert result.exit_code == 1
|
||||
@@ -110,7 +110,7 @@ class TestMain:
|
||||
|
||||
def test_rejects_non_conventional_on_feature_branch(self) -> None:
|
||||
msg_path = self._write_msg("random message")
|
||||
with patch("scripts.validate_commit_msg.get_branch", return_value="feature"):
|
||||
with patch("scripts.ci.validate_commit_msg.get_branch", return_value="feature"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [msg_path])
|
||||
assert result.exit_code == 1
|
||||
@@ -118,7 +118,7 @@ class TestMain:
|
||||
|
||||
def test_accepts_multiline_conventional(self) -> None:
|
||||
msg_path = self._write_msg("feat: add feature\n\nBody text.\nMore text.")
|
||||
with patch("scripts.validate_commit_msg.get_branch", return_value="feature"):
|
||||
with patch("scripts.ci.validate_commit_msg.get_branch", return_value="feature"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [msg_path])
|
||||
assert result.exit_code == 0
|
||||
@@ -136,8 +136,8 @@ def test_main_module_block() -> None:
|
||||
f.write("GRM-1: feat: test")
|
||||
msg_path = f.name
|
||||
|
||||
with patch("scripts.validate_commit_msg.get_branch", return_value="master"):
|
||||
import scripts.validate_commit_msg as vcm
|
||||
with patch("scripts.ci.validate_commit_msg.get_branch", return_value="master"):
|
||||
import scripts.ci.validate_commit_msg as vcm
|
||||
|
||||
with open(vcm.__file__) as f:
|
||||
source = f.read()
|
||||
|
||||
Reference in New Issue
Block a user