Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
713e752860 | ||
|
|
e0ae01e36e | ||
|
|
5511cba1b0 | ||
|
|
56eb241b77 | ||
|
|
6b3f2a5866 | ||
|
|
6f181e85e1 | ||
|
|
539bfea516 | ||
|
|
5b05db4e6d |
@@ -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
-5
@@ -26,15 +26,41 @@ jobs:
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 scripts/check_test_speed.py --max-seconds 10
|
||||
- name: Documentation coverage check
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
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=src python3 scripts/release.py --dry-run || true
|
||||
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:
|
||||
@@ -58,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
|
||||
@@ -73,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" \
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Sync Wiki
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
jobs:
|
||||
sync-wiki:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
run: make setup
|
||||
- name: Sync documentation to wiki
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 scripts/ci/sync_wiki.py --repo "${{ github.repository }}"
|
||||
- name: Tag wiki on release
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
run: |
|
||||
echo "Release tag ${{ github.ref_name }} — wiki synced with release"
|
||||
@@ -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,9 +259,54 @@ 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
|
||||
|
||||
- `ansible-lint` may warn about `command-instead-of-module` for `systemctl --user` calls — this is expected (systemd module doesn't support user services) and skipped in `.ansible-lint`
|
||||
- Molecule Docker driver may print "Event loop is closed" warnings on interrupt — harmless
|
||||
|
||||
## Documentation-as-Code
|
||||
|
||||
All documentation lives in `/docs/` and is synced to the Gitea wiki automatically.
|
||||
|
||||
### Structure
|
||||
|
||||
```
|
||||
docs/
|
||||
├── index.md # Wiki homepage
|
||||
├── mapping.json # File-to-wiki-page title mapping
|
||||
├── user/ # User documentation
|
||||
│ ├── getting-started.md
|
||||
│ ├── installation.md
|
||||
│ ├── cli-commands.md
|
||||
│ ├── troubleshooting.md
|
||||
│ └── faq.md
|
||||
└── tech/ # Technical documentation
|
||||
├── architecture.md
|
||||
├── development-setup.md
|
||||
├── ci-cd-workflow.md
|
||||
├── testing-strategy.md
|
||||
├── decision-log.md
|
||||
└── contributing.md
|
||||
```
|
||||
|
||||
### Wiki Sync
|
||||
|
||||
- **On merge to master**: `sync-wiki.yml` workflow runs `scripts/ci/sync_wiki.py` which pushes all `/docs/` content to the Gitea wiki via API
|
||||
- **On 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/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
|
||||
|
||||
### Updating Documentation
|
||||
|
||||
1. Edit files in `/docs/`
|
||||
2. If adding a new page, add it to `docs/mapping.json`
|
||||
3. Commit and create a PR (standard PR workflow)
|
||||
4. On merge, wiki is automatically synced
|
||||
|
||||
@@ -2,6 +2,29 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [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
|
||||
|
||||
- Implement documentation-as-code with wiki sync and doc-coverage
|
||||
|
||||
## [0.2.2] - 2026-06-21
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2,437 +2,44 @@
|
||||
|
||||
A lean command-line tool to automate the installation, configuration, and lifecycle management of Gitea Actions runners on Arch Linux, Ubuntu, and Debian hosts.
|
||||
|
||||
Each runner runs in an isolated **rootless Docker** environment under a dedicated system user, enabling multiple runners to operate in parallel on the same host without conflicts. The runner binary (`gitea_runner`) is installed directly and managed as a systemd user service.
|
||||
Each runner runs in an isolated **rootless Docker** environment under a dedicated system user, enabling multiple runners to operate in parallel on the same host without conflicts.
|
||||
|
||||
> **Pronunciation note:** GRM is short for *Gitea Runner Manager*, but say it like **ГРЪМ** (roughly "GRUM" in Latin letters) — the Bulgarian word for **thunder**. Wherever there are clouds, there may be thunders. This is an open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian).
|
||||
> **Pronunciation:** GRM is short for *Gitea Runner Manager*, but say it like **ГРЪМ** (roughly "GRUM") — the Bulgarian word for **thunder**. An open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian).
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
|
||||
## Commit Convention & Branch Naming
|
||||
|
||||
This project uses **conventional commits** and **GRM-N branch prefixes**. See [AGENTS.md](AGENTS.md) for the full workflow.
|
||||
|
||||
| What | Format | Example |
|
||||
|------|--------|---------|
|
||||
| Branch name | `GRM-N-short-description` | `GRM-33-add-pr-review-step` |
|
||||
| Branch commits | `<conventional commit>` | `feat: add review script` |
|
||||
| PR title | `GRM-N: <vikunja task title>` | `GRM-33: Add mandatory PR review step` |
|
||||
| Merge commit | `GRM-N <conventional commit>` | `GRM-33 feat: add review script` |
|
||||
|
||||
### PR Workflow
|
||||
|
||||
Every change to master goes through a mandatory review workflow:
|
||||
|
||||
1. **Create Vikunja task** — get a `GRM-N` identifier
|
||||
2. **Create branch** — `GRM-N-short-description`
|
||||
3. **Implement** — write code, tests (100% coverage), update docs
|
||||
4. **Commit** — conventional commits (no `GRM-N:` prefix on branch)
|
||||
5. **Push & create PR** — title: `GRM-N: <vikunja task title>`
|
||||
6. **Review** — review the full diff focusing on: functional completeness, edge cases, technical excellence (architecture, SRP, deduplication, code smells, best practices, code quality, reusability, clean code, readability, maintainability, extensibility), performance, security, UX, documentation completeness/relevance. Post review comments via `scripts/review_pr.py`.
|
||||
7. **Address comments** — fix each comment, commit, push, re-review
|
||||
8. **Approve** — post an `APPROVE` review via `scripts/review_pr.py`
|
||||
9. **Add `ready-to-merge` label** — auto-merge workflow squash-merges with title `GRM-N <conventional commit message>`, post-merge workflow marks the Vikunja task as done, release workflow automatically versions and tags
|
||||
|
||||
### Automated Versioning & Releases
|
||||
|
||||
Versioning is fully automated using [git-cliff](https://git-cliff.org):
|
||||
|
||||
1. **After merge to master** — the release workflow runs `scripts/release.py`
|
||||
2. **git-cliff calculates the next version** from conventional commits since the last tag
|
||||
3. **Version file is updated** (`__init__.py`) and a `chore(release): prepare for vX.Y.Z` commit is created
|
||||
4. **An annotated tag `vX.Y.Z`** is pushed with the changelog as the tag message
|
||||
5. **The publish workflow triggers** on the tag — builds the package, optionally publishes to PyPI, and creates a Gitea release with generated release notes
|
||||
|
||||
| Commit type | Version bump |
|
||||
|-------------|-------------|
|
||||
| `feat:` | minor |
|
||||
| `fix:` | patch |
|
||||
| `feat!:` / `BREAKING CHANGE` | minor (pre-1.0) |
|
||||
| `chore:`, `ci:`, `docs:` | no bump |
|
||||
|
||||
`grm --version` reports the current version from `__init__.py`.
|
||||
|
||||
## Features
|
||||
|
||||
- **Simple and focused** — no unnecessary features.
|
||||
- **Secure** — no hardcoded secrets, uses scoped tokens.
|
||||
- **Idempotent** — can be run multiple times safely.
|
||||
- **Flexible** — accepts a plain IP address or hostname, and allows specifying the SSH user and private key.
|
||||
- **Runner registry** — stores runner connection metadata locally after installation. Subsequent commands need only the runner name.
|
||||
- **Lifecycle management** — start, stop, enable, disable, status, and remove runners via CLI.
|
||||
- **Multi-instance** — run multiple isolated runners on the same host, each with its own system user, rootless Docker daemon, data directory, and systemd user service.
|
||||
- **Rootless Docker** — each runner gets its own rootless Docker daemon, avoiding conflicts with the host's Docker installation and enabling true parallel execution.
|
||||
- **Systemd-managed** — runners run as systemd user services (`gitea-runner.service`) under dedicated per-runner system users.
|
||||
|
||||
## Supported Operating Systems
|
||||
|
||||
- Arch Linux
|
||||
- Ubuntu 22.04 / 24.04
|
||||
- Debian 12
|
||||
|
||||
All supported OSes are tested in CI via molecule scenarios on every PR.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **SSH key authentication** — The remote host must be reachable via SSH using the user specified with `--user` and the private key specified with `--key`. GRM uses Ansible under the hood, which connects to the target host over SSH to execute all installation and configuration tasks. Without valid SSH credentials, Ansible cannot establish a connection and the deployment will fail.
|
||||
- **Sudo access** — GRM requires root privileges on the remote host to create system users, install packages, and configure rootless Docker. By default, you will be prompted interactively for the sudo password. For automation or uninterrupted workflows, configure passwordless sudo on the remote host and pass `--no-ask-become-pass`.
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Developer Setup
|
||||
|
||||
```bash
|
||||
git clone https://git.oblachno.oblachno.com/oblachno/gitea-runner-manager.git
|
||||
cd gitea-runner-manager
|
||||
pyenv install 3.12
|
||||
pyenv local 3.12
|
||||
git clone https://git.oblachno.oblachno.fyi/oblachno-oss/grm.git
|
||||
cd grm
|
||||
make setup
|
||||
```
|
||||
|
||||
### Configure Gitea Credentials
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env:
|
||||
# GITEA_URL=https://git.example.com
|
||||
# GITEA_REGISTRATION_TOKEN=your-registration-token
|
||||
```
|
||||
|
||||
`GITEA_REGISTRATION_TOKEN` is the runner registration token obtained from your Gitea instance (Admin → Actions → Runners → Create Registration Token).
|
||||
|
||||
#### Admin API Token (optional)
|
||||
|
||||
Set `GITEA_ADMIN_TOKEN` to enable informational API checks during integration test. This is **optional** — the test primarily verifies the runner by checking:
|
||||
|
||||
1. **`.runner` registration file** exists and contains valid JSON (proves successful registration)
|
||||
2. **Systemd user service** is active (proves daemon is polling for jobs)
|
||||
|
||||
API checks, if enabled, are purely informational and do not affect pass/fail.
|
||||
|
||||
### Install a Runner
|
||||
|
||||
Using the CLI (you will be prompted for the sudo password by default):
|
||||
|
||||
```bash
|
||||
cp .env.example .env # Edit with your Gitea URL and registration token
|
||||
grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner
|
||||
```
|
||||
|
||||
> **Automation tip:** Configure passwordless sudo on the remote host and pass `--no-ask-become-pass` to skip the password prompt. This is recommended for CI/CD pipelines.
|
||||
## Documentation
|
||||
|
||||
Using Make:
|
||||
Full documentation lives on the [**GRM Wiki**](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki).
|
||||
|
||||
```bash
|
||||
make install HOST=192.168.1.10 USER=ubuntu KEY=~/.ssh/id_ed25519 NAME=prod-runner
|
||||
```
|
||||
### User Documentation
|
||||
|
||||
### Runner Registry
|
||||
- [Getting Started](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Getting-Started) — Installation, quick start, first run
|
||||
- [Installation](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Installation) — Prerequisites, setup, multiple instances
|
||||
- [CLI Commands](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/CLI-Commands) — All commands with arguments and options
|
||||
- [Troubleshooting](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Troubleshooting) — Common issues and solutions
|
||||
- [FAQ](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/FAQ) — Frequently asked questions
|
||||
|
||||
After installation, GRM stores each runner's connection details (host, user, SSH key, Gitea URL) in a local JSON registry at `~/.local/share/grm/runners.json`. This means you rarely need to repeat connection arguments:
|
||||
### Technical Documentation
|
||||
|
||||
```bash
|
||||
# List all registered runners with live systemd status
|
||||
grm list
|
||||
```
|
||||
|
||||
### Manage Runner Lifecycle
|
||||
|
||||
Once a runner is installed, lifecycle commands work by runner name only:
|
||||
|
||||
```bash
|
||||
# Start a runner
|
||||
grm start prod-runner
|
||||
|
||||
# Stop a runner
|
||||
grm stop prod-runner
|
||||
|
||||
# Enable a runner to start on boot
|
||||
grm enable prod-runner
|
||||
|
||||
# Disable a runner (stops, deregisters, and disables systemd)
|
||||
grm disable prod-runner --token <token>
|
||||
|
||||
# Check runner status
|
||||
grm status prod-runner
|
||||
|
||||
# Remove a runner completely
|
||||
grm remove prod-runner --token <token>
|
||||
```
|
||||
|
||||
You can override any stored value by passing the corresponding flag:
|
||||
|
||||
```bash
|
||||
grm start prod-runner --host 192.168.1.11 --user root
|
||||
```
|
||||
|
||||
> **Automation tip:** If the remote host has passwordless sudo configured, pass `--no-ask-become-pass`.
|
||||
|
||||
### Multiple Instances on the Same Host
|
||||
|
||||
Each runner instance is fully isolated with its own system user, rootless Docker daemon, data directory, and systemd user service:
|
||||
|
||||
```bash
|
||||
# Install two runners on the same host
|
||||
grm install 192.168.1.10 --user ubuntu --name workflow-runner
|
||||
grm install 192.168.1.10 --user ubuntu --name build-runner
|
||||
|
||||
# Manage them independently by name
|
||||
grm stop workflow-runner
|
||||
grm status build-runner
|
||||
```
|
||||
|
||||
### Verify Runner
|
||||
|
||||
The installer performs an automated integration test that verifies:
|
||||
|
||||
1. **`.runner` file exists** with valid JSON containing `id`, `uuid`, `token`, `address` — this proves successful registration with Gitea
|
||||
2. **Systemd user service is active** — this proves the daemon is polling for jobs
|
||||
|
||||
You can also check the Gitea UI under **Actions → Runners** to confirm the runner appears as **Online**.
|
||||
|
||||
Optional: If `GITEA_ADMIN_TOKEN` is set, the installer will also query the Gitea API and report whether the runner appears in the admin or repo runners list. This is purely informational.
|
||||
|
||||
### View Logs
|
||||
|
||||
**GRM application logs** (Python CLI output):
|
||||
|
||||
```bash
|
||||
# Application log file (all messages including DEBUG)
|
||||
cat ~/.local/state/grm/logs/grm.log
|
||||
|
||||
# Enable debug logging in the current session
|
||||
GRM_LOG_LEVEL=DEBUG grm install 192.168.1.10 --user ubuntu --name prod-runner
|
||||
```
|
||||
|
||||
**Runner logs** (on the remote host):
|
||||
|
||||
```bash
|
||||
# Runner logs (via systemd user service)
|
||||
sudo -u grm-<name> journalctl --user -u gitea-runner -f
|
||||
```
|
||||
|
||||
The GRM application writes to two destinations:
|
||||
|
||||
| Destination | Level | Content |
|
||||
|-------------|-------|---------|
|
||||
| Console (stdout) | `GRM_LOG_LEVEL` (default: INFO) | Colorised user-facing messages and operation reports |
|
||||
| `~/.local/state/grm/logs/grm.log` | DEBUG | All messages with timestamps and severity |
|
||||
|
||||
Set `GRM_LOG_LEVEL` to one of `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL` to control console verbosity. The log file always captures everything at DEBUG level regardless of the console setting.
|
||||
|
||||
Console output is automatically colorised via ``click.echo``: operation headers in bright cyan, completed steps in green, failures in red, and status updates in yellow.
|
||||
|
||||
## Architecture
|
||||
|
||||
GRM consists of two layers:
|
||||
|
||||
1. **Python CLI** (`src/gitea_runner_manager/`) — built with Click, handles argument parsing, environment loading, i18n translations, and delegates to Ansible via the `ansible-playbook` subprocess.
|
||||
2. **Ansible Role** (`ansible/roles/gitea-runner/`) — idempotent role that creates a dedicated system user, sets up rootless Docker, installs the runner binary, creates a systemd user service, and registers the runner with Gitea.
|
||||
|
||||
```
|
||||
grm install <host>
|
||||
└── RunnerManager.install()
|
||||
└── ansible-playbook ansible/install-runner.yml
|
||||
└── role: gitea-runner
|
||||
├── user_setup.yml (create per-runner system user + lingering)
|
||||
├── rootless_docker.yml (rootless Docker setup under runner user)
|
||||
├── install_runner.yml (download binary, config, register, service)
|
||||
├── prune.yml (Docker prune timer)
|
||||
└── integration_test.yml (validate service is active)
|
||||
```
|
||||
|
||||
Each runner runs as a systemd user service under a dedicated system user (`grm-<name>`). Each instance has fully isolated resources:
|
||||
|
||||
- **User**: `grm-<name>` (dedicated system user with lingering enabled)
|
||||
- **Home**: `/home/grm-<name>/`
|
||||
- **Data**: `/var/lib/gitea-runner/<name>/`
|
||||
- **Config**: `/etc/gitea-runner/<name>/`
|
||||
- **Service**: `gitea-runner.service` (systemd user service)
|
||||
- **Docker socket**: `/run/user/<UID>/docker.sock` (rootless, per-runner)
|
||||
|
||||
## Configuration
|
||||
|
||||
All tunable values are exposed as Ansible variables in `ansible/roles/gitea-runner/defaults/main.yml`:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `gitea_runner_version` | `1.0.8` | Runner binary version |
|
||||
| `runner_labels` | `docker,ubuntu-latest:docker://runner-images:ubuntu-22.04` | Runner labels |
|
||||
| `skip_runner_registration` | `false` | Skip API registration (useful for tests) |
|
||||
| `gitea_runner_user_prefix` | `grm-` | Prefix for per-runner system users |
|
||||
| `gitea_runner_base_home` | `/home` | Base directory for runner user homes |
|
||||
| `gitea_runner_service_user` | `{{ prefix }}{{ runner_name }}` | Per-runner system user |
|
||||
| `gitea_runner_home` | `{{ base_home }}/{{ service_user }}` | Runner user home directory |
|
||||
| `gitea_runner_base_data_dir` | `/var/lib/gitea-runner` | Base data directory (instance-scoped) |
|
||||
| `gitea_runner_base_config_dir` | `/etc/gitea-runner` | Base config directory (instance-scoped) |
|
||||
| `gitea_runner_data_dir` | `{{ base }}/{{ runner_name }}` | Runtime data directory per instance |
|
||||
| `gitea_runner_config_dir` | `{{ base }}/{{ runner_name }}` | Config directory per instance |
|
||||
| `gitea_runner_binary_path` | `/usr/local/bin/gitea_runner` | Binary install path |
|
||||
| `gitea_runner_prune_until` | `24h` | Prune resources older than this |
|
||||
| `gitea_runner_prune_schedule` | `daily` | systemd timer schedule |
|
||||
| `gitea_runner_prune_label` | `gitea-runner=true` | Docker label for pruning |
|
||||
| `gitea_runner_service_restart_sec` | `5` | systemd RestartSec value |
|
||||
| `gitea_runner_log_level` | `info` | Runner log level |
|
||||
| `gitea_runner_container_label` | `gitea-runner=true` | Container label |
|
||||
| `docker_gpg_key_path` | `/etc/apt/keyrings/docker.asc` | Docker GPG key path |
|
||||
| `GRM_LANG` | `en` | CLI language: `en`, `bg`, `de`, `ru`, `zh` |
|
||||
| `GRM_LOG_LEVEL` | `INFO` | Console verbosity: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` |
|
||||
| `GRM_GITEA_API_URL` | `https://git.oblachno.oblachno.fyi/api/v1` | Gitea API URL for CI scripts |
|
||||
| `GRM_VIKUNJA_API_URL` | `https://work.oblachno.oblachno.fyi/api/v1` | Vikunja API URL for post-merge scripts |
|
||||
| `GRM_REPO_OWNER` | `oblachno-oss` | Repository owner for CI scripts |
|
||||
| `GRM_REPO_NAME` | `grm` | Repository name for CI scripts |
|
||||
| `GRM_VIKUNJA_PROJECT_ID` | `6` | Vikunja project ID for task tracking |
|
||||
|
||||
Override any variable by passing it to the CLI with `--extra-vars` or by setting it in your Ansible inventory.
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── src/gitea_runner_manager/ # Python CLI source
|
||||
│ ├── cli.py # Click commands
|
||||
│ ├── runner_manager.py # Ansible orchestration + registry integration
|
||||
│ ├── executor.py # Ansible subprocess execution
|
||||
│ ├── registry.py # Local JSON runner registry
|
||||
│ ├── i18n.py # Translations (en, bg, de, ru, zh)
|
||||
│ └── exceptions.py # Custom exceptions
|
||||
├── ansible/
|
||||
│ ├── roles/gitea-runner/ # Main Ansible role
|
||||
│ │ ├── defaults/main.yml # Default variables
|
||||
│ │ ├── tasks/ # Task files
|
||||
│ │ ├── templates/ # Jinja2 templates
|
||||
│ │ └── molecule/ # Test scenarios
|
||||
│ ├── install-runner.yml # Install playbook
|
||||
│ ├── update-runner.yml # Update playbook
|
||||
│ ├── start-runner.yml # Start playbook
|
||||
│ ├── stop-runner.yml # Stop playbook
|
||||
│ ├── enable-runner.yml # Enable playbook
|
||||
│ ├── disable-runner.yml # Disable playbook
|
||||
│ ├── status-runner.yml # Status playbook
|
||||
│ └── remove-runner.yml # Remove playbook
|
||||
├── tests/
|
||||
│ ├── unit/ # Unit tests
|
||||
│ └── integration/ # Integration tests
|
||||
├── Makefile # Build & test automation
|
||||
└── pyproject.toml # Python project metadata
|
||||
```
|
||||
|
||||
### Setup Development Environment
|
||||
|
||||
```bash
|
||||
make setup # Creates venv, installs deps, sets up hooks
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
### Running Linters
|
||||
|
||||
```bash
|
||||
make lint # Python (ruff + pyright + bandit)
|
||||
make lint-bandit # Security scan only
|
||||
make ansible-lint # Ansible
|
||||
make makefile-lint # Makefile
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```bash
|
||||
make test-unit
|
||||
```
|
||||
|
||||
Runs pytest with 100% coverage requirement.
|
||||
|
||||
### Molecule Tests
|
||||
|
||||
```bash
|
||||
make molecule # Quick: all 6 scenarios on Ubuntu 22.04
|
||||
make molecule-all # Full: all 6 scenarios on all 4 supported OSes
|
||||
```
|
||||
|
||||
Runs six scenarios:
|
||||
|
||||
- **default** — Rootless Docker runner installation
|
||||
- **multi-instance** — Two isolated runner instances on the same host
|
||||
- **lifecycle** — Stop, disable, re-enable, and start sequence
|
||||
- **template-content** — Verify rendered systemd user service and prune templates
|
||||
- **deregister** — Runner deregistration
|
||||
- **update** — Runner binary update
|
||||
|
||||
All scenarios test idempotence (second run produces zero changes).
|
||||
|
||||
CI runs all 6 scenarios × 4 platforms (24 test pairs) distributed across 3 parallel runners.
|
||||
|
||||
### Integration Tests
|
||||
|
||||
```bash
|
||||
make test-integration
|
||||
```
|
||||
|
||||
Tests the full CLI lifecycle commands end-to-end ( mocked executor boundary).
|
||||
|
||||
### Full Test Suite
|
||||
|
||||
```bash
|
||||
make test-all # Runs unit tests + linters + molecule
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Event loop is closed" warning
|
||||
|
||||
This is a harmless cleanup traceback from Molecule's Docker driver when the test process is interrupted. It does not indicate a test failure.
|
||||
|
||||
### Runner appears offline after installation
|
||||
|
||||
- Check that the `GITEA_URL` and `GITEA_REGISTRATION_TOKEN` environment variables are correct.
|
||||
- Verify the runner service is running: `sudo -u grm-<name> systemctl --user status gitea-runner`.
|
||||
- Check logs for registration errors.
|
||||
|
||||
### Integration test fails
|
||||
|
||||
The test checks two things:
|
||||
|
||||
1. **`.runner` file missing or invalid** — Registration failed. Check:
|
||||
- `GITEA_URL` and `GITEA_REGISTRATION_TOKEN` are correct
|
||||
- Runner logs for registration errors
|
||||
- The `.runner` file should exist at `/var/lib/gitea-runner/<name>/.runner`
|
||||
|
||||
2. **Service not running** — Daemon failed to start. Check:
|
||||
- `sudo -u grm-<name> systemctl --user status gitea-runner`
|
||||
- Logs for connection errors
|
||||
|
||||
### Rootless Docker: service fails to start
|
||||
|
||||
- Check the service status: `sudo -u grm-<name> systemctl --user status gitea-runner`.
|
||||
- Verify the rootless Docker daemon is running: `sudo -u grm-<name> systemctl --user status docker`.
|
||||
- Verify the Docker socket exists: `ls /run/user/$(id -u grm-<name>)/docker.sock`.
|
||||
- Check logs: `sudo -u grm-<name> journalctl --user -u gitea-runner -f`.
|
||||
- Ensure lingering is enabled for the runner user: `loginctl show-user grm-<name> | grep Linger`.
|
||||
|
||||
## Makefile Targets
|
||||
|
||||
| Target | Description |
|
||||
|--------|-------------|
|
||||
| `setup` | Full environment setup |
|
||||
| `install` | Installs a runner on a host |
|
||||
| `update` | Updates a runner on a host |
|
||||
| `start` | Starts a runner instance |
|
||||
| `stop` | Stops a runner instance |
|
||||
| `enable` | Enables a runner to start on boot |
|
||||
| `disable` | Disables and deregisters a runner |
|
||||
| `status` | Checks runner status |
|
||||
| `remove` | Removes a runner completely |
|
||||
| `list` | Lists registered runners with live status |
|
||||
| `lint` | Runs Python linters (ruff, pyright, bandit) |
|
||||
| `lint-bandit` | Runs `bandit` security scanner |
|
||||
| `ansible-lint` | Runs `ansible-lint` |
|
||||
| `test-unit` | Runs unit tests with coverage |
|
||||
| `test-integration` | Runs integration tests |
|
||||
| `molecule` | Runs Ansible Molecule tests |
|
||||
| `test-all` | Runs all tests |
|
||||
- [Architecture](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Architecture) — High-level design, component interactions
|
||||
- [Development Setup](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Development-Setup) — Environment setup, dependencies, local testing
|
||||
- [CI/CD Workflow](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/CI-CD-Workflow) — How CI works, release process, branch protection
|
||||
- [Testing Strategy](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Testing-Strategy) — Unit, integration, and Molecule tests
|
||||
- [Decision Log](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Decision-Log) — Key technical decisions and rationale
|
||||
- [Contributing Guide](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Contributing-Guide) — Coding standards, PR workflow, commit rules
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0
|
||||
GPL-3.0
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# GRM — Gitea Runner Manager
|
||||
|
||||
A lean command-line tool to automate the installation, configuration, and lifecycle management of Gitea Actions runners on Arch Linux, Ubuntu, and Debian hosts.
|
||||
|
||||
> **Pronunciation:** GRM is short for *Gitea Runner Manager*, but say it like **ГРЪМ** (roughly "GRUM") — the Bulgarian word for **thunder**. An open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian).
|
||||
|
||||
## User Documentation
|
||||
|
||||
- [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
|
||||
- [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
|
||||
|
||||
## Quick Links
|
||||
|
||||
- [Repository](https://git.oblachno.oblachno.fyi/oblachno-oss/grm)
|
||||
- [CI/CD Pipeline](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
- [Changelog](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/CHANGELOG.md)
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"index.md": "Home",
|
||||
"user/getting-started.md": "Getting-Started",
|
||||
"user/installation.md": "Installation",
|
||||
"user/cli-commands.md": "CLI-Commands",
|
||||
"user/troubleshooting.md": "Troubleshooting",
|
||||
"user/faq.md": "FAQ",
|
||||
"tech/architecture.md": "Architecture",
|
||||
"tech/development-setup.md": "Development-Setup",
|
||||
"tech/ci-cd-workflow.md": "CI-CD-Workflow",
|
||||
"tech/testing-strategy.md": "Testing-Strategy",
|
||||
"tech/decision-log.md": "Decision-Log",
|
||||
"tech/contributing.md": "Contributing-Guide"
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
# Architecture
|
||||
|
||||
GRM consists of two layers:
|
||||
|
||||
1. **Python CLI** (`src/gitea_runner_manager/`) — built with Click, handles argument parsing, environment loading, i18n translations, and delegates to Ansible via the `ansible-playbook` subprocess.
|
||||
2. **Ansible Role** (`ansible/roles/gitea-runner/`) — idempotent role that creates a dedicated system user, sets up rootless Docker, installs the runner binary, creates a systemd user service, and registers the runner with Gitea.
|
||||
|
||||
## Component Tree
|
||||
|
||||
```
|
||||
grm install <host>
|
||||
└── RunnerManager.install()
|
||||
└── ansible-playbook ansible/install-runner.yml
|
||||
└── role: gitea-runner
|
||||
├── user_setup.yml (create per-runner system user + lingering)
|
||||
├── rootless_docker.yml (rootless Docker setup under runner user)
|
||||
├── install_runner.yml (download binary, config, register, service)
|
||||
├── prune.yml (Docker prune timer)
|
||||
└── integration_test.yml (validate service is active)
|
||||
```
|
||||
|
||||
The Ansible role task execution order (from `AGENTS.md`):
|
||||
|
||||
```
|
||||
main.yml → systemd_check → user_setup → rootless_docker → install_runner → prune → integration_test
|
||||
```
|
||||
|
||||
- `install_runner.yml` handles: download, config, validate, register, service
|
||||
- `main.yml` handles: prune, integration_test (NOT install_runner — avoids duplicates)
|
||||
- `systemctl --user` tasks must be guarded by `docker_rootless_setup`
|
||||
- Template creation tasks are NOT guarded by `docker_rootless_setup` (they just create files)
|
||||
|
||||
## Per-Runner Isolation
|
||||
|
||||
Each runner runs as a systemd user service under a dedicated system user (`grm-<name>`). Each instance has fully isolated resources:
|
||||
|
||||
- **User**: `grm-<name>` (dedicated system user with lingering enabled)
|
||||
- **Home**: `/home/grm-<name>/`
|
||||
- **Data**: `/var/lib/gitea-runner/<name>/`
|
||||
- **Config**: `/etc/gitea-runner/<name>/`
|
||||
- **Service**: `gitea-runner.service` (systemd user service)
|
||||
- **Docker socket**: `/run/user/<UID>/docker.sock` (rootless, per-runner)
|
||||
|
||||
## Component Interactions
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
CLI["Python CLI<br/>src/gitea_runner_manager/<br/>(Click)"]
|
||||
RM["RunnerManager<br/>runner_manager.py"]
|
||||
EXEC["Executor<br/>executor.py"]
|
||||
REG["Registry<br/>registry.py<br/>~/.local/share/grm/runners.json"]
|
||||
ANS["ansible-playbook subprocess"]
|
||||
ROLE["Ansible Role<br/>ansible/roles/gitea-runner/"]
|
||||
USER["user_setup.yml<br/>create system user + lingering"]
|
||||
DOCKER["rootless_docker.yml<br/>rootless Docker setup"]
|
||||
INSTALL["install_runner.yml<br/>download, config, register, service"]
|
||||
PRUNE["prune.yml<br/>Docker prune timer"]
|
||||
TEST["integration_test.yml<br/>validate service active"]
|
||||
GITEA["Gitea instance<br/>registration + API"]
|
||||
SYSTEMD["systemd user service<br/>gitea-runner.service"]
|
||||
|
||||
CLI --> RM
|
||||
RM --> REG
|
||||
RM --> EXEC
|
||||
EXEC -->|subprocess| ANS
|
||||
ANS --> ROLE
|
||||
ROLE --> USER
|
||||
ROLE --> DOCKER
|
||||
ROLE --> INSTALL
|
||||
ROLE --> PRUNE
|
||||
ROLE --> TEST
|
||||
INSTALL -->|register| GITEA
|
||||
INSTALL --> SYSTEMD
|
||||
DOCKER --> SYSTEMD
|
||||
```
|
||||
|
||||
## Additional Components
|
||||
|
||||
From `AGENTS.md`, the project also includes:
|
||||
|
||||
- **CI Scripts** (`scripts/`) — Automation for auto-merge, post-merge, release, publishing, molecule distribution, PR reviews, failure notifications
|
||||
- **Versioning** (`cliff.toml`) — git-cliff configuration for automated semver versioning from conventional commits
|
||||
|
||||
## Python Modules
|
||||
|
||||
The Python CLI layer (`src/gitea_runner_manager/`) consists of the following modules:
|
||||
|
||||
| Module | Description |
|
||||
|--------|-------------|
|
||||
| `cli.py` | Click-based CLI entry point — defines all commands (install, update, start, stop, enable, disable, status, remove, list) |
|
||||
| `runner_manager.py` | Ansible orchestration + registry integration — delegates to executor and manages runner lifecycle |
|
||||
| `executor.py` | Ansible subprocess execution — runs `ansible-playbook` with extra-vars via temp JSON files |
|
||||
| `registry.py` | Local JSON runner registry at `~/.local/share/grm/runners.json` — stores connection metadata |
|
||||
| `i18n.py` | Internationalization translations (en, bg, de, ru, zh) |
|
||||
| `exceptions.py` | Custom exceptions (`GRMError`, `APIError`) |
|
||||
| `api_clients.py` | Gitea and Vikunja API client classes for CI automation scripts |
|
||||
| `config.py` | Configuration constants (API URLs, repo owner/name, project IDs) — overridable via environment variables |
|
||||
@@ -0,0 +1,271 @@
|
||||
# CI/CD Workflow
|
||||
|
||||
Every change to master goes through a mandatory PR workflow. No exceptions.
|
||||
|
||||
## PR Workflow
|
||||
|
||||
### 1. Create Vikunja Task
|
||||
|
||||
Create a task in Vikunja project 6 to get a `GRM-N` identifier.
|
||||
|
||||
### 2. Create Branch
|
||||
|
||||
```bash
|
||||
git checkout master && git pull
|
||||
git checkout -b GRM-N-short-description
|
||||
```
|
||||
|
||||
### 3. Implement Changes
|
||||
|
||||
- Write code following conventions
|
||||
- Write/update tests (100% coverage required)
|
||||
- Update documentation (CHANGELOG, README, AGENTS.md as needed)
|
||||
|
||||
### 4. Commit (Conventional Commits)
|
||||
|
||||
Branch commits use conventional commit format (no `GRM-N:` prefix):
|
||||
|
||||
```
|
||||
feat: add new feature
|
||||
fix: resolve bug
|
||||
docs: update README
|
||||
```
|
||||
|
||||
### 5. Push and Create PR
|
||||
|
||||
- **PR title format**: `GRM-N: <vikunja task title>` (must match the Vikunja task title exactly)
|
||||
- PR body: summary of changes, `Closes GRM-N`
|
||||
- Add `ready-to-merge` label **only after review is complete**
|
||||
|
||||
### 6. Review the PR (Mandatory — Before Adding ready-to-merge Label)
|
||||
|
||||
Review the full diff (`git diff master...HEAD`) focusing on:
|
||||
|
||||
- **Functional completeness**: Does the code do what it claims? Are all requirements met?
|
||||
- **Edge cases**: Are boundary conditions, empty inputs, error paths handled?
|
||||
- **Technical excellence**:
|
||||
- Architecture compliance and evolution
|
||||
- Single Responsibility Principle (SRP)
|
||||
- Deduplication (no copy-paste, single source of truth)
|
||||
- Code smells detection and removal
|
||||
- Best industry practices
|
||||
- Industry-grade code quality
|
||||
- Reusability
|
||||
- Clean code
|
||||
- Readability
|
||||
- Maintainability
|
||||
- Extensibility
|
||||
- **Performance**: No unnecessary allocations, O(n) vs O(n²), efficient data structures
|
||||
- **Security**: No secrets in logs/process list, input validation, no injection vectors
|
||||
- **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/ci/review_pr.py`:
|
||||
|
||||
```bash
|
||||
REPO_TOKEN=<token> python3 scripts/ci/review_pr.py <pr_number> <owner/repo> \
|
||||
--event REQUEST_CHANGES \
|
||||
--body "Review summary" \
|
||||
--comments-json comments.json
|
||||
```
|
||||
|
||||
### 7. Address Review Comments
|
||||
|
||||
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/ci/review_pr.py <pr_number> <owner/repo> \
|
||||
--event APPROVE \
|
||||
--body "All comments addressed. LGTM."
|
||||
```
|
||||
|
||||
Then add the `ready-to-merge` label. The auto-merge workflow will:
|
||||
|
||||
1. **Validate** PR title format and match against Vikunja task title
|
||||
2. **Check** that at least one APPROVE review exists
|
||||
3. Wait for all CI checks to pass
|
||||
4. Squash-merge with title: `GRM-N <conventional commit message>` (space-separated)
|
||||
5. The post-merge workflow marks the Vikunja task as done
|
||||
6. The release workflow automatically versions, tags, and publishes
|
||||
|
||||
### 9. Post-Merge Automation
|
||||
|
||||
After the squash-merge:
|
||||
|
||||
- The **post-merge workflow** (`.gitea/workflows/post-merge.yml`) triggers on push to `master` and runs `scripts/ci/post_merge.py` to mark the Vikunja task as done, extracting the task ID from the merge commit message.
|
||||
- The **release workflow** (`.gitea/workflows/release.yml`) triggers on push to `master` and automatically versions, tags, and publishes (see below).
|
||||
|
||||
## Branch Protection (Required Gitea Settings)
|
||||
|
||||
Configure the following branch protection rules for `master` in Gitea repo settings:
|
||||
|
||||
- **Require pull request**: No direct pushes to master
|
||||
- **Require approval review**: At least 1 `APPROVE` review before merge
|
||||
- **Require status checks**: CI quality + molecule tests must pass
|
||||
- **Block force pushes**: No history rewriting on master
|
||||
|
||||
The auto-merge workflow enforces the APPROVE review check programmatically as a defense-in-depth measure, but branch protection is the primary gate.
|
||||
|
||||
## CI Path Filtering
|
||||
|
||||
The CI workflow (`.gitea/workflows/ci.yml`) includes a `detect-changes` job that checks whether any files under `ansible/` or `.ansible-lint` have changed. If no Ansible files are 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.
|
||||
|
||||
The `detect-changes` job:
|
||||
|
||||
- For pull requests: compares `origin/master` against the PR head SHA
|
||||
- For pushes to master: compares `HEAD~1` against `HEAD`
|
||||
- Outputs `ansible-changed` as `true` or `false`
|
||||
|
||||
The `molecule-tests` job depends on both `quality` and `detect-changes`, and only runs if `ansible-changed == 'true'`.
|
||||
|
||||
CI triggers only on `opened` and `synchronize` PR events (not `labeled`).
|
||||
|
||||
## CI Quality Job
|
||||
|
||||
The `quality` job in `.gitea/workflows/ci.yml` runs:
|
||||
|
||||
1. `make setup` — full environment setup
|
||||
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/ci/release.py --dry-run` — release dry-run validation
|
||||
|
||||
## Automated Release Pipeline
|
||||
|
||||
After a PR is merged to master, the release pipeline runs automatically.
|
||||
|
||||
### Release Workflow (`.gitea/workflows/release.yml`)
|
||||
|
||||
- Triggers on push to `master`
|
||||
- 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/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
|
||||
- **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
|
||||
- `--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/ci/notify_failure.py`
|
||||
|
||||
### Publish Workflow (`.gitea/workflows/publish.yml`)
|
||||
|
||||
- Triggers on tag push (`v*`)
|
||||
- Installs git-cliff (version 2.13.0)
|
||||
- Installs build tools (`build`, `twine`, `requests`, `python-dotenv`, `click`)
|
||||
- Validates `PYPI_TOKEN` is set (warns if missing)
|
||||
- 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/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/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/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:
|
||||
|
||||
```toml
|
||||
commit_preprocessors = [
|
||||
# Strip GRM-N task ID prefix from merge commits so git-cliff sees conventional commits
|
||||
{ pattern = "^GRM-\\d+\\s+", replace = "" },
|
||||
]
|
||||
```
|
||||
|
||||
This ensures all merged work appears in the changelog.
|
||||
|
||||
### git-cliff Configuration Highlights (`cliff.toml`)
|
||||
|
||||
- `conventional_commits = true` — parse conventional commit format
|
||||
- `filter_unconventional = true` — skip non-conventional commits
|
||||
- `render_always = true` — always render the changelog
|
||||
- `trim = true` — trim whitespace
|
||||
- Commit parsers group commits into: Features, Bug Fixes, Documentation, Performance, Refactor, Styling, Testing, Miscellaneous Tasks, Security, Revert, Other
|
||||
- `chore(release): prepare for`, `chore(deps.*)`, `chore(pr)`, `chore(pull)` commits are skipped
|
||||
- `sort_commits = "oldest"` — oldest commits first
|
||||
|
||||
## Version Bumping Rules (git-cliff)
|
||||
|
||||
| Commit type | Version bump |
|
||||
|-------------|-------------|
|
||||
| `feat:` | minor (0.X.0) |
|
||||
| `fix:` | patch (0.0.X) |
|
||||
| `feat!:` or `BREAKING CHANGE` | minor (pre-1.0: major would be 1.0.0) |
|
||||
| `chore:`, `ci:`, `docs:` | no bump (excluded by cliff.toml) |
|
||||
|
||||
From `cliff.toml` `[bump]` section:
|
||||
|
||||
- `features_always_bump_minor = true`
|
||||
- `breaking_always_bump_major = false`
|
||||
- `initial_tag = "0.1.0"`
|
||||
|
||||
The version source is `__version__` in `src/gitea_runner_manager/__init__.py`, read by setuptools via `dynamic = ["version"]` in `pyproject.toml`. The release script only updates `__init__.py` — no need to touch `pyproject.toml`. `grm --version` reports this version.
|
||||
|
||||
## Title Format Summary
|
||||
|
||||
| What | Format | Example |
|
||||
|------|--------|---------|
|
||||
| Branch name | `GRM-N-short-description` | `GRM-33-add-pr-review-step` |
|
||||
| Branch commits | `<conventional commit>` | `feat: add review script` |
|
||||
| PR title | `GRM-N: <vikunja task title>` | `GRM-33: Add mandatory PR review step` |
|
||||
| Merge commit | `GRM-N <conventional commit>` | `GRM-33 feat: add review script` |
|
||||
@@ -0,0 +1,100 @@
|
||||
# Contributing Guide
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- Python 3.12+ required (ruff/pyright target `py312`)
|
||||
- 100% test coverage required (`--cov-fail-under=100`)
|
||||
- Conventional commits on feature branches (no `GRM-N:` prefix)
|
||||
- Branch names must include `GRM-N` task ID
|
||||
- Line length: 120 chars
|
||||
- Secrets are passed via temp JSON files, never on the command line (CWE-214)
|
||||
- CI triggers only on `opened` and `synchronize` PR events (not `labeled`)
|
||||
|
||||
## Code Style Rules
|
||||
|
||||
- **Python version**: 3.12+ (ruff and pyright target `py312`)
|
||||
- **Line length**: 120 characters
|
||||
- **Test coverage**: 100% required (`--cov-fail-under=100`)
|
||||
- **Secrets handling**: Secrets are passed via temp JSON files with `0600` permissions, never on the command line (CWE-214). Extra-vars are written to a temporary JSON file and passed via `--extra-vars @tempfile`, which is deleted after execution. This prevents secrets from being visible in the process list (`ps aux`).
|
||||
- **Linting**: `make lint-all` runs ruff + pyright + bandit + ansible-lint + checkmake
|
||||
|
||||
## Commit Rules
|
||||
|
||||
Branch commits use conventional commit format (no `GRM-N:` prefix):
|
||||
|
||||
```
|
||||
feat: add new feature
|
||||
fix: resolve bug
|
||||
docs: update README
|
||||
```
|
||||
|
||||
### Version Bumping Rules
|
||||
|
||||
| Commit type | Version bump |
|
||||
|-------------|-------------|
|
||||
| `feat:` | minor (0.X.0) |
|
||||
| `fix:` | patch (0.0.X) |
|
||||
| `feat!:` or `BREAKING CHANGE` | minor (pre-1.0: major would be 1.0.0) |
|
||||
| `chore:`, `ci:`, `docs:` | no bump (excluded by cliff.toml) |
|
||||
|
||||
## Branch Naming
|
||||
|
||||
| What | Format | Example |
|
||||
|------|--------|---------|
|
||||
| Branch name | `GRM-N-short-description` | `GRM-33-add-pr-review-step` |
|
||||
| Branch commits | `<conventional commit>` | `feat: add review script` |
|
||||
| PR title | `GRM-N: <vikunja task title>` | `GRM-33: Add mandatory PR review step` |
|
||||
| Merge commit | `GRM-N <conventional commit>` | `GRM-33 feat: add review script` |
|
||||
|
||||
## PR Workflow Summary
|
||||
|
||||
Every change to master goes through this workflow. No exceptions.
|
||||
|
||||
1. **Create Vikunja task** — get a `GRM-N` identifier (Vikunja project 6)
|
||||
2. **Create branch** — `GRM-N-short-description`
|
||||
3. **Implement** — write code, tests (100% coverage), update docs
|
||||
4. **Commit** — conventional commits (no `GRM-N:` prefix on branch)
|
||||
5. **Push & create PR** — title: `GRM-N: <vikunja task title>`, body: summary + `Closes GRM-N`
|
||||
6. **Review** — review the full diff focusing on: functional completeness, edge cases, technical excellence (architecture, SRP, deduplication, code smells, best practices, code quality, reusability, clean code, readability, maintainability, extensibility), performance, security, UX, documentation completeness/relevance. Post review comments via `scripts/review_pr.py`.
|
||||
7. **Address comments** — fix each comment, commit, push, re-review
|
||||
8. **Approve** — post an `APPROVE` review via `scripts/review_pr.py`
|
||||
9. **Add `ready-to-merge` label** — auto-merge workflow squash-merges with title `GRM-N <conventional commit message>`, post-merge workflow marks the Vikunja task as done, release workflow automatically versions and tags
|
||||
|
||||
### Branch Protection (Required Gitea Settings)
|
||||
|
||||
Configure the following branch protection rules for `master` in Gitea repo settings:
|
||||
|
||||
- **Require pull request**: No direct pushes to master
|
||||
- **Require approval review**: At least 1 `APPROVE` review before merge
|
||||
- **Require status checks**: CI quality + molecule tests must pass
|
||||
- **Block force pushes**: No history rewriting on master
|
||||
|
||||
The auto-merge workflow enforces the APPROVE review check programmatically as a defense-in-depth measure, but branch protection is the primary gate.
|
||||
|
||||
## Build & Test Commands
|
||||
|
||||
```bash
|
||||
make setup # Create venv, install deps, set up hooks
|
||||
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake
|
||||
make pytest-cov # Unit tests with 100% coverage enforcement
|
||||
make test-unit # Unit tests without coverage
|
||||
make molecule # All 6 scenarios on Ubuntu 22.04
|
||||
make molecule-all # All 6 scenarios on all 4 supported OSes
|
||||
make test-all # pytest-cov + molecule
|
||||
```
|
||||
|
||||
## Ansible Role Conventions
|
||||
|
||||
```
|
||||
main.yml → systemd_check → user_setup → rootless_docker → install_runner → prune → integration_test
|
||||
```
|
||||
|
||||
- `install_runner.yml` handles: download, config, validate, register, service
|
||||
- `main.yml` handles: prune, integration_test (NOT install_runner — avoids duplicates)
|
||||
- `systemctl --user` tasks must be guarded by `docker_rootless_setup`
|
||||
- Template creation tasks are NOT guarded by `docker_rootless_setup` (they just create files)
|
||||
|
||||
## Known Issues
|
||||
|
||||
- `ansible-lint` may warn about `command-instead-of-module` for `systemctl --user` calls — this is expected (systemd module doesn't support user services) and skipped in `.ansible-lint`
|
||||
- Molecule Docker driver may print "Event loop is closed" warnings on interrupt — harmless
|
||||
@@ -0,0 +1,75 @@
|
||||
# Decision Log
|
||||
|
||||
Key technical decisions for the GRM project, extracted from `CHANGELOG.md` and `AGENTS.md`.
|
||||
|
||||
---
|
||||
|
||||
## ADR-001: Dynamic Versioning via `__init__.py`
|
||||
|
||||
**Date:** 2026-06-21 (v0.2.0 unreleased)
|
||||
|
||||
**Decision:** Use `dynamic = ["version"]` in `pyproject.toml` with setuptools `attr` to source the version from `__version__` in `src/gitea_runner_manager/__init__.py`.
|
||||
|
||||
**Rationale:** `__init__.py` is the single source of truth for the version. The release script (`scripts/release.py`) only updates `__init__.py` — there is no need to touch `pyproject.toml`. `grm --version` reports this version directly. This eliminates version duplication across files and ensures the runtime version always matches the tagged release.
|
||||
|
||||
**Source:** `CHANGELOG.md` (Unreleased — Added), `AGENTS.md` (Version Bumping Rules)
|
||||
|
||||
---
|
||||
|
||||
## ADR-002: Rootless Docker per Runner
|
||||
|
||||
**Date:** Project inception (documented in README Architecture)
|
||||
|
||||
**Decision:** Each runner instance runs in an isolated rootless Docker environment under a dedicated system user (`grm-<name>`), with its own Docker socket at `/run/user/<UID>/docker.sock`.
|
||||
|
||||
**Rationale:** Rootless Docker per-runner avoids conflicts with the host's Docker installation and enables true parallel execution of multiple runners on the same host. Each instance has fully isolated resources: user, home, data directory, config directory, systemd user service, and Docker socket. This is a core feature of GRM — enabling multiple isolated runners on the same host.
|
||||
|
||||
**Source:** `README.md` (Architecture, Features), `AGENTS.md` (Architecture)
|
||||
|
||||
---
|
||||
|
||||
## ADR-003: Conventional Commits + git-cliff for Automated Versioning
|
||||
|
||||
**Date:** 2026-06-21 (v0.2.0 unreleased)
|
||||
|
||||
**Decision:** Use conventional commits on feature branches and git-cliff (`cliff.toml`) to calculate the next semver version from commit history, generate the changelog, and automate releases.
|
||||
|
||||
**Rationale:** `scripts/release.py` uses git-cliff to calculate the next version from conventional commits since the last tag. Merge commits on master have the format `GRM-N <conventional commit>`, so `cliff.toml` includes a `commit_preprocessors` entry that strips the `GRM-N ` prefix before parsing. Version bumping rules: `feat:` → minor, `fix:` → patch, `feat!:`/`BREAKING CHANGE` → minor (pre-1.0), `chore:`/`ci:`/`docs:` → no bump. This fully automates versioning and changelog generation.
|
||||
|
||||
**Source:** `CHANGELOG.md` (Unreleased — Added), `AGENTS.md` (Automated Release Pipeline, git-cliff Commit Preprocessing, Version Bumping Rules), `cliff.toml`
|
||||
|
||||
---
|
||||
|
||||
## ADR-004: Enforce Tests Pass Before Tagging a Release
|
||||
|
||||
**Date:** 2026-06-21 (v0.2.2)
|
||||
|
||||
**Decision:** The release workflow runs `make lint-ruff` and `make pytest-cov` before creating a release commit or tag. If lint or tests fail, the release aborts immediately — no commit, no tag.
|
||||
|
||||
**Rationale:** This ensures every tagged release is healthy. A `--skip-tests` flag exists for emergency use only but is not recommended. This decision was made as a bug fix after identifying that releases could be tagged without verifying test health. Loops are prevented by `has_unreleased_changes` — after a release commit is tagged, the next run finds no unreleased changes and exits.
|
||||
|
||||
**Source:** `CHANGELOG.md` (0.2.2 — Bug Fixes: "Enforce tests pass before tagging a release"), `AGENTS.md` (Automated Release Pipeline)
|
||||
|
||||
---
|
||||
|
||||
## ADR-005: Branch Protection + Auto-Merge Workflow
|
||||
|
||||
**Date:** 2026-06-21 (v0.2.0 unreleased)
|
||||
|
||||
**Decision:** Require branch protection on `master` (require pull request, require approval review, require status checks, block force pushes) and use an auto-merge workflow that programmatically enforces the APPROVE review check.
|
||||
|
||||
**Rationale:** Branch protection is the primary gate — no direct pushes to master, at least 1 APPROVE review before merge, CI quality + molecule tests must pass, and no history rewriting. The auto-merge workflow (`scripts/auto_merge.py`) enforces the APPROVE review check programmatically as a defense-in-depth measure. When the `ready-to-merge` label is added, the workflow validates PR title format, checks for APPROVE review, waits for CI, and squash-merges with title `GRM-N <conventional commit message>`. The post-merge workflow then marks the Vikunja task as done.
|
||||
|
||||
**Source:** `CHANGELOG.md` (Unreleased — Added: mandatory PR review step, auto_merge.py), `AGENTS.md` (Branch Protection, PR Workflow step 8)
|
||||
|
||||
---
|
||||
|
||||
## ADR-006: Path-Based CI Filtering for Molecule Tests
|
||||
|
||||
**Date:** 2026-06-21 (v0.2.0 unreleased)
|
||||
|
||||
**Decision:** The CI workflow includes a `detect-changes` job that checks whether any files under `ansible/` or `.ansible-lint` have changed. If no Ansible files are changed, molecule tests are skipped.
|
||||
|
||||
**Rationale:** This prevents non-Ansible changes (e.g., Python scripts, workflow YAML, docs) from being blocked by molecule test infrastructure flakiness. Molecule tests are only relevant when Ansible files change. The `molecule-tests` job depends on both `quality` and `detect-changes`, and only runs if `ansible-changed == 'true'`. CI triggers only on `opened` and `synchronize` PR events (not `labeled`) to avoid redundant runs.
|
||||
|
||||
**Source:** `AGENTS.md` (CI Path Filtering), `.gitea/workflows/ci.yml` (detect-changes job)
|
||||
@@ -0,0 +1,108 @@
|
||||
# Development Setup
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── src/gitea_runner_manager/ # Python CLI source
|
||||
│ ├── cli.py # Click commands
|
||||
│ ├── runner_manager.py # Ansible orchestration + registry integration
|
||||
│ ├── executor.py # Ansible subprocess execution
|
||||
│ ├── registry.py # Local JSON runner registry
|
||||
│ ├── i18n.py # Translations (en, bg, de, ru, zh)
|
||||
│ └── exceptions.py # Custom exceptions
|
||||
├── ansible/
|
||||
│ ├── roles/gitea-runner/ # Main Ansible role
|
||||
│ │ ├── defaults/main.yml # Default variables
|
||||
│ │ ├── tasks/ # Task files
|
||||
│ │ ├── templates/ # Jinja2 templates
|
||||
│ │ └── molecule/ # Test scenarios
|
||||
│ ├── install-runner.yml # Install playbook
|
||||
│ ├── update-runner.yml # Update playbook
|
||||
│ ├── start-runner.yml # Start playbook
|
||||
│ ├── stop-runner.yml # Stop playbook
|
||||
│ ├── enable-runner.yml # Enable playbook
|
||||
│ ├── disable-runner.yml # Disable playbook
|
||||
│ ├── status-runner.yml # Status playbook
|
||||
│ └── remove-runner.yml # Remove playbook
|
||||
├── tests/
|
||||
│ ├── unit/ # Unit tests
|
||||
│ └── integration/ # Integration tests
|
||||
├── Makefile # Build & test automation
|
||||
└── pyproject.toml # Python project metadata
|
||||
```
|
||||
|
||||
## Setup Development Environment
|
||||
|
||||
```bash
|
||||
make setup # Creates venv, installs deps, sets up hooks
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
The `make setup` target (from the `Makefile`):
|
||||
|
||||
- Verifies Python 3.12+ is installed
|
||||
- Creates a virtualenv in `.venv`
|
||||
- Installs/updates `pip`, `setuptools`, and `wheel`
|
||||
- Creates `.env` from `.env.example` if not present
|
||||
- Generates shell activation scripts (`activate.sh`, `activate.fish`, `activate.zsh`)
|
||||
- Installs `checkmake` via `scripts/install_checkmake.py`
|
||||
- Runs `scripts/setup.sh` to install dependencies and hooks
|
||||
|
||||
### Developer Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://git.oblachno.oblachno.com/oblachno/gitea-runner-manager.git
|
||||
cd gitea-runner-manager
|
||||
pyenv install 3.12
|
||||
pyenv local 3.12
|
||||
make setup
|
||||
```
|
||||
|
||||
### Configure Gitea Credentials
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env:
|
||||
# GITEA_URL=https://git.example.com
|
||||
# GITEA_REGISTRATION_TOKEN=your-registration-token
|
||||
```
|
||||
|
||||
`GITEA_REGISTRATION_TOKEN` is the runner registration token obtained from your Gitea instance (Admin → Actions → Runners → Create Registration Token).
|
||||
|
||||
#### Admin API Token (optional)
|
||||
|
||||
Set `GITEA_ADMIN_TOKEN` to enable informational API checks during integration test. This is **optional** — the test primarily verifies the runner by checking:
|
||||
|
||||
1. **`.runner` registration file** exists and contains valid JSON (proves successful registration)
|
||||
2. **Systemd user service** is active (proves daemon is polling for jobs)
|
||||
|
||||
API checks, if enabled, are purely informational and do not affect pass/fail.
|
||||
|
||||
## Running Linters
|
||||
|
||||
```bash
|
||||
make lint # Python (ruff + pyright + bandit)
|
||||
make lint-bandit # Security scan only
|
||||
make ansible-lint # Ansible
|
||||
make makefile-lint # Makefile
|
||||
```
|
||||
|
||||
The full lint target (`make lint-all`) runs all of the above:
|
||||
|
||||
```bash
|
||||
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake
|
||||
```
|
||||
|
||||
Individual lint targets from the `Makefile`:
|
||||
|
||||
| Target | Description |
|
||||
|--------|-------------|
|
||||
| `lint-ruff` | `ruff check src/ tests/` |
|
||||
| `lint-format` | `ruff format --check src/ tests/` |
|
||||
| `typecheck` | `pyright` |
|
||||
| `lint-bandit` | `bandit -r src/ scripts/` |
|
||||
| `ansible-lint` | `ansible-lint ansible/` |
|
||||
| `makefile-lint` | `checkmake Makefile` |
|
||||
| `lint` | ruff + format check + pyright + bandit |
|
||||
| `lint-all` | lint + ansible-lint + makefile-lint |
|
||||
@@ -0,0 +1,87 @@
|
||||
# Testing Strategy
|
||||
|
||||
## Unit Tests
|
||||
|
||||
```bash
|
||||
make test-unit
|
||||
```
|
||||
|
||||
Runs pytest with 100% coverage requirement.
|
||||
|
||||
From the `Makefile`:
|
||||
|
||||
- `test-unit` — `pytest tests/unit/ -v --no-cov` (unit tests without coverage)
|
||||
- `pytest-cov` — `pytest tests/unit/ -v --cov=src/gitea_runner_manager --cov=scripts --cov-report=term-missing --cov-fail-under=100` (unit tests with 100% coverage enforcement)
|
||||
|
||||
The coverage requirement is `--cov-fail-under=100` — 100% test coverage is required.
|
||||
|
||||
## Molecule Tests
|
||||
|
||||
```bash
|
||||
make molecule # Quick: all 6 scenarios on Ubuntu 22.04
|
||||
make molecule-all # Full: all 6 scenarios on all 4 supported OSes
|
||||
```
|
||||
|
||||
Runs six scenarios:
|
||||
|
||||
- **default** — Rootless Docker runner installation
|
||||
- **multi-instance** — Two isolated runner instances on the same host
|
||||
- **lifecycle** — Stop, disable, re-enable, and start sequence
|
||||
- **template-content** — Verify rendered systemd user service and prune templates
|
||||
- **deregister** — Runner deregistration
|
||||
- **update** — Runner binary update
|
||||
|
||||
All scenarios test idempotence (second run produces zero changes).
|
||||
|
||||
### Platforms
|
||||
|
||||
4 platforms are tested: `ubuntu-2204`, `ubuntu-2404`, `debian-12`, `archlinux`.
|
||||
|
||||
The platform list is defined in `scripts/distribute_molecule.py` (single source of truth).
|
||||
|
||||
### CI Test Distribution
|
||||
|
||||
CI runs all 6 scenarios × 4 platforms (24 test pairs) distributed across 3 parallel runners.
|
||||
|
||||
From `.gitea/workflows/ci.yml`, the `molecule-tests` job uses a matrix of `runner-index: [0, 1, 2]` and calls `scripts/distribute_molecule.py --runner-index <index> --max-runners 3` to discover assigned test pairs, then runs `scripts/molecule_ci_guard.py` with those pairs.
|
||||
|
||||
## Integration Tests
|
||||
|
||||
```bash
|
||||
make test-integration
|
||||
```
|
||||
|
||||
Tests the full CLI lifecycle commands end-to-end (mocked executor boundary).
|
||||
|
||||
From the `Makefile`:
|
||||
|
||||
- `test-integration` — `pytest tests/integration/ -v --no-cov`
|
||||
|
||||
## Full Test Suite
|
||||
|
||||
```bash
|
||||
make test-all # Runs unit tests + linters + molecule
|
||||
```
|
||||
|
||||
From the `Makefile`:
|
||||
|
||||
- `test-all` — `pytest-cov + molecule` (unit tests with coverage + all 6 molecule scenarios on Ubuntu 22.04)
|
||||
|
||||
## Build & Test Commands Summary
|
||||
|
||||
From `AGENTS.md`:
|
||||
|
||||
```bash
|
||||
make setup # Create venv, install deps, set up hooks
|
||||
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake
|
||||
make pytest-cov # Unit tests with 100% coverage enforcement
|
||||
make test-unit # Unit tests without coverage
|
||||
make molecule # All 6 scenarios on Ubuntu 22.04
|
||||
make molecule-all # All 6 scenarios on all 4 supported OSes
|
||||
make test-all # pytest-cov + molecule
|
||||
```
|
||||
|
||||
## Known Issues
|
||||
|
||||
- `ansible-lint` may warn about `command-instead-of-module` for `systemctl --user` calls — this is expected (systemd module doesn't support user services) and skipped in `.ansible-lint`
|
||||
- Molecule Docker driver may print "Event loop is closed" warnings on interrupt — harmless
|
||||
@@ -0,0 +1,233 @@
|
||||
# CLI Commands
|
||||
|
||||
GRM provides the following CLI commands for managing Gitea Actions runners. The base command is `grm`.
|
||||
|
||||
## install
|
||||
|
||||
Install and configure a Gitea Runner on a remote host.
|
||||
|
||||
```bash
|
||||
grm install <host> [options]
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `host` | Remote host (IP address or hostname) |
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Short | Default | Description |
|
||||
|--------|-------|---------|-------------|
|
||||
| `--user` | `-u` | `GITEA_RUNNER_USER` env or current login | SSH user |
|
||||
| `--key` | `-k` | `GITEA_RUNNER_KEY` env | Path to SSH private key |
|
||||
| `--name` | `-n` | hostname | Gitea Runner name |
|
||||
| `--token` | `-t` | `GITEA_REGISTRATION_TOKEN` env | Registration token |
|
||||
| `--url` | — | `GITEA_URL` env | Gitea URL |
|
||||
| `--admin-token` | `-a` | `REPO_TOKEN` env | Gitea admin API token for integration test |
|
||||
| `--integration-retries` | `-r` | `3` (`GITEA_INTEGRATION_RETRIES` env) | Integration test API retries |
|
||||
| `--labels` | `-l` | `GITEA_RUNNER_LABELS` env | Runner labels for Gitea Actions. Example: `docker:docker://alpine:latest` |
|
||||
| `--ask-become-pass/--no-ask-become-pass` | — | `--ask-become-pass` | Prompt for sudo password (default) or skip it |
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner
|
||||
```
|
||||
|
||||
## update
|
||||
|
||||
Update the Gitea Runner binary on a remote host.
|
||||
|
||||
```bash
|
||||
grm update <host> [options]
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `host` | Remote host (IP address or hostname) |
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Short | Default | Description |
|
||||
|--------|-------|---------|-------------|
|
||||
| `--user` | `-u` | `GITEA_RUNNER_USER` env or current login | SSH user |
|
||||
| `--key` | `-k` | `GITEA_RUNNER_KEY` env | Path to SSH private key |
|
||||
| `--version` | `-v` | — | Specific Gitea Runner version |
|
||||
| `--ask-become-pass/--no-ask-become-pass` | — | `--ask-become-pass` | Prompt for sudo password (default) or skip it |
|
||||
|
||||
## start
|
||||
|
||||
Start a registered Gitea Runner.
|
||||
|
||||
```bash
|
||||
grm start <runner_name> [options]
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `runner_name` | Name of the registered runner |
|
||||
|
||||
**Options (common lifecycle options):**
|
||||
|
||||
| Option | Short | Description |
|
||||
|--------|-------|-------------|
|
||||
| `--host` | — | Override host from registry |
|
||||
| `--user` | `-u` | Override user from registry |
|
||||
| `--key` | `-k` | Override SSH key from registry |
|
||||
| `--ask-become-pass/--no-ask-become-pass` | — | Prompt for sudo password (default) or skip it |
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
grm start prod-runner
|
||||
# Override stored values:
|
||||
grm start prod-runner --host 192.168.1.11 --user root
|
||||
```
|
||||
|
||||
## stop
|
||||
|
||||
Stop a registered Gitea Runner.
|
||||
|
||||
```bash
|
||||
grm stop <runner_name> [options]
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `runner_name` | Name of the registered runner |
|
||||
|
||||
**Options (common lifecycle options):**
|
||||
|
||||
| Option | Short | Description |
|
||||
|--------|-------|-------------|
|
||||
| `--host` | — | Override host from registry |
|
||||
| `--user` | `-u` | Override user from registry |
|
||||
| `--key` | `-k` | Override SSH key from registry |
|
||||
| `--ask-become-pass/--no-ask-become-pass` | — | Prompt for sudo password (default) or skip it |
|
||||
|
||||
## enable
|
||||
|
||||
Enable a registered Gitea Runner to start on boot.
|
||||
|
||||
```bash
|
||||
grm enable <runner_name> [options]
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `runner_name` | Name of the registered runner |
|
||||
|
||||
**Options (common lifecycle options):**
|
||||
|
||||
| Option | Short | Description |
|
||||
|--------|-------|-------------|
|
||||
| `--host` | — | Override host from registry |
|
||||
| `--user` | `-u` | Override user from registry |
|
||||
| `--key` | `-k` | Override SSH key from registry |
|
||||
| `--ask-become-pass/--no-ask-become-pass` | — | Prompt for sudo password (default) or skip it |
|
||||
|
||||
## disable
|
||||
|
||||
Disable a registered Gitea Runner and deregister it.
|
||||
|
||||
```bash
|
||||
grm disable <runner_name> [options]
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `runner_name` | Name of the registered runner |
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Short | Default | Description |
|
||||
|--------|-------|---------|-------------|
|
||||
| `--host` | — | from registry | Override host from registry |
|
||||
| `--user` | `-u` | from registry | Override user from registry |
|
||||
| `--key` | `-k` | from registry | Override SSH key from registry |
|
||||
| `--token` | `-t` | `GITEA_REGISTRATION_TOKEN` env | Registration token |
|
||||
| `--url` | — | `GITEA_URL` env | Gitea URL |
|
||||
| `--ask-become-pass/--no-ask-become-pass` | — | `--ask-become-pass` | Prompt for sudo password (default) or skip it |
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
grm disable prod-runner --token <token>
|
||||
```
|
||||
|
||||
## status
|
||||
|
||||
Check the status of a registered Gitea Runner.
|
||||
|
||||
```bash
|
||||
grm status <runner_name> [options]
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `runner_name` | Name of the registered runner |
|
||||
|
||||
**Options (common lifecycle options):**
|
||||
|
||||
| Option | Short | Description |
|
||||
|--------|-------|-------------|
|
||||
| `--host` | — | Override host from registry |
|
||||
| `--user` | `-u` | Override user from registry |
|
||||
| `--key` | `-k` | Override SSH key from registry |
|
||||
| `--ask-become-pass/--no-ask-become-pass` | — | Prompt for sudo password (default) or skip it |
|
||||
|
||||
## remove
|
||||
|
||||
Remove a registered Gitea Runner completely.
|
||||
|
||||
```bash
|
||||
grm remove <runner_name> [options]
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `runner_name` | Name of the registered runner |
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Short | Default | Description |
|
||||
|--------|-------|---------|-------------|
|
||||
| `--host` | — | from registry | Override host from registry |
|
||||
| `--user` | `-u` | from registry | Override user from registry |
|
||||
| `--key` | `-k` | from registry | Override SSH key from registry |
|
||||
| `--token` | `-t` | `GITEA_REGISTRATION_TOKEN` env | Registration token |
|
||||
| `--url` | — | `GITEA_URL` env | Gitea URL |
|
||||
| `--force` | `-f` | — | Skip remote cleanup and only remove the local registry entry |
|
||||
| `--ask-become-pass/--no-ask-become-pass` | — | `--ask-become-pass` | Prompt for sudo password (default) or skip it |
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
grm remove prod-runner --token <token>
|
||||
```
|
||||
|
||||
## list
|
||||
|
||||
List all registered runners with live status.
|
||||
|
||||
```bash
|
||||
grm list
|
||||
```
|
||||
|
||||
This command takes no arguments or options. It displays a table with columns: NAME, HOST, USER, LABELS, STATUS for all runners stored in the local registry at `~/.local/share/grm/runners.json`.
|
||||
@@ -0,0 +1,25 @@
|
||||
# FAQ
|
||||
|
||||
### How do I obtain the Gitea registration token?
|
||||
|
||||
The runner registration token is obtained from your Gitea instance: **Admin → Actions → Runners → Create Registration Token**. Set it as `GITEA_REGISTRATION_TOKEN` in your `.env` file or pass it via `--token` on the command line.
|
||||
|
||||
### How do I skip the sudo password prompt for automation?
|
||||
|
||||
Configure passwordless sudo on the remote host and pass `--no-ask-become-pass` to the CLI command. This is recommended for CI/CD pipelines.
|
||||
|
||||
### Can I run multiple runners on the same host?
|
||||
|
||||
Yes. Each runner instance is fully isolated with its own system user (`grm-<name>`), rootless Docker daemon, data directory, and systemd user service. Install additional runners with different `--name` values and manage them independently by name.
|
||||
|
||||
### Why does my runner appear offline after installation?
|
||||
|
||||
Check that `GITEA_URL` and `GITEA_REGISTRATION_TOKEN` are correct, verify the runner service is running with `sudo -u grm-<name> systemctl --user status gitea-runner`, and check the logs for registration errors. You can also confirm the runner appears as **Online** in the Gitea UI under **Actions → Runners**.
|
||||
|
||||
### What does the "Event loop is closed" warning mean?
|
||||
|
||||
This is a harmless cleanup traceback from Molecule's Docker driver when the test process is interrupted. It does not indicate a test failure.
|
||||
|
||||
### Where are runner connection details stored?
|
||||
|
||||
GRM stores each runner's connection details (host, user, SSH key, Gitea URL) in a local JSON registry at `~/.local/share/grm/runners.json`. After installation, lifecycle commands work by runner name only — you can override any stored value by passing the corresponding flag.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Getting Started
|
||||
|
||||
## Developer Setup
|
||||
|
||||
```bash
|
||||
git clone https://git.oblachno.oblachno.com/oblachno/gitea-runner-manager.git
|
||||
cd gitea-runner-manager
|
||||
pyenv install 3.12
|
||||
pyenv local 3.12
|
||||
make setup
|
||||
```
|
||||
|
||||
## Configure Gitea Credentials
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env:
|
||||
# GITEA_URL=https://git.example.com
|
||||
# GITEA_REGISTRATION_TOKEN=your-registration-token
|
||||
```
|
||||
|
||||
`GITEA_REGISTRATION_TOKEN` is the runner registration token obtained from your Gitea instance (Admin → Actions → Runners → Create Registration Token).
|
||||
|
||||
### Admin API Token (optional)
|
||||
|
||||
Set `GITEA_ADMIN_TOKEN` to enable informational API checks during integration test. This is **optional** — the test primarily verifies the runner by checking:
|
||||
|
||||
1. **`.runner` registration file** exists and contains valid JSON (proves successful registration)
|
||||
2. **Systemd user service** is active (proves daemon is polling for jobs)
|
||||
|
||||
API checks, if enabled, are purely informational and do not affect pass/fail.
|
||||
|
||||
## Install a Runner
|
||||
|
||||
Using the CLI (you will be prompted for the sudo password by default):
|
||||
|
||||
```bash
|
||||
grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner
|
||||
```
|
||||
|
||||
> **Automation tip:** Configure passwordless sudo on the remote host and pass `--no-ask-become-pass` to skip the password prompt. This is recommended for CI/CD pipelines.
|
||||
|
||||
Using Make:
|
||||
|
||||
```bash
|
||||
make install HOST=192.168.1.10 USER=ubuntu KEY=~/.ssh/id_ed25519 NAME=prod-runner
|
||||
```
|
||||
|
||||
## Verify Runner
|
||||
|
||||
The installer performs an automated integration test that verifies:
|
||||
|
||||
1. **`.runner` file exists** with valid JSON containing `id`, `uuid`, `token`, `address` — this proves successful registration with Gitea
|
||||
2. **Systemd user service is active** — this proves the daemon is polling for jobs
|
||||
|
||||
You can also check the Gitea UI under **Actions → Runners** to confirm the runner appears as **Online**.
|
||||
|
||||
Optional: If `GITEA_ADMIN_TOKEN` is set, the installer will also query the Gitea API and report whether the runner appears in the admin or repo runners list. This is purely informational.
|
||||
|
||||
## View Logs
|
||||
|
||||
**GRM application logs** (Python CLI output):
|
||||
|
||||
```bash
|
||||
# Application log file (all messages including DEBUG)
|
||||
cat ~/.local/state/grm/logs/grm.log
|
||||
|
||||
# Enable debug logging in the current session
|
||||
GRM_LOG_LEVEL=DEBUG grm install 192.168.1.10 --user ubuntu --name prod-runner
|
||||
```
|
||||
|
||||
**Runner logs** (on the remote host):
|
||||
|
||||
```bash
|
||||
# Runner logs (via systemd user service)
|
||||
sudo -u grm-<name> journalctl --user -u gitea-runner -f
|
||||
```
|
||||
|
||||
The GRM application writes to two destinations:
|
||||
|
||||
| Destination | Level | Content |
|
||||
|-------------|-------|---------|
|
||||
| Console (stdout) | `GRM_LOG_LEVEL` (default: INFO) | Colorised user-facing messages and operation reports |
|
||||
| `~/.local/state/grm/logs/grm.log` | DEBUG | All messages with timestamps and severity |
|
||||
|
||||
Set `GRM_LOG_LEVEL` to one of `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL` to control console verbosity. The log file always captures everything at DEBUG level regardless of the console setting.
|
||||
|
||||
Console output is automatically colorised via ``click.echo``: operation headers in bright cyan, completed steps in green, failures in red, and status updates in yellow.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Installation
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **SSH key authentication** — The remote host must be reachable via SSH using the user specified with `--user` and the private key specified with `--key`. GRM uses Ansible under the hood, which connects to the target host over SSH to execute all installation and configuration tasks. Without valid SSH credentials, Ansible cannot establish a connection and the deployment will fail.
|
||||
- **Sudo access** — GRM requires root privileges on the remote host to create system users, install packages, and configure rootless Docker. By default, you will be prompted interactively for the sudo password. For automation or uninterrupted workflows, configure passwordless sudo on the remote host and pass `--no-ask-become-pass`.
|
||||
|
||||
## Supported Operating Systems
|
||||
|
||||
- Arch Linux
|
||||
- Ubuntu 22.04 / 24.04
|
||||
- Debian 12
|
||||
|
||||
All supported OSes are tested in CI via molecule scenarios on every PR.
|
||||
|
||||
## Quick Start Install
|
||||
|
||||
Using the CLI (you will be prompted for the sudo password by default):
|
||||
|
||||
```bash
|
||||
grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner
|
||||
```
|
||||
|
||||
> **Automation tip:** Configure passwordless sudo on the remote host and pass `--no-ask-become-pass` to skip the password prompt. This is recommended for CI/CD pipelines.
|
||||
|
||||
## Make Install
|
||||
|
||||
Using Make:
|
||||
|
||||
```bash
|
||||
make install HOST=192.168.1.10 USER=ubuntu KEY=~/.ssh/id_ed25519 NAME=prod-runner
|
||||
```
|
||||
|
||||
## Runner Registry
|
||||
|
||||
After installation, GRM stores each runner's connection details (host, user, SSH key, Gitea URL) in a local JSON registry at `~/.local/share/grm/runners.json`. This means you rarely need to repeat connection arguments:
|
||||
|
||||
```bash
|
||||
# List all registered runners with live systemd status
|
||||
grm list
|
||||
```
|
||||
|
||||
## Multiple Instances on the Same Host
|
||||
|
||||
Each runner instance is fully isolated with its own system user, rootless Docker daemon, data directory, and systemd user service:
|
||||
|
||||
```bash
|
||||
# Install two runners on the same host
|
||||
grm install 192.168.1.10 --user ubuntu --name workflow-runner
|
||||
grm install 192.168.1.10 --user ubuntu --name build-runner
|
||||
|
||||
# Manage them independently by name
|
||||
grm stop workflow-runner
|
||||
grm status build-runner
|
||||
```
|
||||
@@ -0,0 +1,50 @@
|
||||
# Troubleshooting
|
||||
|
||||
## "Event loop is closed" warning
|
||||
|
||||
This is a harmless cleanup traceback from Molecule's Docker driver when the test process is interrupted. It does not indicate a test failure.
|
||||
|
||||
## Runner appears offline after installation
|
||||
|
||||
- Check that the `GITEA_URL` and `GITEA_REGISTRATION_TOKEN` environment variables are correct.
|
||||
- Verify the runner service is running: `sudo -u grm-<name> systemctl --user status gitea-runner`.
|
||||
- Check logs for registration errors.
|
||||
|
||||
## Integration test fails
|
||||
|
||||
The test checks two things:
|
||||
|
||||
1. **`.runner` file missing or invalid** — Registration failed. Check:
|
||||
- `GITEA_URL` and `GITEA_REGISTRATION_TOKEN` are correct
|
||||
- Runner logs for registration errors
|
||||
- The `.runner` file should exist at `/var/lib/gitea-runner/<name>/.runner`
|
||||
|
||||
2. **Service not running** — Daemon failed to start. Check:
|
||||
- `sudo -u grm-<name> systemctl --user status gitea-runner`
|
||||
- Logs for connection errors
|
||||
|
||||
## Rootless Docker: service fails to start
|
||||
|
||||
- Check the service status: `sudo -u grm-<name> systemctl --user status gitea-runner`.
|
||||
- Verify the rootless Docker daemon is running: `sudo -u grm-<name> systemctl --user status docker`.
|
||||
- Verify the Docker socket exists: `ls /run/user/$(id -u grm-<name>)/docker.sock`.
|
||||
- Check logs: `sudo -u grm-<name> journalctl --user -u gitea-runner -f`.
|
||||
- Ensure lingering is enabled for the runner user: `loginctl show-user grm-<name> | grep Linger`.
|
||||
|
||||
## Common Issues Reference Table
|
||||
|
||||
| Symptom | Likely Cause | Solution |
|
||||
|---------|-------------|----------|
|
||||
| Pre-commit rejects commit message | Missing conventional format or GRM-N prefix present | Use `feat: description` format without `GRM-N:` |
|
||||
| `make molecule` fails with `runner_name is undefined` | Verify playbook missing variable | Fixed in Phase 1.1; ensure you're on latest master |
|
||||
| CI molecule job fails | Docker not available on runner host | Ensure Gitea runner host has Docker installed and running |
|
||||
| Auto-merge doesn't trigger | Label not exactly `ready-to-merge` or CI checks not all green | Verify label spelling; check CI status |
|
||||
| Vikunja task not updated after merge | VIKUNJA_TOKEN expired or task ID missing from commit | Regenerate token; verify merge commit has `GRM-N:` prefix |
|
||||
| Post-merge can't find Vikunja task | Task not in project 6 or identifier mismatch | Verify task exists in Vikunja project 6 with correct identifier |
|
||||
| `make pytest-cov` fails | Coverage below 100% | Add tests for new code paths |
|
||||
| `scripts/configure_repo.py` fails | REPO_TOKEN missing or invalid | Set token with repo admin scope and re-run |
|
||||
| `configure_repo.py` sets wrong status checks | Stale `BRANCH_PROTECTION_CONFIG` | Updated to include `(pull_request)` suffix; re-run `configure_repo.py` |
|
||||
| Token visible in `ps aux` during install | Old version passed tokens via command line | Fixed: tokens now passed via temp file with `0600` permissions |
|
||||
| `remove-runner.yml` leaves lingering enabled | Old version didn't disable lingering | Fixed: now runs `loginctl disable-linger` and removes subuid/subgid |
|
||||
| apt cache update always reports `changed` | `cache_valid_time: 0` forced update every run | Fixed: changed to `cache_valid_time: 3600` |
|
||||
| Prune/service templates created even when `docker_rootless_setup: false` | Template tasks not guarded | Fixed: template creation now guarded by `docker_rootless_setup` |
|
||||
+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()
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check documentation coverage for CLI commands and major modules.
|
||||
|
||||
Parses Click commands from the CLI source code and checks if each command
|
||||
has corresponding documentation in the wiki/docs. Reports missing
|
||||
documentation as warnings and exits with non-zero if coverage is below 100%.
|
||||
|
||||
Usage:
|
||||
python3 scripts/ci/doc_coverage.py [--docs-dir docs/] [--fail-on-missing]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from gitea_runner_manager.i18n import _
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
DOCS_DIR = REPO_ROOT / "docs"
|
||||
CLI_FILE = REPO_ROOT / "src" / "gitea_runner_manager" / "cli.py"
|
||||
|
||||
# Major modules that should be documented in tech/architecture.md
|
||||
REQUIRED_MODULES = [
|
||||
"cli.py",
|
||||
"runner_manager.py",
|
||||
"executor.py",
|
||||
"registry.py",
|
||||
"i18n.py",
|
||||
"exceptions.py",
|
||||
"api_clients.py",
|
||||
"config.py",
|
||||
]
|
||||
|
||||
# CI scripts that should be documented in tech/ci-cd-workflow.md
|
||||
REQUIRED_SCRIPTS = [
|
||||
"auto_merge.py",
|
||||
"release.py",
|
||||
"publish.py",
|
||||
"review_pr.py",
|
||||
"notify_failure.py",
|
||||
"post_merge.py",
|
||||
"classify_changes.py",
|
||||
"discover_runners.py",
|
||||
]
|
||||
|
||||
|
||||
def extract_cli_commands() -> list[str]:
|
||||
"""Extract command names from the CLI source file."""
|
||||
content = CLI_FILE.read_text()
|
||||
commands: list[str] = []
|
||||
# Find all @cli.command(...) occurrences, then the next def statement
|
||||
for match in re.finditer(r'@cli\.command\b', content):
|
||||
# Check for explicit name="..." in the decorator arguments
|
||||
decorator_end = content.find(")", match.start())
|
||||
decorator_text = content[match.start() : decorator_end + 1]
|
||||
name_match = re.search(r'name\s*=\s*"([^"]+)"', decorator_text)
|
||||
if name_match:
|
||||
commands.append(name_match.group(1))
|
||||
continue
|
||||
# Find the next def statement after this decorator
|
||||
after = content[decorator_end:]
|
||||
def_match = re.search(r'def\s+(\w+)\s*\(', after)
|
||||
if def_match:
|
||||
commands.append(def_match.group(1))
|
||||
return commands
|
||||
|
||||
|
||||
def check_command_documented(command: str, docs_content: str) -> bool:
|
||||
"""Check if a CLI command is documented in the docs content."""
|
||||
# Look for the command name as a heading or in code blocks
|
||||
patterns = [
|
||||
rf"##.*\b{re.escape(command)}\b",
|
||||
rf"`grm\s+{re.escape(command)}\b",
|
||||
rf"\bgrm\s+{re.escape(command)}\b",
|
||||
rf"###.*\b{re.escape(command)}\b",
|
||||
]
|
||||
return any(re.search(p, docs_content, re.IGNORECASE) for p in patterns)
|
||||
|
||||
|
||||
def check_module_documented(module: str, docs_content: str) -> bool:
|
||||
"""Check if a module is mentioned in the docs content."""
|
||||
return module in docs_content
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--docs-dir", default=str(DOCS_DIR), help="Path to the docs directory.")
|
||||
@click.option(
|
||||
"--fail-on-missing",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Exit with non-zero status if any documentation is missing.",
|
||||
)
|
||||
def main(docs_dir: str, fail_on_missing: bool) -> None:
|
||||
docs_path = Path(docs_dir)
|
||||
cli_commands_file = docs_path / "user" / "cli-commands.md"
|
||||
architecture_file = docs_path / "tech" / "architecture.md"
|
||||
ci_cd_file = docs_path / "tech" / "ci-cd-workflow.md"
|
||||
|
||||
missing: list[str] = []
|
||||
total = 0
|
||||
|
||||
# Check CLI commands
|
||||
click.echo(_("Checking CLI command documentation..."))
|
||||
commands = extract_cli_commands()
|
||||
total += len(commands)
|
||||
cli_docs = cli_commands_file.read_text() if cli_commands_file.exists() else ""
|
||||
for cmd in commands:
|
||||
if check_command_documented(cmd, cli_docs):
|
||||
click.echo(_(" OK: grm {cmd}", cmd=cmd))
|
||||
else:
|
||||
click.echo(_(" MISSING: grm {cmd}", cmd=cmd))
|
||||
missing.append(f"CLI command: grm {cmd}")
|
||||
|
||||
# Check modules in architecture.md
|
||||
click.echo(_("\nChecking module documentation in architecture.md..."))
|
||||
total += len(REQUIRED_MODULES)
|
||||
arch_docs = architecture_file.read_text() if architecture_file.exists() else ""
|
||||
for module in REQUIRED_MODULES:
|
||||
if check_module_documented(module, arch_docs):
|
||||
click.echo(_(" OK: {module}", module=module))
|
||||
else:
|
||||
click.echo(_(" MISSING: {module}", module=module))
|
||||
missing.append(f"Module: {module}")
|
||||
|
||||
# Check CI scripts in ci-cd-workflow.md
|
||||
click.echo(_("\nChecking CI script documentation in ci-cd-workflow.md..."))
|
||||
total += len(REQUIRED_SCRIPTS)
|
||||
ci_docs = ci_cd_file.read_text() if ci_cd_file.exists() else ""
|
||||
for script in REQUIRED_SCRIPTS:
|
||||
if check_module_documented(script, ci_docs):
|
||||
click.echo(_(" OK: {script}", script=script))
|
||||
else:
|
||||
click.echo(_(" MISSING: {script}", script=script))
|
||||
missing.append(f"CI script: {script}")
|
||||
|
||||
# Report
|
||||
covered = total - len(missing)
|
||||
percentage = (covered / total * 100) if total > 0 else 100.0
|
||||
click.echo(
|
||||
_(
|
||||
"\nDoc coverage: {covered}/{total} ({pct}%)",
|
||||
covered=covered,
|
||||
total=total,
|
||||
pct=f"{percentage:.0f}%",
|
||||
)
|
||||
)
|
||||
|
||||
if missing:
|
||||
click.echo(_("\nMissing documentation:"))
|
||||
for item in missing:
|
||||
click.echo(f" - {item}")
|
||||
|
||||
if missing and fail_on_missing:
|
||||
click.echo(_("\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce."))
|
||||
sys.exit(1)
|
||||
|
||||
if not missing:
|
||||
click.echo(_("\nAll documentation coverage checks passed!"))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -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,154 @@
|
||||
#!/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:
|
||||
- Create: POST /repos/{owner}/{repo}/wiki/new {title, content, message}
|
||||
- Update: PATCH /repos/{owner}/{repo}/wiki/page/{sub_url} {title, content, message}
|
||||
- List: GET /repos/{owner}/{repo}/wiki/pages → [{title, sub_url, ...}]
|
||||
- Delete: DELETE /repos/{owner}/{repo}/wiki/page/{sub_url}
|
||||
|
||||
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.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: 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 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 via PATCH
|
||||
sub_url = existing_pages[page_title]
|
||||
client._request(
|
||||
"PATCH",
|
||||
f"/wiki/page/{sub_url}",
|
||||
json={"title": page_title, "content": content, "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": 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.2.2"
|
||||
__version__ = "0.3.2"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Unit tests for scripts/ci/doc_coverage.py."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from scripts.ci.doc_coverage import (
|
||||
check_command_documented,
|
||||
check_module_documented,
|
||||
extract_cli_commands,
|
||||
main,
|
||||
)
|
||||
|
||||
|
||||
class TestExtractCliCommands:
|
||||
def test_extracts_commands(self) -> None:
|
||||
commands = extract_cli_commands()
|
||||
# Should find all 9 CLI commands
|
||||
assert "install" in commands
|
||||
assert "update" in commands
|
||||
assert "start" in commands
|
||||
assert "stop" in commands
|
||||
assert "enable" in commands
|
||||
assert "disable" in commands
|
||||
assert "status" in commands
|
||||
assert "remove" in commands
|
||||
assert "list" in commands
|
||||
|
||||
def test_returns_list(self) -> None:
|
||||
commands = extract_cli_commands()
|
||||
assert isinstance(commands, list)
|
||||
assert len(commands) == 9
|
||||
|
||||
|
||||
class TestCheckCommandDocumented:
|
||||
def test_finds_command_in_heading(self) -> None:
|
||||
content = "## install\n\nInstall a runner."
|
||||
assert check_command_documented("install", content) is True
|
||||
|
||||
def test_finds_command_in_code_block(self) -> None:
|
||||
content = "```bash\ngrm install 192.168.1.10\n```"
|
||||
assert check_command_documented("install", content) is True
|
||||
|
||||
def test_finds_command_with_grm_prefix(self) -> None:
|
||||
content = "Use `grm start prod-runner` to start."
|
||||
assert check_command_documented("start", content) is True
|
||||
|
||||
def test_missing_command(self) -> None:
|
||||
content = "## Other stuff\n\nNo commands here."
|
||||
assert check_command_documented("install", content) is False
|
||||
|
||||
|
||||
class TestCheckModuleDocumented:
|
||||
def test_finds_module(self) -> None:
|
||||
content = "The cli.py module handles..."
|
||||
assert check_module_documented("cli.py", content) is True
|
||||
|
||||
def test_missing_module(self) -> None:
|
||||
content = "No modules mentioned."
|
||||
assert check_module_documented("cli.py", content) is False
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_all_present(self, tmp_path: Path) -> None:
|
||||
"""When all docs exist and cover all commands/modules, exit 0."""
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").mkdir(parents=True)
|
||||
(docs / "tech").mkdir(parents=True)
|
||||
# Write cli-commands.md with all commands
|
||||
(docs / "user" / "cli-commands.md").write_text(
|
||||
"## install\n## update\n## start\n## stop\n## enable\n## disable\n## status\n## remove\n## list\n"
|
||||
)
|
||||
# Write architecture.md with all modules
|
||||
(docs / "tech" / "architecture.md").write_text(
|
||||
"cli.py runner_manager.py executor.py registry.py i18n.py exceptions.py api_clients.py config.py"
|
||||
)
|
||||
# Write ci-cd-workflow.md with all scripts
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text(
|
||||
"auto_merge.py release.py publish.py review_pr.py "
|
||||
"notify_failure.py post_merge.py classify_changes.py discover_runners.py"
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
||||
assert result.exit_code == 0
|
||||
assert "100%" in result.output
|
||||
|
||||
def test_missing_docs_fail(self, tmp_path: Path) -> None:
|
||||
"""When docs are missing and --fail-on-missing is set, exit 1."""
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").mkdir(parents=True)
|
||||
(docs / "tech").mkdir(parents=True)
|
||||
(docs / "user" / "cli-commands.md").write_text("No commands here.")
|
||||
(docs / "tech" / "architecture.md").write_text("No modules here.")
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs), "--fail-on-missing"])
|
||||
assert result.exit_code == 1
|
||||
|
||||
def test_missing_docs_warn_only(self, tmp_path: Path) -> None:
|
||||
"""Without --fail-on-missing, missing docs only warn (exit 0)."""
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").mkdir(parents=True)
|
||||
(docs / "tech").mkdir(parents=True)
|
||||
(docs / "user" / "cli-commands.md").write_text("No commands here.")
|
||||
(docs / "tech" / "architecture.md").write_text("No modules here.")
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
||||
assert result.exit_code == 0
|
||||
assert "MISSING" in result.output
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Unit tests for scripts/ci/sync_wiki.py."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from scripts.ci.sync_wiki import (
|
||||
list_wiki_pages,
|
||||
load_mapping,
|
||||
main,
|
||||
read_doc_content,
|
||||
sync_page,
|
||||
)
|
||||
|
||||
|
||||
class TestLoadMapping:
|
||||
def test_loads_mapping(self, tmp_path: Path) -> None:
|
||||
mapping_file = tmp_path / "mapping.json"
|
||||
mapping_file.write_text(json.dumps({"user/getting-started.md": "Getting-Started"}))
|
||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE", mapping_file):
|
||||
result = load_mapping()
|
||||
assert result == {"user/getting-started.md": "Getting-Started"}
|
||||
|
||||
def test_missing_mapping_raises(self, tmp_path: Path) -> None:
|
||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE", tmp_path / "nonexistent.json"):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
load_mapping()
|
||||
|
||||
|
||||
class TestReadDocContent:
|
||||
def test_reads_file(self, tmp_path: Path) -> None:
|
||||
docs_dir = tmp_path / "docs"
|
||||
docs_dir.mkdir()
|
||||
(docs_dir / "test.md").write_text("# Test\n\nContent")
|
||||
with patch("scripts.ci.sync_wiki.DOCS_DIR", docs_dir):
|
||||
content = read_doc_content("test.md")
|
||||
assert content == "# Test\n\nContent"
|
||||
|
||||
def test_missing_file_raises(self, tmp_path: Path) -> None:
|
||||
with patch("scripts.ci.sync_wiki.DOCS_DIR", tmp_path):
|
||||
with pytest.raises(FileNotFoundError):
|
||||
read_doc_content("nonexistent.md")
|
||||
|
||||
|
||||
class TestListWikiPages:
|
||||
def test_returns_empty_on_api_error(self) -> None:
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
|
||||
client = MagicMock()
|
||||
client._request.side_effect = APIError(404, "not found")
|
||||
result = list_wiki_pages(client)
|
||||
assert result == {}
|
||||
|
||||
def test_returns_page_dict(self) -> None:
|
||||
client = MagicMock()
|
||||
client._request.return_value.json.return_value = [
|
||||
{"title": "Home", "sub_url": "Home"},
|
||||
{"title": "Getting-Started", "sub_url": "Getting-Started.-"},
|
||||
]
|
||||
result = list_wiki_pages(client)
|
||||
assert result == {"Home": "Home", "Getting-Started": "Getting-Started.-"}
|
||||
|
||||
|
||||
class TestSyncPage:
|
||||
def test_dry_run_skips(self) -> None:
|
||||
client = MagicMock()
|
||||
result = sync_page(client, "Test-Page", "# Content", {}, dry_run=True)
|
||||
assert result == "skipped"
|
||||
client._request.assert_not_called()
|
||||
|
||||
def test_creates_new_page(self) -> None:
|
||||
client = MagicMock()
|
||||
result = sync_page(client, "New-Page", "# Content", {}, dry_run=False)
|
||||
assert result == "created"
|
||||
client._request.assert_called_once()
|
||||
call_args = client._request.call_args
|
||||
assert call_args.args[0] == "POST"
|
||||
assert call_args.args[1] == "/wiki/new"
|
||||
|
||||
def test_updates_existing_page(self) -> None:
|
||||
client = MagicMock()
|
||||
existing = {"Existing-Page": "Existing-Page.-"}
|
||||
result = sync_page(client, "Existing-Page", "# Updated", existing, dry_run=False)
|
||||
assert result == "updated"
|
||||
client._request.assert_called_once()
|
||||
call_args = client._request.call_args
|
||||
assert call_args.args[0] == "PATCH"
|
||||
assert "/wiki/page/Existing-Page.-" in call_args.args[1]
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.sync_wiki.MAPPING_FILE")
|
||||
@patch("scripts.ci.sync_wiki.DOCS_DIR")
|
||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
||||
def test_dry_run(self, mock_client_cls: MagicMock, mock_docs_dir: Path, mock_mapping_file: Path) -> None:
|
||||
mock_mapping_file.exists.return_value = True
|
||||
mock_mapping_file.__str__ = lambda _: "/docs/mapping.json"
|
||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={}):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
assert "dry-run" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
|
||||
def test_missing_token_exits(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--repo", "owner/repo"])
|
||||
assert result.exit_code == 1
|
||||
assert "REPO_TOKEN" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "GRM_REPO_OWNER": "me", "GRM_REPO_NAME": "myrepo"}, clear=True)
|
||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
||||
def test_auto_detect_repo(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that repo is auto-detected from env vars when --repo is not passed."""
|
||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={}):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--dry-run"])
|
||||
assert result.exit_code == 0
|
||||
mock_client_cls.assert_called_once()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
||||
def test_missing_mapping_file(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that missing mapping.json exits with error."""
|
||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = False
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--repo", "owner/repo"])
|
||||
assert result.exit_code == 1
|
||||
assert "mapping.json" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
||||
def test_existing_pages_message(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that existing wiki pages are reported."""
|
||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
assert "existing wiki pages" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
||||
def test_file_not_found_warning(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that missing doc files are skipped with a warning."""
|
||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"missing.md": "Missing"}):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", side_effect=FileNotFoundError):
|
||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={}):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
assert "not found" in result.output
|
||||
assert "Skipped: 1" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
||||
def test_create_and_update(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that pages are created and updated correctly (non-dry-run)."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
mapping = {"new.md": "New-Page", "existing.md": "Existing-Page"}
|
||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value=mapping):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Content"):
|
||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Existing-Page": "Existing-Page"}):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--repo", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
assert "Created: 1" in result.output
|
||||
assert "Updated: 1" in result.output
|
||||
@@ -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