Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1531ac81f | ||
|
|
0bf78d4f83 | ||
|
|
ad3c43bf8e | ||
|
|
28e61aa166 | ||
|
|
b8ab4f854b | ||
|
|
8bde4cd12b | ||
|
|
b6e87a519b | ||
|
|
bec7b59671 | ||
|
|
7a6d93cddc | ||
|
|
81e8c2a159 | ||
|
|
fe715f99be | ||
|
|
eedef42c13 | ||
|
|
1f75017395 | ||
|
|
8c16703106 | ||
|
|
69089f6d2c | ||
|
|
ff0e733e07 | ||
|
|
8dc014a907 | ||
|
|
599fc17dd3 | ||
|
|
5cd7c15d11 | ||
|
|
1b3c3f6ca8 | ||
|
|
7591c99c02 | ||
|
|
789890b9ea | ||
|
|
f8327a8e89 | ||
|
|
d8d025789b | ||
|
|
5b4aaffa3b | ||
|
|
b2b7383266 | ||
|
|
c8e12dc722 | ||
|
|
31edf866f3 | ||
|
|
9959b9c4ed | ||
|
|
749b4d025f | ||
|
|
28b4acf323 | ||
|
|
f5431c54cf | ||
|
|
cff8a35244 | ||
|
|
54a584d609 | ||
|
|
402e2dce7e | ||
|
|
7dfc9f6014 | ||
|
|
a0e6cd0a73 | ||
|
|
a0b03f01ef | ||
|
|
3f5808d6be | ||
|
|
60c94b2b93 | ||
|
|
2ca56ed317 | ||
|
|
fb76ac91ef | ||
|
|
0c54efbf6b | ||
|
|
945344f960 |
+6
-3
@@ -1,11 +1,14 @@
|
||||
# Gitea instance URL (used for runner registration and API validation)
|
||||
GITEA_URL=https://git.example.com
|
||||
|
||||
# Runner registration token from Gitea admin panel:
|
||||
# Admin → Actions → Runners → Create Registration Token
|
||||
# Runner registration token from Gitea.
|
||||
# Three levels are available:
|
||||
# Instance-level: Site Administration → Actions → Runners → Create Registration Token
|
||||
# Org-level: Organization → Settings → Actions → Runners → Create Registration Token
|
||||
# Repo-level: Repository → Settings → Actions → Runners → Create Registration Token
|
||||
GITEA_REGISTRATION_TOKEN=your-registration-token
|
||||
|
||||
# Gitea API token for optional post-install API checks (informational only).
|
||||
# Gitea admin API token for optional post-install API checks (informational only).
|
||||
# The integration test primarily verifies the runner by checking:
|
||||
# 1. The .runner registration file exists and is valid
|
||||
# 2. The container/service is running
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# actionlint configuration for Gitea Actions workflows
|
||||
# https://github.com/rhysd/actionlint/blob/main/docs/config.md
|
||||
#
|
||||
# Run: actionlint -config-file .gitea/actionlint.yaml .gitea/workflows/*.yml
|
||||
|
||||
# Custom self-hosted runner labels used in runs-on
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
- docker
|
||||
@@ -2,11 +2,16 @@ name: Auto-merge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
types: [labeled, unlabeled]
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
# Always run — the Python script checks for the label via API.
|
||||
# Gitea's `labeled` event payload may not populate pull_request.labels
|
||||
# correctly, so we can't rely on the YAML-level condition.
|
||||
if: github.event.label.name == 'ready-to-merge'
|
||||
runs-on: docker
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
@@ -16,10 +21,14 @@ jobs:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
run: |
|
||||
python3 scripts/ci/auto_merge.py \
|
||||
"${{ github.head_ref }}" \
|
||||
"${{ github.event.pull_request.title }}" \
|
||||
"${{ github.repository }}" \
|
||||
"${{ github.event.number }}" \
|
||||
"${{ github.event.label.name }}"
|
||||
"$HEAD_REF" \
|
||||
"$PR_TITLE" \
|
||||
"$REPOSITORY" \
|
||||
"$PR_NUMBER" \
|
||||
"ready-to-merge"
|
||||
|
||||
+63
-51
@@ -3,13 +3,12 @@ name: CI
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
@@ -17,6 +16,7 @@ jobs:
|
||||
- name: Lint all
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
make lint-all
|
||||
- name: Unit tests with 100% coverage
|
||||
run: |
|
||||
@@ -27,37 +27,41 @@ jobs:
|
||||
. .venv/bin/activate
|
||||
python3 scripts/check_test_speed.py --max-seconds 10
|
||||
- name: Documentation coverage check
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
PYTHONPATH=src python3 scripts/ci/doc_coverage.py
|
||||
python3 scripts/ci/doc_coverage.py --fail-on-missing
|
||||
- name: Dependency security scan
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
# Install pip in venv if missing (needed by pip-audit)
|
||||
.venv/bin/python -m ensurepip 2>/dev/null || true
|
||||
PIPAPI_PYTHON_LOCATION=$PWD/.venv/bin/python \
|
||||
pip-audit --desc --skip-editable 2>&1 || true
|
||||
|
||||
release-dry-run:
|
||||
needs: [quality, detect-changes]
|
||||
if: needs.detect-changes.outputs.user-facing-changed == 'true'
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
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
|
||||
env:
|
||||
PYTHONPATH: .
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
PYTHONPATH=. python3 scripts/ci/release.py --dry-run || true
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 scripts/ci/release.py --dry-run || true
|
||||
|
||||
detect-changes:
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
ansible-changed: ${{ steps.detect.outputs.ansible-changed }}
|
||||
user-facing-changed: ${{ steps.detect.outputs.user-facing-changed }}
|
||||
@@ -65,39 +69,24 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
run: make setup
|
||||
- name: Detect changed paths
|
||||
id: detect
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
BASE="origin/master"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
else
|
||||
BASE="HEAD~1"
|
||||
HEAD="HEAD"
|
||||
fi
|
||||
# Check if any Ansible-related files changed
|
||||
ANSIBLE_CHANGED=$(git diff --name-only "$BASE" "$HEAD" -- ansible/ .ansible-lint 2>/dev/null | head -1)
|
||||
if [ -n "$ANSIBLE_CHANGED" ]; then
|
||||
echo "ansible-changed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Ansible files changed — molecule tests will run."
|
||||
else
|
||||
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
|
||||
. .venv/bin/activate
|
||||
python3 scripts/ci/classify_changes.py \
|
||||
--base "origin/master" \
|
||||
--head "${{ github.event.pull_request.head.sha || github.sha }}" \
|
||||
--github-output
|
||||
|
||||
discover-runners:
|
||||
needs: [detect-changes]
|
||||
if: needs.detect-changes.outputs.ansible-changed == 'true'
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
runner-count: ${{ steps.discover.outputs.runner-count }}
|
||||
runner-indices: ${{ steps.discover.outputs.runner-indices }}
|
||||
@@ -113,35 +102,39 @@ jobs:
|
||||
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"
|
||||
python3 scripts/ci/discover_runners.py \
|
||||
--owner "${{ github.repository_owner }}" \
|
||||
--repo "${{ github.event.repository.name }}" \
|
||||
--github-output
|
||||
|
||||
molecule-tests:
|
||||
needs: [quality, detect-changes, discover-runners]
|
||||
if: needs.detect-changes.outputs.ansible-changed == 'true'
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
matrix:
|
||||
runner-index: ${{ fromJSON(needs.discover-runners.outputs.runner-indices) }}
|
||||
runner-index: [1, 2, 3]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
run: make setup
|
||||
- name: Discover assigned test pairs
|
||||
env:
|
||||
RUNNER_INDEX: ${{ matrix.runner-index }}
|
||||
MAX_RUNNERS: ${{ needs.discover-runners.outputs.runner-count }}
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
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
|
||||
python3 scripts/ci/distribute_molecule.py \
|
||||
--runner-index "$RUNNER_INDEX" \
|
||||
--max-runners "$MAX_RUNNERS" \
|
||||
--github-env --skip-if-excess
|
||||
- name: Run molecule tests
|
||||
if: env.SKIP != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. .venv/bin/activate
|
||||
if [ -z "$TEST_PAIRS" ]; then exit 0; fi
|
||||
# shellcheck disable=SC2086 # intentional word splitting for argument expansion
|
||||
python3 scripts/ci/molecule_ci_guard.py $TEST_PAIRS
|
||||
env:
|
||||
GITEA_URL: ${{ github.server_url }}
|
||||
@@ -150,3 +143,22 @@ jobs:
|
||||
JOB_NAME: ${{ github.job }}
|
||||
MATRIX_INDEX: ${{ matrix.runner-index }}
|
||||
GITEA_REPOSITORY: ${{ github.repository }}
|
||||
|
||||
pr-review:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
run: make setup
|
||||
- name: Run automated PR review
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. .venv/bin/activate
|
||||
python3 scripts/ci/pr_review.py \
|
||||
"${{ github.event.number }}" \
|
||||
"${{ github.repository }}"
|
||||
|
||||
@@ -1,12 +1,146 @@
|
||||
name: Post-merge Vikunja update
|
||||
name: Post-merge
|
||||
|
||||
# Runs on every push to master. A single workflow with conditional jobs
|
||||
# replaces the previous 4 separate workflows (release.yml, post-merge.yml,
|
||||
# sync-wiki.yml, and the badges job from ci.yml).
|
||||
#
|
||||
# Job dependency graph:
|
||||
#
|
||||
# detect-type ──┬── release (skip if release commit)
|
||||
# ├── sync-wiki (skip if release commit)
|
||||
# ├── badges (runs after release, even if it fails)
|
||||
# └── vikunja (skip if release commit)
|
||||
#
|
||||
# The badges job depends on release so it picks up the latest version
|
||||
# number. It uses `if: always()` to run even if release fails or is
|
||||
# skipped, ensuring badges always reflect the current repo state.
|
||||
#
|
||||
# When release.py creates a "release: vX.Y.Z" commit, all jobs skip
|
||||
# because it's a release commit. The tag push triggers publish.yml.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
vikunja:
|
||||
detect-type:
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
is-release: ${{ steps.check.outputs.is-release }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Check if this is a release commit
|
||||
id: check
|
||||
run: python3 scripts/ci/detect_release_commit.py
|
||||
|
||||
release:
|
||||
needs: [detect-type]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.REPO_TOKEN }}
|
||||
- name: Set up environment
|
||||
run: make setup
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.name "grm-ci-bot"
|
||||
git config user.email "grm-ci-bot@oblachno.fyi"
|
||||
- name: Run release
|
||||
env:
|
||||
PYTHONPATH: .
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 scripts/ci/release.py
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: .:src
|
||||
run: |
|
||||
python3 scripts/ci/notify_failure.py \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/release" \
|
||||
--commit "${{ github.sha }}"
|
||||
|
||||
sync-wiki:
|
||||
needs: [detect-type]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
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 }}" --strict
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: .:src
|
||||
run: |
|
||||
python3 scripts/ci/notify_failure.py \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/sync-wiki" \
|
||||
--commit "${{ github.sha }}"
|
||||
|
||||
badges:
|
||||
needs: [detect-type, release]
|
||||
if: always() && needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: master
|
||||
token: ${{ secrets.REPO_TOKEN }}
|
||||
- name: Fetch latest master
|
||||
run: |
|
||||
git fetch origin master
|
||||
git reset --hard origin/master
|
||||
- name: Set up environment
|
||||
run: make setup
|
||||
- name: Generate and push badges
|
||||
env:
|
||||
PRE_COMMIT_ALLOW_NO_CONFIG: "1"
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 scripts/ci/push_badges.py
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: .:src
|
||||
run: |
|
||||
python3 scripts/ci/notify_failure.py \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/badges" \
|
||||
--commit "${{ github.sha }}"
|
||||
|
||||
vikunja:
|
||||
needs: [detect-type]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -17,7 +151,15 @@ jobs:
|
||||
env:
|
||||
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: python3 scripts/ci/post_merge.py --git-sha "${{ github.sha }}"
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: .:src
|
||||
run: |
|
||||
python3 scripts/ci/post_merge.py \
|
||||
"$(git log -1 --pretty=%B)" \
|
||||
--commit-sha "$(git rev-parse HEAD)"
|
||||
python3 scripts/ci/notify_failure.py \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/vikunja" \
|
||||
--commit "${{ github.sha }}"
|
||||
|
||||
@@ -8,35 +8,29 @@ on:
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- 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"
|
||||
"$HOME/.local/bin/git-cliff" --version
|
||||
- name: Install CI tools
|
||||
run: python3 scripts/install_tools.py --tool git-cliff --tool tea
|
||||
- name: Install build tools
|
||||
run: python3 -m pip install --break-system-packages build twine requests python-dotenv click
|
||||
- name: Configure tea login
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
run: |
|
||||
python3 -m pip install --break-system-packages build twine requests python-dotenv click
|
||||
- name: Validate PYPI_TOKEN
|
||||
run: |
|
||||
if [ -z "${{ secrets.PYPI_TOKEN }}" ]; then
|
||||
echo "::warning::PYPI_TOKEN is not set — package will be built but not published to PyPI."
|
||||
fi
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
tea login add --name grm --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
|
||||
tea login default grm || true
|
||||
- name: Build and publish release
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
PYTHONPATH: .:src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 scripts/ci/publish.py \
|
||||
"${{ github.ref_name }}" \
|
||||
"${{ github.repository }}"
|
||||
@@ -44,8 +38,9 @@ jobs:
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
PYTHONPATH: .:src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 scripts/ci/notify_failure.py \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.REPO_TOKEN }}
|
||||
- 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"
|
||||
"$HOME/.local/bin/git-cliff" --version
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.name "grm-ci-bot"
|
||||
git config user.email "grm-ci-bot@oblachno.fyi"
|
||||
- name: Run release
|
||||
env:
|
||||
PYTHONPATH: .
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 scripts/ci/release.py
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: .
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 scripts/ci/notify_failure.py \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "release" \
|
||||
--commit "${{ github.sha }}"
|
||||
@@ -1,31 +0,0 @@
|
||||
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"
|
||||
@@ -35,3 +35,6 @@ bandit-report.*
|
||||
activate.sh
|
||||
activate.fish
|
||||
activate.zsh
|
||||
|
||||
# Generated badges (CI pushes to badges branch)
|
||||
.badges/
|
||||
|
||||
@@ -48,6 +48,15 @@ repos:
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: workflow-lint
|
||||
name: actionlint (workflow YAML)
|
||||
entry: make workflow-lint
|
||||
language: system
|
||||
files: ^\.gitea/workflows/
|
||||
types: [yaml]
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: pytest-cov
|
||||
name: pytest with 100% coverage
|
||||
entry: make pytest-cov
|
||||
|
||||
@@ -3,15 +3,42 @@
|
||||
## Build & Test Commands
|
||||
|
||||
```bash
|
||||
make setup # Create venv, install deps, set up hooks
|
||||
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake
|
||||
make setup # Create venv, install deps, set up hooks, install CI tools
|
||||
make install-tools # Install actionlint, git-cliff, act_runner to ~/.local/bin
|
||||
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake + actionlint
|
||||
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
|
||||
make workflow-lint # Static lint of .gitea/workflows/*.yml (actionlint)
|
||||
make workflow-dryrun # Dry-run all workflows in Docker (act_runner exec --dryrun)
|
||||
make workflow-check # workflow-lint + workflow-dryrun
|
||||
```
|
||||
|
||||
`make setup` automatically installs all development tools:
|
||||
- **Python deps** via `scripts/setup.py` (pip install -e .[dev], ansible-galaxy, pre-commit hooks)
|
||||
- **checkmake** via `scripts/install_checkmake.py` (Makefile linter)
|
||||
- **actionlint, git-cliff, act_runner, tea** via `scripts/install_tools.py` (CI/CD tools to ~/.local/bin)
|
||||
- **tea CLI login** via `scripts/setup.py` (configures `tea login` from `.env` `REPO_TOKEN`)
|
||||
|
||||
## Workflow Verification (Before Push)
|
||||
|
||||
Workflow YAML files (`.gitea/workflows/*.yml`) are verified with two tools:
|
||||
|
||||
1. **actionlint** — Static linter that catches syntax errors, invalid
|
||||
expressions, unknown keys, type mismatches, and shellcheck issues.
|
||||
Config: `.gitea/actionlint.yaml` (registers custom `docker` runner label).
|
||||
Installed automatically by `make setup` via `scripts/install_tools.py`.
|
||||
|
||||
2. **act_runner exec --dryrun** — Gitea's own runner in dry-run mode.
|
||||
Validates job dependencies, step ordering, and Docker image selection
|
||||
without starting containers. Installed automatically by `make setup`.
|
||||
|
||||
Both run via `make workflow-check` and are part of `make lint-all`.
|
||||
The pre-commit hook runs actionlint automatically when workflow files change.
|
||||
The CI `quality` job runs `make setup` (which installs all tools) then `make lint-all`.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Python CLI** (`src/gitea_runner_manager/`) — Click-based CLI that delegates to Ansible
|
||||
@@ -62,26 +89,32 @@ docs: update README
|
||||
- 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
|
||||
**Review checklist:** Every PR is reviewed against
|
||||
[REVIEW_CHECKLIST.md](REVIEW_CHECKLIST.md) — 10 categories covering
|
||||
architecture, code quality, security, i18n, testing, performance,
|
||||
UX, documentation, workflow compliance, and maintainability.
|
||||
|
||||
**Automated review (CI `pr-review` job):** Every PR triggers an automated
|
||||
review via `scripts/ci/pr_review.py`. This job posts a review with
|
||||
`COMMENT` (no issues) or `REQUEST_CHANGES` (issues found) based on
|
||||
the **[auto]** items in the checklist:
|
||||
|
||||
- Architecture compliance (no subprocess in CLI, no hardcoded URLs)
|
||||
- Best practices (no `print()`, no bare `except`, no `TODO`/`FIXME`,
|
||||
no functions > 50 lines)
|
||||
- Security (no hardcoded secrets, no `shell=True`, no `eval`/`exec`)
|
||||
- Documentation (source changes must include doc updates)
|
||||
- Test coverage (source changes must include test updates)
|
||||
|
||||
The automated review posts inline comments on specific lines and
|
||||
includes a link to the full checklist. The agent **must** address all
|
||||
`REQUEST_CHANGES` issues before proceeding.
|
||||
|
||||
**Manual review (agent):** After the automated review passes, the agent
|
||||
must go through **every category** in `REVIEW_CHECKLIST.md` and verify
|
||||
the **[manual]** items by reviewing the full diff
|
||||
(`git diff master...HEAD`).
|
||||
|
||||
Post review comments using `scripts/ci/review_pr.py`:
|
||||
```bash
|
||||
@@ -95,21 +128,33 @@ REPO_TOKEN=<token> python3 scripts/ci/review_pr.py <pr_number> <owner/repo> \
|
||||
Fix each comment one by one, commit, and push. Re-review until satisfied.
|
||||
|
||||
### 8. Approve and Merge
|
||||
Once all comments are addressed:
|
||||
Once all checklist items are verified and comments are addressed, post
|
||||
an approval review with `--checklist-confirmed`:
|
||||
```bash
|
||||
REPO_TOKEN=<token> python3 scripts/ci/review_pr.py <pr_number> <owner/repo> \
|
||||
--event APPROVE \
|
||||
--body "All comments addressed. LGTM."
|
||||
--event APPROVE --checklist-confirmed \
|
||||
--body "All 10 REVIEW_CHECKLIST.md categories verified. Architecture: <summary>. Security: <summary>. Tests: <summary>. Docs: <summary>."
|
||||
```
|
||||
|
||||
The `--checklist-confirmed` flag is **required** for APPROVE events —
|
||||
it attests that the reviewer has gone through every checklist category.
|
||||
The review body must also be substantive (> 20 characters) — trivial
|
||||
"LGTM" approvals are rejected by the auto-merge gate.
|
||||
|
||||
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)
|
||||
1. **Validate** PR title format (`GRM-N: <vikunja task title>`) and match against Vikunja task title
|
||||
2. **Check** that at least one substantive APPROVE review exists (body > 20 chars or has inline comments)
|
||||
3. Wait for all CI checks to pass (including the `pr-review` job)
|
||||
4. Squash-merge with title: `GRM-N <conventional commit message>` (space-separated, no colon after GRM-N)
|
||||
5. The post-merge workflow marks the Vikunja task as done
|
||||
6. The release workflow automatically versions, tags, and publishes (see below)
|
||||
|
||||
> **IMPORTANT**: Never manually merge PRs via the API. Always use the auto-merge
|
||||
> workflow by adding the `ready-to-merge` label. Manual merges bypass the
|
||||
> `GRM-N <conventional>` format enforcement, producing incorrectly named commits.
|
||||
> The auto-merge script validates the PR title matches the Vikunja task ID
|
||||
> and conventional commit format before merging.
|
||||
|
||||
### CI Path Filtering
|
||||
|
||||
The CI workflow includes a `detect-changes` job that checks whether any files
|
||||
@@ -134,27 +179,43 @@ then to a default of 3.
|
||||
|
||||
### Automated Release Pipeline
|
||||
|
||||
After a PR is merged to master, the release pipeline runs automatically:
|
||||
After a PR is merged to master, the **post-merge workflow**
|
||||
(`.gitea/workflows/post-merge.yml`) runs automatically. This single
|
||||
workflow consolidates release, wiki sync, badge generation, and
|
||||
Vikunja task updates:
|
||||
|
||||
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/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
|
||||
1. **detect-type** — Checks if the commit is a regular merge or a
|
||||
release commit (`release: vX.Y.Z`). All subsequent jobs skip for
|
||||
release commits (the `[skip ci]` tag also prevents re-triggering).
|
||||
|
||||
2. **release** — 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 [skip ci]` prefix (the `[skip ci]` prevents
|
||||
re-triggering post-merge on the release commit)
|
||||
- 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/ci/notify_failure.py`
|
||||
|
||||
3. **sync-wiki** — Syncs documentation to the Gitea wiki.
|
||||
|
||||
4. **badges** — Generates and pushes quality badge SVGs to the `badges` branch.
|
||||
Runs **after** the release job (even if release fails or is skipped) so the
|
||||
version badge always reflects the latest state. The script fetches the
|
||||
latest master before generating badges to pick up any release commits.
|
||||
|
||||
5. **vikunja** — Marks the corresponding Vikunja task as done.
|
||||
|
||||
The tag push triggers the **publish workflow** (`.gitea/workflows/publish.yml`)
|
||||
which builds and publishes the package to PyPI.
|
||||
|
||||
### Smart CI: User-Facing vs Workflow-Only Changes
|
||||
|
||||
@@ -168,7 +229,7 @@ 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
|
||||
- `scripts/setup.py`, `scripts/molecule_all.py`, `scripts/install_tools.py`, `scripts/__init__.py` — Dev tooling and package init
|
||||
- `docs/**` — Documentation
|
||||
- `tests/**` — Test files
|
||||
- `AGENTS.md`, `README.md`, `CHANGELOG.md`, `TROUBLESHOOTING.md` — Project docs
|
||||
@@ -177,15 +238,14 @@ types from accidentally skipping releases.
|
||||
- `hooks/**` — Git hooks
|
||||
|
||||
**User-facing paths** (tool changes → release needed) — everything else:
|
||||
- `src/gitea_runner_manager/**` — Python CLI source
|
||||
- `src/gitea_runner_manager/**` — Python CLI source (except `__init__.py` and `api_clients.py`)
|
||||
- `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.
|
||||
- `scripts/` — Dev tools (run locally by developers): `check_test_speed.py`, `configure_repo.py`, `install_checkmake.py`, `install_tools.py`, `setup.py`, `molecule_all.py`, `generate_badges.py`, `gitea_cli.py`
|
||||
- `scripts/ci/` — CI/CD automation (run by workflows): `release.py`, `publish.py`, `auto_merge.py`, `classify_changes.py`, `detect_release_commit.py`, `push_badges.py`, `doc_coverage.py`, `sync_wiki.py`, `distribute_molecule.py`, `molecule_ci_guard.py`, `discover_runners.py`, `notify_failure.py`, `post_merge.py`, `pr_review.py`, `review_pr.py`, `validate_commit_msg.py`, `platforms.py`
|
||||
|
||||
**CI behavior based on classification:**
|
||||
- **Molecule tests**: Only run when `ansible/` or `.ansible-lint` files change
|
||||
@@ -199,6 +259,78 @@ types from accidentally skipping releases.
|
||||
- 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/`.
|
||||
|
||||
## Script Separation and Import Rules
|
||||
|
||||
The codebase enforces strict separation between the GRM tool and CI/dev scripts:
|
||||
|
||||
### Directory Layout
|
||||
|
||||
| Directory | Purpose | Release impact |
|
||||
|-----------|---------|----------------|
|
||||
| `src/gitea_runner_manager/` | User-facing GRM CLI tool | Changes trigger release |
|
||||
| `scripts/` | Dev tools (run locally) | Workflow-only (no release) |
|
||||
| `scripts/ci/` | CI/CD automation (run by workflows) | Workflow-only (no release) |
|
||||
| `ansible/` | Ansible role for runner setup | Changes trigger release |
|
||||
|
||||
### Import Rules
|
||||
|
||||
1. **`src/gitea_runner_manager/` NEVER imports from `scripts/`** — the tool is self-contained
|
||||
2. **Scripts MAY import from `gitea_runner_manager`** — one-way dependency (scripts use the tool's API clients, config, i18n)
|
||||
3. **Cross-script imports** (scripts importing from other scripts) are allowed within `scripts/ci/` but must be documented
|
||||
4. **`scripts/gitea_cli.py`** is a shared wrapper around the `tea` CLI — CI scripts import from it for Gitea API operations (issues, labels, PRs, releases, reviews)
|
||||
|
||||
### tea CLI Integration
|
||||
|
||||
The `tea` Gitea CLI tool is used for Gitea API interactions in CI scripts. It is installed by `scripts/install_tools.py` and configured by `scripts/setup.py` (login profile from `.env` `REPO_TOKEN`).
|
||||
|
||||
**`scripts/gitea_cli.py`** — Python wrapper around `tea` CLI with JSON output parsing:
|
||||
- `TeaCLI.create_issue()` — Create issues with labels
|
||||
- `TeaCLI.list_labels()` / `TeaCLI.create_label()` / `TeaCLI.add_label()` — Label management
|
||||
- `TeaCLI.create_pr()` / `TeaCLI.merge_pr()` / `TeaCLI.review_pr()` — Pull request operations
|
||||
- `TeaCLI.create_release()` / `TeaCLI.list_releases()` — Release management
|
||||
- `TeaCLI.list_branches()` — Branch listing
|
||||
|
||||
**Scripts using tea (via `gitea_cli.py`):**
|
||||
- `scripts/ci/publish.py` — Creates Gitea releases via `tea releases create`
|
||||
- `scripts/ci/notify_failure.py` — Creates issues via `tea issues create` (falls back to `GiteaClient` if tea not installed)
|
||||
- `scripts/configure_repo.py` — Creates labels via `tea labels create` (falls back to `GiteaClient` if tea fails; branch protection still uses `GiteaClient` since tea only supports basic protect/unprotect)
|
||||
|
||||
**Operations still using `GiteaClient` (not supported by tea):**
|
||||
- PR reviews (`review_pr.py`) — tea v0.14.1 only supports interactive reviews
|
||||
- Wiki page management (`sync_wiki.py`)
|
||||
- Commit status checks (`auto_merge.py`)
|
||||
- Runner discovery (`discover_runners.py`)
|
||||
- Branch protection with detailed config (`configure_repo.py`)
|
||||
- PR file/commit listing (`pr_review.py`)
|
||||
|
||||
### PYTHONPATH Configuration
|
||||
|
||||
Scripts have different import requirements. Workflows must set `PYTHONPATH` accordingly:
|
||||
|
||||
| PYTHONPATH | When to use | Example scripts |
|
||||
|------------|-------------|-----------------|
|
||||
| `src` | Script imports from `gitea_runner_manager` | `auto_merge.py`, `pr_review.py`, `review_pr.py`, `sync_wiki.py`, `post_merge.py`, `classify_changes.py`, `discover_runners.py`, `doc_coverage.py` |
|
||||
| `.:src` | Script imports from both `gitea_runner_manager` and `scripts.gitea_cli` | `publish.py`, `notify_failure.py`, `configure_repo.py` |
|
||||
| `.` | Script imports from other `scripts.ci.*` modules | `release.py` (imports `classify_changes.has_user_facing_changes`) |
|
||||
| (none) | Script has no GRM or cross-script imports | `detect_release_commit.py`, `distribute_molecule.py`, `molecule_ci_guard.py`, `push_badges.py`, `validate_commit_msg.py` |
|
||||
|
||||
**In workflows**, always use `env:` blocks (not inline `PYTHONPATH=value`):
|
||||
```yaml
|
||||
- name: Run script
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: python3 scripts/ci/example.py
|
||||
```
|
||||
|
||||
**Locally**, the current directory is in `sys.path` by default, so `PYTHONPATH` is usually not needed.
|
||||
|
||||
### Shared Constants
|
||||
|
||||
`scripts/ci/platforms.py` is the single source of truth for the molecule
|
||||
platform matrix. Both `scripts/ci/distribute_molecule.py` (CI) and
|
||||
`scripts/molecule_all.py` (dev tool) import `PLATFORMS` from it — this
|
||||
avoids dev tools importing directly from CI scripts.
|
||||
|
||||
2. **Publish workflow** (`.gitea/workflows/publish.yml`):
|
||||
- Triggers on tag push (`v*`)
|
||||
- Validates `PYPI_TOKEN` is set (warns if missing)
|
||||
@@ -259,7 +391,7 @@ main.yml → systemd_check → user_setup → rootless_docker → install_runner
|
||||
|
||||
6 scenarios: `default`, `multi-instance`, `lifecycle`, `template-content`, `deregister`, `update`
|
||||
4 platforms: `ubuntu-2204`, `ubuntu-2404`, `debian-12`, `archlinux`
|
||||
Platform list is defined in `scripts/ci/distribute_molecule.py` (single source of truth)
|
||||
Platform list is defined in `scripts/ci/platforms.py` (single source of truth)
|
||||
|
||||
## Known Issues
|
||||
|
||||
|
||||
+150
-100
@@ -2,127 +2,177 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.3.2] - 2026-06-21
|
||||
## [0.6.0] - 2026-06-22
|
||||
|
||||
### Features
|
||||
|
||||
- Add self-updating quality badges to README
|
||||
- Enforce mandatory PR reviews with automated checks
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Set PYTHONPATH=. for release.py to find scripts.ci module (#32)
|
||||
- Bridge test suite gaps — lint scripts, include integration tests
|
||||
- Badge regex patterns and doc_coverage double-percent
|
||||
- Generate self-contained SVG badges instead of shields.io JSON
|
||||
- Auto_merge handles single-token workflow (self-approval)
|
||||
- Workflow timing, timeouts, status polling, and release classification
|
||||
- API resilience with retry, idempotent releases, and graceful Vikunja errors
|
||||
- Release pipeline determinism with lock, rebase, and consistent classification
|
||||
- Enforce conventional commit check in automated PR review
|
||||
- Molecule-tests matrix runner-index renders as empty for 0
|
||||
- Use 1-based runner indices for Gitea Actions compatibility
|
||||
- Molecule-tests static matrix and role_dir path fix
|
||||
- Auto-merge label condition uses pull_request.labels
|
||||
- Badges job runs after release to reflect actual state
|
||||
- Revert review_pr.py to GiteaClient (tea v0.14.1 is interactive-only) (#70)
|
||||
- Fix broken automation pipeline (auto-merge, Vikunja, CI enforcement)
|
||||
|
||||
### Other
|
||||
### Refactor
|
||||
|
||||
- Smart CI and release skipping for workflow-only changes
|
||||
- Split CI scripts, fix release PYTHONPATH, dynamic runner discovery
|
||||
- Consolidate CI workflows to eliminate redundant runs
|
||||
- Convert shell scripts and inline workflow scripts to Python
|
||||
- Enforce script separation and document import rules
|
||||
|
||||
## [0.3.1] - 2026-06-21
|
||||
### Revert
|
||||
|
||||
- Remove v0.6.0 release (no user-facing changes)
|
||||
|
||||
## [0.5.0] - 2026-06-21
|
||||
|
||||
### Packaging
|
||||
|
||||
- `pyproject.toml` now uses `dynamic = ["version"]` with setuptools `attr` to source version from `__init__.py` (single source of truth)
|
||||
- Added `console_scripts` entry point (`grm = "gitea_runner_manager.cli:cli"`)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use correct Gitea 1.26 wiki API endpoints
|
||||
- Arch Linux: pacman cache update now runs separately before package installation (fixes idempotence)
|
||||
- `rootless_docker.yml`: separated `update_cache` from package installation task
|
||||
|
||||
### Internal
|
||||
|
||||
- Added `GiteaClient.create_issue`, `GiteaClient.get_pr_files`, `GiteaClient.create_review` API methods
|
||||
- Added `VikunjaClient.get_task` method
|
||||
- Config URLs and repo settings now overridable via environment variables
|
||||
|
||||
## [0.4.0] - 2026-06-21
|
||||
|
||||
### Rootless Docker Support
|
||||
|
||||
- Full rootless Docker installation and configuration via Ansible
|
||||
- `docker_rootless_setup` variable controls whether rootless Docker tasks run
|
||||
- User setup tasks (subuid/subgid, lingering, dockerd-rootless)
|
||||
- Proper gating of all Docker-dependent and `systemctl --user` tasks
|
||||
|
||||
### Runner Labels
|
||||
|
||||
- `--labels` option on `grm install` — specify runner labels (e.g., `--labels "ubuntu-latest:docker://node:20"`)
|
||||
- Labels passed through to runner config YAML
|
||||
|
||||
### Security Fix (CWE-214)
|
||||
|
||||
- **Critical**: Registration tokens and admin tokens are no longer passed via `--extra-vars` on the command line
|
||||
- Extra-vars are now written to a temporary JSON file with `0600` permissions and passed via `--extra-vars @tempfile`
|
||||
- This prevents secrets from being visible in the process list (`ps aux`)
|
||||
|
||||
### Configuration via Environment Variables
|
||||
|
||||
- API URLs and repo configuration in `config.py` are now overridable via environment variables:
|
||||
- `GRM_GITEA_API_URL`
|
||||
- `GRM_VIKUNJA_API_URL`
|
||||
- `GRM_REPO_OWNER`
|
||||
- `GRM_REPO_NAME`
|
||||
- `GRM_VIKUNJA_PROJECT_ID`
|
||||
|
||||
### Ansible Role Improvements
|
||||
|
||||
- Dead code cleanup (removed `config.yml`, legacy system-level service, duplicate task includes)
|
||||
- `remove-runner.yml` now disables lingering and removes subuid/subgid entries for complete cleanup
|
||||
- Arch Linux: `gnupg` package name fix, pacman cache handling
|
||||
- Docker APT repository: deb822 format, proper GPG handling, arch mapping
|
||||
- Idempotence fixes for user_setup and download tasks
|
||||
|
||||
## [0.3.0] - 2026-06-21
|
||||
|
||||
### Features
|
||||
### New CLI Options
|
||||
|
||||
- Implement documentation-as-code with wiki sync and doc-coverage
|
||||
- `--force` flag on `grm remove` — remove a runner even when the host is unreachable (skips Ansible playbook, only deregisters via API)
|
||||
- `--url` option — override the Gitea URL for any command (useful for multiple Gitea instances)
|
||||
- `--ask-become-pass` is now the default behavior (no need to pass it explicitly)
|
||||
|
||||
## [0.2.2] - 2026-06-21
|
||||
### Status Detection Fixes
|
||||
|
||||
### Bug Fixes
|
||||
- `grm list` now correctly retrieves runner status (was showing "unknown" for active runners)
|
||||
- Docker mode status detection via `docker inspect`
|
||||
- Host/user context added to status output
|
||||
|
||||
- Enforce tests pass before tagging a release
|
||||
- Bypass commit-msg hook for release commits
|
||||
### Output Improvements
|
||||
|
||||
## [0.2.1] - 2026-06-21
|
||||
- Colorized output for better visual feedback (green/red/yellow)
|
||||
- Translated operation reports for success and failure cases
|
||||
- Dual logging: `click.echo()` for user-facing messages, `logging` for debug
|
||||
- `GRM_LOG_LEVEL` environment variable for controlling verbosity
|
||||
- Full i18n support (all user-facing strings translated)
|
||||
|
||||
### Bug Fixes
|
||||
### Internal Refactoring
|
||||
|
||||
- Strip git-cliff header from CHANGELOG.md updates
|
||||
- Validation moved from CLI layer to business layer
|
||||
- Centralized API clients and HTTP status codes
|
||||
- User-friendly Click errors with i18n
|
||||
|
||||
## [0.2.0] - 2026-06-21
|
||||
|
||||
### New CLI Commands
|
||||
|
||||
- `grm start <host>` — start a runner's systemd service
|
||||
- `grm stop <host>` — stop a runner's systemd service
|
||||
- `grm enable <host>` — enable a runner to start on boot
|
||||
- `grm disable <host>` — disable a runner from starting on boot
|
||||
- `grm status <host>` — check runner service status
|
||||
- `grm remove <host>` — deregister and remove a runner
|
||||
- `grm list-runners` — list all runners from the local registry
|
||||
|
||||
### Runner Registry
|
||||
|
||||
- Runners are tracked in `~/.config/grm/runners.toml` for simplified CLI usage
|
||||
- No need to specify `--url`, `--user`, `--key` for every command — the registry remembers
|
||||
|
||||
### Multi-Instance Support
|
||||
|
||||
- systemd template units (`gitea-runner@.service`) for running multiple runners per host
|
||||
- Per-instance config and data directories
|
||||
|
||||
### Ansible Role Improvements
|
||||
|
||||
- Parameterized all hardcoded configuration values as Ansible variables
|
||||
- Idempotence fixes for repeated runs
|
||||
- Runner config converted from TOML to YAML format
|
||||
- Registration timeout to prevent indefinite hangs
|
||||
- Docker container entrypoint override and working directory fix for `.runner` persistence
|
||||
|
||||
## [0.1.0] - 2026-06-21
|
||||
|
||||
### Initial Release
|
||||
|
||||
The first release of GRM, a lean CLI for managing Gitea Actions runners via SSH.
|
||||
|
||||
### CLI Commands
|
||||
|
||||
- `grm install <host>` — install and register a Gitea Runner on a remote host via SSH
|
||||
- `grm token` — generate a registration token via the Gitea API
|
||||
- `grm list` — list all registered runners
|
||||
- `grm update <host>` — update a runner to the latest version
|
||||
|
||||
### Ansible Role
|
||||
|
||||
- Installs Gitea Runner binary in binary or Docker mode
|
||||
- Registers runner with Gitea instance
|
||||
- Configures systemd service
|
||||
- Supports Arch Linux, Ubuntu, and Debian
|
||||
|
||||
### Features
|
||||
|
||||
- Fix 12 critical workflow gaps in release pipeline
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Release push permission and notify_failure label IDs
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Automated semver versioning and releases**: `scripts/release.py` — CI script that uses git-cliff to calculate the next version from conventional commits, update version files, create a release commit, tag, and push.
|
||||
- `cliff.toml` — git-cliff configuration for conventional commit parsing, semver bumping, and changelog generation.
|
||||
- Release workflow (`.gitea/workflows/release.yml`) — triggers on push to master, runs `scripts/release.py` to automatically version and tag releases.
|
||||
- `publish.py` now uses git-cliff to generate release notes for Gitea releases (falls back to generic message if git-cliff is not available).
|
||||
- `pyproject.toml` now uses `dynamic = ["version"]` with setuptools `attr` to source version from `__init__.py` (single source of truth — release script only updates `__init__.py`).
|
||||
- **Mandatory PR review step**: `scripts/review_pr.py` — CLI to post Gitea PR reviews (COMMENT, APPROVE, REQUEST_CHANGES) with inline comments via `--comments-json` or `--comments-stdin`.
|
||||
- `GiteaClient.get_pr_files`, `GiteaClient.get_pr_commits`, `GiteaClient.create_review` — API methods for PR review workflow.
|
||||
- `VikunjaClient.get_task` — fetch a single task by numeric ID.
|
||||
- PR title format: `GRM-N: <vikunja task title>` (colon-separated, human-friendly).
|
||||
- Merge commit format: `GRM-N <conventional commit message>` (space-separated, conventional).
|
||||
- `auto_merge.py` now extracts the conventional commit message from PR commits and constructs the merge title as `GRM-N <conventional commit>`.
|
||||
- `post_merge.py` `extract_conventional_msg` now handles both legacy (`GRM-N: <msg>`) and current (`GRM-N <msg>`) merge commit formats.
|
||||
- Full PR workflow documented in `AGENTS.md` and `README.md` (Vikunja task → branch → implement → commit → PR → review → address comments → approve → merge).
|
||||
|
||||
### Changed
|
||||
|
||||
- Parameterized all hardcoded configuration values as Ansible variables in `defaults/main.yml`:
|
||||
- `gitea_runner_data_dir` — Runtime data directory
|
||||
- `gitea_runner_config_dir` — Config directory
|
||||
- `gitea_runner_binary_path` — Binary install path
|
||||
- `gitea_runner_prune_until` — Prune age filter
|
||||
- `gitea_runner_prune_schedule` — Prune timer schedule
|
||||
- `gitea_runner_prune_label` — Docker label for pruning
|
||||
- `gitea_runner_service_restart_sec` — systemd restart interval
|
||||
- `gitea_runner_service_user` — Service user
|
||||
- `gitea_runner_log_level` — Runner log level
|
||||
- `gitea_runner_container_label` — Container label
|
||||
- `gitea_runner_file` — Runner metadata file
|
||||
- `docker_gpg_key_path` — Docker GPG key path
|
||||
- Added `console_scripts` entry point in `pyproject.toml` (`grm = "gitea_runner_manager.cli:cli"`).
|
||||
- Added shared `molecule/common/prepare.yml` to eliminate duplicated prepare playbooks.
|
||||
- Extracted repeated systemd availability check into `tasks/systemd_check.yml`.
|
||||
- Added idempotence checks to all Molecule scenarios.
|
||||
- Comprehensive README overhaul with Architecture, Configuration, Development, Testing, and Troubleshooting sections.
|
||||
- API URLs and repo configuration in `config.py` are now overridable via environment variables (`GRM_GITEA_API_URL`, `GRM_VIKUNJA_API_URL`, `GRM_REPO_OWNER`, `GRM_REPO_NAME`, `GRM_VIKUNJA_PROJECT_ID`).
|
||||
- `remove-runner.yml` now disables lingering and removes subuid/subgid entries for complete cleanup.
|
||||
|
||||
### Security
|
||||
|
||||
- **Critical fix**: Registration tokens and admin tokens are no longer passed via `--extra-vars` on the command line (CWE-214). Extra-vars are now written to a temporary JSON file with `0600` permissions and passed via `--extra-vars @tempfile`, which is deleted after execution. This prevents secrets from being visible in the process list (`ps aux`).
|
||||
|
||||
### Changed
|
||||
|
||||
- Replaced legacy runner terminology with `gitea_runner` / `gitea-runner` / `Gitea Runner`.
|
||||
- Updated default Docker image from `gitea/gitea_runner` to `gitea/runner`.
|
||||
- `Makefile` now uses the installed `grm` console script instead of `python grm`.
|
||||
- `pyproject.toml` ruff and pyright target versions updated from `py311` to `py312` to match `requires-python = ">=3.12"`.
|
||||
- `BRANCH_PROTECTION_CONFIG` updated with correct Gitea Actions status check contexts (including `(pull_request)` suffix) and `required_approvals: 0` for auto-merge.
|
||||
- `CONVENTIONAL_RE` no longer matches `BREAKING CHANGE` as a commit type (it is a footer, not a type).
|
||||
- `rootless_docker.yml` apt cache update now only runs when the Docker repo file changes (idempotent, but always refreshes on first add).
|
||||
- `service.yml` and `prune.yml` template creation tasks are not guarded by `docker_rootless_setup` (templates just create files, they don't need Docker; molecule tests set `docker_rootless_setup: false` but still verify the service file exists).
|
||||
- `molecule_all.sh` now sources the platform list from `distribute_molecule.py` to avoid duplication.
|
||||
|
||||
### Removed
|
||||
|
||||
- Deleted `setup.py` (redundant with `pyproject.toml`).
|
||||
- Deleted `grm` shell entrypoint script (replaced by `console_scripts`).
|
||||
- Deleted `initial-plan.md` and `tests/integration/test_provision.py` (dead code).
|
||||
- Removed empty `__init__.py` files from `tests/` directories.
|
||||
- Removed unused `runner_validated` fact from `validate.yml`.
|
||||
- Removed duplicate `prune.yml` and `integration_test.yml` includes from `install_runner.yml` (already included from `main.yml`).
|
||||
- Removed dead `tasks/config.yml` (never included by any playbook).
|
||||
- Removed dead `templates/gitea-runner.service.j2` (legacy system-level service, replaced by rootless `gitea-runner-user.service.j2`).
|
||||
- Removed dead "Reload systemd" handler (system-level reload, never notified, wrong scope for user services).
|
||||
- Removed dead `scripts/run_molecule_parallel.py` and its test (replaced by `molecule_ci_guard.py`).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Molecule idempotence failures caused by non-idempotent service restart.
|
||||
- Missing `/etc/docker` directory handling in Molecule tests.
|
||||
- `ansible-lint` formatting warnings (yaml empty lines).
|
||||
- Verify playbooks now explicitly load role defaults so parameterized variables are available during verification.
|
||||
- Duplicate execution of prune and integration test tasks during installation (were included from both `main.yml` and `install_runner.yml`).
|
||||
- apt cache update reporting `changed` on every run due to `cache_valid_time: 0`.
|
||||
- SSH-based remote execution via Ansible
|
||||
- Automatic registration token generation
|
||||
- Docker and binary installation modes
|
||||
- Integration test verification after installation
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: all setup install update lint ansible-lint makefile-lint lint-all test test-unit pytest-cov molecule molecule-all test-all clean
|
||||
.PHONY: all setup install update lint ansible-lint makefile-lint lint-all test test-unit pytest-cov molecule molecule-all test-all clean workflow-lint workflow-dryrun workflow-check install-tools
|
||||
|
||||
PYTHON := python3
|
||||
VENV := .venv
|
||||
@@ -7,8 +7,8 @@ CHECKMAKE := $(shell command -v checkmake 2>/dev/null || echo $(HOME)/go/bin/che
|
||||
|
||||
all: setup
|
||||
|
||||
setup: $(VENV)/bin/activate .env activate-scripts checkmake
|
||||
@bash scripts/setup.sh "$(BIN)"
|
||||
setup: $(VENV)/bin/activate .env activate-scripts checkmake install-tools
|
||||
@$(PYTHON) scripts/setup.py --bin "$(BIN)"
|
||||
|
||||
.env:
|
||||
@if [ ! -f .env ]; then \
|
||||
@@ -34,6 +34,9 @@ install-hooks:
|
||||
checkmake:
|
||||
@python3 scripts/install_checkmake.py
|
||||
|
||||
install-tools:
|
||||
@$(PYTHON) scripts/install_tools.py
|
||||
|
||||
install:
|
||||
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make install HOST=192.168.1.10"; exit 1; fi
|
||||
$(BIN)/grm install $(HOST) $(if $(USER),--user $(USER),) $(if $(KEY),--key $(KEY),) $(if $(NAME),--name $(NAME),) $(if $(TOKEN),--token $(TOKEN),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
|
||||
@@ -67,10 +70,10 @@ remove:
|
||||
$(BIN)/grm remove $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(TOKEN),--token $(TOKEN),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
|
||||
|
||||
lint-ruff:
|
||||
$(BIN)/ruff check src/ tests/
|
||||
$(BIN)/ruff check src/ tests/ scripts/
|
||||
|
||||
lint-format:
|
||||
$(BIN)/ruff format --check src/ tests/
|
||||
$(BIN)/ruff format --check src/ tests/ scripts/
|
||||
|
||||
typecheck:
|
||||
$(BIN)/pyright
|
||||
@@ -78,7 +81,13 @@ typecheck:
|
||||
lint: lint-ruff lint-format typecheck lint-bandit
|
||||
|
||||
lint-bandit:
|
||||
$(BIN)/bandit -r src/ scripts/ scripts/ci/
|
||||
$(BIN)/bandit -r src/ scripts/
|
||||
|
||||
lint-deps:
|
||||
@echo "Checking dependencies for known vulnerabilities..."
|
||||
@.venv/bin/python -m ensurepip 2>/dev/null || true
|
||||
@PIPAPI_PYTHON_LOCATION=$$(pwd)/.venv/bin/python \
|
||||
.venv/bin/pip-audit --desc --skip-editable 2>&1 || true
|
||||
|
||||
ansible-lint:
|
||||
$(BIN)/ansible-lint ansible/
|
||||
@@ -86,7 +95,22 @@ ansible-lint:
|
||||
makefile-lint:
|
||||
@$(CHECKMAKE) Makefile
|
||||
|
||||
lint-all: lint ansible-lint makefile-lint
|
||||
lint-all: lint ansible-lint makefile-lint workflow-lint
|
||||
|
||||
workflow-lint:
|
||||
@command -v actionlint >/dev/null 2>&1 || { \
|
||||
echo "actionlint not found. Install: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)"; \
|
||||
exit 1; \
|
||||
}
|
||||
actionlint -config-file .gitea/actionlint.yaml .gitea/workflows/*.yml
|
||||
|
||||
workflow-dryrun:
|
||||
@command -v act_runner >/dev/null 2>&1 || { echo "act_runner not found. Install: https://gitea.com/gitea/act_runner/releases"; exit 1; }
|
||||
@echo "Dry-running all workflows (no Docker containers started)..."
|
||||
act_runner exec --dryrun -W .gitea/workflows/ 2>&1 | grep -E 'DRYRUN|ERROR|FAIL|Job'
|
||||
|
||||
workflow-check: workflow-lint workflow-dryrun
|
||||
@echo "Workflow checks passed (static lint + dry-run)."
|
||||
|
||||
test-unit:
|
||||
$(BIN)/pytest tests/unit/ -v --no-cov
|
||||
@@ -95,7 +119,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=scripts/ci --cov-report=term-missing --cov-fail-under=100
|
||||
$(BIN)/pytest tests/ -v --cov=src/gitea_runner_manager --cov=scripts --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)
|
||||
@@ -106,7 +130,7 @@ molecule:
|
||||
|
||||
# All scenarios on all supported platforms (sequential; use CI matrix for parallel execution)
|
||||
molecule-all:
|
||||
@bash scripts/molecule_all.sh
|
||||
@$(PYTHON) scripts/molecule_all.py --bin "$(BIN)"
|
||||
|
||||
test: test-all
|
||||
|
||||
|
||||
@@ -8,37 +8,48 @@ Each runner runs in an isolated **rootless Docker** environment under a dedicate
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://git.oblachno.oblachno.fyi/oblachno-oss/grm.git
|
||||
cd grm
|
||||
git checkout $(git describe --tags --abbrev=0) # Checkout latest stable release
|
||||
make setup
|
||||
cp .env.example .env # Edit with your Gitea URL and registration token
|
||||
cp .env.example .env # Edit with your Gitea URL and tokens
|
||||
grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner
|
||||
```
|
||||
|
||||
> **Important:** Always checkout the latest release tag before running `make setup`. The `master` branch may contain unreleased changes that are not yet stable. The command above automatically selects the most recent tagged release.
|
||||
|
||||
> **Tokens:** You need two tokens from your Gitea instance — a **registration token** to register runners, and an **admin API token** for optional post-install verification. See [Getting Started](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Getting-Started.-) for detailed setup instructions.
|
||||
|
||||
## Documentation
|
||||
|
||||
Full documentation lives on the [**GRM Wiki**](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki).
|
||||
|
||||
### User Documentation
|
||||
|
||||
- [Getting Started](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Getting-Started) — Installation, quick start, first run
|
||||
- [Getting Started](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Getting-Started.-) — Installation, quick start, token setup, 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
|
||||
- [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
|
||||
|
||||
### Technical Documentation
|
||||
|
||||
- [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
|
||||
- [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
|
||||
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
# Review Checklist
|
||||
|
||||
This checklist is **mandatory** for every PR. The automated `pr-review` CI
|
||||
job checks items marked **[auto]**. The agent must verify all items
|
||||
marked **[manual]** before posting an APPROVE review.
|
||||
|
||||
The `review_pr.py` script requires `--checklist-confirmed` for APPROVE
|
||||
events. This flag attests that every category below has been reviewed.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture Compliance [auto + manual]
|
||||
|
||||
- [ ] **No business logic in CLI** (`cli.py`): no `subprocess`, no
|
||||
`os.system`, no `ansible-playbook` — delegate to `executor.py`
|
||||
- [ ] **No hardcoded URLs or config values** that belong in `config.py`
|
||||
with env var overrides
|
||||
- [ ] **Layer boundaries respected**: CLI → runner_manager → executor →
|
||||
subprocess/Ansible. No skipping layers.
|
||||
- [ ] **Single Responsibility**: each module/function has one reason to
|
||||
change. If a function does two things, split it.
|
||||
- [ ] **No circular imports** introduced
|
||||
|
||||
## 2. Code Quality and Best Practices [auto + manual]
|
||||
|
||||
- [ ] **No `print()`** in `src/` — use `click.echo()` for user output
|
||||
- [ ] **No bare `except:`** — catch specific exceptions
|
||||
- [ ] **No broad `except Exception:`** without justification
|
||||
- [ ] **No `TODO`/`FIXME`/`HACK`/`XXX`** left in merged code
|
||||
- [ ] **No functions > 50 lines** (excluding docstrings and decorators)
|
||||
- [ ] **No dead code** — unused imports, unreachable branches, commented-out code
|
||||
- [ ] **No copy-paste duplication** — extract shared logic into a helper
|
||||
- [ ] **Idiomatic Python** — use comprehensions, context managers, dataclasses
|
||||
- [ ] **Type hints** on all public functions
|
||||
|
||||
## 3. Security [auto + manual]
|
||||
|
||||
- [ ] **No hardcoded secrets** (tokens, passwords, keys in string literals)
|
||||
- [ ] **No `shell=True`** in subprocess calls — use argument lists
|
||||
- [ ] **No `eval()` or `exec()`** — use `ast.literal_eval` if parsing literals
|
||||
- [ ] **No secrets in logs or process arguments** — pass via env vars or files
|
||||
- [ ] **Input validation** on all external inputs (CLI args, API responses, file contents)
|
||||
- [ ] **No injection vectors** — parameterize subprocess args, SQL queries, etc.
|
||||
|
||||
## 4. Internationalization (i18n) [manual]
|
||||
|
||||
- [ ] **All user-facing strings wrapped in `_()`** — `click.echo(_("..."))`,
|
||||
error messages, help text, prompts
|
||||
- [ ] **No raw English strings** in `click.echo()`, `click.ClickException()`,
|
||||
or `raise` messages visible to users
|
||||
- [ ] **String interpolation uses named placeholders**: `_("Hello {name}", name=x)`
|
||||
not `f"Hello {x}"` for translatable strings
|
||||
|
||||
## 5. Testability and Test Coverage [auto + manual]
|
||||
|
||||
- [ ] **Source file changes include corresponding test updates**
|
||||
- [ ] **100% coverage maintained** (enforced by `pytest-cov`)
|
||||
- [ ] **Tests are fast** (< 10 seconds total, enforced by `check_test_speed.py`)
|
||||
- [ ] **Edge cases tested**: empty inputs, boundary values, error paths
|
||||
- [ ] **No flaky tests** — no `sleep()`, no race conditions, no external dependencies
|
||||
- [ ] **Test names describe the scenario**: `test_<condition>_<expected_result>`
|
||||
|
||||
## 6. Performance [manual]
|
||||
|
||||
- [ ] **No unnecessary allocations** in hot paths (list comprehensions vs generators)
|
||||
- [ ] **Correct data structures** — O(1) lookups use `set`/`dict`, not `list`
|
||||
- [ ] **No N+1 query patterns** in API calls or file I/O
|
||||
- [ ] **No blocking I/O on hot paths** without justification
|
||||
|
||||
## 7. User Experience [manual]
|
||||
|
||||
- [ ] **Clear error messages** — tell the user what went wrong and how to fix it
|
||||
- [ ] **Consistent CLI flag naming** — `--long-name` with `--short` aliases
|
||||
- [ ] **Help text on all commands and options** — `--help` should be useful
|
||||
- [ ] **No silent failures** — if something fails, the user should know
|
||||
- [ ] **Output is actionable** — not just "Error" but "Error: X failed because Y. Try Z."
|
||||
|
||||
## 8. Documentation [auto + manual]
|
||||
|
||||
- [ ] **Source changes include doc updates** — README, wiki, AGENTS.md as needed
|
||||
- [ ] **New functions/classes have docstrings** — Google style
|
||||
- [ ] **Public API changes documented** in CHANGELOG (auto-generated by git-cliff)
|
||||
- [ ] **AGENTS.md updated** if workflow, conventions, or processes changed
|
||||
- [ ] **No stale documentation** — if code changed, docs must reflect it
|
||||
|
||||
## 9. Workflow Compliance [manual]
|
||||
|
||||
- [ ] **PR title matches Vikunja task title** (`GRM-N: <task title>`)
|
||||
- [ ] **Commit messages follow conventional format** (`type: description`)
|
||||
- [ ] **No force-push after review** — creates new commits and re-trigger CI
|
||||
- [ ] **Branch is up to date** with master before merging
|
||||
- [ ] **No merge commits** in the PR branch — use squash merge via auto-merge
|
||||
|
||||
## 10. Extensibility and Maintainability [manual]
|
||||
|
||||
- [ ] **Open/Closed Principle** — code is open for extension, closed for modification
|
||||
- [ ] **No magic numbers** — constants are named and documented
|
||||
- [ ] **Configuration over hardcoding** — use `config.py` with env var overrides
|
||||
- [ ] **Future-proof error handling** — don't catch specific error messages that may change
|
||||
- [ ] **Dependencies are justified** — no new dependency without rationale
|
||||
+10
-9
@@ -46,19 +46,20 @@ commit_preprocessors = [
|
||||
commit_parsers = [
|
||||
{ message = "^feat", group = "<!-- 0 -->Features" },
|
||||
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
|
||||
{ message = "^doc", group = "<!-- 3 -->Documentation" },
|
||||
{ message = "^perf", group = "<!-- 4 -->Performance" },
|
||||
{ message = "^refactor", group = "<!-- 2 -->Refactor" },
|
||||
{ message = "^style", group = "<!-- 5 -->Styling" },
|
||||
{ message = "^test", group = "<!-- 6 -->Testing" },
|
||||
{ message = "^chore\\(release\\): prepare for", skip = true },
|
||||
{ message = "^chore\\(deps.*\\)", skip = true },
|
||||
{ message = "^chore\\(pr\\)", skip = true },
|
||||
{ message = "^chore\\(pull\\)", skip = true },
|
||||
{ message = "^chore|^ci", group = "<!-- 7 -->Miscellaneous Tasks" },
|
||||
# Skip infrastructure-only commits — they don't affect users
|
||||
{ message = "^doc", skip = true },
|
||||
{ message = "^test", skip = true },
|
||||
{ message = "^style", skip = true },
|
||||
{ message = "^chore", skip = true },
|
||||
{ message = "^ci", skip = true },
|
||||
# Skip release commits — they are release artifacts, not features
|
||||
{ message = "^release:", skip = true },
|
||||
{ body = ".*security", group = "<!-- 8 -->Security" },
|
||||
{ message = "^revert", group = "<!-- 9 -->Revert" },
|
||||
{ message = ".*", group = "<!-- 10 -->Other" },
|
||||
# Skip anything that doesn't match above — safe default
|
||||
{ message = ".*", skip = true },
|
||||
]
|
||||
|
||||
[bump]
|
||||
|
||||
+14
-7
@@ -4,22 +4,29 @@ A lean command-line tool to automate the installation, configuration, and lifecy
|
||||
|
||||
> **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)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
|
||||
|
||||
## User Documentation
|
||||
|
||||
- [Getting Started](Getting-Started) — Installation, quick start, first run
|
||||
- [Getting Started](Getting-Started.-) — Installation, quick start, first run
|
||||
- [Installation](Installation) — Prerequisites, setup, multiple instances
|
||||
- [CLI Commands](CLI-Commands) — All commands with arguments and options
|
||||
- [CLI Commands](CLI-Commands.-) — All commands with arguments and options
|
||||
- [Troubleshooting](Troubleshooting) — Common issues and solutions
|
||||
- [FAQ](FAQ) — Frequently asked questions
|
||||
|
||||
## Technical Documentation
|
||||
|
||||
- [Architecture](Architecture) — High-level design, component interactions, data flow
|
||||
- [Development Setup](Development-Setup) — Environment setup, dependencies, local testing
|
||||
- [CI/CD Workflow](CI-CD-Workflow) — How CI works, release process, branch protection
|
||||
- [Testing Strategy](Testing-Strategy) — Unit, integration, and Molecule tests
|
||||
- [Decision Log](Decision-Log) — Key technical decisions and rationale
|
||||
- [Contributing Guide](Contributing-Guide) — Coding standards, PR workflow, commit rules
|
||||
- [Development Setup](Development-Setup.-) — Environment setup, dependencies, local testing
|
||||
- [CI/CD Workflow](CI-CD-Workflow.-) — How CI works, release process, branch protection
|
||||
- [Testing Strategy](Testing-Strategy.-) — Unit, integration, and Molecule tests
|
||||
- [Decision Log](Decision-Log.-) — Key technical decisions and rationale
|
||||
- [Contributing Guide](Contributing-Guide.-) — Coding standards, PR workflow, commit rules
|
||||
|
||||
## Quick Links
|
||||
|
||||
|
||||
@@ -221,6 +221,48 @@ When adding or removing Gitea runners:
|
||||
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
|
||||
|
||||
### Molecule Test Distribution
|
||||
|
||||
`scripts/ci/distribute_molecule.py` discovers all molecule scenarios
|
||||
under `ansible/roles/*/molecule/` and crosses them with the supported
|
||||
OS platform matrix (defined in `scripts/ci/platforms.py`), then splits
|
||||
the resulting test pairs evenly across the requested number of runners.
|
||||
Each pair is encoded as `scenario|platform_name|platform_image|platform_command`.
|
||||
|
||||
`scripts/ci/molecule_ci_guard.py` runs the actual molecule test for a
|
||||
given test pair, with CI context (Gitea URL, token, run ID) for
|
||||
reporting results back to the commit status API.
|
||||
|
||||
### Commit Message Validation
|
||||
|
||||
`scripts/ci/validate_commit_msg.py` validates that commit messages
|
||||
follow the conventional commit format (`feat:`, `fix:`, `docs:`, etc.).
|
||||
It is used by the pre-commit hook to enforce conventional commits on
|
||||
feature branches.
|
||||
|
||||
### Release Commit Detection
|
||||
|
||||
The `detect-type` job in the post-merge workflow runs
|
||||
`scripts/ci/detect_release_commit.py` to check whether the latest commit
|
||||
is a release commit (format: `release: vX.Y.Z`). When a release commit
|
||||
is detected, all post-merge jobs (release, sync-wiki, badges, vikunja)
|
||||
are skipped — the tag push triggers the publish workflow instead.
|
||||
|
||||
### Badge Generation and Push
|
||||
|
||||
The `badges` job in the post-merge workflow runs
|
||||
`scripts/ci/push_badges.py` which:
|
||||
1. Fetches the latest master and hard-resets to it (picks up release commits)
|
||||
2. Generates quality badge SVG files via `scripts/generate_badges.py`
|
||||
3. Creates an orphan `badges` branch
|
||||
4. Copies SVG files to the branch root
|
||||
5. Force-pushes the branch to the remote
|
||||
|
||||
The badges job depends on the `release` job and uses `if: always()` so it
|
||||
runs even if release fails or is skipped. This ensures the version badge
|
||||
always reflects the actual state of the repository after any release
|
||||
commits have been pushed.
|
||||
|
||||
## 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:
|
||||
|
||||
+15
-1
@@ -2,7 +2,21 @@
|
||||
|
||||
### 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.
|
||||
There are three levels of registration tokens, depending on which repositories the runner should serve:
|
||||
|
||||
- **Instance-level** — Site Administration → Actions → Runners → Create Registration Token. The runner will handle jobs from all repositories.
|
||||
- **Organization-level** — Organization → Settings → Actions → Runners → Create Registration Token. The runner will only handle jobs from repositories in that organization.
|
||||
- **Repository-level** — Repository → Settings → Actions → Runners → Create Registration Token. The runner will only handle jobs from that specific repository.
|
||||
|
||||
Set the token as `GITEA_REGISTRATION_TOKEN` in your `.env` file or pass it via `--token` on the command line.
|
||||
|
||||
### What is the REPO_TOKEN and do I need it?
|
||||
|
||||
`REPO_TOKEN` is a Gitea admin API token used for optional post-install verification. When set, GRM queries the Gitea API after installation to confirm the runner appears in the runner list. This is purely informational — the integration test passes/fails based on the `.runner` file and systemd service, not the API check.
|
||||
|
||||
To generate one: Settings → Applications → Generate New Token, with the `admin` scope (or at minimum `read:user`, `read:repository`, `read:admin`).
|
||||
|
||||
If you skip it, GRM will still verify the runner correctly — it just won't show the extra API confirmation.
|
||||
|
||||
### How do I skip the sudo password prompt for automation?
|
||||
|
||||
|
||||
@@ -3,32 +3,81 @@
|
||||
## Developer Setup
|
||||
|
||||
```bash
|
||||
git clone https://git.oblachno.oblachno.com/oblachno/gitea-runner-manager.git
|
||||
cd gitea-runner-manager
|
||||
git clone https://git.oblachno.oblachno.fyi/oblachno-oss/grm.git
|
||||
cd grm
|
||||
git checkout $(git describe --tags --abbrev=0) # Checkout latest stable release
|
||||
pyenv install 3.12
|
||||
pyenv local 3.12
|
||||
make setup
|
||||
```
|
||||
|
||||
> **Important:** Always checkout the latest release tag before running `make setup`. The `master` branch may contain unreleased changes that are not yet stable. The `git describe --tags --abbrev=0` command automatically selects the most recent tagged release. To see all available releases, run `git tag --sort=-version:refname` or check the [releases page](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases).
|
||||
|
||||
## Configure Gitea Credentials
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env:
|
||||
# GITEA_URL=https://git.example.com
|
||||
# GITEA_REGISTRATION_TOKEN=your-registration-token
|
||||
```
|
||||
GRM needs two tokens from your Gitea instance: a **registration token** (required) and an **admin API token** (optional, for post-install verification).
|
||||
|
||||
`GITEA_REGISTRATION_TOKEN` is the runner registration token obtained from your Gitea instance (Admin → Actions → Runners → Create Registration Token).
|
||||
### Step 1: Get the Registration Token
|
||||
|
||||
### Admin API Token (optional)
|
||||
The registration token tells Gitea to accept the runner when it connects.
|
||||
|
||||
Set `GITEA_ADMIN_TOKEN` to enable informational API checks during integration test. This is **optional** — the test primarily verifies the runner by checking:
|
||||
1. Log in to your Gitea instance as an administrator
|
||||
2. Navigate to **Site Administration → Actions → Runners**
|
||||
3. Click **Create Registration Token**
|
||||
4. Copy the token — it starts with `GR`
|
||||
|
||||
> **Note:** There are three levels of registration tokens:
|
||||
> - **Instance-level** (Site Administration → Actions → Runners) — registers a runner for all repositories
|
||||
> - **Organization-level** (Organization → Settings → Actions → Runners) — registers a runner for repos in that organization
|
||||
> - **Repository-level** (Repository → Settings → Actions → Runners) — registers a runner for a single repository
|
||||
>
|
||||
> Use instance-level tokens for shared runners, and repo-level tokens for dedicated runners.
|
||||
|
||||
### Step 2: Get the Admin API Token (optional)
|
||||
|
||||
The admin API token enables post-install API checks that verify the runner appears in Gitea's runner list. This is purely informational — the integration 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.
|
||||
To get an admin API token:
|
||||
|
||||
1. Go to **Settings → Applications → Generate New Token**
|
||||
2. Give it a name (e.g., "GRM Install Verification")
|
||||
3. Select the **admin** scope (or at minimum: `read:user`, `read:repository`, `read:admin`)
|
||||
4. Click **Generate Token** and copy it immediately (it won't be shown again)
|
||||
|
||||
### Step 3: Create the `.env` File
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` with your tokens:
|
||||
|
||||
```bash
|
||||
# Your Gitea instance URL
|
||||
GITEA_URL=https://git.example.com
|
||||
|
||||
# Registration token from Step 1
|
||||
GITEA_REGISTRATION_TOKEN=GRxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# Admin API token from Step 2 (optional)
|
||||
REPO_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
### Environment Variables Reference
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `GITEA_URL` | Yes | Gitea instance URL (e.g., `https://git.example.com`) |
|
||||
| `GITEA_REGISTRATION_TOKEN` | Yes | Runner registration token from Gitea admin panel |
|
||||
| `REPO_TOKEN` | No | Admin API token for post-install verification |
|
||||
| `GITEA_INTEGRATION_RETRIES` | No | API check retries (default: 3) |
|
||||
| `GITEA_RUNNER_USER` | No | Default SSH user (overrides `--user`) |
|
||||
| `GITEA_RUNNER_KEY` | No | Default SSH key path (overrides `--key`) |
|
||||
| `GITEA_RUNNER_LABELS` | No | Default runner labels (overrides `--labels`) |
|
||||
| `GRM_LANG` | No | UI language: `en`, `bg`, `de`, `ru`, `zh` (default: `en`) |
|
||||
|
||||
## Install a Runner
|
||||
|
||||
@@ -55,7 +104,7 @@ The installer performs an automated integration test that verifies:
|
||||
|
||||
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.
|
||||
Optional: If `REPO_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
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
# Installation
|
||||
|
||||
> **Before you start:** Make sure you have cloned the repo and checked out the latest stable release tag. See [Getting Started](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Getting-Started.-) for setup instructions. Do not run from `master` — it may contain unreleased changes.
|
||||
|
||||
## 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`.
|
||||
- **Gitea registration token** — You need a runner registration token from your Gitea instance. See [Getting Started](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Getting-Started.-) for detailed instructions on obtaining tokens.
|
||||
|
||||
## Supported Operating Systems
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ This is a harmless cleanup traceback from Molecule's Docker driver when the test
|
||||
## Runner appears offline after installation
|
||||
|
||||
- Check that the `GITEA_URL` and `GITEA_REGISTRATION_TOKEN` environment variables are correct.
|
||||
- Verify the registration token has not expired — generate a new one from Gitea if needed (Site Administration → Actions → Runners → Create Registration Token).
|
||||
- Verify the runner service is running: `sudo -u grm-<name> systemctl --user status gitea-runner`.
|
||||
- Check logs for registration errors.
|
||||
|
||||
@@ -16,6 +17,7 @@ The test checks two things:
|
||||
|
||||
1. **`.runner` file missing or invalid** — Registration failed. Check:
|
||||
- `GITEA_URL` and `GITEA_REGISTRATION_TOKEN` are correct
|
||||
- The registration token is valid and has not expired
|
||||
- Runner logs for registration errors
|
||||
- The `.runner` file should exist at `/var/lib/gitea-runner/<name>/.runner`
|
||||
|
||||
|
||||
+3
-2
@@ -36,6 +36,7 @@ dev = [
|
||||
"molecule-docker>=2.1.0",
|
||||
"ansible-lint>=26.4.0",
|
||||
"bandit>=1.8.2",
|
||||
"pip-audit>=2.10",
|
||||
"pre-commit>=4.6.0",
|
||||
# Non-Python dev dependency: checkmake (Makefile linter)
|
||||
# Install via: go install github.com/checkmake/checkmake/cmd/checkmake@latest
|
||||
@@ -50,7 +51,7 @@ gitea_runner_manager = ["translations.json"]
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src", "."]
|
||||
addopts = "--cov=src/gitea_runner_manager --cov=scripts --cov=scripts/ci --cov-report=term-missing --cov-fail-under=100"
|
||||
addopts = "--cov=src/gitea_runner_manager --cov=scripts --cov-report=term-missing --cov-fail-under=100"
|
||||
markers = [
|
||||
"integration: marks tests as integration tests (not counted in coverage)",
|
||||
]
|
||||
@@ -68,6 +69,6 @@ quote-style = "double"
|
||||
indent-style = "space"
|
||||
|
||||
[tool.pyright]
|
||||
include = ["src", "scripts", "scripts/ci"]
|
||||
include = ["src", "scripts"]
|
||||
pythonVersion = "3.12"
|
||||
strict = ["src/gitea_runner_manager"]
|
||||
|
||||
+85
-20
@@ -16,6 +16,7 @@ Usage:
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
@@ -35,8 +36,23 @@ from gitea_runner_manager.exceptions import APIError
|
||||
from gitea_runner_manager.i18n import _
|
||||
|
||||
READY_TO_MERGE = "ready-to-merge"
|
||||
MAX_WAIT_SECONDS = 900 # 15 minutes
|
||||
POLL_INTERVAL_SECONDS = 30
|
||||
MAX_WAIT_SECONDS = 600 # 10 minutes max — CI may still be running when label is added
|
||||
POLL_INTERVAL_SECONDS = 15 # Poll every 15 seconds
|
||||
|
||||
|
||||
def run_cmd(args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
"""Run a command and return the completed process."""
|
||||
result = subprocess.run(args, capture_output=True, text=True, check=False) # nosec B603
|
||||
if check and result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Command failed ({cmd}): {stderr}",
|
||||
cmd=" ".join(args),
|
||||
stderr=result.stderr.strip() or result.stdout.strip(),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
# PR title: GRM-N: <vikunja task title>
|
||||
PR_TITLE_RE = re.compile(r"^GRM-\d+:\s+.+")
|
||||
@@ -119,9 +135,39 @@ def validate_pr_title_matches_vikunja(pr_title: str, task_id: str) -> None:
|
||||
|
||||
|
||||
def has_approval_review(client: GiteaClient, pr_number: str) -> bool:
|
||||
"""Check whether the PR has at least one APPROVE review."""
|
||||
"""Check whether the PR has at least one substantive APPROVE review.
|
||||
|
||||
A substantive review has a body longer than 20 characters (not just
|
||||
"LGTM" or "OK"). This ensures the reviewer actually reviewed the PR
|
||||
rather than rubber-stamping it.
|
||||
|
||||
Falls back to checking that no REQUEST_CHANGES reviews are pending
|
||||
when self-approval is not possible (single-token workflow).
|
||||
"""
|
||||
reviews = client.get_pr_reviews(pr_number)
|
||||
return any(r.get("state") == "APPROVED" for r in reviews)
|
||||
has_approved = False
|
||||
has_changes_requested = False
|
||||
|
||||
for r in reviews:
|
||||
state = r.get("state", "")
|
||||
if state == "APPROVED":
|
||||
body = str(r.get("body", "")).strip()
|
||||
if len(body) > 20 or r.get("comments", []):
|
||||
has_approved = True
|
||||
elif state == "REQUEST_CHANGES":
|
||||
has_changes_requested = True
|
||||
|
||||
if has_approved:
|
||||
return True
|
||||
# In single-token workflows, self-approval is not allowed.
|
||||
# Allow merge if no changes are requested (the automated pr-review
|
||||
# job and CI quality gate serve as the review enforcement).
|
||||
if not has_changes_requested:
|
||||
click.echo(
|
||||
_("No APPROVE review found, but no REQUEST_CHANGES either. Proceeding (single-token workflow fallback).")
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
|
||||
@@ -155,6 +201,10 @@ def wait_for_ci(
|
||||
"""Poll commit statuses until all CI checks are complete (not pending).
|
||||
|
||||
Returns True if all checks are successful, False if any failed or timed out.
|
||||
|
||||
Uses the combined status endpoint which returns one entry per context
|
||||
(deduplicated server-side). Filters to "CI /" contexts only, excluding
|
||||
"Auto-merge / merge" and other non-CI contexts.
|
||||
"""
|
||||
elapsed = 0
|
||||
while elapsed < max_wait:
|
||||
@@ -165,14 +215,9 @@ def wait_for_ci(
|
||||
elapsed += poll_interval
|
||||
continue
|
||||
|
||||
# Deduplicate by context — keep the latest status per context.
|
||||
latest: dict[str, dict[str, object]] = {}
|
||||
for s in statuses:
|
||||
ctx = s.get("context", "")
|
||||
if ctx not in latest or s.get("updated_at", "") > latest[ctx].get("updated_at", ""):
|
||||
latest[ctx] = s
|
||||
|
||||
ci_statuses = {ctx: s for ctx, s in latest.items() if ctx.startswith("CI /")}
|
||||
# Combined endpoint already deduplicates — one entry per context.
|
||||
# Filter to CI contexts only (excludes "Auto-merge / merge" etc).
|
||||
ci_statuses = {s.get("context", ""): s for s in statuses if s.get("context", "").startswith("CI /")}
|
||||
if not ci_statuses:
|
||||
click.echo(_("No CI checks found yet, waiting..."))
|
||||
time.sleep(poll_interval)
|
||||
@@ -182,7 +227,8 @@ def wait_for_ci(
|
||||
pending = [ctx for ctx, s in ci_statuses.items() if s.get("status") in ("pending", "waiting")]
|
||||
if not pending:
|
||||
# All CI checks are complete — check if they all succeeded.
|
||||
failed = [ctx for ctx, s in ci_statuses.items() if s.get("status") not in ("success", "ok")]
|
||||
# "skipped" jobs are considered passing (conditional jobs that didn't run).
|
||||
failed = [ctx for ctx, s in ci_statuses.items() if s.get("status") not in ("success", "ok", "skipped")]
|
||||
if failed:
|
||||
click.echo(_("CI checks failed: {failed}", failed=", ".join(sorted(failed))))
|
||||
return False
|
||||
@@ -267,13 +313,32 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str, label_name: str)
|
||||
try:
|
||||
client.merge_pr(pr_number, merge_title)
|
||||
except APIError as e:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.",
|
||||
status=e.status,
|
||||
message=e.message,
|
||||
)
|
||||
) from None
|
||||
if e.status == 405 and "behind" in e.message.lower():
|
||||
# Head branch is behind master — pull master and rebase, then retry
|
||||
click.echo(_("Head branch is behind master. Pulling and rebasing..."))
|
||||
try:
|
||||
run_cmd(["git", "fetch", "origin", "master"])
|
||||
run_cmd(["git", "rebase", "origin/master"])
|
||||
run_cmd(["git", "push", "--force-with-lease"])
|
||||
click.echo(_("Rebased and pushed. Retrying merge..."))
|
||||
client.merge_pr(pr_number, merge_title)
|
||||
except (APIError, Exception) as retry_err:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Merge failed after rebase retry: {error}\n"
|
||||
"Please rebase the PR manually and re-add the ready-to-merge label.",
|
||||
error=str(retry_err),
|
||||
)
|
||||
) from None
|
||||
else:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Merge failed with HTTP {status}: {message}\n"
|
||||
"Please check the PR is ready and you have merge rights.",
|
||||
status=e.status,
|
||||
message=e.message,
|
||||
)
|
||||
) from None
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
|
||||
@@ -17,13 +17,14 @@ Classification strategy (safe-by-default):
|
||||
|
||||
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
|
||||
- scripts/** — All scripts (CI/CD, dev tools, setup)
|
||||
- src/gitea_runner_manager/__init__.py — Version file (release artifact)
|
||||
- src/gitea_runner_manager/api_clients.py — Gitea API client (CI/CD only, not used by CLI)
|
||||
- docs/** — Documentation
|
||||
- tests/** — Test files
|
||||
- hooks/** — Git hooks
|
||||
- AGENTS.md — Agent conventions
|
||||
- REVIEW_CHECKLIST.md — Review checklist
|
||||
- README.md — README (lean, links to wiki)
|
||||
- CHANGELOG.md — Changelog (generated)
|
||||
- TROUBLESHOOTING.md — Troubleshooting guide
|
||||
@@ -38,7 +39,7 @@ Classification strategy (safe-by-default):
|
||||
|
||||
Everything else is user-facing (tool changes → release needed),
|
||||
including but not limited to:
|
||||
- src/gitea_runner_manager/** — Python CLI source
|
||||
- src/gitea_runner_manager/*.py — Python CLI source (except __init__.py)
|
||||
- ansible/** — Ansible role
|
||||
- pyproject.toml — Package metadata
|
||||
- Any new file type not in the allowlist
|
||||
@@ -63,13 +64,18 @@ WORKFLOW_ONLY_PATTERNS = frozenset(
|
||||
[
|
||||
# CI/CD infrastructure
|
||||
".gitea/",
|
||||
"scripts/ci/",
|
||||
"scripts/setup.sh",
|
||||
"scripts/molecule_all.sh",
|
||||
"scripts/__init__.py",
|
||||
# All scripts are infrastructure (CI/CD, dev tools, setup)
|
||||
# User-facing code lives in src/gitea_runner_manager/
|
||||
"scripts/",
|
||||
# Version file — only contains __version__, not user-facing code.
|
||||
# Version bumps are a release artifact, not a feature.
|
||||
"src/gitea_runner_manager/__init__.py",
|
||||
# Gitea API client — used only by CI/CD scripts, not by the GRM CLI.
|
||||
"src/gitea_runner_manager/api_clients.py",
|
||||
# Documentation
|
||||
"docs/",
|
||||
"AGENTS.md",
|
||||
"REVIEW_CHECKLIST.md",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
"TROUBLESHOOTING.md",
|
||||
@@ -148,7 +154,11 @@ def classify_changes(files: list[str]) -> dict[str, list[str]]:
|
||||
|
||||
|
||||
def has_user_facing_changes(base: str, head: str) -> bool:
|
||||
"""Check if any user-facing files changed between base and head."""
|
||||
"""Check if any user-facing files changed between base and head.
|
||||
|
||||
Imported by ``scripts/ci/release.py`` to decide whether a release
|
||||
is needed. This is a cross-CI import that requires ``PYTHONPATH=.``.
|
||||
"""
|
||||
files = get_changed_files(base, head)
|
||||
return any(is_user_facing(f) for f in files)
|
||||
|
||||
@@ -166,14 +176,43 @@ def get_latest_tag() -> str:
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _write_github_output(key: str, value: str) -> None:
|
||||
"""Append a key=value line to the $GITHUB_OUTPUT file."""
|
||||
import os
|
||||
|
||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
||||
if not gh_output:
|
||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
||||
with open(gh_output, "a") as f: # noqa: PTH123
|
||||
f.write(f"{key}={value}\n")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--base", default=None, help="Base ref (default: latest tag).")
|
||||
@click.option("--head", default="HEAD", help="Head ref (default: HEAD).")
|
||||
@click.option("--quiet", is_flag=True, default=False, help="Only output true/false.")
|
||||
def main(base: str | None, head: str, quiet: bool) -> None:
|
||||
@click.option(
|
||||
"--check",
|
||||
type=click.Choice(["all", "ansible", "user-facing"]),
|
||||
default="all",
|
||||
help="Check specific category: all (default), ansible, or user-facing.",
|
||||
)
|
||||
@click.option(
|
||||
"--github-output",
|
||||
"github_output",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps).",
|
||||
)
|
||||
def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool) -> None:
|
||||
if base is None:
|
||||
base = get_latest_tag()
|
||||
if not base:
|
||||
if github_output:
|
||||
_write_github_output("ansible-changed", "true")
|
||||
_write_github_output("user-facing-changed", "true")
|
||||
click.echo("No tags found — treating all changes as user-facing.")
|
||||
return
|
||||
if quiet:
|
||||
click.echo("true")
|
||||
else:
|
||||
@@ -182,12 +221,54 @@ def main(base: str | None, head: str, quiet: bool) -> None:
|
||||
|
||||
files = get_changed_files(base, head)
|
||||
if not files:
|
||||
if github_output:
|
||||
_write_github_output("ansible-changed", "false")
|
||||
_write_github_output("user-facing-changed", "false")
|
||||
click.echo(f"No changes between {base} and {head}.")
|
||||
return
|
||||
if quiet:
|
||||
click.echo("false")
|
||||
else:
|
||||
click.echo(_("No changes between {base} and {head}.", base=base, head=head))
|
||||
return
|
||||
|
||||
if github_output:
|
||||
ansible_files = [f for f in files if f.startswith("ansible/") or f == ".ansible-lint"]
|
||||
user_files = [f for f in files if is_user_facing(f)]
|
||||
_write_github_output("ansible-changed", "true" if ansible_files else "false")
|
||||
_write_github_output("user-facing-changed", "true" if user_files else "false")
|
||||
click.echo(f"Ansible files changed: {bool(ansible_files)}")
|
||||
click.echo(f"User-facing files changed: {bool(user_files)}")
|
||||
return
|
||||
|
||||
if check == "ansible":
|
||||
# Check only for Ansible-related file changes
|
||||
ansible_files = [f for f in files if f.startswith("ansible/") or f == ".ansible-lint"]
|
||||
has_ansible = bool(ansible_files)
|
||||
if quiet:
|
||||
click.echo("true" if has_ansible else "false")
|
||||
return
|
||||
click.echo(_("\nAnsible files changed ({count}):", count=len(ansible_files)))
|
||||
for f in ansible_files:
|
||||
click.echo(f" {f}")
|
||||
click.echo(_("\nResult: {status}", status="Ansible changes detected" if has_ansible else "No Ansible changes"))
|
||||
return
|
||||
|
||||
if check == "user-facing":
|
||||
# Check only for user-facing file changes (inverse of workflow-only)
|
||||
user_files = [f for f in files if is_user_facing(f)]
|
||||
has_user = bool(user_files)
|
||||
if quiet:
|
||||
click.echo("true" if has_user else "false")
|
||||
return
|
||||
click.echo(_("\nUser-facing files changed ({count}):", count=len(user_files)))
|
||||
for f in user_files:
|
||||
click.echo(f" {f}")
|
||||
click.echo(
|
||||
_("\nResult: {status}", status="User-facing changes detected" if has_user else "No user-facing changes")
|
||||
)
|
||||
return
|
||||
|
||||
result = classify_changes(files)
|
||||
has_user = bool(result["user_facing"])
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Detect whether the latest git commit is a release commit.
|
||||
|
||||
Release commits have the format ``release: vX.Y.Z [skip ci]``.
|
||||
This script writes ``is-release=true`` or ``is-release=false`` to
|
||||
``$GITHUB_OUTPUT`` for use in CI workflow conditionals.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 scripts/ci/detect_release_commit.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
|
||||
RELEASE_RE = re.compile(r"^release: v\d+\.\d+\.\d+")
|
||||
|
||||
|
||||
def get_commit_message() -> str:
|
||||
"""Get the subject of the latest git commit."""
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["git", "log", "-1", "--pretty=%s"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(f"git log failed: {result.stderr.strip()}")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def is_release_commit(message: str) -> bool:
|
||||
"""Check if a commit message matches the release commit format."""
|
||||
return bool(RELEASE_RE.match(message))
|
||||
|
||||
|
||||
def write_github_output(key: str, value: str) -> None:
|
||||
"""Append a key=value line to the $GITHUB_OUTPUT file."""
|
||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
||||
if not gh_output:
|
||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
||||
with open(gh_output, "a") as f: # noqa: PTH123
|
||||
f.write(f"{key}={value}\n")
|
||||
|
||||
|
||||
@click.command()
|
||||
def main() -> None:
|
||||
"""Detect if the latest commit is a release commit and set GITHUB_OUTPUT."""
|
||||
msg = get_commit_message()
|
||||
click.echo(f"Commit message: {msg}")
|
||||
is_release = is_release_commit(msg)
|
||||
write_github_output("is-release", "true" if is_release else "false")
|
||||
if is_release:
|
||||
click.echo("Release commit — skipping all post-merge jobs.")
|
||||
else:
|
||||
click.echo("Regular merge commit — running all post-merge jobs.")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main() # pragma: no cover
|
||||
@@ -111,9 +111,16 @@ def get_runner_count(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
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))
|
||||
def generate_indices(count: int) -> list[str]:
|
||||
"""Generate a list of runner indices ["1", "2", ..., "N"].
|
||||
|
||||
Uses 1-based string indices because Gitea Actions renders
|
||||
integer 0 and string "0" as empty in ${{ matrix.runner-index }}
|
||||
expressions, causing --runner-index to be passed without a value.
|
||||
The distribute_molecule.py script converts these back to 0-based
|
||||
internally.
|
||||
"""
|
||||
return [str(i + 1) for i in range(count)]
|
||||
|
||||
|
||||
@click.command()
|
||||
@@ -121,7 +128,20 @@ def generate_indices(count: int) -> list[int]:
|
||||
@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:
|
||||
@click.option(
|
||||
"--github-output",
|
||||
"github_output",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps).",
|
||||
)
|
||||
def main(
|
||||
owner: str | None,
|
||||
repo: str | None,
|
||||
output_count: bool,
|
||||
output_indices: bool,
|
||||
github_output: bool,
|
||||
) -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
|
||||
if owner is None:
|
||||
@@ -132,6 +152,17 @@ def main(owner: str | None, repo: str | None, output_count: bool, output_indices
|
||||
count = get_runner_count(GITEA_API_URL, token, owner, repo)
|
||||
indices = generate_indices(count)
|
||||
|
||||
if github_output:
|
||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
||||
if not gh_output:
|
||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
||||
with open(gh_output, "a") as f: # noqa: PTH123
|
||||
f.write(f"runner-count={count}\n")
|
||||
f.write(f"runner-indices={json.dumps(indices)}\n")
|
||||
click.echo(f"Runner count: {count}")
|
||||
click.echo(f"Runner indices: {indices}")
|
||||
return
|
||||
|
||||
if output_count:
|
||||
click.echo(str(count))
|
||||
return
|
||||
|
||||
@@ -9,7 +9,7 @@ Each pair is printed as ``scenario|platform_name|platform_image|platform_command
|
||||
so the CI workflow can set the appropriate environment variables.
|
||||
|
||||
Usage:
|
||||
python3 scripts/distribute_molecule.py --runner-index 0 --max-runners 3
|
||||
python3 scripts/distribute_molecule.py --runner-index 1 --max-runners 3
|
||||
# prints: default|ubuntu-2204|ubuntu:22.04| lifecycle|ubuntu-2204|ubuntu:22.04| ...
|
||||
python3 scripts/distribute_molecule.py --list
|
||||
# prints all scenarios, one per line
|
||||
@@ -25,20 +25,11 @@ from pathlib import Path
|
||||
import click
|
||||
|
||||
from gitea_runner_manager.i18n import _
|
||||
from scripts.ci.platforms import PLATFORMS
|
||||
|
||||
DEFAULT_MAX_RUNNERS = 3
|
||||
MOLECULE_ROOT = Path("ansible/roles/gitea-runner/molecule")
|
||||
|
||||
#: Supported OS platform matrix.
|
||||
#: Each entry maps a short name to (image, command).
|
||||
#: The command must be systemd since rootless Docker requires loginctl/systemctl --user.
|
||||
PLATFORMS: list[dict[str, str]] = [
|
||||
{"name": "ubuntu-2204", "image": "geerlingguy/docker-ubuntu2204-ansible:latest", "command": "/lib/systemd/systemd"},
|
||||
{"name": "ubuntu-2404", "image": "geerlingguy/docker-ubuntu2404-ansible:latest", "command": "/lib/systemd/systemd"},
|
||||
{"name": "debian-12", "image": "geerlingguy/docker-debian12-ansible:latest", "command": "/lib/systemd/systemd"},
|
||||
{"name": "archlinux", "image": "marcstraube/archlinux-ansible:latest", "command": "/usr/lib/systemd/systemd"},
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestPair:
|
||||
@@ -66,14 +57,8 @@ def discover_scenarios(root: Path | None = None) -> list[str]:
|
||||
if root is None:
|
||||
root = MOLECULE_ROOT
|
||||
if not root.is_dir():
|
||||
raise click.ClickException(
|
||||
_("Molecule directory not found: {path}", path=str(root))
|
||||
)
|
||||
scenarios = [
|
||||
d.name
|
||||
for d in root.iterdir()
|
||||
if d.is_dir() and not d.name.startswith("_") and d.name != "common"
|
||||
]
|
||||
raise click.ClickException(_("Molecule directory not found: {path}", path=str(root)))
|
||||
scenarios = [d.name for d in root.iterdir() if d.is_dir() and not d.name.startswith("_") and d.name != "common"]
|
||||
return sorted(scenarios)
|
||||
|
||||
|
||||
@@ -92,9 +77,7 @@ def distribute(pairs: list[TestPair], max_runners: int) -> list[list[TestPair]]:
|
||||
return groups
|
||||
|
||||
|
||||
def pairs_for_runner(
|
||||
pairs: list[TestPair], runner_index: int, max_runners: int
|
||||
) -> list[TestPair]:
|
||||
def pairs_for_runner(pairs: list[TestPair], runner_index: int, max_runners: int) -> list[TestPair]:
|
||||
"""Return the subset of pairs assigned to *runner_index*."""
|
||||
groups = distribute(pairs, max_runners)
|
||||
if runner_index < 0 or runner_index >= len(groups):
|
||||
@@ -108,12 +91,24 @@ def pairs_for_runner(
|
||||
return groups[runner_index]
|
||||
|
||||
|
||||
def _write_github_env(key: str, value: str) -> None:
|
||||
"""Append a key=value line to the $GITHUB_ENV file."""
|
||||
import os
|
||||
|
||||
gh_env = os.environ.get("GITHUB_ENV")
|
||||
if not gh_env:
|
||||
raise click.ClickException("GITHUB_ENV environment variable is not set")
|
||||
with open(gh_env, "a") as f: # noqa: PTH123
|
||||
f.write(f"{key}={value}\n")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--runner-index",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Zero-based runner index. If omitted, prints all groups.",
|
||||
help="One-based runner index (Gitea Actions renders 0 as empty). "
|
||||
"Converted to zero-based internally. If omitted, prints all groups.",
|
||||
)
|
||||
@click.option(
|
||||
"--max-runners",
|
||||
@@ -134,7 +129,27 @@ def pairs_for_runner(
|
||||
is_flag=True,
|
||||
help="List all supported platforms, one per line.",
|
||||
)
|
||||
def cli(runner_index: int | None, max_runners: int, list_all: bool, list_platforms: bool) -> None:
|
||||
@click.option(
|
||||
"--github-env",
|
||||
"github_env",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Write TEST_PAIRS and SKIP to $GITHUB_ENV (for CI workflow steps).",
|
||||
)
|
||||
@click.option(
|
||||
"--skip-if-excess",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="With --github-env: write SKIP=true when runner-index exceeds max-runners.",
|
||||
)
|
||||
def cli(
|
||||
runner_index: int | None,
|
||||
max_runners: int,
|
||||
list_all: bool,
|
||||
list_platforms: bool,
|
||||
github_env: bool,
|
||||
skip_if_excess: bool,
|
||||
) -> None:
|
||||
scenarios = discover_scenarios()
|
||||
if list_all:
|
||||
for s in scenarios:
|
||||
@@ -151,8 +166,26 @@ def cli(runner_index: int | None, max_runners: int, list_all: bool, list_platfor
|
||||
labels = " ".join(p.encode() for p in group) if group else "(none)"
|
||||
click.echo(f"Runner {i}: {labels}")
|
||||
return
|
||||
assigned = pairs_for_runner(pairs, runner_index, max_runners)
|
||||
click.echo(" ".join(p.encode() for p in assigned))
|
||||
|
||||
# Skip if runner index exceeds available runners (CI static matrix has 3 slots)
|
||||
if skip_if_excess and github_env and runner_index > max_runners:
|
||||
click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}")
|
||||
_write_github_env("TEST_PAIRS", "")
|
||||
_write_github_env("SKIP", "true")
|
||||
return
|
||||
|
||||
# Convert 1-based CLI index to 0-based internal index
|
||||
zero_based = runner_index - 1
|
||||
assigned = pairs_for_runner(pairs, zero_based, max_runners)
|
||||
encoded = " ".join(p.encode() for p in assigned)
|
||||
|
||||
if github_env:
|
||||
_write_github_env("TEST_PAIRS", encoded)
|
||||
_write_github_env("SKIP", "false")
|
||||
click.echo(f"Assigned pairs: {encoded}")
|
||||
return
|
||||
|
||||
click.echo(encoded)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -45,6 +45,11 @@ REQUIRED_SCRIPTS = [
|
||||
"post_merge.py",
|
||||
"classify_changes.py",
|
||||
"discover_runners.py",
|
||||
"detect_release_commit.py",
|
||||
"push_badges.py",
|
||||
"distribute_molecule.py",
|
||||
"molecule_ci_guard.py",
|
||||
"validate_commit_msg.py",
|
||||
]
|
||||
|
||||
|
||||
@@ -53,7 +58,7 @@ def extract_cli_commands() -> list[str]:
|
||||
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):
|
||||
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]
|
||||
@@ -63,7 +68,7 @@ def extract_cli_commands() -> list[str]:
|
||||
continue
|
||||
# Find the next def statement after this decorator
|
||||
after = content[decorator_end:]
|
||||
def_match = re.search(r'def\s+(\w+)\s*\(', after)
|
||||
def_match = re.search(r"def\s+(\w+)\s*\(", after)
|
||||
if def_match:
|
||||
commands.append(def_match.group(1))
|
||||
return commands
|
||||
@@ -145,7 +150,7 @@ def main(docs_dir: str, fail_on_missing: bool) -> None:
|
||||
"\nDoc coverage: {covered}/{total} ({pct}%)",
|
||||
covered=covered,
|
||||
total=total,
|
||||
pct=f"{percentage:.0f}%",
|
||||
pct=f"{percentage:.0f}",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -40,9 +40,7 @@ from gitea_runner_manager.i18n import _
|
||||
POLL_INTERVAL = 10
|
||||
|
||||
|
||||
def get_running_jobs(
|
||||
gitea_url: str, owner: str, repo: str, token: str, run_id: int
|
||||
) -> list[dict]:
|
||||
def get_running_jobs(gitea_url: str, owner: str, repo: str, token: str, run_id: int) -> list[dict]:
|
||||
"""Return jobs for the given workflow run."""
|
||||
url = f"{gitea_url}/api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/jobs"
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
@@ -52,9 +50,7 @@ def get_running_jobs(
|
||||
return data.get("jobs", [])
|
||||
|
||||
|
||||
def any_other_runner_failed(
|
||||
jobs: list[dict], current_job_name: str, current_index: int
|
||||
) -> bool:
|
||||
def any_other_runner_failed(jobs: list[dict], current_job_name: str, current_index: int) -> bool:
|
||||
"""Return True if any other molecule matrix job has failed."""
|
||||
for job in jobs:
|
||||
name = job.get("name", "")
|
||||
@@ -83,11 +79,7 @@ def poll_for_other_failures(
|
||||
try:
|
||||
jobs = get_running_jobs(gitea_url, owner, repo, token, run_id)
|
||||
if any_other_runner_failed(jobs, job_name, current_index):
|
||||
click.echo(
|
||||
_(
|
||||
"Another molecule runner failed. Stopping this runner early."
|
||||
)
|
||||
)
|
||||
click.echo(_("Another molecule runner failed. Stopping this runner early."))
|
||||
failed_event.set()
|
||||
return
|
||||
except requests.RequestException as exc:
|
||||
@@ -132,13 +124,9 @@ def cli(pairs: tuple[str, ...]) -> None:
|
||||
owner, repo = "oblachno-oss", "grm"
|
||||
|
||||
if not all([gitea_url, token, run_id]):
|
||||
click.echo(
|
||||
_(
|
||||
"GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."
|
||||
)
|
||||
)
|
||||
click.echo(_("GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
|
||||
|
||||
repo_root = Path(__file__).resolve().parent.parent
|
||||
repo_root = Path(__file__).resolve().parent.parent.parent
|
||||
role_dir = repo_root / "ansible" / "roles" / "gitea-runner"
|
||||
|
||||
base_env = os.environ.copy()
|
||||
@@ -173,9 +161,7 @@ def cli(pairs: tuple[str, ...]) -> None:
|
||||
|
||||
scenario = pair.split("|")[0]
|
||||
platform_name = pair.split("|")[1]
|
||||
click.echo(
|
||||
_("Running: {scenario} on {platform}", scenario=scenario, platform=platform_name)
|
||||
)
|
||||
click.echo(_("Running: {scenario} on {platform}", scenario=scenario, platform=platform_name))
|
||||
|
||||
cmd = build_molecule_cmd(scenario)
|
||||
env = build_env_for_pair(pair, base_env)
|
||||
@@ -208,9 +194,7 @@ def cli(pairs: tuple[str, ...]) -> None:
|
||||
|
||||
rc = process.returncode
|
||||
if rc != 0:
|
||||
click.echo(
|
||||
_("FAILED: {pair} exited with code {code}", pair=pair, code=rc)
|
||||
)
|
||||
click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc))
|
||||
sys.exit(rc)
|
||||
|
||||
click.echo(_("PASSED: {pair}", pair=pair))
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
"""Create a Gitea issue when a CI workflow fails.
|
||||
|
||||
Used by the release and publish workflows to alert on failures that would
|
||||
otherwise go unnoticed in the Actions tab.
|
||||
otherwise go unnoticed in the Actions tab. Uses the ``tea`` Gitea CLI
|
||||
for issue creation when available, falling back to ``GiteaClient`` (direct
|
||||
HTTP API) when tea is not installed.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 scripts/notify_failure.py \
|
||||
@@ -14,7 +16,9 @@ Usage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import os
|
||||
import shutil
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
@@ -27,6 +31,48 @@ from gitea_runner_manager.i18n import _
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
def _create_issue_via_tea(repo: str, title: str, body: str) -> int | None:
|
||||
"""Try creating issue via tea CLI. Returns issue index or None on failure."""
|
||||
if shutil.which("tea") is None:
|
||||
return None
|
||||
from scripts.gitea_cli import TeaCLI, TeaCLIError
|
||||
|
||||
tea = TeaCLI(repo=repo)
|
||||
try:
|
||||
# Check if "bug" label exists
|
||||
labels: list[str] = []
|
||||
try:
|
||||
existing_labels = tea.list_labels(repo)
|
||||
if any(label.get("name") == "bug" for label in existing_labels):
|
||||
labels = ["bug"]
|
||||
except TeaCLIError:
|
||||
pass
|
||||
|
||||
issue = tea.create_issue(repo, title=title, body=body, labels=labels if labels else None)
|
||||
if labels:
|
||||
with contextlib.suppress(TeaCLIError):
|
||||
tea.add_label(repo, issue["index"], labels)
|
||||
return int(issue.get("index", 0))
|
||||
except TeaCLIError:
|
||||
return None
|
||||
|
||||
|
||||
def _create_issue_via_client(repo: str, title: str, body: str) -> int:
|
||||
"""Create issue via GiteaClient (direct HTTP API). Returns issue ID."""
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
owner, repo_name = repo.split("/")
|
||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
||||
|
||||
# Look up label IDs by name (Gitea API expects integer IDs, not strings)
|
||||
label_ids: list[int] = []
|
||||
for label in client.list_labels():
|
||||
if label.get("name") == "bug":
|
||||
label_ids.append(int(label["id"]))
|
||||
break
|
||||
issue = client.create_issue(title=title, body=body, labels=label_ids if label_ids else None)
|
||||
return int(issue.get("id", 0))
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--repo", required=True, help="Repository in owner/name format.")
|
||||
@click.option("--run-id", required=True, help="CI run ID.")
|
||||
@@ -37,9 +83,6 @@ def main(repo: str, run_id: str, workflow: str, commit: str) -> None:
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
|
||||
owner, repo_name = repo.split("/")
|
||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
||||
|
||||
title = f"[CI] {workflow} workflow failed (run #{run_id})"
|
||||
body = (
|
||||
f"The **{workflow}** workflow failed.\n\n"
|
||||
@@ -50,23 +93,20 @@ def main(repo: str, run_id: str, workflow: str, commit: str) -> None:
|
||||
f"Please investigate and fix the issue."
|
||||
)
|
||||
|
||||
try:
|
||||
# Look up label IDs by name (Gitea API expects integer IDs, not strings)
|
||||
label_ids: list[int] = []
|
||||
for label in client.list_labels():
|
||||
if label.get("name") == "bug":
|
||||
label_ids.append(int(label["id"]))
|
||||
break
|
||||
issue = client.create_issue(title=title, body=body, labels=label_ids if label_ids else None)
|
||||
except APIError as e:
|
||||
raise click.ClickException(
|
||||
_("Failed to create issue: HTTP {status} — {message}", status=e.status, message=e.message)
|
||||
) from None
|
||||
# Try tea CLI first, fall back to GiteaClient
|
||||
issue_id = _create_issue_via_tea(repo, title, body)
|
||||
if issue_id is None:
|
||||
try:
|
||||
issue_id = _create_issue_via_client(repo, title, body)
|
||||
except APIError as e:
|
||||
raise click.ClickException(
|
||||
_("Failed to create issue: HTTP {status} — {message}", status=e.status, message=e.message)
|
||||
) from None
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
"Created issue #{issue_id}: {title}",
|
||||
issue_id=issue.get("id", "?"),
|
||||
issue_id=issue_id or "?",
|
||||
title=title,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Supported OS platform matrix for molecule tests.
|
||||
|
||||
Single source of truth for the platform list used by both:
|
||||
- ``scripts/ci/distribute_molecule.py`` (CI parallel matrix)
|
||||
- ``scripts/molecule_all.py`` (local sequential runner)
|
||||
|
||||
Keeping this in a dedicated module avoids cross-imports between
|
||||
dev tools and CI scripts.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
#: Supported OS platform matrix.
|
||||
#: Each entry maps a short name to (image, command).
|
||||
#: The command must be systemd since rootless Docker requires
|
||||
#: loginctl/systemctl --user.
|
||||
PLATFORMS: list[dict[str, str]] = [
|
||||
{"name": "ubuntu-2204", "image": "geerlingguy/docker-ubuntu2204-ansible:latest", "command": "/lib/systemd/systemd"},
|
||||
{"name": "ubuntu-2404", "image": "geerlingguy/docker-ubuntu2404-ansible:latest", "command": "/lib/systemd/systemd"},
|
||||
{"name": "debian-12", "image": "geerlingguy/docker-debian12-ansible:latest", "command": "/lib/systemd/systemd"},
|
||||
{"name": "archlinux", "image": "marcstraube/archlinux-ansible:latest", "command": "/usr/lib/systemd/systemd"},
|
||||
]
|
||||
@@ -7,6 +7,7 @@ Usage:
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
@@ -19,6 +20,32 @@ from gitea_runner_manager.i18n import _
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
def _get_git_commit_message() -> str:
|
||||
"""Get the full commit message of the latest commit."""
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["git", "log", "-1", "--pretty=%B"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(f"git log failed: {result.stderr.strip()}")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _get_git_commit_sha() -> str:
|
||||
"""Get the SHA of the latest commit."""
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["git", "rev-parse", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(f"git rev-parse failed: {result.stderr.strip()}")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def extract_task_id(commit_msg: str) -> str:
|
||||
"""Extract GRM-N task identifier from the first line of commit message."""
|
||||
first_line = commit_msg.split("\n")[0]
|
||||
@@ -69,16 +96,67 @@ def build_comment(task_id: str, conv_msg: str, commit_sha: str) -> str:
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("commit_msg")
|
||||
@click.argument("commit_msg", required=False)
|
||||
@click.option("--commit-sha", default="", help="Commit SHA")
|
||||
def main(commit_msg: str, commit_sha: str) -> None:
|
||||
@click.option("--from-git", is_flag=True, default=False, help="Read commit message and SHA from git.")
|
||||
@click.option(
|
||||
"--git-sha",
|
||||
default="",
|
||||
help="Read commit message from a specific git SHA (avoids race condition with parallel jobs).",
|
||||
)
|
||||
def main(commit_msg: str | None, commit_sha: str, from_git: bool, git_sha: str) -> None:
|
||||
if git_sha:
|
||||
# Read commit message from a specific SHA — this avoids the race
|
||||
# condition where a parallel job (e.g., release) pushes a new commit
|
||||
# to master before this job reads HEAD.
|
||||
result = subprocess.run( # nosec B603 B607
|
||||
["git", "log", "-1", "--pretty=%B", git_sha],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(f"git log failed for SHA {git_sha}: {result.stderr.strip()}")
|
||||
commit_msg = result.stdout.strip()
|
||||
if not commit_sha:
|
||||
commit_sha = git_sha
|
||||
elif from_git:
|
||||
commit_msg = _get_git_commit_message()
|
||||
if not commit_sha:
|
||||
commit_sha = _get_git_commit_sha()
|
||||
if not commit_msg:
|
||||
raise click.ClickException("commit_msg argument is required (or use --from-git or --git-sha)")
|
||||
token = os.environ.get("VIKUNJA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: VIKUNJA_TOKEN is not set."))
|
||||
|
||||
task_id = extract_task_id(commit_msg)
|
||||
if not task_id:
|
||||
click.echo(_("No task ID in commit message, skipping Vikunja update. All good — nothing to do here!"))
|
||||
first_line = commit_msg.split("\n")[0]
|
||||
# Skip gracefully for infrastructure commits that don't follow
|
||||
# the GRM-N convention: release commits, reverts, bot commits, etc.
|
||||
infra_patterns = [
|
||||
r"^release: v\d+\.\d+\.\d+", # release commits
|
||||
r"^revert: ", # git revert commits
|
||||
r"^Merge ", # merge commits
|
||||
r"^\[skip ci\]", # skip-ci commits
|
||||
]
|
||||
for pattern in infra_patterns:
|
||||
if re.match(pattern, first_line):
|
||||
click.echo(
|
||||
_(
|
||||
"Infrastructure commit (no GRM-N task ID), skipping Vikunja update: {msg}",
|
||||
msg=first_line,
|
||||
)
|
||||
)
|
||||
return
|
||||
# Non-infrastructure commits without GRM-N prefix — warn but don't fail
|
||||
click.echo(
|
||||
_(
|
||||
"Warning: No task ID (GRM-N) found in commit message: {msg}. Skipping Vikunja update.",
|
||||
msg=first_line,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||
@@ -92,13 +170,19 @@ def main(commit_msg: str, commit_sha: str) -> None:
|
||||
client.post_comment(vikunja_task_id, html)
|
||||
client.update_task(vikunja_task_id, done=True)
|
||||
except APIError as e:
|
||||
raise click.ClickException(
|
||||
# Vikunja is a project management tool — if it's down, the merge
|
||||
# still succeeded. Warn but don't fail the post-merge workflow.
|
||||
click.echo(
|
||||
_(
|
||||
"Vikunja API error: HTTP {status} — {message}",
|
||||
"Warning: Vikunja API error (HTTP {status}): {message}. "
|
||||
"Task {task_id} was NOT updated. The merge succeeded — "
|
||||
"please update the Vikunja task manually.",
|
||||
status=e.status,
|
||||
message=e.message,
|
||||
task_id=task_id,
|
||||
)
|
||||
) from None
|
||||
)
|
||||
return
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Automated PR review: check architecture compliance, best practices, and quality.
|
||||
|
||||
Fetches the PR diff via the Gitea API, runs a series of automated checks,
|
||||
and posts a structured review using GiteaClient.create_review.
|
||||
|
||||
Checks performed:
|
||||
1. Architecture compliance — no business logic in CLI, no direct subprocess
|
||||
calls outside executor, no hardcoded config that should be in config.py
|
||||
2. Best practices — no bare except, no print() (use click.echo), no TODO/FIXME
|
||||
left in merged code, no functions > 50 lines
|
||||
3. Security — no secrets in code, no shell=True, no eval/exec
|
||||
4. Documentation — new CLI commands documented, new modules in architecture.md
|
||||
5. Test coverage — 100% enforced by pytest-cov (checked in quality job)
|
||||
6. Commit conventions — conventional commit format on branch commits
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 scripts/ci/pr_review.py <pr_number> <owner/repo>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from gitea_runner_manager.api_clients import GiteaClient
|
||||
from gitea_runner_manager.config import GITEA_API_URL
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from gitea_runner_manager.i18n import _
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
# Files that are exempt from certain checks
|
||||
WORKFLOW_ONLY_SUFFIXES = (".yml", ".yaml", ".md", ".json", ".toml", ".cfg", ".ini", ".txt")
|
||||
PYTHON_SUFFIX = ".py"
|
||||
|
||||
# Architecture rules
|
||||
CLI_FILE = "src/gitea_runner_manager/cli.py"
|
||||
EXECUTOR_FILE = "src/gitea_runner_manager/executor.py"
|
||||
CONFIG_FILE = "src/gitea_runner_manager/config.py"
|
||||
|
||||
# Patterns that indicate business logic in CLI (should be in runner_manager.py)
|
||||
BUSINESS_LOGIC_IN_CLI = [
|
||||
(r"subprocess\.(run|call|Popen|check_output|check_call)", "subprocess call in CLI — delegate to executor.py"),
|
||||
(r"\bos\.system\b", "os.system call in CLI — delegate to executor.py"),
|
||||
(r"\bansible-playbook\b", "ansible-playbook reference in CLI — delegate to executor.py"),
|
||||
]
|
||||
|
||||
# Patterns that indicate bad practices
|
||||
BAD_PRACTICES = [
|
||||
(r"\bprint\s*\(", "print() found — use click.echo() for user output"),
|
||||
(r"\beval\s*\(", "eval() found — security risk, avoid dynamic code execution"),
|
||||
(r"\bexec\s*\(", "exec() found — security risk, avoid dynamic code execution"),
|
||||
(r"shell\s*=\s*True", "shell=True found — security risk, use shell=False with list args"),
|
||||
(r"except\s*:", "bare except found — catch specific exceptions"),
|
||||
(r"except\s+Exception\s*:", "broad Exception catch — catch specific exceptions"),
|
||||
(r"#\s*(TODO|FIXME|HACK|XXX)", "TODO/FIXME found — resolve before merging"),
|
||||
]
|
||||
|
||||
# Patterns for hardcoded config values that should be in config.py
|
||||
HARDCODED_CONFIG = [
|
||||
(r"https?://[a-z]+\.[a-z]+\.[a-z]+", "hardcoded URL — move to config.py with env var override"),
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReviewResult:
|
||||
"""Result of automated review checks."""
|
||||
|
||||
issues: list[dict[str, Any]] = field(default_factory=list)
|
||||
summary: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def has_issues(self) -> bool:
|
||||
return bool(self.issues)
|
||||
|
||||
def add_issue(self, file_path: str, line: int, message: str, severity: str = "warning") -> None:
|
||||
self.issues.append(
|
||||
{
|
||||
"path": file_path,
|
||||
"body": f"[{severity}] {message}",
|
||||
"new_position": line,
|
||||
}
|
||||
)
|
||||
|
||||
def add_summary(self, text: str) -> None:
|
||||
self.summary.append(text)
|
||||
|
||||
|
||||
def is_python_file(path: str) -> bool:
|
||||
"""Check if a file is a Python source file."""
|
||||
return path.endswith(PYTHON_SUFFIX) and not path.startswith("tests/")
|
||||
|
||||
|
||||
def is_workflow_only(path: str) -> bool:
|
||||
"""Check if a file is workflow/config/docs only (not Python source)."""
|
||||
return path.endswith(WORKFLOW_ONLY_SUFFIXES) or path.startswith((".gitea/", "docs/", "ansible/"))
|
||||
|
||||
|
||||
def check_architecture_compliance(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
||||
"""Check that changes follow the documented architecture."""
|
||||
for f in files:
|
||||
path = f.get("filename", "")
|
||||
if not is_python_file(path):
|
||||
continue
|
||||
|
||||
patch = f.get("patch", "")
|
||||
if not patch:
|
||||
continue
|
||||
|
||||
lines = patch.split("\n")
|
||||
current_line = 0
|
||||
for line in lines:
|
||||
if line.startswith("@@"):
|
||||
match = re.search(r"\+(\d+)", line)
|
||||
if match:
|
||||
current_line = int(match.group(1)) - 1
|
||||
continue
|
||||
if line.startswith("+") and not line.startswith("+++"):
|
||||
current_line += 1
|
||||
content = line[1:]
|
||||
|
||||
# Check for business logic in CLI
|
||||
if path == CLI_FILE:
|
||||
for pattern, msg in BUSINESS_LOGIC_IN_CLI:
|
||||
if re.search(pattern, content):
|
||||
result.add_issue(path, current_line, msg, "error")
|
||||
|
||||
if not result.issues:
|
||||
result.add_summary("- Architecture compliance: OK")
|
||||
|
||||
|
||||
def check_best_practices(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
||||
"""Check for common code quality issues."""
|
||||
for f in files:
|
||||
path = f.get("filename", "")
|
||||
if not is_python_file(path):
|
||||
continue
|
||||
|
||||
patch = f.get("patch", "")
|
||||
if not patch:
|
||||
continue
|
||||
|
||||
lines = patch.split("\n")
|
||||
current_line = 0
|
||||
for line in lines:
|
||||
if line.startswith("@@"):
|
||||
match = re.search(r"\+(\d+)", line)
|
||||
if match:
|
||||
current_line = int(match.group(1)) - 1
|
||||
continue
|
||||
if line.startswith("+") and not line.startswith("+++"):
|
||||
current_line += 1
|
||||
content = line[1:]
|
||||
|
||||
for pattern, msg in BAD_PRACTICES:
|
||||
if re.search(pattern, content):
|
||||
result.add_issue(path, current_line, msg, "warning")
|
||||
|
||||
if not any(i["body"].startswith("[warning]") for i in result.issues):
|
||||
result.add_summary("- Best practices: OK")
|
||||
|
||||
|
||||
def check_security(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
||||
"""Check for security issues in changed files."""
|
||||
for f in files:
|
||||
path = f.get("filename", "")
|
||||
if not is_python_file(path):
|
||||
continue
|
||||
|
||||
patch = f.get("patch", "")
|
||||
if not patch:
|
||||
continue
|
||||
|
||||
lines = patch.split("\n")
|
||||
current_line = 0
|
||||
for line in lines:
|
||||
if line.startswith("@@"):
|
||||
match = re.search(r"\+(\d+)", line)
|
||||
if match:
|
||||
current_line = int(match.group(1)) - 1
|
||||
continue
|
||||
if line.startswith("+") and not line.startswith("+++"):
|
||||
current_line += 1
|
||||
content = line[1:]
|
||||
|
||||
# Check for hardcoded secrets
|
||||
secret_re = r'(token|password|secret|key)\s*=\s*["\'][^"\']{8,}["\']' # nosec B105
|
||||
is_secret = re.search(secret_re, content, re.IGNORECASE)
|
||||
is_comment = content.strip().startswith("#")
|
||||
is_example = "your-" in content or "example" in content
|
||||
if is_secret and not is_comment and not is_example:
|
||||
result.add_issue(
|
||||
path,
|
||||
current_line,
|
||||
"potential hardcoded secret — use environment variable",
|
||||
"error",
|
||||
)
|
||||
|
||||
if not any(i["body"].startswith("[error]") and "secret" in i["body"] for i in result.issues):
|
||||
result.add_summary("- Security: OK")
|
||||
|
||||
|
||||
def check_function_length(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
||||
"""Check that no new function is excessively long (> 50 lines)."""
|
||||
for f in files:
|
||||
path = f.get("filename", "")
|
||||
if not is_python_file(path):
|
||||
continue
|
||||
|
||||
patch = f.get("patch", "")
|
||||
if not patch:
|
||||
continue
|
||||
|
||||
# Count consecutive added lines within a function
|
||||
lines = patch.split("\n")
|
||||
current_line = 0
|
||||
func_start = 0
|
||||
func_name = ""
|
||||
added_in_func = 0
|
||||
|
||||
for line in lines:
|
||||
if line.startswith("@@"):
|
||||
if func_name and added_in_func > 50:
|
||||
result.add_issue(
|
||||
path,
|
||||
func_start,
|
||||
f"function '{func_name}' adds {added_in_func} lines — consider splitting (> 50 lines)",
|
||||
"warning",
|
||||
)
|
||||
match = re.search(r"\+(\d+)", line)
|
||||
if match:
|
||||
current_line = int(match.group(1)) - 1
|
||||
func_name = ""
|
||||
added_in_func = 0
|
||||
continue
|
||||
if line.startswith("+") and not line.startswith("+++"):
|
||||
current_line += 1
|
||||
content = line[1:]
|
||||
func_match = re.match(r"\s*def\s+(\w+)\s*\(", content)
|
||||
if func_match:
|
||||
if func_name and added_in_func > 50:
|
||||
result.add_issue(
|
||||
path,
|
||||
func_start,
|
||||
f"function '{func_name}' adds {added_in_func} lines — consider splitting (> 50 lines)",
|
||||
"warning",
|
||||
)
|
||||
func_name = func_match.group(1)
|
||||
func_start = current_line
|
||||
added_in_func = 0
|
||||
else:
|
||||
added_in_func += 1
|
||||
elif line.startswith(" ") or line.startswith("-"):
|
||||
pass # context or removed line
|
||||
|
||||
# Check last function
|
||||
if func_name and added_in_func > 50:
|
||||
result.add_issue(
|
||||
path,
|
||||
func_start,
|
||||
f"function '{func_name}' adds {added_in_func} lines — consider splitting (> 50 lines)",
|
||||
"warning",
|
||||
)
|
||||
|
||||
|
||||
def check_documentation(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
||||
"""Check that documentation is updated for relevant changes."""
|
||||
has_src_changes = any(
|
||||
is_python_file(f.get("filename", "")) and f.get("filename", "").startswith("src/") for f in files
|
||||
)
|
||||
has_doc_changes = any(
|
||||
f.get("filename", "").startswith("docs/") or f.get("filename", "") in ("README.md", "AGENTS.md", "CHANGELOG.md")
|
||||
for f in files
|
||||
)
|
||||
has_ansible_changes = any(f.get("filename", "").startswith("ansible/") for f in files)
|
||||
|
||||
if has_src_changes and not has_doc_changes:
|
||||
result.add_summary("- Documentation: WARNING — source files changed but no docs updated")
|
||||
elif has_ansible_changes and not has_doc_changes:
|
||||
result.add_summary("- Documentation: WARNING — Ansible role changed but no docs updated")
|
||||
else:
|
||||
result.add_summary("- Documentation: OK")
|
||||
|
||||
|
||||
def check_test_coverage(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
||||
"""Check that tests are updated for source changes."""
|
||||
has_src_changes = any(
|
||||
is_python_file(f.get("filename", "")) and f.get("filename", "").startswith("src/") for f in files
|
||||
)
|
||||
has_test_changes = any(f.get("filename", "").startswith("tests/") for f in files)
|
||||
|
||||
if has_src_changes and not has_test_changes:
|
||||
result.add_summary("- Tests: WARNING — source files changed but no test files updated")
|
||||
else:
|
||||
result.add_summary("- Tests: OK")
|
||||
|
||||
|
||||
def check_commit_conventions(client: GiteaClient, pr_number: str, result: ReviewResult) -> None:
|
||||
"""Check that PR commits follow conventional commit format.
|
||||
|
||||
Verifies that at least one commit on the PR branch matches the
|
||||
conventional commit pattern (type: description). Merge commits
|
||||
and revert commits are exempt.
|
||||
"""
|
||||
try:
|
||||
commits = client.get_pr_commits(pr_number)
|
||||
except APIError as e:
|
||||
result.add_summary(f"- Commit conventions: ERROR — could not fetch commits: {e.message}")
|
||||
return
|
||||
|
||||
if not commits:
|
||||
result.add_summary("- Commit conventions: OK (no commits to check)")
|
||||
return
|
||||
|
||||
from gitea_runner_manager.config import CONVENTIONAL_RE
|
||||
|
||||
has_conventional = False
|
||||
non_conventional: list[str] = []
|
||||
|
||||
for commit in commits:
|
||||
commit_info = commit.get("commit", {})
|
||||
message = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
|
||||
# Skip merge commits and revert commits
|
||||
if message.startswith(("Merge", "Revert")):
|
||||
continue
|
||||
if CONVENTIONAL_RE.match(message):
|
||||
has_conventional = True
|
||||
else:
|
||||
non_conventional.append(message[:60])
|
||||
|
||||
if has_conventional:
|
||||
result.add_summary("- Commit conventions: OK")
|
||||
elif non_conventional:
|
||||
result.add_summary(
|
||||
f"- Commit conventions: WARNING — no conventional commit found. "
|
||||
f"Non-conventional commits: {', '.join(non_conventional[:3])}"
|
||||
)
|
||||
else:
|
||||
result.add_summary("- Commit conventions: OK (all commits are merges/reverts)")
|
||||
|
||||
|
||||
def run_review(client: GiteaClient, pr_number: str) -> ReviewResult:
|
||||
"""Run all review checks and return the result."""
|
||||
result = ReviewResult()
|
||||
|
||||
try:
|
||||
files = client.get_pr_files(pr_number)
|
||||
except APIError as e:
|
||||
result.add_summary(f"- ERROR: Could not fetch PR files: {e.message}")
|
||||
return result
|
||||
|
||||
if not files:
|
||||
result.add_summary("- No files changed in this PR")
|
||||
return result
|
||||
|
||||
# Run all checks
|
||||
check_architecture_compliance(files, result)
|
||||
check_best_practices(files, result)
|
||||
check_security(files, result)
|
||||
check_function_length(files, result)
|
||||
check_documentation(files, result)
|
||||
check_test_coverage(files, result)
|
||||
check_commit_conventions(client, pr_number, result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def build_review_body(result: ReviewResult) -> str:
|
||||
"""Build the review body text from the review result."""
|
||||
lines = ["## Automated PR Review", ""]
|
||||
|
||||
for item in result.summary:
|
||||
lines.append(item)
|
||||
|
||||
if result.issues:
|
||||
lines.append("")
|
||||
lines.append(f"**{len(result.issues)} issue(s) found:**")
|
||||
lines.append("")
|
||||
for issue in result.issues:
|
||||
lines.append(f"- `{issue['path']}:{issue['new_position']}` — {issue['body']}")
|
||||
else:
|
||||
lines.append("")
|
||||
lines.append("No issues found by automated checks.")
|
||||
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append("**Manual review required:** Before approving, review every category in")
|
||||
lines.append("[REVIEW_CHECKLIST.md](REVIEW_CHECKLIST.md) and confirm with:")
|
||||
lines.append("```bash")
|
||||
lines.append("python3 scripts/ci/review_pr.py <PR> <owner/repo> \\")
|
||||
lines.append(" --event APPROVE --checklist-confirmed \\")
|
||||
lines.append(' --body "<substantive review summary>"')
|
||||
lines.append("```")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def post_review(client: GiteaClient, pr_number: str, result: ReviewResult) -> dict[str, Any]:
|
||||
"""Post the review to the PR.
|
||||
|
||||
Uses REQUEST_CHANGES when issues are found, COMMENT otherwise.
|
||||
Never uses APPROVE — the bot shares the PR author's token, so
|
||||
Gitea rejects self-approval. The actual APPROVE must come from
|
||||
the manual review step.
|
||||
"""
|
||||
body = build_review_body(result)
|
||||
event = "REQUEST_CHANGES" if result.has_issues else "COMMENT"
|
||||
comments = result.issues if result.has_issues else []
|
||||
|
||||
return client.create_review(pr_number, event=event, body=body, comments=comments)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("pr_number")
|
||||
@click.argument("repo")
|
||||
@click.option("--dry-run", is_flag=True, default=False, help="Print review without posting.")
|
||||
def main(pr_number: str, repo: str, dry_run: bool) -> None:
|
||||
"""Run automated PR review and post results to Gitea."""
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
|
||||
owner, repo_name = repo.split("/")
|
||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
||||
|
||||
result = run_review(client, pr_number)
|
||||
|
||||
body = build_review_body(result)
|
||||
event = "REQUEST_CHANGES" if result.has_issues else "COMMENT"
|
||||
|
||||
click.echo(f"Review event: {event}")
|
||||
click.echo(f"Issues found: {len(result.issues)}")
|
||||
click.echo("")
|
||||
click.echo(body)
|
||||
|
||||
if dry_run:
|
||||
click.echo("\n[dry-run] Review not posted.")
|
||||
return
|
||||
|
||||
try:
|
||||
review = post_review(client, pr_number, result)
|
||||
except APIError as e:
|
||||
if "approve" in e.message.lower() or "422" in str(e.status):
|
||||
click.echo(_("Note: Self-approval not allowed. Posting COMMENT instead."))
|
||||
review = client.create_review(pr_number, event="COMMENT", body=body)
|
||||
else:
|
||||
raise
|
||||
review_id = review.get("id", "?")
|
||||
click.echo(
|
||||
_(
|
||||
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
|
||||
review_id=review_id,
|
||||
pr_number=pr_number,
|
||||
event=event,
|
||||
num_comments=len(result.issues),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main() # pragma: no cover
|
||||
+6
-18
@@ -2,6 +2,7 @@
|
||||
"""Build package, optionally publish to PyPI, and create Gitea release.
|
||||
|
||||
Uses git-cliff to generate the release notes from conventional commits.
|
||||
Uses the ``tea`` Gitea CLI for release creation.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> [PYPI_TOKEN=<token>] python3 scripts/publish.py <tag> <repo>
|
||||
@@ -15,10 +16,8 @@ import sys
|
||||
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 _
|
||||
from scripts.gitea_cli import TeaCLI, TeaCLIError
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
@@ -109,24 +108,13 @@ def main(tag: str, repo: str) -> None:
|
||||
else:
|
||||
click.echo(_("PYPI_TOKEN not set — skipping PyPI publish. No worries, we'll just create the Gitea release."))
|
||||
|
||||
owner, repo_name = repo.split("/")
|
||||
client = GiteaClient(GITEA_API_URL, gitea_token, owner, repo_name)
|
||||
|
||||
tea = TeaCLI(repo=repo)
|
||||
release_body = generate_release_notes(tag)
|
||||
|
||||
try:
|
||||
client.create_release(
|
||||
tag=tag,
|
||||
body=release_body,
|
||||
)
|
||||
except APIError as e:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Release creation failed with HTTP {status}: {message}",
|
||||
status=e.status,
|
||||
message=e.message,
|
||||
)
|
||||
) from None
|
||||
tea.create_release(repo, tag=tag, title=tag, body=release_body)
|
||||
except TeaCLIError as e:
|
||||
raise click.ClickException(_("Release creation failed: {error}", error=str(e))) from None
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate badge SVG files and push them to the ``badges`` branch.
|
||||
|
||||
Replaces the inline shell script in the post-merge workflow with a
|
||||
tested Python equivalent.
|
||||
|
||||
The script fetches the latest master before generating badges so that
|
||||
the version badge always reflects the current state of the repository
|
||||
(even if a release commit was pushed moments before by the parallel
|
||||
release job).
|
||||
|
||||
Usage::
|
||||
|
||||
python3 scripts/ci/push_badges.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
|
||||
def _run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
||||
"""Run a command and return the result."""
|
||||
return subprocess.run(cmd, check=True, text=True, **kwargs) # nosec B603
|
||||
|
||||
|
||||
def fetch_latest_master(branch: str = "master") -> None:
|
||||
"""Fetch and hard-reset to the latest remote branch.
|
||||
|
||||
Ensures the working tree reflects the absolute latest state of the
|
||||
remote, which is critical when the release job may have just pushed
|
||||
a new version commit.
|
||||
"""
|
||||
_run(["git", "fetch", "origin", branch]) # nosec B607
|
||||
_run(["git", "reset", "--hard", f"origin/{branch}"]) # nosec B607
|
||||
click.echo(f"Synced to latest origin/{branch}")
|
||||
|
||||
|
||||
def generate_badges(output_dir: str) -> None:
|
||||
"""Generate badge SVG files using generate_badges.py."""
|
||||
_run([sys.executable, "scripts/generate_badges.py", "--output-dir", output_dir])
|
||||
badges = list(Path(output_dir).glob("*.svg"))
|
||||
if not badges:
|
||||
raise click.ClickException("No badge SVG files generated")
|
||||
click.echo(f"Generated {len(badges)} badge files")
|
||||
|
||||
|
||||
def push_to_badges_branch(badges_dir: str) -> None:
|
||||
"""Push generated badges to the orphan ``badges`` branch."""
|
||||
_run(["git", "config", "user.name", "gitea-actions-bot"]) # nosec B607
|
||||
_run(["git", "config", "user.email", "actions@oblachno.fyi"]) # nosec B607
|
||||
_run(["git", "checkout", "--orphan", "badges"]) # nosec B607
|
||||
_run(["git", "rm", "-rf", "."]) # nosec B607
|
||||
|
||||
# Copy badge files to root
|
||||
import shutil
|
||||
|
||||
for svg in Path(badges_dir).glob("*.svg"):
|
||||
shutil.copy2(svg, Path.cwd() / svg.name)
|
||||
|
||||
_run(["git", "add", "./*.svg"]) # nosec B607
|
||||
_run(["git", "commit", "--no-verify", "-m", "Update badges [skip ci]"]) # nosec B607
|
||||
_run(["git", "push", "origin", "badges", "--force"]) # nosec B607
|
||||
click.echo("Badges pushed to badges branch")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--output-dir", default=".badges/", help="Temporary directory for badge files.")
|
||||
@click.option("--branch", default="master", help="Branch to sync before generating badges.")
|
||||
def main(output_dir: str, branch: str) -> None:
|
||||
"""Generate badges and push them to the badges branch."""
|
||||
fetch_latest_master(branch)
|
||||
generate_badges(output_dir)
|
||||
push_to_badges_branch(output_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main() # pragma: no cover
|
||||
+40
-17
@@ -36,7 +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
|
||||
from scripts.ci.classify_changes import has_user_facing_changes # cross-CI import, needs PYTHONPATH=.
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
@@ -105,24 +105,25 @@ def get_changelog(new_version: str) -> str:
|
||||
|
||||
|
||||
def has_unreleased_changes(bumped_version: str | None = None) -> bool:
|
||||
"""Check if there are conventional commits since the last tag.
|
||||
"""Check if there are unreleased conventional commits since the last tag.
|
||||
|
||||
If ``bumped_version`` is provided (from a prior git-cliff call), reuses it
|
||||
to avoid a duplicate subprocess invocation.
|
||||
Uses ``git log`` to check for commits between the last tag and HEAD.
|
||||
This is more reliable than comparing version strings — if git-cliff
|
||||
bumps to the same version (e.g., two fix commits between tags), the
|
||||
version comparison would incorrectly report "no unreleased changes"
|
||||
even though there are commits that haven't been released yet.
|
||||
"""
|
||||
if bumped_version is None:
|
||||
result = run_cmd(
|
||||
["git-cliff", "--bumped-version", "--config", CLIFF_CONFIG],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
bumped_version = result.stdout.strip().lstrip("v")
|
||||
latest = get_latest_tag()
|
||||
if not latest:
|
||||
return True
|
||||
current = latest.lstrip("v")
|
||||
return bumped_version != current
|
||||
# Check for any commits since the last tag
|
||||
result = run_cmd(
|
||||
["git", "log", f"{latest}..HEAD", "--oneline"],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
return bool(result.stdout.strip())
|
||||
|
||||
|
||||
def update_init_version(new_version: str) -> None:
|
||||
@@ -189,7 +190,7 @@ def commit_release_changes(new_version: str) -> bool:
|
||||
if status.returncode == 0:
|
||||
click.echo(_("No staged changes — version and changelog already up to date."))
|
||||
return False
|
||||
run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version}"])
|
||||
run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version} [skip ci]"])
|
||||
return True
|
||||
|
||||
|
||||
@@ -252,10 +253,29 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
|
||||
help="Skip lint and test verification (NOT recommended — only for emergency releases).",
|
||||
)
|
||||
def main(dry_run: bool, skip_tests: bool) -> None:
|
||||
# Ensure we're on master
|
||||
# Ensure we're on master (skip this check in dry-run mode for PR validation)
|
||||
branch = run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip()
|
||||
if branch != "master":
|
||||
if branch != "master" and not dry_run:
|
||||
raise click.ClickException(_("Release must be run on master, currently on '{branch}'.", branch=branch))
|
||||
if branch != "master" and dry_run:
|
||||
click.echo(
|
||||
_(
|
||||
"Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
|
||||
branch=branch,
|
||||
)
|
||||
)
|
||||
|
||||
# Release lock: if HEAD is already a release commit, another release
|
||||
# run is in progress (or already completed). Skip to prevent duplicate tags.
|
||||
head_msg = run_cmd(["git", "log", "-1", "--pretty=%s"]).stdout.strip()
|
||||
if re.match(r"^release: v\d+\.\d+\.\d+", head_msg):
|
||||
click.echo(
|
||||
_(
|
||||
"HEAD is already a release commit ('{msg}'). Another release may have just completed. Skipping.",
|
||||
msg=head_msg,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
# Check if any user-facing files changed since the last tag.
|
||||
# If only workflow/infra files changed, skip the release entirely.
|
||||
@@ -319,6 +339,9 @@ def main(dry_run: bool, skip_tests: bool) -> None:
|
||||
committed = commit_release_changes(new_version)
|
||||
if committed:
|
||||
click.echo(_("Created release commit."))
|
||||
# Pull --rebase before push to handle the case where master
|
||||
# advanced between checkout and commit (e.g., another merge).
|
||||
run_cmd(["git", "pull", "--rebase", "origin", "master"], check=False)
|
||||
run_cmd(["git", "push", "origin", "master"])
|
||||
click.echo(_("Pushed release commit to master."))
|
||||
else:
|
||||
|
||||
+36
-13
@@ -3,9 +3,14 @@
|
||||
|
||||
Used by the GRM workflow to post structured PR reviews. The review body
|
||||
is provided via --body and inline comments via a JSON file
|
||||
(--comments-json) or stdin (--comments-stdin). This script is a thin
|
||||
CLI wrapper around ``GiteaClient.create_review`` — the actual review
|
||||
analysis is performed by the agent before invoking this tool.
|
||||
(--comments-json) or stdin (--comments-stdin).
|
||||
|
||||
.. note::
|
||||
The ``tea`` CLI v0.14.1 only supports interactive reviews (no
|
||||
``--approve``/``--comment`` flags), so this script uses
|
||||
``GiteaClient`` (direct HTTP API) for posting reviews. When a newer
|
||||
version of tea adds non-interactive review support, this can be
|
||||
switched to use ``TeaCLI.review_pr()``.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 scripts/review_pr.py <pr_number> <repo> \
|
||||
@@ -19,16 +24,11 @@ The comments JSON file is a list of objects with keys:
|
||||
- new_position: line number in the new file (1-based)
|
||||
- old_position: (optional) line number in the old file
|
||||
|
||||
Review focus areas (for the reviewer, not enforced by this script):
|
||||
- Functional completeness
|
||||
- Edge cases
|
||||
- Technical excellence: architecture compliance, SRP, deduplication,
|
||||
code smells, best practices, code quality, reusability, clean code,
|
||||
readability, maintainability, extensibility
|
||||
- Performance
|
||||
- Security
|
||||
- User experience
|
||||
- Documentation completeness and relevance
|
||||
For APPROVE events, --checklist-confirmed is required. This attests
|
||||
that the reviewer has gone through every category in
|
||||
REVIEW_CHECKLIST.md. The review body must also be substantive
|
||||
(> 20 characters) — trivial "LGTM" approvals are rejected by the
|
||||
auto-merge gate.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -93,6 +93,12 @@ def parse_comments(comments_json: str | None, comments_stdin: bool) -> list[dict
|
||||
default=False,
|
||||
help="Read inline comments JSON from stdin.",
|
||||
)
|
||||
@click.option(
|
||||
"--checklist-confirmed",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Required for APPROVE: confirms all REVIEW_CHECKLIST.md categories reviewed.",
|
||||
)
|
||||
def main(
|
||||
pr_number: str,
|
||||
repo: str,
|
||||
@@ -100,6 +106,7 @@ def main(
|
||||
body: str,
|
||||
comments_json: str | None,
|
||||
comments_stdin: bool,
|
||||
checklist_confirmed: bool,
|
||||
) -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
if not token:
|
||||
@@ -113,6 +120,22 @@ def main(
|
||||
if event != "APPROVE" and not body and not comments:
|
||||
raise click.ClickException(_("Review body or inline comments are required for event '{event}'.", event=event))
|
||||
|
||||
if event == "APPROVE":
|
||||
if not checklist_confirmed:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"APPROVE requires --checklist-confirmed. "
|
||||
"Review every category in REVIEW_CHECKLIST.md before approving."
|
||||
)
|
||||
)
|
||||
if len(body.strip()) <= 20 and not comments:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"APPROVE review body must be substantive (> 20 characters) "
|
||||
"or include inline comments. Trivial approvals are rejected."
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
review = client.create_review(pr_number, event=event, body=body, comments=comments)
|
||||
except APIError as e:
|
||||
|
||||
+164
-7
@@ -6,18 +6,20 @@ 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}
|
||||
Gitea 1.26 wiki API endpoints (all use content_base64, NOT content):
|
||||
- Create: POST /repos/{owner}/{repo}/wiki/new {title, content_base64, message}
|
||||
- Update: PATCH /repos/{owner}/{repo}/wiki/page/{sub_url} {title, content_base64, message}
|
||||
- List: GET /repos/{owner}/{repo}/wiki/pages → [{title, sub_url, ...}]
|
||||
- Fetch: GET /repos/{owner}/{repo}/wiki/page/{sub_url} → {title, content_base64, ...}
|
||||
- Delete: DELETE /repos/{owner}/{repo}/wiki/page/{sub_url}
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 scripts/sync_wiki.py [--dry-run] [--repo owner/repo]
|
||||
REPO_TOKEN=<token> python3 scripts/ci/sync_wiki.py [--dry-run] [--repo owner/repo]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
@@ -49,6 +51,23 @@ def read_doc_content(file_path: str) -> str:
|
||||
return f.read()
|
||||
|
||||
|
||||
def encode_content(content: str) -> str:
|
||||
"""Encode content as base64 for the Gitea wiki API.
|
||||
|
||||
The Gitea wiki API requires content_base64, not plain content.
|
||||
Sending plain content silently fails (pages are created/updated
|
||||
but with empty content).
|
||||
"""
|
||||
return base64.b64encode(content.encode("utf-8")).decode("ascii")
|
||||
|
||||
|
||||
def decode_content(content_b64: str) -> str:
|
||||
"""Decode base64 content from the Gitea wiki API."""
|
||||
if not content_b64:
|
||||
return ""
|
||||
return base64.b64decode(content_b64).decode("utf-8")
|
||||
|
||||
|
||||
def list_wiki_pages(client: GiteaClient) -> dict[str, str]:
|
||||
"""List existing wiki pages, returning {title: sub_url}."""
|
||||
try:
|
||||
@@ -58,6 +77,15 @@ def list_wiki_pages(client: GiteaClient) -> dict[str, str]:
|
||||
return {page.get("title", ""): page.get("sub_url", page.get("title", "")) for page in pages}
|
||||
|
||||
|
||||
def fetch_page_content(client: GiteaClient, sub_url: str) -> str:
|
||||
"""Fetch a wiki page's content by sub_url, decoded from base64."""
|
||||
try:
|
||||
page = client._request("GET", f"/wiki/page/{sub_url}").json()
|
||||
return decode_content(page.get("content_base64", ""))
|
||||
except APIError:
|
||||
return ""
|
||||
|
||||
|
||||
def sync_page(
|
||||
client: GiteaClient,
|
||||
page_title: str,
|
||||
@@ -73,13 +101,19 @@ def sync_page(
|
||||
click.echo(_("[dry-run] Would sync page: {title} ({chars} chars)", title=page_title, chars=len(content)))
|
||||
return "skipped"
|
||||
|
||||
content_b64 = encode_content(content)
|
||||
|
||||
if page_title in existing_pages:
|
||||
# Update existing page via PATCH
|
||||
sub_url = existing_pages[page_title]
|
||||
client._request(
|
||||
"PATCH",
|
||||
f"/wiki/page/{sub_url}",
|
||||
json={"title": page_title, "content": content, "message": f"Sync from docs/ — update {page_title}"},
|
||||
json={
|
||||
"title": page_title,
|
||||
"content_base64": content_b64,
|
||||
"message": f"Sync from docs/ — update {page_title}",
|
||||
},
|
||||
)
|
||||
return "updated"
|
||||
|
||||
@@ -87,15 +121,93 @@ def sync_page(
|
||||
client._request(
|
||||
"POST",
|
||||
"/wiki/new",
|
||||
json={"title": page_title, "content": content, "message": f"Sync from docs/ — create {page_title}"},
|
||||
json={
|
||||
"title": page_title,
|
||||
"content_base64": content_b64,
|
||||
"message": f"Sync from docs/ — create {page_title}",
|
||||
},
|
||||
)
|
||||
return "created"
|
||||
|
||||
|
||||
def verify_wiki_page(
|
||||
client: GiteaClient, page_title: str, expected_content: str, existing_pages: dict[str, str]
|
||||
) -> bool:
|
||||
"""Verify that a wiki page has non-empty content matching the docs.
|
||||
|
||||
Returns True if the page content matches, False otherwise.
|
||||
"""
|
||||
if page_title not in existing_pages:
|
||||
return False
|
||||
sub_url = existing_pages[page_title]
|
||||
actual = fetch_page_content(client, sub_url)
|
||||
return actual.strip() == expected_content.strip()
|
||||
|
||||
|
||||
def verify_wiki_integrity(
|
||||
client: GiteaClient,
|
||||
mapping: dict[str, str],
|
||||
synced: dict[str, str],
|
||||
) -> list[str]:
|
||||
"""Comprehensive wiki verification.
|
||||
|
||||
Checks:
|
||||
1. Every mapped page exists in the wiki
|
||||
2. Every mapped page has non-empty content
|
||||
3. Every mapped page's content matches the docs
|
||||
4. No stale pages exist in the wiki (pages not in mapping)
|
||||
5. Page count matches
|
||||
|
||||
Returns a list of failure messages (empty if all checks pass).
|
||||
"""
|
||||
failures: list[str] = []
|
||||
existing_pages = list_wiki_pages(client)
|
||||
expected_titles = set(mapping.values())
|
||||
|
||||
# Check 1: Page count
|
||||
if len(existing_pages) != len(expected_titles):
|
||||
failures.append(f"Page count mismatch: wiki has {len(existing_pages)}, mapping has {len(expected_titles)}")
|
||||
|
||||
# Check 2: Missing pages (in mapping but not in wiki)
|
||||
missing = expected_titles - set(existing_pages.keys())
|
||||
for title in sorted(missing):
|
||||
failures.append(f"Missing page: {title}")
|
||||
|
||||
# Check 3: Stale pages (in wiki but not in mapping)
|
||||
stale = set(existing_pages.keys()) - expected_titles
|
||||
for title in sorted(stale):
|
||||
failures.append(f"Stale page (not in mapping): {title}")
|
||||
|
||||
# Check 4: Content verification
|
||||
for page_title, expected_content in sorted(synced.items()):
|
||||
ok = verify_wiki_page(client, page_title, expected_content, existing_pages)
|
||||
if not ok:
|
||||
sub_url = existing_pages.get(page_title, "?")
|
||||
actual = fetch_page_content(client, sub_url)
|
||||
if not actual.strip():
|
||||
failures.append(f"Empty content: {page_title}")
|
||||
else:
|
||||
failures.append(f"Content mismatch: {page_title}")
|
||||
|
||||
return failures
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--dry-run", is_flag=True, default=False, help="Show what would happen without making changes.")
|
||||
@click.option("--repo", default=None, help="Repository in owner/name format (auto-detected if omitted).")
|
||||
def main(dry_run: bool, repo: str | None) -> None:
|
||||
@click.option(
|
||||
"--verify",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="After syncing, verify each page has non-empty content. Exit 1 if any page is empty or mismatched.",
|
||||
)
|
||||
@click.option(
|
||||
"--strict",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Full integrity check: verify page count, missing pages, stale pages, and content. Implies --verify.",
|
||||
)
|
||||
def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
@@ -121,6 +233,7 @@ def main(dry_run: bool, repo: str | None) -> None:
|
||||
created = 0
|
||||
updated = 0
|
||||
skipped = 0
|
||||
synced: dict[str, str] = {} # title -> content, for verification
|
||||
|
||||
for file_path, page_title in sorted(mapping.items()):
|
||||
try:
|
||||
@@ -130,6 +243,11 @@ def main(dry_run: bool, repo: str | None) -> None:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
if not content.strip():
|
||||
click.echo(_("WARNING: File {file} is empty — skipping.", file=file_path))
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
result = sync_page(client, page_title, content, existing_pages, dry_run)
|
||||
if result == "created":
|
||||
created += 1
|
||||
@@ -140,6 +258,8 @@ def main(dry_run: bool, repo: str | None) -> None:
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
synced[page_title] = content
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
"\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
|
||||
@@ -149,6 +269,43 @@ def main(dry_run: bool, repo: str | None) -> None:
|
||||
)
|
||||
)
|
||||
|
||||
# --strict implies --verify
|
||||
do_verify = verify or strict
|
||||
|
||||
if do_verify and not dry_run:
|
||||
if strict:
|
||||
click.echo(_("\nRunning full wiki integrity check..."))
|
||||
failures = verify_wiki_integrity(client, mapping, synced)
|
||||
if failures:
|
||||
click.echo(_("\nIntegrity check FAILED ({count} issues):", count=len(failures)))
|
||||
for f in failures:
|
||||
click.echo(f" - {f}")
|
||||
raise click.ClickException(_("Wiki integrity check failed — {count} issue(s)", count=len(failures)))
|
||||
click.echo(_("\nIntegrity check passed — all {count} pages verified.", count=len(synced)))
|
||||
else:
|
||||
click.echo(_("\nVerifying wiki pages have content..."))
|
||||
# Re-fetch the page list to get updated sub_urls
|
||||
existing_pages = list_wiki_pages(client)
|
||||
failures = 0
|
||||
for page_title, expected_content in sorted(synced.items()):
|
||||
ok = verify_wiki_page(client, page_title, expected_content, existing_pages)
|
||||
if ok:
|
||||
click.echo(_(" OK: {title} ({chars} chars)", title=page_title, chars=len(expected_content)))
|
||||
else:
|
||||
click.echo(_(" FAIL: {title} — content mismatch or empty!", title=page_title))
|
||||
failures += 1
|
||||
if failures > 0:
|
||||
click.echo(
|
||||
_(
|
||||
"\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
|
||||
failures=failures,
|
||||
)
|
||||
)
|
||||
raise click.ClickException(
|
||||
_("Wiki verification failed — {failures} page(s) empty or mismatched", failures=failures)
|
||||
)
|
||||
click.echo(_("\nVerification passed — all wiki pages have correct content."))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Configure GRM repository: branch protection + labels via Gitea REST API.
|
||||
|
||||
Uses the ``tea`` Gitea CLI for label creation and the ``GiteaClient`` for
|
||||
branch protection and repo settings (tea only supports basic protect/unprotect,
|
||||
not the detailed config we need with status checks and required approvals).
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 scripts/configure_repo.py
|
||||
"""
|
||||
@@ -23,6 +27,7 @@ from gitea_runner_manager.config import (
|
||||
)
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from gitea_runner_manager.i18n import _
|
||||
from scripts.gitea_cli import TeaCLI, TeaCLIError
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
@@ -38,9 +43,35 @@ def _handle_http_error(e: APIError) -> None:
|
||||
status=e.status,
|
||||
)
|
||||
)
|
||||
raise click.ClickException(
|
||||
_("HTTP error: {status} — {message}", status=e.status, message=e.message)
|
||||
raise click.ClickException(_("HTTP error: {status} — {message}", status=e.status, message=e.message))
|
||||
|
||||
|
||||
def _ensure_label_via_tea(tea: TeaCLI, repo: str, name: str, color: str, description: str) -> bool:
|
||||
"""Create a label via tea if it doesn't already exist.
|
||||
|
||||
Returns True if created, False if it already existed.
|
||||
"""
|
||||
try:
|
||||
existing = tea.list_labels(repo)
|
||||
if any(label.get("name") == name for label in existing):
|
||||
return False
|
||||
tea.create_label(repo, name=name, color=color, description=description)
|
||||
return True
|
||||
except TeaCLIError:
|
||||
# Fall back to GiteaClient if tea fails
|
||||
return _ensure_label_via_client(name, color, description)
|
||||
|
||||
|
||||
def _ensure_label_via_client(name: str, color: str, description: str) -> bool:
|
||||
"""Fallback: create label via GiteaClient. Returns True if created."""
|
||||
client = GiteaClient(
|
||||
GITEA_API_URL,
|
||||
os.environ.get("REPO_TOKEN", ""),
|
||||
REPO_OWNER,
|
||||
REPO_NAME,
|
||||
)
|
||||
result = client.ensure_label(name=name, color=color, description=description)
|
||||
return result is not None
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -48,7 +79,9 @@ def main() -> None:
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
|
||||
repo = f"{REPO_OWNER}/{REPO_NAME}"
|
||||
client = GiteaClient(GITEA_API_URL, token, REPO_OWNER, REPO_NAME)
|
||||
tea = TeaCLI(repo=repo)
|
||||
|
||||
try:
|
||||
click.echo(_("Configuring branch protection for {branch}...", branch="master"))
|
||||
@@ -69,15 +102,17 @@ def main() -> None:
|
||||
click.echo("")
|
||||
label_name = cast(str, LABEL_CONFIG["name"])
|
||||
click.echo(_("Creating {label} label...", label=label_name))
|
||||
result = client.ensure_label(
|
||||
name=cast(str, LABEL_CONFIG["name"]),
|
||||
created = _ensure_label_via_tea(
|
||||
tea,
|
||||
repo,
|
||||
name=label_name,
|
||||
color=cast(str, LABEL_CONFIG["color"]),
|
||||
description=cast(str, LABEL_CONFIG["description"]),
|
||||
)
|
||||
if result is None:
|
||||
click.echo(_(" Label '{label}' already exists.", label=label_name))
|
||||
else:
|
||||
if created:
|
||||
click.echo(_(" Label '{label}' created.", label=label_name))
|
||||
else:
|
||||
click.echo(_(" Label '{label}' already exists.", label=label_name))
|
||||
|
||||
click.echo("")
|
||||
click.echo(_("Configuring repository settings..."))
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate self-contained SVG badge files from project metrics.
|
||||
|
||||
Runs pytest-cov, doc-coverage, lint checks, and version extraction,
|
||||
then writes SVG badge files that can be served as static files from
|
||||
the Gitea raw file API.
|
||||
|
||||
Usage:
|
||||
python3 scripts/generate_badges.py --output-dir .badges/
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
INIT_FILE = REPO_ROOT / "src" / "gitea_runner_manager" / "__init__.py"
|
||||
|
||||
_COVERAGE_RE = re.compile(r"TOTAL.*?(\d+(?:\.\d+)?)%")
|
||||
_PASSED_RE = re.compile(r"(\d+) passed")
|
||||
_DOC_COVERAGE_RE = re.compile(r"Doc coverage:\s+\d+/\d+\s+\((\d+)%")
|
||||
|
||||
# shields.io color names to hex values
|
||||
COLOR_HEX: dict[str, str] = {
|
||||
"brightgreen": "#4c1",
|
||||
"green": "#97ca00",
|
||||
"yellowgreen": "#a4a61d",
|
||||
"yellow": "#dfb317",
|
||||
"orange": "#fe7d37",
|
||||
"red": "#e05d44",
|
||||
"blue": "#007ec6",
|
||||
"lightgrey": "#9f9f9f",
|
||||
}
|
||||
|
||||
|
||||
def _xml_escape(text: str) -> str:
|
||||
"""Escape XML special characters."""
|
||||
return text.replace("&", "&").replace("<", "<").replace(">", ">").replace('"', """)
|
||||
|
||||
|
||||
def run_command(cmd: list[str]) -> tuple[int, str, str]:
|
||||
"""Run a command and return (returncode, stdout, stderr)."""
|
||||
result = subprocess.run( # nosec B603
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
cwd=str(REPO_ROOT),
|
||||
)
|
||||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
|
||||
def make_badge(label: str, message: str, color: str) -> dict[str, str | int]:
|
||||
"""Build a badge data dict."""
|
||||
return {
|
||||
"schemaVersion": 1,
|
||||
"label": label,
|
||||
"message": message,
|
||||
"color": color,
|
||||
}
|
||||
|
||||
|
||||
def render_svg(label: str, message: str, color: str) -> str:
|
||||
"""Render a shields.io-style SVG badge."""
|
||||
color_hex = COLOR_HEX.get(color, color if color.startswith("#") else "#9f9f9f")
|
||||
|
||||
# Approximate text width: 7px per character + 10px padding
|
||||
label_text = _xml_escape(label)
|
||||
message_text = _xml_escape(message)
|
||||
label_w = max(len(label) * 7 + 10, 30)
|
||||
message_w = max(len(message) * 7 + 10, 30)
|
||||
total_w = label_w + message_w
|
||||
|
||||
return f'''<svg xmlns="http://www.w3.org/2000/svg" width="{total_w}" height="20" role="img"
|
||||
aria-label="{label_text}: {message_text}">
|
||||
<title>{label_text}: {message_text}</title>
|
||||
<linearGradient id="s" x2="0" y2="100%">
|
||||
<stop offset="0" stop-color="#fff" stop-opacity=".7"/>
|
||||
<stop offset=".1" stop-color="#bbb" stop-opacity=".1"/>
|
||||
<stop offset=".9" stop-color="#000" stop-opacity=".3"/>
|
||||
<stop offset="1" stop-color="#bbb" stop-opacity=".1"/>
|
||||
</linearGradient>
|
||||
<clipPath id="r"><rect width="{total_w}" height="20" rx="3" fill="#fff"/></clipPath>
|
||||
<g clip-path="url(#r)">
|
||||
<rect width="{label_w}" height="20" fill="#555"/>
|
||||
<rect x="{label_w}" width="{message_w}" height="20" fill="{color_hex}"/>
|
||||
<rect width="{total_w}" height="20" fill="url(#s)"/>
|
||||
</g>
|
||||
<g fill="#fff" text-anchor="middle" font-family="Verdana,DejaVu Sans,sans-serif" font-size="11">
|
||||
<text x="{label_w // 2}" y="14">{label_text}</text>
|
||||
<text x="{label_w + message_w // 2}" y="14">{message_text}</text>
|
||||
</g>
|
||||
</svg>
|
||||
'''
|
||||
|
||||
|
||||
def extract_coverage(output: str) -> float | None:
|
||||
"""Extract total coverage percentage from pytest-cov output."""
|
||||
for line in output.splitlines():
|
||||
match = _COVERAGE_RE.search(line)
|
||||
if match:
|
||||
return float(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def extract_test_count(output: str) -> int | None:
|
||||
"""Extract number of passed tests from pytest output."""
|
||||
for line in output.splitlines():
|
||||
match = _PASSED_RE.search(line)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def extract_doc_coverage(output: str) -> int | None:
|
||||
"""Extract doc coverage percentage from doc_coverage.py output."""
|
||||
match = _DOC_COVERAGE_RE.search(output)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
return None
|
||||
|
||||
|
||||
def read_version() -> str:
|
||||
"""Read __version__ from the package __init__.py."""
|
||||
content = INIT_FILE.read_text()
|
||||
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return "unknown"
|
||||
|
||||
|
||||
def coverage_color(pct: float) -> str:
|
||||
"""Map coverage percentage to a color."""
|
||||
if pct >= 100:
|
||||
return "brightgreen"
|
||||
if pct >= 90:
|
||||
return "green"
|
||||
if pct >= 80:
|
||||
return "yellowgreen"
|
||||
if pct >= 70:
|
||||
return "yellow"
|
||||
if pct >= 60:
|
||||
return "orange"
|
||||
return "red"
|
||||
|
||||
|
||||
def doc_coverage_color(pct: int) -> str:
|
||||
"""Map doc coverage percentage to a color."""
|
||||
if pct >= 100:
|
||||
return "brightgreen"
|
||||
if pct >= 90:
|
||||
return "green"
|
||||
if pct >= 80:
|
||||
return "yellowgreen"
|
||||
if pct >= 70:
|
||||
return "yellow"
|
||||
return "orange"
|
||||
|
||||
|
||||
def generate_badges(output_dir: Path) -> dict[str, dict[str, str | int]]:
|
||||
"""Generate all badge SVG files and return badge data as a dict."""
|
||||
badges: dict[str, dict[str, str | int]] = {}
|
||||
|
||||
# 1. Code coverage + test count (single pytest-cov run)
|
||||
rc, stdout, stderr = run_command(
|
||||
[
|
||||
".venv/bin/pytest",
|
||||
"tests/",
|
||||
"-v",
|
||||
"--cov=src/gitea_runner_manager",
|
||||
"--cov=scripts",
|
||||
"--cov-report=term-missing",
|
||||
"--cov-fail-under=0",
|
||||
]
|
||||
)
|
||||
combined = stdout + "\n" + stderr
|
||||
|
||||
coverage = extract_coverage(combined)
|
||||
if coverage is not None:
|
||||
badges["coverage"] = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage))
|
||||
else:
|
||||
badges["coverage"] = make_badge("coverage", "unknown", "red")
|
||||
|
||||
test_count = extract_test_count(combined)
|
||||
if test_count is not None:
|
||||
badges["tests"] = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red")
|
||||
else:
|
||||
badges["tests"] = make_badge("tests", "unknown", "red")
|
||||
|
||||
# 2. Documentation coverage
|
||||
rc, stdout, _ = run_command(
|
||||
[
|
||||
".venv/bin/python3",
|
||||
"scripts/ci/doc_coverage.py",
|
||||
]
|
||||
)
|
||||
doc_pct = extract_doc_coverage(stdout)
|
||||
if doc_pct is not None:
|
||||
badges["docs"] = make_badge("docs", f"{doc_pct}%", doc_coverage_color(doc_pct))
|
||||
else:
|
||||
badges["docs"] = make_badge("docs", "unknown", "red")
|
||||
|
||||
# 3. Code quality (ruff + pyright + bandit all pass)
|
||||
lint_rc, _, _ = run_command([".venv/bin/ruff", "check", "src/", "tests/", "scripts/"])
|
||||
format_rc, _, _ = run_command([".venv/bin/ruff", "format", "--check", "src/", "tests/", "scripts/"])
|
||||
type_rc, _, _ = run_command([".venv/bin/pyright"])
|
||||
bandit_rc, _, _ = run_command([".venv/bin/bandit", "-r", "src/", "scripts/"])
|
||||
|
||||
all_pass = all(rc == 0 for rc in [lint_rc, format_rc, type_rc, bandit_rc])
|
||||
badges["quality"] = make_badge("code quality", "A" if all_pass else "F", "brightgreen" if all_pass else "red")
|
||||
|
||||
# 4. Version
|
||||
version = read_version()
|
||||
badges["version"] = make_badge("version", f"v{version}", "blue")
|
||||
|
||||
# 5. Python version (static but nice)
|
||||
badges["python"] = make_badge("python", "3.12", "blue")
|
||||
|
||||
# Write SVG files
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
for name, badge in badges.items():
|
||||
svg = render_svg(str(badge["label"]), str(badge["message"]), str(badge["color"]))
|
||||
path = output_dir / f"{name}.svg"
|
||||
path.write_text(svg)
|
||||
click.echo(f" Generated: {path}")
|
||||
|
||||
return badges
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--output-dir",
|
||||
default=str(REPO_ROOT / ".badges"),
|
||||
help="Directory to write badge SVG files.",
|
||||
)
|
||||
def cli(output_dir: str) -> None:
|
||||
"""Generate self-contained SVG badge files from project metrics."""
|
||||
out = Path(output_dir)
|
||||
click.echo(f"Generating badges in {out}...")
|
||||
badges = generate_badges(out)
|
||||
click.echo(f"\nGenerated {len(badges)} badges:")
|
||||
for name, badge in badges.items():
|
||||
click.echo(f" {name}: {badge['label']}={badge['message']} ({badge['color']})")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -0,0 +1,323 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Thin Python wrapper around the ``tea`` Gitea CLI for CI/CD scripts.
|
||||
|
||||
This module provides a programmatic interface to the ``tea`` CLI tool,
|
||||
parsing JSON output for structured data. It is used by CI scripts to
|
||||
avoid hand-rolling HTTP requests and to leverage the official Gitea CLI
|
||||
for reliability.
|
||||
|
||||
The wrapper requires ``tea`` to be installed and configured (run
|
||||
``make setup`` which calls ``scripts/install_tools.py`` and
|
||||
``scripts/setup.py``).
|
||||
|
||||
Operations supported via tea:
|
||||
- Creating pull requests
|
||||
- Creating issues
|
||||
- Adding labels to issues/PRs
|
||||
- Creating labels
|
||||
- Merging pull requests
|
||||
- Creating releases
|
||||
- Posting reviews on PRs
|
||||
- Listing branches
|
||||
|
||||
Operations NOT supported via tea (still use GiteaClient):
|
||||
- Wiki page management
|
||||
- Commit status checks
|
||||
- Runner discovery
|
||||
- PR file/commit listing (tea has limited support)
|
||||
- Branch protection with detailed config (tea only has basic protect/unprotect)
|
||||
|
||||
Usage::
|
||||
|
||||
from scripts.gitea_cli import TeaCLI
|
||||
|
||||
tea = TeaCLI()
|
||||
tea.create_issue("owner/repo", title="Bug", body="Description", labels=["bug"])
|
||||
tea.add_label("owner/repo", 42, ["ready-to-merge"])
|
||||
tea.create_release("owner/repo", tag="v1.0.0", title="Release 1.0.0", body="Notes")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
from typing import Any
|
||||
|
||||
|
||||
class TeaCLIError(Exception):
|
||||
"""Raised when a tea CLI command fails."""
|
||||
|
||||
|
||||
class TeaCLI:
|
||||
"""Wrapper around the ``tea`` Gitea CLI tool.
|
||||
|
||||
All methods parse JSON output from tea for structured access.
|
||||
Commands are run with ``--output json`` where structured data is expected.
|
||||
"""
|
||||
|
||||
def __init__(self, tea_bin: str | None = None, repo: str | None = None) -> None:
|
||||
"""Initialize the tea CLI wrapper.
|
||||
|
||||
Args:
|
||||
tea_bin: Path to the tea binary. If None, auto-detect via shutil.which.
|
||||
repo: Default repo in ``owner/name`` format for commands that need it.
|
||||
"""
|
||||
self._tea = tea_bin or shutil.which("tea") or "tea"
|
||||
self._repo = repo
|
||||
|
||||
def _run(self, args: list[str], json_output: bool = True) -> str:
|
||||
"""Run a tea command and return stdout.
|
||||
|
||||
Args:
|
||||
args: Command arguments (without the leading ``tea``).
|
||||
json_output: If True, append ``--output json`` to the command.
|
||||
|
||||
Returns:
|
||||
stdout as a string.
|
||||
|
||||
Raises:
|
||||
TeaCLIError: If the command fails.
|
||||
"""
|
||||
cmd = [self._tea, *args]
|
||||
if json_output:
|
||||
cmd.extend(["--output", "json"])
|
||||
result = subprocess.run( # nosec B603
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise TeaCLIError(
|
||||
f"tea command failed (rc={result.returncode}): {' '.join(args)}\nstderr: {result.stderr.strip()}"
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
def _run_raw(self, args: list[str]) -> str:
|
||||
"""Run a tea command without JSON output and return stdout."""
|
||||
return self._run(args, json_output=False)
|
||||
|
||||
def _repo_arg(self, repo: str | None = None) -> list[str]:
|
||||
"""Build the --repo argument list."""
|
||||
target = repo or self._repo
|
||||
if target:
|
||||
return ["--repo", target]
|
||||
return []
|
||||
|
||||
# -- Issues --
|
||||
|
||||
def create_issue(
|
||||
self,
|
||||
repo: str,
|
||||
title: str,
|
||||
body: str = "",
|
||||
labels: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Create an issue and return the issue dict.
|
||||
|
||||
Args:
|
||||
repo: Repository in ``owner/name`` format.
|
||||
title: Issue title.
|
||||
body: Issue body (markdown).
|
||||
labels: List of label names to attach.
|
||||
|
||||
Returns:
|
||||
The created issue as a dict (parsed from tea JSON output).
|
||||
"""
|
||||
args = ["issues", "create", "--title", title, "--body", body, *self._repo_arg(repo)]
|
||||
output = self._run(args, json_output=False)
|
||||
# tea issues create doesn't output JSON; extract issue number from output
|
||||
# Format: "Created issue #42: <title>"
|
||||
issue_index = _extract_issue_number(output)
|
||||
return {"title": title, "body": body, "index": issue_index, "url": output.strip()}
|
||||
|
||||
# -- Labels --
|
||||
|
||||
def list_labels(self, repo: str) -> list[dict[str, Any]]:
|
||||
"""List all labels for a repository."""
|
||||
output = self._run(["labels", "list", *self._repo_arg(repo)])
|
||||
if not output:
|
||||
return []
|
||||
return json.loads(output)
|
||||
|
||||
def create_label(
|
||||
self,
|
||||
repo: str,
|
||||
name: str,
|
||||
color: str = "",
|
||||
description: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Create a label. Returns the label dict.
|
||||
|
||||
Args:
|
||||
repo: Repository in ``owner/name`` format.
|
||||
name: Label name.
|
||||
color: Hex color (without #), e.g. ``2ecc71``.
|
||||
description: Label description.
|
||||
"""
|
||||
args = ["labels", "create", name, *self._repo_arg(repo)]
|
||||
if color:
|
||||
args.extend(["--color", f"#{color}"])
|
||||
if description:
|
||||
args.extend(["--description", description])
|
||||
output = self._run(args, json_output=False)
|
||||
return {"name": name, "color": color, "description": description, "output": output}
|
||||
|
||||
def add_label(self, repo: str, issue_index: int, labels: list[str]) -> None:
|
||||
"""Add labels to an issue or PR.
|
||||
|
||||
Args:
|
||||
repo: Repository in ``owner/name`` format.
|
||||
issue_index: Issue or PR number.
|
||||
labels: List of label names to add.
|
||||
"""
|
||||
for _label in labels:
|
||||
self._run_raw(["issues", "edit", "--add-labels", ",".join(labels), str(issue_index), *self._repo_arg(repo)])
|
||||
return # tea edit handles all labels at once
|
||||
# No labels to add — nothing to do
|
||||
|
||||
# -- Pull Requests --
|
||||
|
||||
def create_pr(
|
||||
self,
|
||||
repo: str,
|
||||
title: str,
|
||||
head: str,
|
||||
base: str,
|
||||
body: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Create a pull request and return the PR dict.
|
||||
|
||||
Args:
|
||||
repo: Repository in ``owner/name`` format.
|
||||
title: PR title.
|
||||
head: Head branch name.
|
||||
base: Base branch name.
|
||||
body: PR description (markdown).
|
||||
"""
|
||||
args = [
|
||||
"pulls",
|
||||
"create",
|
||||
"--title",
|
||||
title,
|
||||
"--base",
|
||||
base,
|
||||
"--head",
|
||||
head,
|
||||
*self._repo_arg(repo),
|
||||
]
|
||||
if body:
|
||||
args.extend(["--body", body])
|
||||
output = self._run(args, json_output=False)
|
||||
pr_index = _extract_pr_number(output)
|
||||
return {"title": title, "index": pr_index, "url": output.strip()}
|
||||
|
||||
def merge_pr(self, repo: str, pr_index: int, style: str = "squash") -> None:
|
||||
"""Merge a pull request.
|
||||
|
||||
Args:
|
||||
repo: Repository in ``owner/name`` format.
|
||||
pr_index: PR number.
|
||||
style: Merge style: ``squash``, ``merge``, ``rebase``, ``rebase-edit``.
|
||||
"""
|
||||
self._run_raw(["pulls", "merge", "--style", style, str(pr_index), *self._repo_arg(repo)])
|
||||
|
||||
def review_pr(
|
||||
self,
|
||||
repo: str,
|
||||
pr_index: int,
|
||||
event: str = "COMMENT",
|
||||
body: str = "",
|
||||
) -> None:
|
||||
"""Post a review on a pull request.
|
||||
|
||||
Args:
|
||||
repo: Repository in ``owner/name`` format.
|
||||
pr_index: PR number.
|
||||
event: Review event: ``APPROVE``, ``REQUEST_CHANGES``, ``COMMENT``.
|
||||
body: Review body text.
|
||||
"""
|
||||
args = ["pulls", "review", str(pr_index), *self._repo_arg(repo)]
|
||||
if event == "APPROVE":
|
||||
args.append("--approve")
|
||||
elif event == "REQUEST_CHANGES":
|
||||
args.extend(["--reject"])
|
||||
if body:
|
||||
args.extend(["--comment", body])
|
||||
self._run_raw(args)
|
||||
|
||||
# -- Releases --
|
||||
|
||||
def create_release(
|
||||
self,
|
||||
repo: str,
|
||||
tag: str,
|
||||
title: str = "",
|
||||
body: str = "",
|
||||
target: str = "",
|
||||
draft: bool = False,
|
||||
prerelease: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a release and return the release dict.
|
||||
|
||||
Args:
|
||||
repo: Repository in ``owner/name`` format.
|
||||
tag: Tag name (e.g. ``v1.0.0``).
|
||||
title: Release title.
|
||||
body: Release notes (markdown).
|
||||
target: Target branch/commit for the tag.
|
||||
draft: If True, create as draft.
|
||||
prerelease: If True, mark as prerelease.
|
||||
"""
|
||||
args = ["releases", "create", tag, *self._repo_arg(repo)]
|
||||
if title:
|
||||
args.extend(["--title", title])
|
||||
if body:
|
||||
args.extend(["--note", body])
|
||||
if target:
|
||||
args.extend(["--target", target])
|
||||
if draft:
|
||||
args.append("--draft")
|
||||
if prerelease:
|
||||
args.append("--prerelease")
|
||||
output = self._run(args, json_output=False)
|
||||
return {"tag": tag, "title": title, "url": output.strip()}
|
||||
|
||||
def list_releases(self, repo: str) -> list[dict[str, Any]]:
|
||||
"""List all releases for a repository."""
|
||||
output = self._run(["releases", "list", *self._repo_arg(repo)])
|
||||
if not output:
|
||||
return []
|
||||
return json.loads(output)
|
||||
|
||||
# -- Branches --
|
||||
|
||||
def list_branches(self, repo: str) -> list[dict[str, Any]]:
|
||||
"""List all branches for a repository."""
|
||||
output = self._run(["branches", "list", *self._repo_arg(repo)])
|
||||
if not output:
|
||||
return []
|
||||
return json.loads(output)
|
||||
|
||||
# -- Utility --
|
||||
|
||||
def whoami(self) -> str:
|
||||
"""Return the current authenticated user."""
|
||||
return self._run_raw(["whoami"])
|
||||
|
||||
|
||||
def _extract_issue_number(output: str) -> int:
|
||||
"""Extract the issue number from tea output like 'Created issue #42: ...'."""
|
||||
for part in output.split():
|
||||
if part.startswith("#"):
|
||||
try:
|
||||
return int(part[1:].rstrip(":"))
|
||||
except ValueError:
|
||||
continue
|
||||
return 0
|
||||
|
||||
|
||||
def _extract_pr_number(output: str) -> int:
|
||||
"""Extract the PR number from tea output like 'Created PR #42: ...'."""
|
||||
return _extract_issue_number(output)
|
||||
@@ -30,9 +30,7 @@ def _arch() -> str:
|
||||
return "amd64"
|
||||
if machine in {"aarch64", "arm64"}:
|
||||
return "arm64"
|
||||
raise click.ClickException(
|
||||
f"Unsupported architecture: {machine}"
|
||||
)
|
||||
raise click.ClickException(f"Unsupported architecture: {machine}")
|
||||
|
||||
|
||||
def _install_with_go() -> bool:
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install CI/CD development tools that are not Python packages.
|
||||
|
||||
Handles installation of:
|
||||
- actionlint (workflow YAML linter)
|
||||
- git-cliff (changelog generator)
|
||||
- act_runner (Gitea Actions local runner, optional)
|
||||
- tea (Gitea CLI — official command-line tool for Gitea API operations)
|
||||
|
||||
Each tool is installed to ``~/.local/bin`` if not already on PATH.
|
||||
Idempotent: skips tools that are already available.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 scripts/install_tools.py # install all
|
||||
python3 scripts/install_tools.py --tool actionlint # install one
|
||||
python3 scripts/install_tools.py --list # list status
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import tarfile
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
TARGET_DIR = Path.home() / ".local" / "bin"
|
||||
|
||||
ACTIONLINT_VERSION = "1.7.12"
|
||||
|
||||
GIT_CLIFF_VERSION = "2.13.0"
|
||||
|
||||
ACT_RUNNER_VERSION = "0.2.11"
|
||||
|
||||
TEA_VERSION = "0.14.1"
|
||||
|
||||
|
||||
def _arch() -> str:
|
||||
"""Return the architecture string used by release assets."""
|
||||
machine = platform.machine().lower()
|
||||
if machine in {"x86_64", "amd64"}:
|
||||
return "amd64"
|
||||
if machine in {"aarch64", "arm64"}:
|
||||
return "arm64"
|
||||
raise click.ClickException(f"Unsupported architecture: {machine}")
|
||||
|
||||
|
||||
def _ensure_target_dir() -> Path:
|
||||
"""Ensure the target directory exists and return it."""
|
||||
TARGET_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return TARGET_DIR
|
||||
|
||||
|
||||
def _download(url: str, dest: Path) -> None:
|
||||
"""Download a file from ``url`` to ``dest``."""
|
||||
urllib.request.urlretrieve(url, dest) # nosec B310
|
||||
|
||||
|
||||
def _download_and_extract_tarball(url: str, binary_name: str) -> Path:
|
||||
"""Download a tarball, extract the binary, and install it to TARGET_DIR.
|
||||
|
||||
Returns the path to the installed binary.
|
||||
"""
|
||||
target_dir = _ensure_target_dir()
|
||||
dest = target_dir / binary_name
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
tarball = Path(tmpdir) / "archive.tar.gz"
|
||||
_download(url, tarball)
|
||||
with tarfile.open(tarball, "r:gz") as tar:
|
||||
tar.extractall(tmpdir) # nosec B202
|
||||
# Find the binary in the extracted tree
|
||||
extracted = Path(tmpdir).rglob(binary_name)
|
||||
found = next(extracted, None)
|
||||
if found is None:
|
||||
raise click.ClickException(f"Binary {binary_name} not found in archive from {url}")
|
||||
shutil.copy2(found, dest)
|
||||
dest.chmod(0o755)
|
||||
return dest
|
||||
|
||||
|
||||
def _download_binary(url: str, binary_name: str) -> Path:
|
||||
"""Download a standalone binary and install it to TARGET_DIR.
|
||||
|
||||
Returns the path to the installed binary.
|
||||
"""
|
||||
target_dir = _ensure_target_dir()
|
||||
dest = target_dir / binary_name
|
||||
_download(url, dest)
|
||||
dest.chmod(0o755)
|
||||
return dest
|
||||
|
||||
|
||||
def _is_installed(name: str) -> bool:
|
||||
"""Check if a tool is already on PATH or in TARGET_DIR."""
|
||||
if shutil.which(name) is not None:
|
||||
return True
|
||||
return (TARGET_DIR / name).exists()
|
||||
|
||||
|
||||
def install_actionlint() -> bool:
|
||||
"""Install actionlint if not already present. Returns True if installed/skipped."""
|
||||
if _is_installed("actionlint"):
|
||||
click.echo("actionlint: already installed")
|
||||
return True
|
||||
arch = _arch()
|
||||
url = (
|
||||
f"https://github.com/rhysd/actionlint/releases/download/"
|
||||
f"v{ACTIONLINT_VERSION}/actionlint_{ACTIONLINT_VERSION}_linux_{arch}.tar.gz"
|
||||
)
|
||||
dest = _download_and_extract_tarball(url, "actionlint")
|
||||
click.echo(f"actionlint: installed to {dest}")
|
||||
return True
|
||||
|
||||
|
||||
def install_git_cliff() -> bool:
|
||||
"""Install git-cliff if not already present. Returns True if installed/skipped."""
|
||||
if _is_installed("git-cliff"):
|
||||
click.echo("git-cliff: already installed")
|
||||
return True
|
||||
arch = _arch()
|
||||
url = (
|
||||
f"https://github.com/orhun/git-cliff/releases/download/"
|
||||
f"v{GIT_CLIFF_VERSION}/git-cliff-{GIT_CLIFF_VERSION}-{arch}-unknown-linux-gnu.tar.gz"
|
||||
)
|
||||
dest = _download_and_extract_tarball(url, "git-cliff")
|
||||
click.echo(f"git-cliff: installed to {dest}")
|
||||
return True
|
||||
|
||||
|
||||
def install_act_runner() -> bool:
|
||||
"""Install act_runner if not already present. Returns True if installed/skipped."""
|
||||
if _is_installed("act_runner"):
|
||||
click.echo("act_runner: already installed")
|
||||
return True
|
||||
arch = _arch()
|
||||
url = (
|
||||
f"https://gitea.com/gitea/act_runner/releases/download/"
|
||||
f"v{ACT_RUNNER_VERSION}/act_runner-{ACT_RUNNER_VERSION}-linux-{arch}"
|
||||
)
|
||||
dest = _download_binary(url, "act_runner")
|
||||
click.echo(f"act_runner: installed to {dest}")
|
||||
return True
|
||||
|
||||
|
||||
def install_tea() -> bool:
|
||||
"""Install tea (Gitea CLI) if not already present. Returns True if installed/skipped."""
|
||||
if _is_installed("tea"):
|
||||
click.echo("tea: already installed")
|
||||
return True
|
||||
arch = _arch()
|
||||
url = f"https://dl.gitea.com/tea/{TEA_VERSION}/tea-{TEA_VERSION}-linux-{arch}"
|
||||
dest = _download_binary(url, "tea")
|
||||
click.echo(f"tea: installed to {dest}")
|
||||
return True
|
||||
|
||||
|
||||
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea"]
|
||||
|
||||
|
||||
def _install_tool(name: str) -> bool:
|
||||
"""Install a single tool by name."""
|
||||
if name == "actionlint":
|
||||
return install_actionlint()
|
||||
if name == "git-cliff":
|
||||
return install_git_cliff()
|
||||
if name == "act_runner":
|
||||
return install_act_runner()
|
||||
if name == "tea":
|
||||
return install_tea()
|
||||
raise click.ClickException(f"Unknown tool: {name}")
|
||||
|
||||
|
||||
def list_tools() -> None:
|
||||
"""Print the installation status of all tools."""
|
||||
for name in TOOL_NAMES:
|
||||
status = "installed" if _is_installed(name) else "not installed"
|
||||
click.echo(f" {name}: {status}")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--tool",
|
||||
"tools",
|
||||
multiple=True,
|
||||
type=click.Choice(TOOL_NAMES),
|
||||
help="Install specific tool(s). Can be repeated.",
|
||||
)
|
||||
@click.option("--list", "list_status", is_flag=True, help="List tool installation status.")
|
||||
def main(tools: tuple[str, ...], list_status: bool) -> None:
|
||||
"""Install CI/CD development tools to ~/.local/bin."""
|
||||
if list_status:
|
||||
list_tools()
|
||||
return
|
||||
|
||||
tools_to_install = list(tools) if tools else TOOL_NAMES
|
||||
failed: list[str] = []
|
||||
for name in tools_to_install:
|
||||
try:
|
||||
_install_tool(name)
|
||||
except Exception as exc:
|
||||
click.echo(f" {name}: FAILED — {exc}", err=True)
|
||||
failed.append(name)
|
||||
|
||||
if failed:
|
||||
raise click.ClickException(f"Failed to install: {', '.join(failed)}")
|
||||
|
||||
# Remind user to add ~/.local/bin to PATH if not already there
|
||||
path_env = os.environ.get("PATH", "")
|
||||
if str(TARGET_DIR) not in path_env:
|
||||
click.echo(f"\nAdd {TARGET_DIR} to your PATH to use these tools.")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main() # pragma: no cover
|
||||
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run all molecule scenarios on all supported OS platforms.
|
||||
|
||||
Replaces the previous ``scripts/molecule_all.sh`` with a tested Python equivalent.
|
||||
Sequential execution — CI uses the parallel matrix instead.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 scripts/molecule_all.py
|
||||
python3 scripts/molecule_all.py --bin .venv/bin
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from scripts.ci.platforms import PLATFORMS
|
||||
|
||||
ROLE_DIR = Path("ansible/roles/gitea-runner")
|
||||
SCENARIOS = ["default", "multi-instance", "lifecycle", "template-content", "deregister", "update"]
|
||||
|
||||
|
||||
def _run_molecule(molecule_bin: str, scenario: str, role_dir: Path, env: dict[str, str]) -> int:
|
||||
"""Run a single molecule scenario. Returns the exit code."""
|
||||
cmd = [molecule_bin, "test"]
|
||||
if scenario != "default":
|
||||
cmd.extend(["-s", scenario])
|
||||
|
||||
click.echo(f"--- Scenario: {scenario} ---")
|
||||
result = subprocess.run( # nosec B603
|
||||
cmd,
|
||||
cwd=str(role_dir),
|
||||
env=env,
|
||||
)
|
||||
return result.returncode
|
||||
|
||||
|
||||
def _run_platform(
|
||||
molecule_bin: str,
|
||||
platform: dict[str, str],
|
||||
role_dir: Path,
|
||||
scenarios: list[str],
|
||||
base_env: dict[str, str],
|
||||
) -> int:
|
||||
"""Run all scenarios for a single platform. Returns the first non-zero exit code."""
|
||||
env = dict(base_env)
|
||||
env["MOLECULE_PLATFORM_NAME"] = platform["name"]
|
||||
env["MOLECULE_PLATFORM_IMAGE"] = platform["image"]
|
||||
if platform.get("command"):
|
||||
env["MOLECULE_PLATFORM_COMMAND"] = platform["command"]
|
||||
else:
|
||||
env.pop("MOLECULE_PLATFORM_COMMAND", None)
|
||||
|
||||
click.echo(f"=== Platform: {platform['name']} ===")
|
||||
for scenario in scenarios:
|
||||
rc = _run_molecule(molecule_bin, scenario, role_dir, env)
|
||||
if rc != 0:
|
||||
return rc
|
||||
return 0
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--bin", "bin_dir", default=".venv/bin", help="Path to the virtualenv bin directory.")
|
||||
def main(bin_dir: str) -> None:
|
||||
"""Run all molecule scenarios on all supported OS platforms sequentially."""
|
||||
molecule_bin = str(Path(bin_dir) / "molecule")
|
||||
if not Path(molecule_bin).exists():
|
||||
raise click.ClickException(f"molecule not found at {molecule_bin}. Run 'make setup' first.")
|
||||
|
||||
if not ROLE_DIR.exists():
|
||||
raise click.ClickException(f"Role directory not found: {ROLE_DIR}")
|
||||
|
||||
base_env = dict(os.environ)
|
||||
base_env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] = "true"
|
||||
base_env["ANSIBLE_INJECT_INVOCATION"] = "1"
|
||||
|
||||
for platform in PLATFORMS:
|
||||
rc = _run_platform(molecule_bin, platform, ROLE_DIR, SCENARIOS, base_env)
|
||||
if rc != 0:
|
||||
click.echo(f"FAILED on platform {platform['name']}", err=True)
|
||||
sys.exit(rc)
|
||||
|
||||
click.echo("All molecule scenarios passed on all platforms.")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main() # pragma: no cover
|
||||
@@ -1,35 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run all molecule scenarios on all supported OS platforms.
|
||||
# Used by `make molecule-all`. Sequential — CI uses parallel matrix instead.
|
||||
# Platform list is sourced from scripts/distribute_molecule.py to avoid duplication.
|
||||
set -euo pipefail
|
||||
|
||||
MOLECULE_BIN="$(realpath "${BIN:-.venv/bin}/molecule")"
|
||||
ROLE_DIR="$(cd "$(dirname "$0")/.." && pwd)/ansible/roles/gitea-runner"
|
||||
SCRIPTS_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Read platforms from distribute_molecule.py (single source of truth)
|
||||
PLATFORMS_OUTPUT="$("$MOLECULE_BIN" python "${SCRIPTS_DIR}/distribute_molecule.py" --list-platforms 2>/dev/null || \
|
||||
python3 "${SCRIPTS_DIR}/distribute_molecule.py" --list-platforms)"
|
||||
|
||||
for p in $PLATFORMS_OUTPUT; do
|
||||
IFS="|" read -r name image command <<< "$p"
|
||||
export MOLECULE_PLATFORM_NAME="$name" MOLECULE_PLATFORM_IMAGE="$image"
|
||||
if [ -n "$command" ]; then
|
||||
export MOLECULE_PLATFORM_COMMAND="$command"
|
||||
else
|
||||
unset MOLECULE_PLATFORM_COMMAND
|
||||
fi
|
||||
echo "=== Platform: $name ==="
|
||||
for s in default multi-instance lifecycle template-content deregister update; do
|
||||
echo "--- Scenario: $s on $name ---"
|
||||
(
|
||||
cd "$ROLE_DIR"
|
||||
if [ "$s" = "default" ]; then
|
||||
ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true ANSIBLE_INJECT_INVOCATION=1 "$MOLECULE_BIN" test
|
||||
else
|
||||
ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true ANSIBLE_INJECT_INVOCATION=1 "$MOLECULE_BIN" test -s "$s"
|
||||
fi
|
||||
)
|
||||
done
|
||||
done
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Project setup: install Python deps, Ansible collections, and pre-commit hooks.
|
||||
|
||||
Also configures the ``tea`` Gitea CLI login profile from ``.env`` so that
|
||||
CI scripts and dev tools can use ``tea`` for Gitea API operations.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 scripts/setup.py --bin .venv/bin
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
def _run(cmd: list[str], bin_dir: str) -> None:
|
||||
"""Run a command, streaming output to stdout/stderr."""
|
||||
click.echo(f" $ {' '.join(cmd)}")
|
||||
subprocess.run(cmd, check=True) # nosec B603
|
||||
|
||||
|
||||
def _install_python_deps(bin_dir: str) -> None:
|
||||
"""Install the project with dev extras in editable mode."""
|
||||
pip = str(Path(bin_dir) / "pip")
|
||||
_run([pip, "install", "-e", ".[dev]"], bin_dir)
|
||||
|
||||
|
||||
def _install_ansible_collections(bin_dir: str) -> None:
|
||||
"""Install required Ansible Galaxy collections."""
|
||||
galaxy = str(Path(bin_dir) / "ansible-galaxy")
|
||||
requirements = Path("ansible/requirements.yml")
|
||||
if not requirements.exists():
|
||||
click.echo(" ansible/requirements.yml not found — skipping collections.")
|
||||
return
|
||||
_run([galaxy, "collection", "install", "-r", str(requirements)], bin_dir)
|
||||
|
||||
|
||||
def _install_pre_commit_hooks(bin_dir: str) -> None:
|
||||
"""Install pre-commit hooks for commit-msg, pre-commit, and pre-push."""
|
||||
pre_commit = str(Path(bin_dir) / "pre-commit")
|
||||
for hook_type in ["pre-commit", "commit-msg", "pre-push"]:
|
||||
_run([pre_commit, "install", "--hook-type", hook_type], bin_dir)
|
||||
|
||||
|
||||
def _configure_tea_login() -> None:
|
||||
"""Configure tea CLI login from .env if REPO_TOKEN is set.
|
||||
|
||||
Idempotent: if a login with the same name already exists, it is not re-added.
|
||||
Skips silently if tea is not installed or REPO_TOKEN is not set.
|
||||
"""
|
||||
tea_bin = shutil.which("tea")
|
||||
if tea_bin is None:
|
||||
click.echo("tea: not installed — skipping login configuration.")
|
||||
return
|
||||
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
if not token:
|
||||
click.echo("tea: REPO_TOKEN not set — skipping login configuration.")
|
||||
return
|
||||
|
||||
# Derive the Gitea URL from the API URL (strip /api/v1 suffix)
|
||||
api_url = os.environ.get("GRM_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
|
||||
gitea_url = api_url.replace("/api/v1", "")
|
||||
login_name = "grm"
|
||||
|
||||
# Check if login already exists
|
||||
result = subprocess.run( # nosec B603
|
||||
[tea_bin, "login", "list", "--output", "simple"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0 and login_name in result.stdout:
|
||||
click.echo(f"tea: login '{login_name}' already configured.")
|
||||
return
|
||||
|
||||
# Add login profile
|
||||
click.echo(f"tea: configuring login '{login_name}' for {gitea_url}...")
|
||||
add_result = subprocess.run( # nosec B603
|
||||
[tea_bin, "login", "add", "--name", login_name, "--url", gitea_url, "--token", token],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if add_result.returncode != 0:
|
||||
click.echo(f"tea: login configuration failed: {add_result.stderr.strip()}", err=True)
|
||||
else:
|
||||
# Set as default login
|
||||
subprocess.run( # nosec B603
|
||||
[tea_bin, "login", "default", login_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
click.echo(f"tea: login '{login_name}' configured and set as default.")
|
||||
|
||||
|
||||
def _verify(bin_dir: str) -> None:
|
||||
"""Print versions of installed tools for verification."""
|
||||
grm = str(Path(bin_dir) / "grm")
|
||||
pre_commit = str(Path(bin_dir) / "pre-commit")
|
||||
for tool in [grm, pre_commit]:
|
||||
try:
|
||||
result = subprocess.run([tool, "--version"], capture_output=True, text=True, timeout=10) # nosec B603
|
||||
if result.returncode == 0:
|
||||
click.echo(f" {result.stdout.strip()}")
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--bin", "bin_dir", default=".venv/bin", help="Path to the virtualenv bin directory.")
|
||||
def main(bin_dir: str) -> None:
|
||||
"""Install Python deps, Ansible collections, and pre-commit hooks."""
|
||||
if not Path(bin_dir).exists():
|
||||
raise click.ClickException(f"Bin directory not found: {bin_dir}. Run 'python3 -m venv .venv' first.")
|
||||
|
||||
click.echo("Installing Python dependencies...")
|
||||
_install_python_deps(bin_dir)
|
||||
|
||||
click.echo("Installing Ansible collections...")
|
||||
_install_ansible_collections(bin_dir)
|
||||
|
||||
click.echo("Installing pre-commit hooks...")
|
||||
_install_pre_commit_hooks(bin_dir)
|
||||
|
||||
click.echo("Configuring tea CLI login...")
|
||||
_configure_tea_login()
|
||||
|
||||
click.echo("")
|
||||
click.echo("Setup complete.")
|
||||
click.echo("Activate the virtual environment with one of:")
|
||||
click.echo(" source .venv/bin/activate (generic)")
|
||||
click.echo(" source activate.sh (bash)")
|
||||
click.echo(" source activate.fish (fish)")
|
||||
click.echo(" source activate.zsh (zsh)")
|
||||
click.echo("")
|
||||
|
||||
_verify(bin_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main() # pragma: no cover
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
BIN="${1:-.venv/bin}"
|
||||
|
||||
"$BIN/pip" install -e ".[dev]"
|
||||
"$BIN/ansible-galaxy" collection install -r ansible/requirements.yml
|
||||
"$BIN/pre-commit" install
|
||||
"$BIN/pre-commit" install --hook-type commit-msg
|
||||
"$BIN/pre-commit" install --hook-type pre-push
|
||||
|
||||
echo ""
|
||||
echo "Setup complete."
|
||||
echo "Activate the virtual environment with one of:"
|
||||
echo " source .venv/bin/activate (generic)"
|
||||
echo " source activate.sh (bash)"
|
||||
echo " source activate.fish (fish)"
|
||||
echo " source activate.zsh (zsh)"
|
||||
|
||||
# Verification
|
||||
"$BIN/grm" --version 2>/dev/null || true
|
||||
"$BIN/pre-commit" --version 2>/dev/null || true
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Gitea Runner Manager — lean CLI for managing Gitea Actions runners."""
|
||||
|
||||
__version__ = "0.3.2"
|
||||
__version__ = "0.6.0"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
@@ -12,6 +13,11 @@ from .exceptions import APIError
|
||||
|
||||
logger = logging.getLogger("grm")
|
||||
|
||||
# Retry configuration for transient errors (429, 5xx, connection errors)
|
||||
MAX_RETRIES = 3
|
||||
RETRY_BACKOFF_BASE = 2 # seconds: 2, 4, 8
|
||||
RETRY_STATUS_CODES = {429, 500, 502, 503, 504}
|
||||
|
||||
|
||||
def _parse_error(e: requests.HTTPError) -> tuple[int, str]:
|
||||
"""Extract status code and message from an HTTPError response."""
|
||||
@@ -25,6 +31,16 @@ def _parse_error(e: requests.HTTPError) -> tuple[int, str]:
|
||||
return status, message
|
||||
|
||||
|
||||
def _is_retryable(e: Exception) -> bool:
|
||||
"""Check if an exception is a transient error worth retrying."""
|
||||
if isinstance(e, requests.ConnectionError):
|
||||
return True
|
||||
if isinstance(e, requests.HTTPError):
|
||||
status, _ = _parse_error(e)
|
||||
return status in RETRY_STATUS_CODES
|
||||
return isinstance(e, requests.Timeout)
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
"""Low-level Gitea REST API client with connection pooling."""
|
||||
|
||||
@@ -45,13 +61,48 @@ class GiteaClient:
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
|
||||
url = self._url(path)
|
||||
try:
|
||||
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as e:
|
||||
status, message = _parse_error(e)
|
||||
raise APIError(status, message) from e
|
||||
return response
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except requests.HTTPError as e:
|
||||
status, message = _parse_error(e)
|
||||
if _is_retryable(e) and attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
||||
logger.warning(
|
||||
"Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)",
|
||||
status,
|
||||
method,
|
||||
path,
|
||||
wait,
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
last_exc = e
|
||||
continue
|
||||
raise APIError(status, message) from e
|
||||
except (requests.ConnectionError, requests.Timeout) as e:
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
||||
logger.warning(
|
||||
"Connection error on %s %s, retrying in %ds (attempt %d/%d)",
|
||||
method,
|
||||
path,
|
||||
wait,
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
last_exc = e
|
||||
continue
|
||||
raise APIError(0, str(e)) from e
|
||||
# Should not reach here, but just in case
|
||||
if last_exc: # pragma: no cover
|
||||
raise APIError(0, str(last_exc)) from last_exc
|
||||
raise APIError(0, "Max retries exceeded") # pragma: no cover
|
||||
|
||||
# -- repo settings --
|
||||
|
||||
@@ -129,9 +180,16 @@ class GiteaClient:
|
||||
self._request("POST", f"/pulls/{pr_number}/merge", json=payload)
|
||||
|
||||
def get_commit_status(self, sha: str) -> list[dict[str, Any]]:
|
||||
"""Fetch all status check contexts reported for a commit."""
|
||||
r = self._request("GET", f"/commits/{sha}/statuses")
|
||||
return r.json()
|
||||
"""Fetch all status check contexts reported for a commit.
|
||||
|
||||
Uses the combined status endpoint (/commits/{sha}/status) which
|
||||
returns one entry per context (the latest), deduplicated server-side.
|
||||
The plural endpoint (/commits/{sha}/statuses) returns every historical
|
||||
entry including stale "pending" ones that never got updated.
|
||||
"""
|
||||
r = self._request("GET", f"/commits/{sha}/status")
|
||||
data = r.json()
|
||||
return data.get("statuses", [])
|
||||
|
||||
def get_pr(self, pr_number: str | int) -> dict[str, Any]:
|
||||
"""Fetch pull request details including mergeable state."""
|
||||
@@ -163,12 +221,15 @@ class GiteaClient:
|
||||
"""Post a review on a pull request.
|
||||
|
||||
Args:
|
||||
event: ``APPROVE``, ``REQUEST_CHANGES``, or ``COMMENT``.
|
||||
event: ``APPROVED``, ``REQUEST_CHANGES``, or ``COMMENT``.
|
||||
body: Top-level review body text.
|
||||
comments: Line-level comments with ``path``, ``body``,
|
||||
``new_position`` (and optionally ``old_position``).
|
||||
"""
|
||||
payload: dict[str, Any] = {"event": event, "body": body}
|
||||
# Map common event names to Gitea API values
|
||||
event_map = {"APPROVE": "APPROVED", "REQUEST_CHANGES": "REQUEST_CHANGES", "COMMENT": "COMMENT"}
|
||||
gitea_event = event_map.get(event, event)
|
||||
payload: dict[str, Any] = {"event": gitea_event, "body": body}
|
||||
if comments:
|
||||
payload["comments"] = comments
|
||||
r = self._request("POST", f"/pulls/{pr_number}/reviews", json=payload)
|
||||
@@ -192,6 +253,32 @@ class GiteaClient:
|
||||
r = self._request("POST", "/releases", json=payload)
|
||||
return r.json()
|
||||
|
||||
def get_release_by_tag(self, tag: str) -> dict[str, Any] | None:
|
||||
"""Fetch a release by its tag name. Returns None if not found."""
|
||||
try:
|
||||
r = self._request("GET", f"/releases/tags/{tag}")
|
||||
return r.json()
|
||||
except APIError:
|
||||
return None
|
||||
|
||||
def create_release_idempotent(
|
||||
self,
|
||||
tag: str,
|
||||
name: str = "",
|
||||
body: str = "",
|
||||
draft: bool = False,
|
||||
prerelease: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a release, or return the existing one if it already exists.
|
||||
|
||||
This is idempotent — safe to call multiple times for the same tag.
|
||||
"""
|
||||
existing = self.get_release_by_tag(tag)
|
||||
if existing:
|
||||
logger.info("Release for tag %s already exists (ID %s), skipping creation.", tag, existing.get("id"))
|
||||
return existing
|
||||
return self.create_release(tag=tag, name=name, body=body, draft=draft, prerelease=prerelease)
|
||||
|
||||
|
||||
class VikunjaClient:
|
||||
"""Low-level Vikunja REST API client with connection pooling."""
|
||||
@@ -203,13 +290,47 @@ class VikunjaClient:
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
|
||||
url = f"{self._base_url}{path}"
|
||||
try:
|
||||
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as e:
|
||||
status, message = _parse_error(e)
|
||||
raise APIError(status, message) from e
|
||||
return response
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except requests.HTTPError as e:
|
||||
status, message = _parse_error(e)
|
||||
if _is_retryable(e) and attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
||||
logger.warning(
|
||||
"Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)",
|
||||
status,
|
||||
method,
|
||||
path,
|
||||
wait,
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
last_exc = e
|
||||
continue
|
||||
raise APIError(status, message) from e
|
||||
except (requests.ConnectionError, requests.Timeout) as e:
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
||||
logger.warning(
|
||||
"Connection error on %s %s, retrying in %ds (attempt %d/%d)",
|
||||
method,
|
||||
path,
|
||||
wait,
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
last_exc = e
|
||||
continue
|
||||
raise APIError(0, str(e)) from e
|
||||
if last_exc: # pragma: no cover
|
||||
raise APIError(0, str(last_exc)) from last_exc
|
||||
raise APIError(0, "Max retries exceeded") # pragma: no cover
|
||||
|
||||
def list_tasks(self, **params: Any) -> list[dict[str, Any]]:
|
||||
r = self._request("GET", "/tasks", params=params)
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""Unit tests for api_clients module."""
|
||||
|
||||
import http
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from gitea_runner_manager.api_clients import GiteaClient, VikunjaClient, _parse_error
|
||||
from gitea_runner_manager.api_clients import GiteaClient, VikunjaClient, _is_retryable, _parse_error
|
||||
from gitea_runner_manager.config import (
|
||||
BRANCH_PROTECTION_CONFIG,
|
||||
DEFAULT_PER_PAGE,
|
||||
@@ -25,6 +25,15 @@ def _mock_response(json_data: object | None = None, raise_on_status: bool = Fals
|
||||
return mock
|
||||
|
||||
|
||||
def _mock_http_error(status_code: int, message: str = "") -> requests.HTTPError:
|
||||
"""Create an HTTPError with a proper response attached (for _parse_error)."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.json.return_value = {"message": message or str(status_code)}
|
||||
err = requests.HTTPError(f"{status_code} {message}", response=resp)
|
||||
return err
|
||||
|
||||
|
||||
class TestParseError:
|
||||
def test_json_parse_fallback(self) -> None:
|
||||
mock_response = MagicMock()
|
||||
@@ -202,16 +211,17 @@ class TestGiteaClient:
|
||||
)
|
||||
|
||||
def test_get_commit_status(self) -> None:
|
||||
"""Uses combined status endpoint (/status, not /statuses)."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response([{"context": "CI / quality", "status": "success"}])
|
||||
return_value=_mock_response({"statuses": [{"context": "CI / quality", "status": "success"}]})
|
||||
)
|
||||
|
||||
result = client.get_commit_status("abc123")
|
||||
assert result == [{"context": "CI / quality", "status": "success"}]
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/commits/abc123/statuses",
|
||||
"https://git.example.com/repos/owner/repo/commits/abc123/status",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
@@ -310,6 +320,19 @@ class TestGiteaClient:
|
||||
json={"event": "COMMENT", "body": "Looks good"},
|
||||
)
|
||||
|
||||
def test_create_review_approve_maps_to_approved(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"id": 44, "state": "APPROVED"}))
|
||||
|
||||
result = client.create_review(7, event="APPROVE", body="Good work")
|
||||
assert result["id"] == 44
|
||||
client._session.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://git.example.com/repos/owner/repo/pulls/7/reviews",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json={"event": "APPROVED", "body": "Good work"},
|
||||
)
|
||||
|
||||
def test_create_review_with_inline_comments(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"id": 43}))
|
||||
@@ -350,6 +373,115 @@ class TestGiteaClient:
|
||||
json={"tag_name": "v1.0.0", "name": "v1.0.0", "body": "", "draft": False, "prerelease": False},
|
||||
)
|
||||
|
||||
def test_get_release_by_tag_found(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"id": 1, "tag_name": "v1.0.0"}))
|
||||
result = client.get_release_by_tag("v1.0.0")
|
||||
assert result is not None
|
||||
assert result["id"] == 1
|
||||
|
||||
def test_get_release_by_tag_not_found(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.side_effect = requests.HTTPError("404")
|
||||
mock_resp.status_code = 404
|
||||
client._session.request = MagicMock(return_value=mock_resp)
|
||||
result = client.get_release_by_tag("v9.9.9")
|
||||
assert result is None
|
||||
|
||||
def test_create_release_idempotent_existing(self) -> None:
|
||||
"""If release already exists, should return it without creating a new one."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
existing_response = _mock_response({"id": 42, "tag_name": "v1.0.0"})
|
||||
client._session.request = MagicMock(return_value=existing_response)
|
||||
result = client.create_release_idempotent("v1.0.0")
|
||||
assert result["id"] == 42
|
||||
# Should only call GET (check), not POST (create)
|
||||
assert client._session.request.call_count == 1
|
||||
assert client._session.request.call_args[0][0] == "GET"
|
||||
|
||||
def test_create_release_idempotent_new(self) -> None:
|
||||
"""If release doesn't exist, should create it."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
not_found_resp = MagicMock()
|
||||
not_found_resp.raise_for_status.side_effect = requests.HTTPError("404")
|
||||
not_found_resp.status_code = 404
|
||||
create_resp = _mock_response({"id": 1, "tag_name": "v1.0.0"})
|
||||
client._session.request = MagicMock(side_effect=[not_found_resp, create_resp])
|
||||
result = client.create_release_idempotent("v1.0.0")
|
||||
assert result["id"] == 1
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_request_retries_on_429(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should retry on 429 rate limit with exponential backoff."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
rate_limited = MagicMock()
|
||||
rate_limited.raise_for_status.side_effect = _mock_http_error(429, "rate limited")
|
||||
success = _mock_response({"ok": True})
|
||||
client._session.request = MagicMock(side_effect=[rate_limited, rate_limited, success])
|
||||
result = client._request("GET", "/test")
|
||||
assert result.json() == {"ok": True}
|
||||
assert client._session.request.call_count == 3
|
||||
assert mock_sleep.call_count == 2
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_request_retries_on_503(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should retry on 503 service unavailable."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
unavailable = MagicMock()
|
||||
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
|
||||
success = _mock_response({"ok": True})
|
||||
client._session.request = MagicMock(side_effect=[unavailable, success])
|
||||
result = client._request("GET", "/test")
|
||||
assert result.json() == {"ok": True}
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_request_no_retry_on_404(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should NOT retry on 404 — it's not a transient error."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
not_found = MagicMock()
|
||||
not_found.raise_for_status.side_effect = _mock_http_error(404, "not found")
|
||||
client._session.request = MagicMock(return_value=not_found)
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client._request("GET", "/test")
|
||||
assert exc_info.value.status == 404
|
||||
assert client._session.request.call_count == 1
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_request_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should retry on connection errors."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
success = _mock_response({"ok": True})
|
||||
client._session.request = MagicMock(side_effect=[requests.ConnectionError("refused"), success])
|
||||
result = client._request("GET", "/test")
|
||||
assert result.json() == {"ok": True}
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_request_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should raise APIError after max retries on persistent 503."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
unavailable = MagicMock()
|
||||
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
|
||||
client._session.request = MagicMock(return_value=unavailable)
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client._request("GET", "/test")
|
||||
assert exc_info.value.status == 503
|
||||
assert client._session.request.call_count == 3 # MAX_RETRIES
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_request_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should raise APIError after max retries on persistent connection errors."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(side_effect=requests.ConnectionError("refused"))
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client._request("GET", "/test")
|
||||
assert exc_info.value.status == 0
|
||||
assert client._session.request.call_count == 3 # MAX_RETRIES
|
||||
|
||||
|
||||
class TestVikunjaClient:
|
||||
def test_init_sets_headers(self) -> None:
|
||||
@@ -426,7 +558,9 @@ class TestVikunjaClient:
|
||||
|
||||
def test_http_error_raises_api_error(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(return_value=_mock_response(raise_on_status=True))
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.side_effect = _mock_http_error(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
|
||||
client._session.request = MagicMock(return_value=mock_resp)
|
||||
|
||||
with pytest.raises(APIError):
|
||||
client.list_tasks()
|
||||
@@ -435,8 +569,73 @@ class TestVikunjaClient:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
err = requests.HTTPError("connection failed")
|
||||
err.response = None # type: ignore[assignment]
|
||||
client._session.request = MagicMock(side_effect=err)
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.side_effect = err
|
||||
client._session.request = MagicMock(return_value=mock_resp)
|
||||
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client.list_tasks()
|
||||
assert "connection failed" in str(exc_info.value)
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_vikunja_retries_on_503(self, mock_sleep: MagicMock) -> None:
|
||||
"""VikunjaClient should also retry on 503."""
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
unavailable = MagicMock()
|
||||
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
|
||||
success = _mock_response([{"id": 1}])
|
||||
client._session.request = MagicMock(side_effect=[unavailable, success])
|
||||
result = client.list_tasks()
|
||||
assert len(result) == 1
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_vikunja_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
|
||||
"""VikunjaClient should retry on connection errors."""
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
success = _mock_response([{"id": 1}])
|
||||
client._session.request = MagicMock(side_effect=[requests.ConnectionError("refused"), success])
|
||||
result = client.list_tasks()
|
||||
assert len(result) == 1
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_vikunja_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||
"""VikunjaClient should raise APIError after max retries on persistent 503."""
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
unavailable = MagicMock()
|
||||
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
|
||||
client._session.request = MagicMock(return_value=unavailable)
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client.list_tasks()
|
||||
assert exc_info.value.status == 503
|
||||
assert client._session.request.call_count == 3 # MAX_RETRIES
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_vikunja_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||
"""VikunjaClient should raise APIError after max retries on persistent connection errors."""
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(side_effect=requests.ConnectionError("refused"))
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client.list_tasks()
|
||||
assert exc_info.value.status == 0
|
||||
assert client._session.request.call_count == 3 # MAX_RETRIES
|
||||
|
||||
|
||||
class TestIsRetryable:
|
||||
def test_connection_error_is_retryable(self) -> None:
|
||||
assert _is_retryable(requests.ConnectionError("refused")) is True
|
||||
|
||||
def test_timeout_is_retryable(self) -> None:
|
||||
assert _is_retryable(requests.Timeout("timed out")) is True
|
||||
|
||||
def test_429_is_retryable(self) -> None:
|
||||
err = _mock_http_error(429, "rate limited")
|
||||
assert _is_retryable(err) is True
|
||||
|
||||
def test_404_is_not_retryable(self) -> None:
|
||||
err = _mock_http_error(404, "not found")
|
||||
assert _is_retryable(err) is False
|
||||
|
||||
def test_generic_exception_is_not_retryable(self) -> None:
|
||||
assert _is_retryable(ValueError("oops")) is False
|
||||
|
||||
@@ -16,6 +16,7 @@ from scripts.ci.auto_merge import (
|
||||
has_approval_review,
|
||||
has_ready_to_merge_label,
|
||||
main,
|
||||
run_cmd,
|
||||
validate_pr_title,
|
||||
validate_pr_title_matches_vikunja,
|
||||
wait_for_ci,
|
||||
@@ -145,20 +146,47 @@ class TestHasReadyToMergeLabel:
|
||||
|
||||
|
||||
class TestHasApprovalReview:
|
||||
def test_has_approved(self) -> None:
|
||||
def test_has_substantive_approved(self) -> None:
|
||||
client = MagicMock()
|
||||
client.get_pr_reviews.return_value = [{"state": "APPROVED"}, {"state": "COMMENT"}]
|
||||
client.get_pr_reviews.return_value = [
|
||||
{"state": "APPROVED", "body": "All comments addressed. LGTM.", "comments": []},
|
||||
{"state": "COMMENT"},
|
||||
]
|
||||
assert has_approval_review(client, "5") is True
|
||||
|
||||
def test_no_approved(self) -> None:
|
||||
def test_has_approved_with_inline_comments(self) -> None:
|
||||
client = MagicMock()
|
||||
client.get_pr_reviews.return_value = [{"state": "COMMENT"}, {"state": "REQUEST_CHANGES"}]
|
||||
client.get_pr_reviews.return_value = [
|
||||
{"state": "APPROVED", "body": "", "comments": [{"body": "good"}]},
|
||||
]
|
||||
assert has_approval_review(client, "5") is True
|
||||
|
||||
def test_trivial_approved_falls_back_to_no_changes(self) -> None:
|
||||
"""A bare 'LGTM' approval (< 20 chars) without comments falls back to
|
||||
checking no REQUEST_CHANGES exist (single-token workflow)."""
|
||||
client = MagicMock()
|
||||
client.get_pr_reviews.return_value = [
|
||||
{"state": "APPROVED", "body": "LGTM", "comments": []},
|
||||
]
|
||||
assert has_approval_review(client, "5") is True
|
||||
|
||||
def test_no_approved_but_no_changes_requested(self) -> None:
|
||||
"""Single-token workflow: no APPROVE but no REQUEST_CHANGES either."""
|
||||
client = MagicMock()
|
||||
client.get_pr_reviews.return_value = [{"state": "COMMENT"}]
|
||||
assert has_approval_review(client, "5") is True
|
||||
|
||||
def test_changes_requested_blocks_merge(self) -> None:
|
||||
"""REQUEST_CHANGES blocks merge even in single-token workflow."""
|
||||
client = MagicMock()
|
||||
client.get_pr_reviews.return_value = [{"state": "REQUEST_CHANGES", "body": "Fix this"}]
|
||||
assert has_approval_review(client, "5") is False
|
||||
|
||||
def test_no_reviews(self) -> None:
|
||||
def test_no_reviews_allows_merge(self) -> None:
|
||||
"""No reviews at all allows merge (single-token workflow fallback)."""
|
||||
client = MagicMock()
|
||||
client.get_pr_reviews.return_value = []
|
||||
assert has_approval_review(client, "5") is False
|
||||
assert has_approval_review(client, "5") is True
|
||||
|
||||
|
||||
class TestValidatePrTitleMatchesVikunja:
|
||||
@@ -294,6 +322,8 @@ class TestWaitForCi:
|
||||
assert wait_for_ci(client, "abc123", max_wait=10) is True
|
||||
|
||||
def test_deduplicates_by_latest(self) -> None:
|
||||
"""Combined endpoint returns one entry per context; if multiple
|
||||
entries appear, the last one wins (dict comprehension)."""
|
||||
client = MagicMock()
|
||||
client.get_commit_status.return_value = [
|
||||
_status("CI / quality (pull_request)", CI_PENDING, "2026-01-01T00:00:00Z"),
|
||||
@@ -301,6 +331,17 @@ class TestWaitForCi:
|
||||
]
|
||||
assert wait_for_ci(client, "abc123", max_wait=10) is True
|
||||
|
||||
def test_skipped_jobs_count_as_passing(self) -> None:
|
||||
"""Conditional jobs that are skipped should not block merge."""
|
||||
client = MagicMock()
|
||||
client.get_commit_status.return_value = [
|
||||
_status("CI / quality (pull_request)", CI_SUCCESS),
|
||||
_status("CI / badges (pull_request)", "skipped"),
|
||||
_status("CI / molecule-tests (pull_request)", "skipped"),
|
||||
_status("CI / discover-runners (pull_request)", "skipped"),
|
||||
]
|
||||
assert wait_for_ci(client, "abc123", max_wait=10) is True
|
||||
|
||||
def test_only_non_ci_contexts_waits_then_ci_appears(self) -> None:
|
||||
client = MagicMock()
|
||||
client.get_commit_status.side_effect = [
|
||||
@@ -564,3 +605,66 @@ class TestMain:
|
||||
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: Some task title", "owner/repo", "1"])
|
||||
assert result.exit_code == 0
|
||||
assert "squash-merged" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@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_405_behind_retries(
|
||||
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
|
||||
) -> None:
|
||||
"""405 'behind' error should trigger rebase and retry."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
|
||||
mock_client.get_pr.return_value = _mock_pr()
|
||||
mock_client.get_commit_status.return_value = _mock_ci_passing()
|
||||
mock_client.get_pr_commits.return_value = _mock_commits()
|
||||
# First merge_pr raises 405 "behind", second succeeds
|
||||
mock_client.merge_pr.side_effect = [
|
||||
APIError(http.HTTPStatus.METHOD_NOT_ALLOWED, "head branch is behind base"),
|
||||
None,
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
with patch("scripts.ci.auto_merge.run_cmd") as mock_run_cmd:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: Some task title", "owner/repo", "1"])
|
||||
assert result.exit_code == 0
|
||||
assert "Rebased" in result.output or "rebase" in result.output.lower()
|
||||
assert mock_client.merge_pr.call_count == 2
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@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_405_behind_rebase_fails(
|
||||
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
|
||||
) -> None:
|
||||
"""405 'behind' with rebase failure should raise ClickException."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
|
||||
mock_client.get_pr.return_value = _mock_pr()
|
||||
mock_client.get_commit_status.return_value = _mock_ci_passing()
|
||||
mock_client.get_pr_commits.return_value = _mock_commits()
|
||||
mock_client.merge_pr.side_effect = APIError(http.HTTPStatus.METHOD_NOT_ALLOWED, "head branch is behind base")
|
||||
mock_client_cls.return_value = mock_client
|
||||
with patch("scripts.ci.auto_merge.run_cmd") as mock_run_cmd:
|
||||
mock_run_cmd.side_effect = click.ClickException("rebase failed")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: Some task title", "owner/repo", "1"])
|
||||
assert result.exit_code == 1
|
||||
assert "rebase" in result.output.lower() or "retry" in result.output.lower()
|
||||
|
||||
def test_run_cmd_success(self) -> None:
|
||||
"""run_cmd should return CompletedProcess on success."""
|
||||
with patch("scripts.ci.auto_merge.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
result = run_cmd(["echo", "ok"])
|
||||
assert result.returncode == 0
|
||||
|
||||
def test_run_cmd_failure_raises(self) -> None:
|
||||
"""run_cmd should raise ClickException on non-zero exit."""
|
||||
with patch("scripts.ci.auto_merge.subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
|
||||
with pytest.raises(click.ClickException):
|
||||
run_cmd(["false"])
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Unit tests for scripts/ci/classify_changes.py."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
import scripts.ci.classify_changes as classify_changes_mod
|
||||
from scripts.ci.classify_changes import (
|
||||
classify_changes,
|
||||
get_changed_files,
|
||||
@@ -34,12 +36,12 @@ class TestIsUserFacing:
|
||||
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_dev_scripts_are_not_user_facing(self) -> None:
|
||||
"""All scripts under scripts/ are infrastructure (CI/CD, dev tools).
|
||||
User-facing code lives in src/gitea_runner_manager/."""
|
||||
assert is_user_facing("scripts/check_test_speed.py") is False
|
||||
assert is_user_facing("scripts/configure_repo.py") is False
|
||||
assert is_user_facing("scripts/install_checkmake.py") is False
|
||||
|
||||
def test_shell_scripts_are_not_user_facing(self) -> None:
|
||||
assert is_user_facing("scripts/setup.sh") is False
|
||||
@@ -48,6 +50,19 @@ class TestIsUserFacing:
|
||||
def test_scripts_init_is_not_user_facing(self) -> None:
|
||||
assert is_user_facing("scripts/__init__.py") is False
|
||||
|
||||
def test_version_file_is_not_user_facing(self) -> None:
|
||||
"""__init__.py only contains __version__ — a release artifact,
|
||||
not user-facing code. Version bumps alone should not trigger releases."""
|
||||
assert is_user_facing("src/gitea_runner_manager/__init__.py") is False
|
||||
|
||||
def test_api_clients_is_not_user_facing(self) -> None:
|
||||
"""api_clients.py is used only by CI/CD scripts, not by the GRM CLI."""
|
||||
assert is_user_facing("src/gitea_runner_manager/api_clients.py") is False
|
||||
|
||||
def test_review_checklist_is_not_user_facing(self) -> None:
|
||||
"""REVIEW_CHECKLIST.md is agent infrastructure, not user-facing."""
|
||||
assert is_user_facing("REVIEW_CHECKLIST.md") is False
|
||||
|
||||
def test_docs_are_not_user_facing(self) -> None:
|
||||
assert is_user_facing("docs/user/getting-started.md") is False
|
||||
|
||||
@@ -239,3 +254,120 @@ class TestMain:
|
||||
result = runner.invoke(main, ["--base", "v0.2.0", "--head", "HEAD"])
|
||||
assert result.exit_code == 0
|
||||
assert "release needed" in result.output
|
||||
|
||||
@patch("scripts.ci.classify_changes.get_changed_files")
|
||||
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_check_ansible_true(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
"""--check ansible with Ansible changes outputs true."""
|
||||
mock_changes.return_value = ["ansible/tasks/main.yml", ".gitea/workflows/ci.yml"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--check", "ansible", "--quiet"])
|
||||
assert result.exit_code == 0
|
||||
assert "true" in result.output
|
||||
|
||||
@patch("scripts.ci.classify_changes.get_changed_files")
|
||||
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_check_ansible_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
"""--check ansible with no Ansible changes outputs false."""
|
||||
mock_changes.return_value = ["src/gitea_runner_manager/cli.py", ".gitea/workflows/ci.yml"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--check", "ansible", "--quiet"])
|
||||
assert result.exit_code == 0
|
||||
assert "false" in result.output
|
||||
|
||||
@patch("scripts.ci.classify_changes.get_changed_files")
|
||||
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_check_user_facing_true(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
"""--check user-facing with user-facing changes outputs true."""
|
||||
mock_changes.return_value = ["src/gitea_runner_manager/cli.py", ".gitea/workflows/ci.yml"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--check", "user-facing", "--quiet"])
|
||||
assert result.exit_code == 0
|
||||
assert "true" in result.output
|
||||
|
||||
@patch("scripts.ci.classify_changes.get_changed_files")
|
||||
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_check_user_facing_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
"""--check user-facing with only workflow changes outputs false."""
|
||||
mock_changes.return_value = [".gitea/workflows/ci.yml", "tests/test_foo.py"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--check", "user-facing", "--quiet"])
|
||||
assert result.exit_code == 0
|
||||
assert "false" in result.output
|
||||
|
||||
@patch("scripts.ci.classify_changes.get_changed_files")
|
||||
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_check_ansible_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
"""--check ansible in non-quiet mode prints file list."""
|
||||
mock_changes.return_value = ["ansible/tasks/main.yml"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--check", "ansible"])
|
||||
assert result.exit_code == 0
|
||||
assert "Ansible changes detected" in result.output
|
||||
|
||||
@patch("scripts.ci.classify_changes.get_changed_files")
|
||||
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_check_user_facing_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
"""--check user-facing in non-quiet mode prints file list."""
|
||||
mock_changes.return_value = ["src/gitea_runner_manager/cli.py"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--check", "user-facing"])
|
||||
assert result.exit_code == 0
|
||||
assert "User-facing changes detected" in result.output
|
||||
|
||||
|
||||
class TestGithubOutput:
|
||||
def test_writes_outputs(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
with patch.object(
|
||||
classify_changes_mod, "get_changed_files", return_value=["src/cli.py", "ansible/tasks/main.yml"]
|
||||
):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "ansible-changed=true" in content
|
||||
assert "user-facing-changed=true" in content
|
||||
|
||||
def test_no_changes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
with patch.object(classify_changes_mod, "get_changed_files", return_value=[]):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "ansible-changed=false" in content
|
||||
assert "user-facing-changed=false" in content
|
||||
|
||||
def test_no_tags(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
with patch.object(classify_changes_mod, "get_latest_tag", return_value=""):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--github-output"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "ansible-changed=true" in content
|
||||
assert "user-facing-changed=true" in content
|
||||
|
||||
def test_no_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
|
||||
with patch.object(classify_changes_mod, "get_changed_files", return_value=["src/cli.py"]):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_workflow_only(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
with patch.object(
|
||||
classify_changes_mod, "get_changed_files", return_value=[".gitea/workflows/ci.yml", "AGENTS.md"]
|
||||
):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "ansible-changed=false" in content
|
||||
assert "user-facing-changed=false" in content
|
||||
|
||||
@@ -9,9 +9,12 @@ import pytest
|
||||
from gitea_runner_manager.config import BRANCH_PROTECTION_CONFIG, REPO_SETTINGS_CONFIG
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from scripts.configure_repo import (
|
||||
_ensure_label_via_client,
|
||||
_ensure_label_via_tea,
|
||||
_handle_http_error,
|
||||
main,
|
||||
)
|
||||
from scripts.gitea_cli import TeaCLIError
|
||||
|
||||
|
||||
class TestHandleHttpError:
|
||||
@@ -36,6 +39,50 @@ class TestHandleHttpError:
|
||||
assert str(http.HTTPStatus.BAD_GATEWAY) in str(exc.value)
|
||||
|
||||
|
||||
class TestEnsureLabelViaTea:
|
||||
def test_creates_new_label(self) -> None:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.return_value = [{"name": "bug"}]
|
||||
result = _ensure_label_via_tea(mock_tea, "owner/repo", "ready-to-merge", "2ecc71", "desc")
|
||||
assert result is True
|
||||
mock_tea.create_label.assert_called_once()
|
||||
|
||||
def test_label_already_exists(self) -> None:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.return_value = [{"name": "ready-to-merge"}]
|
||||
result = _ensure_label_via_tea(mock_tea, "owner/repo", "ready-to-merge", "2ecc71", "desc")
|
||||
assert result is False
|
||||
mock_tea.create_label.assert_not_called()
|
||||
|
||||
def test_tea_error_falls_back_to_client(self) -> None:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.side_effect = TeaCLIError("network error")
|
||||
with patch("scripts.configure_repo._ensure_label_via_client", return_value=True) as mock_fallback:
|
||||
result = _ensure_label_via_tea(mock_tea, "owner/repo", "ready-to-merge", "2ecc71", "desc")
|
||||
assert result is True
|
||||
mock_fallback.assert_called_once_with("ready-to-merge", "2ecc71", "desc")
|
||||
|
||||
|
||||
class TestEnsureLabelViaClient:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.configure_repo.GiteaClient")
|
||||
def test_creates_label(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.ensure_label.return_value = {"id": 1}
|
||||
mock_client_cls.return_value = mock_client
|
||||
result = _ensure_label_via_client("bug", "ff0000", "A bug")
|
||||
assert result is True
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.configure_repo.GiteaClient")
|
||||
def test_label_already_exists(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.ensure_label.return_value = None
|
||||
mock_client_cls.return_value = mock_client
|
||||
result = _ensure_label_via_client("bug", "ff0000", "A bug")
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_main_missing_token(self) -> None:
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
@@ -43,57 +90,90 @@ class TestMain:
|
||||
main()
|
||||
assert "REPO_TOKEN" in str(exc.value)
|
||||
|
||||
def test_main_success(self) -> None:
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
with patch("scripts.configure_repo.GiteaClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.configure_repo.TeaCLI")
|
||||
@patch("scripts.configure_repo.GiteaClient")
|
||||
def test_main_success(self, mock_client_cls: MagicMock, mock_tea_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.return_value = [] # No existing labels
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
main()
|
||||
main()
|
||||
|
||||
mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
|
||||
mock_client.ensure_label.assert_called_once()
|
||||
mock_client.update_repo_settings.assert_called_once_with(REPO_SETTINGS_CONFIG)
|
||||
mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
|
||||
mock_tea.create_label.assert_called_once()
|
||||
mock_client.update_repo_settings.assert_called_once_with(REPO_SETTINGS_CONFIG)
|
||||
|
||||
def test_main_label_already_exists(self) -> None:
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
with patch("scripts.configure_repo.GiteaClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.ensure_label.return_value = None
|
||||
mock_client_cls.return_value = mock_client
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.configure_repo.TeaCLI")
|
||||
@patch("scripts.configure_repo.GiteaClient")
|
||||
def test_main_label_already_exists(self, mock_client_cls: MagicMock, mock_tea_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.return_value = [{"name": "ready-to-merge"}]
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
main()
|
||||
main()
|
||||
|
||||
mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
|
||||
mock_client.ensure_label.assert_called_once()
|
||||
mock_client.update_repo_settings.assert_called_once_with(REPO_SETTINGS_CONFIG)
|
||||
mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
|
||||
mock_tea.create_label.assert_not_called()
|
||||
mock_client.update_repo_settings.assert_called_once_with(REPO_SETTINGS_CONFIG)
|
||||
|
||||
def test_main_api_error(self) -> None:
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
with patch("scripts.configure_repo.GiteaClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.ensure_branch_protection.side_effect = APIError(http.HTTPStatus.FORBIDDEN, "Forbidden")
|
||||
mock_client_cls.return_value = mock_client
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.configure_repo.TeaCLI")
|
||||
@patch("scripts.configure_repo.GiteaClient")
|
||||
def test_main_tea_error_falls_back(self, mock_client_cls: MagicMock, mock_tea_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.ensure_label.return_value = {"id": 1}
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.side_effect = TeaCLIError("network error")
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
main()
|
||||
assert "HTTP" in str(exc.value)
|
||||
main()
|
||||
|
||||
mock_client.ensure_branch_protection.assert_called_once()
|
||||
# Fallback to GiteaClient for label creation
|
||||
mock_client.ensure_label.assert_called_once()
|
||||
mock_client.update_repo_settings.assert_called_once()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.configure_repo.TeaCLI")
|
||||
@patch("scripts.configure_repo.GiteaClient")
|
||||
def test_main_api_error(self, mock_client_cls: MagicMock, mock_tea_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.ensure_branch_protection.side_effect = APIError(http.HTTPStatus.FORBIDDEN, "Forbidden")
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_tea = MagicMock()
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
main()
|
||||
assert "HTTP" in str(exc.value)
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
with patch("scripts.configure_repo.GiteaClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
import scripts.configure_repo as cr
|
||||
with patch("scripts.configure_repo.TeaCLI") as mock_tea_cls:
|
||||
with patch("scripts.configure_repo.GiteaClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.return_value = []
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
import scripts.configure_repo as cr
|
||||
|
||||
with open(cr.__file__) as f:
|
||||
source = f.read()
|
||||
# Remove __main__ block so exec doesn't call main() before we inject the mock
|
||||
source = source.replace('if __name__ == "__main__":\n main()\n', "")
|
||||
namespace = dict(cr.__dict__)
|
||||
exec(compile(source, cr.__file__, "exec"), namespace)
|
||||
namespace["GiteaClient"] = mock_client_cls
|
||||
namespace["main"]()
|
||||
mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
|
||||
mock_client.update_repo_settings.assert_called_once_with(REPO_SETTINGS_CONFIG)
|
||||
with open(cr.__file__) as f:
|
||||
source = f.read()
|
||||
# Remove __main__ block so exec doesn't call main() before we inject the mock
|
||||
source = source.replace('if __name__ == "__main__":\n main()\n', "")
|
||||
namespace = dict(cr.__dict__)
|
||||
exec(compile(source, cr.__file__, "exec"), namespace)
|
||||
namespace["GiteaClient"] = mock_client_cls
|
||||
namespace["TeaCLI"] = mock_tea_cls
|
||||
namespace["main"]()
|
||||
mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
|
||||
mock_client.update_repo_settings.assert_called_once_with(REPO_SETTINGS_CONFIG)
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from click import ClickException
|
||||
from click.testing import CliRunner
|
||||
|
||||
import scripts.ci.detect_release_commit as detect_release_commit
|
||||
|
||||
|
||||
class TestGetCommitMessage:
|
||||
def test_success(self) -> None:
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="feat: add feature\n", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
assert detect_release_commit.get_commit_message() == "feat: add feature"
|
||||
|
||||
def test_failure(self) -> None:
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="git error")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
with pytest.raises(ClickException, match="git log failed"):
|
||||
detect_release_commit.get_commit_message()
|
||||
|
||||
|
||||
class TestIsReleaseCommit:
|
||||
def test_release_commit(self) -> None:
|
||||
assert detect_release_commit.is_release_commit("release: v1.0.0 [skip ci]") is True
|
||||
|
||||
def test_release_commit_no_skip(self) -> None:
|
||||
assert detect_release_commit.is_release_commit("release: v0.1.0") is True
|
||||
|
||||
def test_regular_commit(self) -> None:
|
||||
assert detect_release_commit.is_release_commit("feat: add feature") is False
|
||||
|
||||
def test_empty(self) -> None:
|
||||
assert detect_release_commit.is_release_commit("") is False
|
||||
|
||||
|
||||
class TestWriteGithubOutput:
|
||||
def test_write(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
detect_release_commit.write_github_output("is-release", "true")
|
||||
with open(gh_file) as f:
|
||||
assert f.read() == "is-release=true\n"
|
||||
|
||||
def test_no_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
|
||||
with pytest.raises(ClickException, match="GITHUB_OUTPUT"):
|
||||
detect_release_commit.write_github_output("is-release", "true")
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_release_commit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
with patch.object(detect_release_commit, "get_commit_message", return_value="release: v1.0.0 [skip ci]"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(detect_release_commit.main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "Release commit" in result.output
|
||||
with open(gh_file) as f:
|
||||
assert "is-release=true" in f.read()
|
||||
|
||||
def test_regular_commit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
with patch.object(detect_release_commit, "get_commit_message", return_value="feat: add feature"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(detect_release_commit.main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "Regular merge commit" in result.output
|
||||
with open(gh_file) as f:
|
||||
assert "is-release=false" in f.read()
|
||||
@@ -1,8 +1,10 @@
|
||||
"""Unit tests for scripts/ci/discover_runners.py."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from scripts.ci.discover_runners import (
|
||||
@@ -19,13 +21,13 @@ class TestGenerateIndices:
|
||||
assert generate_indices(0) == []
|
||||
|
||||
def test_one(self) -> None:
|
||||
assert generate_indices(1) == [0]
|
||||
assert generate_indices(1) == ["1"]
|
||||
|
||||
def test_three(self) -> None:
|
||||
assert generate_indices(3) == [0, 1, 2]
|
||||
assert generate_indices(3) == ["1", "2", "3"]
|
||||
|
||||
def test_five(self) -> None:
|
||||
assert generate_indices(5) == [0, 1, 2, 3, 4]
|
||||
assert generate_indices(5) == ["1", "2", "3", "4", "5"]
|
||||
|
||||
|
||||
class TestQueryRunners:
|
||||
@@ -166,7 +168,7 @@ class TestMain:
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "count=3" in result.output
|
||||
assert "indices=[0, 1, 2]" in result.output
|
||||
assert 'indices=["1", "2", "3"]' in result.output
|
||||
|
||||
@patch("scripts.ci.discover_runners.get_runner_count", return_value=5)
|
||||
def test_count_only(self, mock_count: MagicMock) -> None:
|
||||
@@ -180,11 +182,29 @@ class TestMain:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--indices"])
|
||||
assert result.exit_code == 0
|
||||
assert json.loads(result.output.strip()) == [0, 1, 2, 3]
|
||||
assert json.loads(result.output.strip()) == ["1", "2", "3", "4"]
|
||||
|
||||
@patch("scripts.ci.discover_runners.get_runner_count", return_value=1)
|
||||
def test_single_runner(self, mock_count: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--indices"])
|
||||
assert result.exit_code == 0
|
||||
assert json.loads(result.output.strip()) == [0]
|
||||
assert json.loads(result.output.strip()) == ["1"]
|
||||
|
||||
@patch("scripts.ci.discover_runners.get_runner_count", return_value=3)
|
||||
def test_github_output(self, mock_count: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--github-output"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "runner-count=3" in content
|
||||
assert "runner-indices=" in content
|
||||
|
||||
@patch("scripts.ci.discover_runners.get_runner_count", return_value=3)
|
||||
def test_github_output_no_env(self, mock_count: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--github-output"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
@@ -5,12 +5,14 @@ from unittest.mock import patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from scripts.ci.distribute_molecule import (
|
||||
MOLECULE_ROOT,
|
||||
PLATFORMS,
|
||||
TestPair,
|
||||
build_pairs,
|
||||
cli,
|
||||
discover_scenarios,
|
||||
distribute,
|
||||
pairs_for_runner,
|
||||
@@ -184,13 +186,59 @@ class TestCli:
|
||||
(root / "alpha").mkdir(parents=True)
|
||||
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--runner-index", "0", "--max-runners", "3"])
|
||||
# 1-based index: "1" maps to internal 0
|
||||
result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3"])
|
||||
assert result.exit_code == 0
|
||||
# Output should contain encoded pairs with platform info
|
||||
assert "alpha|" in result.output
|
||||
assert "ubuntu-2204" in result.output
|
||||
|
||||
|
||||
class TestGithubEnv:
|
||||
def test_writes_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gh_file = tmp_path / "env.txt"
|
||||
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
|
||||
root = tmp_path / "molecule"
|
||||
scenario = root / "alpha"
|
||||
scenario.mkdir(parents=True)
|
||||
(scenario / "molecule.yml").write_text("name: alpha\n")
|
||||
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3", "--github-env"])
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "TEST_PAIRS=" in content
|
||||
assert "SKIP=false" in content
|
||||
|
||||
def test_skip_if_excess(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gh_file = tmp_path / "env.txt"
|
||||
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
|
||||
root = tmp_path / "molecule"
|
||||
scenario = root / "alpha"
|
||||
scenario.mkdir(parents=True)
|
||||
(scenario / "molecule.yml").write_text("name: alpha\n")
|
||||
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
cli, ["--runner-index", "5", "--max-runners", "3", "--github-env", "--skip-if-excess"]
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
content = gh_file.read_text()
|
||||
assert "TEST_PAIRS=\n" in content
|
||||
assert "SKIP=true" in content
|
||||
|
||||
def test_no_env_var(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("GITHUB_ENV", raising=False)
|
||||
root = tmp_path / "molecule"
|
||||
scenario = root / "alpha"
|
||||
scenario.mkdir(parents=True)
|
||||
(scenario / "molecule.yml").write_text("name: alpha\n")
|
||||
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3", "--github-env"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
import scripts.ci.distribute_molecule as dm
|
||||
|
||||
|
||||
@@ -77,7 +77,9 @@ class TestMain:
|
||||
# Write ci-cd-workflow.md with all scripts
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text(
|
||||
"auto_merge.py release.py publish.py review_pr.py "
|
||||
"notify_failure.py post_merge.py classify_changes.py discover_runners.py"
|
||||
"notify_failure.py post_merge.py classify_changes.py discover_runners.py "
|
||||
"detect_release_commit.py push_badges.py "
|
||||
"distribute_molecule.py molecule_ci_guard.py validate_commit_msg.py"
|
||||
)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
"""Unit tests for scripts/generate_badges.py."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from scripts.generate_badges import (
|
||||
COLOR_HEX,
|
||||
cli,
|
||||
coverage_color,
|
||||
doc_coverage_color,
|
||||
extract_coverage,
|
||||
extract_doc_coverage,
|
||||
extract_test_count,
|
||||
generate_badges,
|
||||
make_badge,
|
||||
read_version,
|
||||
render_svg,
|
||||
run_command,
|
||||
)
|
||||
|
||||
|
||||
class TestRunCommand:
|
||||
@patch("scripts.generate_badges.subprocess.run")
|
||||
def test_returns_returncode_stdout_stderr(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="out", stderr="err")
|
||||
rc, out, err = run_command(["echo", "hello"])
|
||||
assert rc == 0
|
||||
assert out == "out"
|
||||
assert err == "err"
|
||||
mock_run.assert_called_once()
|
||||
|
||||
|
||||
class TestMakeBadge:
|
||||
def test_creates_valid_badge_dict(self) -> None:
|
||||
badge = make_badge("coverage", "100%", "brightgreen")
|
||||
assert badge == {
|
||||
"schemaVersion": 1,
|
||||
"label": "coverage",
|
||||
"message": "100%",
|
||||
"color": "brightgreen",
|
||||
}
|
||||
|
||||
|
||||
class TestRenderSvg:
|
||||
def test_generates_valid_svg(self) -> None:
|
||||
svg = render_svg("coverage", "100%", "brightgreen")
|
||||
assert svg.startswith("<svg")
|
||||
assert svg.endswith("</svg>\n")
|
||||
assert "coverage" in svg
|
||||
assert "100%" in svg
|
||||
assert COLOR_HEX["brightgreen"] in svg
|
||||
|
||||
def test_uses_color_hex_for_known_colors(self) -> None:
|
||||
svg = render_svg("tests", "573 passing", "brightgreen")
|
||||
assert "#4c1" in svg
|
||||
|
||||
def test_uses_hex_directly_for_unknown_hex_color(self) -> None:
|
||||
svg = render_svg("label", "msg", "#abc123")
|
||||
assert "#abc123" in svg
|
||||
|
||||
def test_uses_lightgrey_for_unknown_named_color(self) -> None:
|
||||
svg = render_svg("label", "msg", "nonexistent")
|
||||
assert "#9f9f9f" in svg
|
||||
|
||||
def test_escapes_xml_special_chars(self) -> None:
|
||||
svg = render_svg("label", "<script>", "red")
|
||||
assert "<script>" not in svg
|
||||
assert "<script>" in svg
|
||||
|
||||
def test_has_correct_dimensions(self) -> None:
|
||||
svg = render_svg("coverage", "100%", "brightgreen")
|
||||
assert 'width="' in svg
|
||||
assert 'height="20"' in svg
|
||||
|
||||
|
||||
class TestExtractCoverage:
|
||||
def test_extracts_from_total_line(self) -> None:
|
||||
output = (
|
||||
"src/gitea_runner_manager/cli.py 118 0 100%\n"
|
||||
"TOTAL 1798 0 100.00%\n"
|
||||
)
|
||||
assert extract_coverage(output) == 100.0
|
||||
|
||||
def test_extracts_partial_coverage(self) -> None:
|
||||
output = "TOTAL 100 20 80.00%\n"
|
||||
assert extract_coverage(output) == 80.0
|
||||
|
||||
def test_extracts_without_decimal(self) -> None:
|
||||
output = "TOTAL 1900 0 100%\n"
|
||||
assert extract_coverage(output) == 100.0
|
||||
|
||||
def test_returns_none_when_no_match(self) -> None:
|
||||
assert extract_coverage("no coverage here") is None
|
||||
|
||||
|
||||
class TestExtractTestCount:
|
||||
def test_extracts_passed_count(self) -> None:
|
||||
assert extract_test_count("543 passed in 2.32s") == 543
|
||||
|
||||
def test_extracts_with_warnings(self) -> None:
|
||||
assert extract_test_count("543 passed, 1 warning in 2.32s") == 543
|
||||
|
||||
def test_returns_none_when_no_match(self) -> None:
|
||||
assert extract_test_count("no tests here") is None
|
||||
|
||||
|
||||
class TestExtractDocCoverage:
|
||||
def test_extracts_percentage(self) -> None:
|
||||
output = "\nDoc coverage: 20/20 (100%)"
|
||||
assert extract_doc_coverage(output) == 100
|
||||
|
||||
def test_extracts_partial(self) -> None:
|
||||
output = "\nDoc coverage: 18/20 (90%)"
|
||||
assert extract_doc_coverage(output) == 90
|
||||
|
||||
def test_returns_none_when_no_match(self) -> None:
|
||||
assert extract_doc_coverage("no doc coverage here") is None
|
||||
|
||||
|
||||
class TestCoverageColor:
|
||||
def test_100_is_brightgreen(self) -> None:
|
||||
assert coverage_color(100.0) == "brightgreen"
|
||||
|
||||
def test_90_is_green(self) -> None:
|
||||
assert coverage_color(90.0) == "green"
|
||||
|
||||
def test_80_is_yellowgreen(self) -> None:
|
||||
assert coverage_color(80.0) == "yellowgreen"
|
||||
|
||||
def test_70_is_yellow(self) -> None:
|
||||
assert coverage_color(70.0) == "yellow"
|
||||
|
||||
def test_60_is_orange(self) -> None:
|
||||
assert coverage_color(60.0) == "orange"
|
||||
|
||||
def test_below_60_is_red(self) -> None:
|
||||
assert coverage_color(50.0) == "red"
|
||||
|
||||
|
||||
class TestDocCoverageColor:
|
||||
def test_100_is_brightgreen(self) -> None:
|
||||
assert doc_coverage_color(100) == "brightgreen"
|
||||
|
||||
def test_90_is_green(self) -> None:
|
||||
assert doc_coverage_color(90) == "green"
|
||||
|
||||
def test_80_is_yellowgreen(self) -> None:
|
||||
assert doc_coverage_color(80) == "yellowgreen"
|
||||
|
||||
def test_70_is_yellow(self) -> None:
|
||||
assert doc_coverage_color(70) == "yellow"
|
||||
|
||||
def test_below_70_is_orange(self) -> None:
|
||||
assert doc_coverage_color(60) == "orange"
|
||||
|
||||
|
||||
class TestReadVersion:
|
||||
def test_reads_version_from_init(self) -> None:
|
||||
with patch("scripts.generate_badges.INIT_FILE") as mock_file:
|
||||
mock_file.read_text.return_value = '__version__ = "0.5.0"\n'
|
||||
assert read_version() == "0.5.0"
|
||||
|
||||
def test_returns_unknown_when_no_version(self) -> None:
|
||||
with patch("scripts.generate_badges.INIT_FILE") as mock_file:
|
||||
mock_file.read_text.return_value = "no version here\n"
|
||||
assert read_version() == "unknown"
|
||||
|
||||
|
||||
class TestGenerateBadges:
|
||||
@patch("scripts.generate_badges.run_command")
|
||||
@patch("scripts.generate_badges.read_version", return_value="0.5.0")
|
||||
@patch("scripts.generate_badges.extract_coverage", return_value=100.0)
|
||||
@patch("scripts.generate_badges.extract_test_count", return_value=573)
|
||||
@patch("scripts.generate_badges.extract_doc_coverage", return_value=100)
|
||||
def test_generates_all_badge_files(
|
||||
self,
|
||||
mock_doc_cov: MagicMock,
|
||||
mock_test_count: MagicMock,
|
||||
mock_cov: MagicMock,
|
||||
mock_version: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
mock_run.return_value = (0, "output", "")
|
||||
badges = generate_badges(tmp_path)
|
||||
|
||||
expected = {"coverage", "tests", "docs", "quality", "version", "python"}
|
||||
assert set(badges.keys()) == expected
|
||||
|
||||
# Verify SVG files were written
|
||||
for name in expected:
|
||||
svg_file = tmp_path / f"{name}.svg"
|
||||
assert svg_file.exists()
|
||||
content = svg_file.read_text()
|
||||
assert content.startswith("<svg")
|
||||
assert "</svg>" in content
|
||||
|
||||
@patch("scripts.generate_badges.run_command")
|
||||
@patch("scripts.generate_badges.read_version", return_value="0.5.0")
|
||||
@patch("scripts.generate_badges.extract_coverage", return_value=100.0)
|
||||
@patch("scripts.generate_badges.extract_test_count", return_value=573)
|
||||
@patch("scripts.generate_badges.extract_doc_coverage", return_value=100)
|
||||
def test_quality_badge_pass_when_all_lint_passes(
|
||||
self,
|
||||
mock_doc_cov: MagicMock,
|
||||
mock_test_count: MagicMock,
|
||||
mock_cov: MagicMock,
|
||||
mock_version: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
mock_run.return_value = (0, "output", "")
|
||||
badges = generate_badges(tmp_path)
|
||||
assert badges["quality"]["message"] == "A"
|
||||
assert badges["quality"]["color"] == "brightgreen"
|
||||
|
||||
@patch("scripts.generate_badges.run_command")
|
||||
@patch("scripts.generate_badges.read_version", return_value="0.5.0")
|
||||
@patch("scripts.generate_badges.extract_coverage", return_value=100.0)
|
||||
@patch("scripts.generate_badges.extract_test_count", return_value=573)
|
||||
@patch("scripts.generate_badges.extract_doc_coverage", return_value=100)
|
||||
def test_quality_badge_fails_when_lint_fails(
|
||||
self,
|
||||
mock_doc_cov: MagicMock,
|
||||
mock_test_count: MagicMock,
|
||||
mock_cov: MagicMock,
|
||||
mock_version: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
mock_run.side_effect = [
|
||||
(0, "output", ""),
|
||||
(0, "output", ""),
|
||||
(1, "error", ""),
|
||||
(0, "output", ""),
|
||||
(0, "output", ""),
|
||||
(0, "output", ""),
|
||||
]
|
||||
badges = generate_badges(tmp_path)
|
||||
assert badges["quality"]["message"] == "F"
|
||||
assert badges["quality"]["color"] == "red"
|
||||
|
||||
@patch("scripts.generate_badges.run_command")
|
||||
@patch("scripts.generate_badges.read_version", return_value="0.5.0")
|
||||
@patch("scripts.generate_badges.extract_coverage", return_value=None)
|
||||
@patch("scripts.generate_badges.extract_test_count", return_value=None)
|
||||
@patch("scripts.generate_badges.extract_doc_coverage", return_value=None)
|
||||
def test_badges_show_unknown_when_extraction_fails(
|
||||
self,
|
||||
mock_doc_cov: MagicMock,
|
||||
mock_test_count: MagicMock,
|
||||
mock_cov: MagicMock,
|
||||
mock_version: MagicMock,
|
||||
mock_run: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
mock_run.return_value = (1, "garbled output", "")
|
||||
badges = generate_badges(tmp_path)
|
||||
assert badges["coverage"]["message"] == "unknown"
|
||||
assert badges["coverage"]["color"] == "red"
|
||||
assert badges["tests"]["message"] == "unknown"
|
||||
assert badges["tests"]["color"] == "red"
|
||||
assert badges["docs"]["message"] == "unknown"
|
||||
assert badges["docs"]["color"] == "red"
|
||||
|
||||
|
||||
class TestCli:
|
||||
@patch("scripts.generate_badges.generate_badges")
|
||||
def test_cli_generates_badges(self, mock_gen: MagicMock, tmp_path: Path) -> None:
|
||||
mock_gen.return_value = {
|
||||
"coverage": make_badge("coverage", "100%", "brightgreen"),
|
||||
"tests": make_badge("tests", "573 passing", "brightgreen"),
|
||||
}
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--output-dir", str(tmp_path)])
|
||||
assert result.exit_code == 0
|
||||
assert "Generating badges" in result.output
|
||||
assert "Generated 2 badges" in result.output
|
||||
mock_gen.assert_called_once_with(tmp_path)
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
import scripts.generate_badges as gb
|
||||
|
||||
with patch.object(gb, "cli") as mock_cli:
|
||||
with patch.object(gb, "__name__", "__main__"):
|
||||
gb.cli([])
|
||||
mock_cli.assert_called_once_with([])
|
||||
@@ -0,0 +1,355 @@
|
||||
"""Unit tests for scripts/gitea_cli.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.gitea_cli import TeaCLI, TeaCLIError, _extract_issue_number, _extract_pr_number
|
||||
|
||||
|
||||
class TestExtractIssueNumber:
|
||||
def test_extract_from_created_issue(self) -> None:
|
||||
assert _extract_issue_number("Created issue #42: Bug title") == 42
|
||||
|
||||
def test_extract_no_hash(self) -> None:
|
||||
assert _extract_issue_number("No issue number here") == 0
|
||||
|
||||
def test_extract_multiple_hashes(self) -> None:
|
||||
assert _extract_issue_number("Issue #5 and PR #10") == 5
|
||||
|
||||
def test_extract_with_colon(self) -> None:
|
||||
assert _extract_issue_number("Created issue #7: title") == 7
|
||||
|
||||
def test_extract_invalid_number(self) -> None:
|
||||
assert _extract_issue_number("Issue #abc: title") == 0
|
||||
|
||||
|
||||
class TestExtractPrNumber:
|
||||
def test_extract_from_created_pr(self) -> None:
|
||||
assert _extract_pr_number("Created PR #128: Feature") == 128
|
||||
|
||||
def test_extract_no_number(self) -> None:
|
||||
assert _extract_pr_number("No PR number") == 0
|
||||
|
||||
|
||||
class TestTeaCLIInit:
|
||||
def test_auto_detect_tea(self) -> None:
|
||||
with patch("shutil.which", return_value="/usr/bin/tea"):
|
||||
cli = TeaCLI()
|
||||
assert cli._tea == "/usr/bin/tea"
|
||||
|
||||
def test_explicit_tea_bin(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/custom/tea")
|
||||
assert cli._tea == "/custom/tea"
|
||||
|
||||
def test_fallback_to_tea(self) -> None:
|
||||
with patch("shutil.which", return_value=None):
|
||||
cli = TeaCLI()
|
||||
assert cli._tea == "tea"
|
||||
|
||||
def test_with_repo(self) -> None:
|
||||
cli = TeaCLI(repo="owner/repo")
|
||||
assert cli._repo == "owner/repo"
|
||||
|
||||
|
||||
class TestTeaCLIRun:
|
||||
def test_run_success_json(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout='[{"id": 1}]', stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
output = cli._run(["labels", "list"])
|
||||
assert output == '[{"id": 1}]'
|
||||
|
||||
def test_run_success_raw(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Created issue #42", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
output = cli._run_raw(["issues", "create"])
|
||||
assert output == "Created issue #42"
|
||||
|
||||
def test_run_failure_raises(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=1, stdout="", stderr="auth error")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
with pytest.raises(TeaCLIError, match="auth error"):
|
||||
cli._run(["labels", "list"])
|
||||
|
||||
def test_run_includes_json_flag(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="[]", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli._run(["labels", "list"])
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--output" in cmd
|
||||
assert "json" in cmd
|
||||
|
||||
def test_run_raw_no_json_flag(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli._run_raw(["whoami"])
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--output" not in cmd
|
||||
|
||||
|
||||
class TestRepoArg:
|
||||
def test_with_repo_arg(self) -> None:
|
||||
cli = TeaCLI(repo="owner/repo")
|
||||
assert cli._repo_arg() == ["--repo", "owner/repo"]
|
||||
|
||||
def test_with_explicit_repo(self) -> None:
|
||||
cli = TeaCLI()
|
||||
assert cli._repo_arg("other/repo") == ["--repo", "other/repo"]
|
||||
|
||||
def test_without_repo(self) -> None:
|
||||
cli = TeaCLI()
|
||||
assert cli._repo_arg() == []
|
||||
|
||||
def test_explicit_overrides_default(self) -> None:
|
||||
cli = TeaCLI(repo="default/repo")
|
||||
assert cli._repo_arg("override/repo") == ["--repo", "override/repo"]
|
||||
|
||||
|
||||
class TestCreateIssue:
|
||||
def test_create_issue_basic(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea", repo="owner/repo")
|
||||
mock_result = MagicMock(returncode=0, stdout="Created issue #42: Bug", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
issue = cli.create_issue("owner/repo", title="Bug", body="Description")
|
||||
assert issue["index"] == 42
|
||||
assert issue["title"] == "Bug"
|
||||
|
||||
def test_create_issue_with_labels(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Created issue #5: Title", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
issue = cli.create_issue("owner/repo", title="Title", body="Body", labels=["bug"])
|
||||
assert issue["index"] == 5
|
||||
|
||||
|
||||
class TestListLabels:
|
||||
def test_list_labels_with_data(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
labels_json = json.dumps([{"id": 1, "name": "bug"}, {"id": 2, "name": "enhancement"}])
|
||||
mock_result = MagicMock(returncode=0, stdout=labels_json, stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
labels = cli.list_labels("owner/repo")
|
||||
assert len(labels) == 2
|
||||
assert labels[0]["name"] == "bug"
|
||||
|
||||
def test_list_labels_empty(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
labels = cli.list_labels("owner/repo")
|
||||
assert labels == []
|
||||
|
||||
|
||||
class TestCreateLabel:
|
||||
def test_create_label_full(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Label created", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
label = cli.create_label("owner/repo", name="bug", color="ff0000", description="A bug")
|
||||
assert label["name"] == "bug"
|
||||
assert label["color"] == "ff0000"
|
||||
|
||||
def test_create_label_name_only(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Label created", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
label = cli.create_label("owner/repo", name="wip")
|
||||
assert label["name"] == "wip"
|
||||
assert label["color"] == ""
|
||||
|
||||
|
||||
class TestAddLabel:
|
||||
def test_add_label_single(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.add_label("owner/repo", 42, ["ready-to-merge"])
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--add-labels" in cmd
|
||||
assert "ready-to-merge" in cmd
|
||||
assert "42" in cmd
|
||||
|
||||
def test_add_label_multiple(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.add_label("owner/repo", 42, ["bug", "urgent"])
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--add-labels" in cmd
|
||||
|
||||
def test_add_label_empty_list(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
with patch("subprocess.run") as mock_run:
|
||||
cli.add_label("owner/repo", 42, [])
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
class TestCreatePR:
|
||||
def test_create_pr_basic(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Created PR #128: Feature", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
pr = cli.create_pr("owner/repo", title="Feature", head="feature-branch", base="master")
|
||||
assert pr["index"] == 128
|
||||
|
||||
def test_create_pr_with_body(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Created PR #10: Title", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.create_pr("owner/repo", title="Title", head="feat", base="master", body="Description")
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--body" in cmd
|
||||
assert "Description" in cmd
|
||||
|
||||
|
||||
class TestMergePR:
|
||||
def test_merge_pr_squash(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Merged", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.merge_pr("owner/repo", 42, style="squash")
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--style" in cmd
|
||||
assert "squash" in cmd
|
||||
assert "42" in cmd
|
||||
|
||||
def test_merge_pr_default_style(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Merged", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.merge_pr("owner/repo", 42)
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "squash" in cmd
|
||||
|
||||
|
||||
class TestReviewPR:
|
||||
def test_review_approve(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.review_pr("owner/repo", 42, event="APPROVE", body="LGTM")
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--approve" in cmd
|
||||
assert "--comment" in cmd
|
||||
|
||||
def test_review_reject(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.review_pr("owner/repo", 42, event="REQUEST_CHANGES", body="Needs work")
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--reject" in cmd
|
||||
|
||||
def test_review_comment(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.review_pr("owner/repo", 42, event="COMMENT", body="Note")
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--approve" not in cmd
|
||||
assert "--reject" not in cmd
|
||||
assert "--comment" in cmd
|
||||
|
||||
def test_review_no_body(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.review_pr("owner/repo", 42, event="COMMENT")
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--comment" not in cmd
|
||||
|
||||
|
||||
class TestCreateRelease:
|
||||
def test_create_release_full(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Release created", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
release = cli.create_release(
|
||||
"owner/repo",
|
||||
tag="v1.0.0",
|
||||
title="Release 1.0.0",
|
||||
body="Notes",
|
||||
target="master",
|
||||
)
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "v1.0.0" in cmd
|
||||
assert "--title" in cmd
|
||||
assert "--note" in cmd
|
||||
assert "--target" in cmd
|
||||
assert release["tag"] == "v1.0.0"
|
||||
|
||||
def test_create_release_draft(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Release created", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.create_release("owner/repo", tag="v0.1.0", draft=True)
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--draft" in cmd
|
||||
|
||||
def test_create_release_prerelease(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Release created", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
cli.create_release("owner/repo", tag="v0.1.0-rc1", prerelease=True)
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--prerelease" in cmd
|
||||
|
||||
def test_create_release_minimal(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="Release created", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
release = cli.create_release("owner/repo", tag="v1.0.0")
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "--title" not in cmd
|
||||
assert "--note" not in cmd
|
||||
assert release["tag"] == "v1.0.0"
|
||||
|
||||
|
||||
class TestListReleases:
|
||||
def test_list_releases_with_data(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
releases_json = json.dumps([{"tag": "v1.0.0"}, {"tag": "v0.9.0"}])
|
||||
mock_result = MagicMock(returncode=0, stdout=releases_json, stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
releases = cli.list_releases("owner/repo")
|
||||
assert len(releases) == 2
|
||||
|
||||
def test_list_releases_empty(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
releases = cli.list_releases("owner/repo")
|
||||
assert releases == []
|
||||
|
||||
|
||||
class TestListBranches:
|
||||
def test_list_branches_with_data(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
branches_json = json.dumps([{"name": "master"}, {"name": "develop"}])
|
||||
mock_result = MagicMock(returncode=0, stdout=branches_json, stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
branches = cli.list_branches("owner/repo")
|
||||
assert len(branches) == 2
|
||||
|
||||
def test_list_branches_empty(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
branches = cli.list_branches("owner/repo")
|
||||
assert branches == []
|
||||
|
||||
|
||||
class TestWhoami:
|
||||
def test_whoami(self) -> None:
|
||||
cli = TeaCLI(tea_bin="/fake/tea")
|
||||
mock_result = MagicMock(returncode=0, stdout="emil", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
assert cli.whoami() == "emil"
|
||||
@@ -0,0 +1,312 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from click import ClickException
|
||||
from click.testing import CliRunner
|
||||
|
||||
import scripts.install_tools as install_tools
|
||||
|
||||
|
||||
class TestArch:
|
||||
def test_amd64(self) -> None:
|
||||
with patch.object(platform, "machine", return_value="x86_64"):
|
||||
assert install_tools._arch() == "amd64"
|
||||
|
||||
def test_arm64(self) -> None:
|
||||
with patch.object(platform, "machine", return_value="aarch64"):
|
||||
assert install_tools._arch() == "arm64"
|
||||
|
||||
def test_unsupported(self) -> None:
|
||||
with patch.object(platform, "machine", return_value="riscv64"):
|
||||
with pytest.raises(ClickException):
|
||||
install_tools._arch()
|
||||
|
||||
|
||||
class TestIsInstalled:
|
||||
def test_on_path(self) -> None:
|
||||
with patch("shutil.which", return_value="/usr/bin/actionlint"):
|
||||
assert install_tools._is_installed("actionlint") is True
|
||||
|
||||
def test_in_target_dir(self, tmp_path: Path) -> None:
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
(tmp_path / "actionlint").touch()
|
||||
with patch("shutil.which", return_value=None):
|
||||
assert install_tools._is_installed("actionlint") is True
|
||||
|
||||
def test_not_installed(self, tmp_path: Path) -> None:
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
with patch("shutil.which", return_value=None):
|
||||
assert install_tools._is_installed("actionlint") is False
|
||||
|
||||
|
||||
class TestDownload:
|
||||
def test_download(self, tmp_path: Path) -> None:
|
||||
dest = tmp_path / "file.bin"
|
||||
|
||||
def _write_file(url: str, path: Path) -> tuple[str, None]:
|
||||
Path(path).write_bytes(b"data")
|
||||
return str(path), None
|
||||
|
||||
with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve:
|
||||
install_tools._download("https://example.com/file", dest)
|
||||
mock_retrieve.assert_called_once()
|
||||
assert dest.read_bytes() == b"data"
|
||||
|
||||
|
||||
class TestDownloadBinary:
|
||||
def test_download(self, tmp_path: Path) -> None:
|
||||
dest = tmp_path / "act_runner"
|
||||
|
||||
def _write_file(url: str, path: Path) -> tuple[str, None]:
|
||||
Path(path).write_bytes(b"binary")
|
||||
return str(path), None
|
||||
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
with patch.object(install_tools, "_download", side_effect=_write_file):
|
||||
result = install_tools._download_binary("https://example.com/act_runner", "act_runner")
|
||||
assert result == dest
|
||||
assert dest.exists()
|
||||
assert dest.stat().st_mode & 0o111
|
||||
|
||||
|
||||
class TestDownloadAndExtractTarball:
|
||||
def test_extract(self, tmp_path: Path) -> None:
|
||||
import tarfile
|
||||
|
||||
# Create a fake tarball with a binary
|
||||
tarball_path = tmp_path / "archive.tar.gz"
|
||||
binary_content = b"fake binary"
|
||||
with tarfile.open(tarball_path, "w:gz") as tar:
|
||||
import io
|
||||
|
||||
info = tarfile.TarInfo(name="actionlint")
|
||||
info.size = len(binary_content)
|
||||
tar.addfile(info, io.BytesIO(binary_content))
|
||||
|
||||
target_dir = tmp_path / "bin"
|
||||
target_dir.mkdir()
|
||||
|
||||
with patch.object(install_tools, "TARGET_DIR", target_dir):
|
||||
with patch.object(
|
||||
install_tools,
|
||||
"_download",
|
||||
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
|
||||
):
|
||||
result = install_tools._download_and_extract_tarball(
|
||||
"https://example.com/actionlint.tar.gz", "actionlint"
|
||||
)
|
||||
|
||||
assert result == target_dir / "actionlint"
|
||||
assert result.exists()
|
||||
assert result.read_bytes() == binary_content
|
||||
|
||||
def test_binary_not_found(self, tmp_path: Path) -> None:
|
||||
import tarfile
|
||||
|
||||
tarball_path = tmp_path / "archive.tar.gz"
|
||||
with tarfile.open(tarball_path, "w:gz") as tar:
|
||||
import io
|
||||
|
||||
info = tarfile.TarInfo(name="other_file")
|
||||
info.size = 0
|
||||
tar.addfile(info, io.BytesIO(b""))
|
||||
|
||||
target_dir = tmp_path / "bin"
|
||||
target_dir.mkdir()
|
||||
|
||||
with patch.object(install_tools, "TARGET_DIR", target_dir):
|
||||
with patch.object(
|
||||
install_tools,
|
||||
"_download",
|
||||
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
|
||||
):
|
||||
with pytest.raises(ClickException, match="not found in archive"):
|
||||
install_tools._download_and_extract_tarball("https://example.com/actionlint.tar.gz", "actionlint")
|
||||
|
||||
|
||||
class TestInstallActionlint:
|
||||
def test_already_installed(self) -> None:
|
||||
with patch.object(install_tools, "_is_installed", return_value=True):
|
||||
assert install_tools.install_actionlint() is True
|
||||
|
||||
def test_install(self, tmp_path: Path) -> None:
|
||||
import tarfile
|
||||
|
||||
tarball_path = tmp_path / "archive.tar.gz"
|
||||
binary_content = b"fake actionlint"
|
||||
with tarfile.open(tarball_path, "w:gz") as tar:
|
||||
import io
|
||||
|
||||
info = tarfile.TarInfo(name="actionlint")
|
||||
info.size = len(binary_content)
|
||||
tar.addfile(info, io.BytesIO(binary_content))
|
||||
|
||||
with patch.object(install_tools, "_is_installed", return_value=False):
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
with patch.object(platform, "machine", return_value="x86_64"):
|
||||
with patch.object(
|
||||
install_tools,
|
||||
"_download",
|
||||
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
|
||||
):
|
||||
assert install_tools.install_actionlint() is True
|
||||
assert (tmp_path / "actionlint").exists()
|
||||
|
||||
|
||||
class TestInstallGitCliff:
|
||||
def test_already_installed(self) -> None:
|
||||
with patch.object(install_tools, "_is_installed", return_value=True):
|
||||
assert install_tools.install_git_cliff() is True
|
||||
|
||||
def test_install(self, tmp_path: Path) -> None:
|
||||
import tarfile
|
||||
|
||||
tarball_path = tmp_path / "archive.tar.gz"
|
||||
binary_content = b"fake git-cliff"
|
||||
with tarfile.open(tarball_path, "w:gz") as tar:
|
||||
import io
|
||||
|
||||
info = tarfile.TarInfo(name="git-cliff")
|
||||
info.size = len(binary_content)
|
||||
tar.addfile(info, io.BytesIO(binary_content))
|
||||
|
||||
with patch.object(install_tools, "_is_installed", return_value=False):
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
with patch.object(platform, "machine", return_value="x86_64"):
|
||||
with patch.object(
|
||||
install_tools,
|
||||
"_download",
|
||||
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
|
||||
):
|
||||
assert install_tools.install_git_cliff() is True
|
||||
assert (tmp_path / "git-cliff").exists()
|
||||
|
||||
|
||||
class TestInstallActRunner:
|
||||
def test_already_installed(self) -> None:
|
||||
with patch.object(install_tools, "_is_installed", return_value=True):
|
||||
assert install_tools.install_act_runner() is True
|
||||
|
||||
def test_install(self, tmp_path: Path) -> None:
|
||||
def _write_file(url: str, path: Path) -> tuple[str, None]:
|
||||
Path(path).write_bytes(b"binary")
|
||||
return str(path), None
|
||||
|
||||
with patch.object(install_tools, "_is_installed", return_value=False):
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
with patch.object(platform, "machine", return_value="x86_64"):
|
||||
with patch.object(install_tools, "_download", side_effect=_write_file):
|
||||
assert install_tools.install_act_runner() is True
|
||||
assert (tmp_path / "act_runner").exists()
|
||||
|
||||
|
||||
class TestInstallTea:
|
||||
def test_already_installed(self) -> None:
|
||||
with patch.object(install_tools, "_is_installed", return_value=True):
|
||||
assert install_tools.install_tea() is True
|
||||
|
||||
def test_install(self, tmp_path: Path) -> None:
|
||||
def _write_file(url: str, path: Path) -> tuple[str, None]:
|
||||
Path(path).write_bytes(b"binary")
|
||||
return str(path), None
|
||||
|
||||
with patch.object(install_tools, "_is_installed", return_value=False):
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
with patch.object(platform, "machine", return_value="x86_64"):
|
||||
with patch.object(install_tools, "_download", side_effect=_write_file):
|
||||
assert install_tools.install_tea() is True
|
||||
assert (tmp_path / "tea").exists()
|
||||
|
||||
|
||||
class TestListTools:
|
||||
def test_list(self, tmp_path: Path) -> None:
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
with patch("shutil.which", return_value=None):
|
||||
with patch.object(install_tools, "TOOL_NAMES", ["actionlint", "git-cliff", "act_runner"]):
|
||||
install_tools.list_tools()
|
||||
|
||||
|
||||
class TestInstallTool:
|
||||
def test_actionlint(self) -> None:
|
||||
with patch.object(install_tools, "install_actionlint", return_value=True) as mock:
|
||||
assert install_tools._install_tool("actionlint") is True
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_git_cliff(self) -> None:
|
||||
with patch.object(install_tools, "install_git_cliff", return_value=True) as mock:
|
||||
assert install_tools._install_tool("git-cliff") is True
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_act_runner(self) -> None:
|
||||
with patch.object(install_tools, "install_act_runner", return_value=True) as mock:
|
||||
assert install_tools._install_tool("act_runner") is True
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_tea(self) -> None:
|
||||
with patch.object(install_tools, "install_tea", return_value=True) as mock:
|
||||
assert install_tools._install_tool("tea") is True
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_unknown_tool(self) -> None:
|
||||
with pytest.raises(ClickException, match="Unknown tool"):
|
||||
install_tools._install_tool("unknown")
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_list_status(self) -> None:
|
||||
runner = CliRunner()
|
||||
with patch.object(install_tools, "_is_installed", return_value=True):
|
||||
result = runner.invoke(install_tools.main, ["--list"])
|
||||
assert result.exit_code == 0
|
||||
assert "actionlint" in result.output
|
||||
|
||||
def test_install_all(self) -> None:
|
||||
runner = CliRunner()
|
||||
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
|
||||
result = runner.invoke(install_tools.main, [])
|
||||
assert result.exit_code == 0
|
||||
assert mock_install.call_count == 4
|
||||
|
||||
def test_install_specific_tool(self) -> None:
|
||||
runner = CliRunner()
|
||||
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
|
||||
result = runner.invoke(install_tools.main, ["--tool", "actionlint"])
|
||||
assert result.exit_code == 0
|
||||
mock_install.assert_called_once_with("actionlint")
|
||||
|
||||
def test_install_multiple_specific_tools(self) -> None:
|
||||
runner = CliRunner()
|
||||
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
|
||||
result = runner.invoke(install_tools.main, ["--tool", "git-cliff", "--tool", "tea"])
|
||||
assert result.exit_code == 0
|
||||
assert mock_install.call_count == 2
|
||||
|
||||
def test_install_failure(self) -> None:
|
||||
runner = CliRunner()
|
||||
with patch.object(install_tools, "_install_tool", side_effect=Exception("network error")):
|
||||
result = runner.invoke(install_tools.main, ["--tool", "actionlint"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_path_reminder(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When TARGET_DIR is not in PATH, a reminder is printed."""
|
||||
monkeypatch.setenv("PATH", "/usr/bin:/bin")
|
||||
runner = CliRunner()
|
||||
with patch.object(install_tools, "_install_tool", return_value=True):
|
||||
result = runner.invoke(install_tools.main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "Add" in result.output
|
||||
assert "PATH" in result.output
|
||||
|
||||
def test_no_path_reminder_when_in_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When TARGET_DIR is in PATH, no reminder is printed."""
|
||||
target_dir = str(install_tools.TARGET_DIR)
|
||||
monkeypatch.setenv("PATH", f"/usr/bin:{target_dir}:/bin")
|
||||
runner = CliRunner()
|
||||
with patch.object(install_tools, "_install_tool", return_value=True):
|
||||
result = runner.invoke(install_tools.main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "Add" not in result.output
|
||||
@@ -0,0 +1,130 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
import scripts.molecule_all as molecule_all
|
||||
|
||||
|
||||
class TestRunMolecule:
|
||||
def test_success(self) -> None:
|
||||
import subprocess
|
||||
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
rc = molecule_all._run_molecule("/bin/molecule", "default", Path("/tmp/role"), {})
|
||||
assert rc == 0
|
||||
mock_run.assert_called_once()
|
||||
|
||||
def test_failure(self) -> None:
|
||||
import subprocess
|
||||
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
rc = molecule_all._run_molecule("/bin/molecule", "default", Path("/tmp/role"), {})
|
||||
assert rc == 1
|
||||
|
||||
def test_non_default_scenario_adds_flag(self) -> None:
|
||||
import subprocess
|
||||
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
||||
molecule_all._run_molecule("/bin/molecule", "lifecycle", Path("/tmp/role"), {})
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert "-s" in cmd
|
||||
assert "lifecycle" in cmd
|
||||
|
||||
|
||||
class TestRunPlatform:
|
||||
def test_all_scenarios_pass(self) -> None:
|
||||
platform = {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": "/lib/systemd/systemd"}
|
||||
with patch("scripts.molecule_all._run_molecule", return_value=0) as mock_run:
|
||||
rc = molecule_all._run_platform("/bin/molecule", platform, Path("/tmp/role"), ["default", "lifecycle"], {})
|
||||
assert rc == 0
|
||||
assert mock_run.call_count == 2
|
||||
|
||||
def test_stops_on_failure(self) -> None:
|
||||
platform = {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": "/lib/systemd/systemd"}
|
||||
with patch("scripts.molecule_all._run_molecule", side_effect=[1, 0]) as mock_run:
|
||||
rc = molecule_all._run_platform("/bin/molecule", platform, Path("/tmp/role"), ["default", "lifecycle"], {})
|
||||
assert rc == 1
|
||||
assert mock_run.call_count == 1
|
||||
|
||||
def test_sets_env_vars(self) -> None:
|
||||
platform = {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": "/lib/systemd/systemd"}
|
||||
captured_env: dict[str, str] = {}
|
||||
|
||||
def _capture_env(cmd, cwd, env):
|
||||
captured_env.update(env)
|
||||
import subprocess
|
||||
|
||||
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
|
||||
|
||||
with patch("subprocess.run", side_effect=_capture_env):
|
||||
molecule_all._run_platform("/bin/molecule", platform, Path("/tmp/role"), ["default"], {"PATH": "/usr/bin"})
|
||||
assert captured_env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2204"
|
||||
assert captured_env["MOLECULE_PLATFORM_IMAGE"] == "ubuntu:22.04"
|
||||
assert captured_env["MOLECULE_PLATFORM_COMMAND"] == "/lib/systemd/systemd"
|
||||
|
||||
def test_empty_command_removes_env(self) -> None:
|
||||
platform = {"name": "custom", "image": "custom:latest", "command": ""}
|
||||
captured_env: dict[str, str] = {}
|
||||
|
||||
def _capture_env(cmd, cwd, env):
|
||||
captured_env.update(env)
|
||||
import subprocess
|
||||
|
||||
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
|
||||
|
||||
with patch("subprocess.run", side_effect=_capture_env):
|
||||
molecule_all._run_platform(
|
||||
"/bin/molecule", platform, Path("/tmp/role"), ["default"], {"MOLECULE_PLATFORM_COMMAND": "old"}
|
||||
)
|
||||
assert "MOLECULE_PLATFORM_COMMAND" not in captured_env
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_molecule_not_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(molecule_all.main, ["--bin", "nonexistent/bin"])
|
||||
assert result.exit_code != 0
|
||||
assert "molecule not found" in result.output
|
||||
|
||||
def test_role_dir_not_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
bin_dir = tmp_path / ".venv" / "bin"
|
||||
bin_dir.mkdir(parents=True)
|
||||
(bin_dir / "molecule").touch()
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(molecule_all.main, ["--bin", str(bin_dir)])
|
||||
assert result.exit_code != 0
|
||||
assert "Role directory not found" in result.output
|
||||
|
||||
def test_all_pass(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
bin_dir = tmp_path / ".venv" / "bin"
|
||||
bin_dir.mkdir(parents=True)
|
||||
(bin_dir / "molecule").touch()
|
||||
(tmp_path / "ansible" / "roles" / "gitea-runner").mkdir(parents=True)
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("scripts.molecule_all._run_platform", return_value=0):
|
||||
result = runner.invoke(molecule_all.main, ["--bin", str(bin_dir)])
|
||||
assert result.exit_code == 0
|
||||
assert "All molecule scenarios passed" in result.output
|
||||
|
||||
def test_platform_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
bin_dir = tmp_path / ".venv" / "bin"
|
||||
bin_dir.mkdir(parents=True)
|
||||
(bin_dir / "molecule").touch()
|
||||
(tmp_path / "ansible" / "roles" / "gitea-runner").mkdir(parents=True)
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("scripts.molecule_all._run_platform", return_value=1):
|
||||
result = runner.invoke(molecule_all.main, ["--bin", str(bin_dir)])
|
||||
assert result.exit_code != 0
|
||||
@@ -1,21 +1,24 @@
|
||||
"""Unit tests for scripts/ci/notify_failure.py."""
|
||||
|
||||
import http
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from scripts.ci.notify_failure import main
|
||||
from scripts.gitea_cli import TeaCLIError
|
||||
|
||||
|
||||
class TestNotifyFailure:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.notify_failure.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"}]
|
||||
mock_client.create_issue.return_value = {"id": 42}
|
||||
mock_client_cls.return_value = mock_client
|
||||
@patch("scripts.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("scripts.gitea_cli.TeaCLI")
|
||||
def test_creates_issue_with_tea(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.return_value = [{"id": 5, "name": "bug"}]
|
||||
mock_tea.create_issue.return_value = {"index": 42, "title": "test"}
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
@@ -33,64 +36,127 @@ class TestNotifyFailure:
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "issue #42" in result.output
|
||||
mock_client.create_issue.assert_called_once()
|
||||
call_kwargs = mock_client.create_issue.call_args
|
||||
assert call_kwargs.kwargs["labels"] == [5]
|
||||
mock_tea.create_issue.assert_called_once()
|
||||
mock_tea.add_label.assert_called_once_with("owner/repo", 42, ["bug"])
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.notify_failure.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()
|
||||
mock_client.list_labels.return_value = [{"id": 1, "name": "enhancement"}]
|
||||
mock_client.create_issue.return_value = {"id": 43}
|
||||
mock_client_cls.return_value = mock_client
|
||||
@patch("scripts.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("scripts.gitea_cli.TeaCLI")
|
||||
def test_tea_creates_issue_without_bug_label(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.return_value = [{"id": 1, "name": "enhancement"}]
|
||||
mock_tea.create_issue.return_value = {"index": 43, "title": "test"}
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"--repo",
|
||||
"owner/repo",
|
||||
"--run-id",
|
||||
"124",
|
||||
"--workflow",
|
||||
"publish",
|
||||
"--commit",
|
||||
"def789",
|
||||
],
|
||||
["--repo", "owner/repo", "--run-id", "124", "--workflow", "publish", "--commit", "def789"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "issue #43" in result.output
|
||||
mock_client.create_issue.assert_called_once()
|
||||
call_kwargs = mock_client.create_issue.call_args
|
||||
assert call_kwargs.kwargs.get("labels") is None
|
||||
mock_tea.add_label.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("scripts.gitea_cli.TeaCLI")
|
||||
def test_tea_error_falls_back_to_client(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None:
|
||||
"""When tea fails, fall back to GiteaClient."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.side_effect = TeaCLIError("network error")
|
||||
mock_tea.create_issue.side_effect = TeaCLIError("network error")
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
with patch("scripts.ci.notify_failure.GiteaClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_labels.return_value = [{"id": 5, "name": "bug"}]
|
||||
mock_client.create_issue.return_value = {"id": 50}
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--repo", "owner/repo", "--run-id", "125", "--workflow", "release", "--commit", "abc"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "issue #50" in result.output
|
||||
mock_client.create_issue.assert_called_once()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.notify_failure.shutil.which", return_value=None)
|
||||
@patch("scripts.ci.notify_failure.GiteaClient")
|
||||
def test_api_error_raises(self, mock_client_cls: MagicMock) -> None:
|
||||
def test_tea_not_installed_uses_client(self, mock_client_cls: MagicMock, mock_which: MagicMock) -> None:
|
||||
"""When tea is not installed, use GiteaClient directly."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_labels.return_value = []
|
||||
mock_client.create_issue.side_effect = APIError(403, "forbidden")
|
||||
mock_client.list_labels.return_value = [{"id": 5, "name": "bug"}]
|
||||
mock_client.create_issue.return_value = {"id": 51}
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"--repo",
|
||||
"owner/repo",
|
||||
"--run-id",
|
||||
"125",
|
||||
"--workflow",
|
||||
"release",
|
||||
"--commit",
|
||||
"abc",
|
||||
],
|
||||
["--repo", "owner/repo", "--run-id", "126", "--workflow", "release", "--commit", "abc"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "issue #51" in result.output
|
||||
mock_client.create_issue.assert_called_once()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.notify_failure.shutil.which", return_value=None)
|
||||
@patch("scripts.ci.notify_failure.GiteaClient")
|
||||
def test_client_api_error_raises(self, mock_client_cls: MagicMock, mock_which: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_labels.return_value = []
|
||||
mock_client.create_issue.side_effect = APIError(http.HTTPStatus.FORBIDDEN, "forbidden")
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--repo", "owner/repo", "--run-id", "127", "--workflow", "release", "--commit", "abc"],
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "403" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("scripts.gitea_cli.TeaCLI")
|
||||
def test_tea_list_labels_error_continues_without_labels(
|
||||
self, mock_tea_cls: MagicMock, mock_which: MagicMock
|
||||
) -> None:
|
||||
"""If listing labels fails via tea, issue is still created without labels."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.side_effect = TeaCLIError("network error")
|
||||
mock_tea.create_issue.return_value = {"index": 50, "title": "test"}
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--repo", "owner/repo", "--run-id", "128", "--workflow", "release", "--commit", "abc"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "issue #50" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("scripts.gitea_cli.TeaCLI")
|
||||
def test_tea_add_label_error_is_ignored(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None:
|
||||
"""If adding label fails via tea, issue is still reported as created."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_labels.return_value = [{"id": 5, "name": "bug"}]
|
||||
mock_tea.create_issue.return_value = {"index": 51, "title": "test"}
|
||||
mock_tea.add_label.side_effect = TeaCLIError("permission denied")
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--repo", "owner/repo", "--run-id", "129", "--workflow", "release", "--commit", "abc"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "issue #51" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
|
||||
def test_missing_token_exits(self) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Unit tests for scripts/ci/platforms.py."""
|
||||
|
||||
from scripts.ci.platforms import PLATFORMS
|
||||
|
||||
|
||||
class TestPlatforms:
|
||||
def test_platforms_not_empty(self) -> None:
|
||||
assert len(PLATFORMS) >= 4
|
||||
|
||||
def test_each_platform_has_required_keys(self) -> None:
|
||||
for p in PLATFORMS:
|
||||
assert "name" in p
|
||||
assert "image" in p
|
||||
assert "command" in p
|
||||
|
||||
def test_platform_names_unique(self) -> None:
|
||||
names = [p["name"] for p in PLATFORMS]
|
||||
assert len(names) == len(set(names))
|
||||
|
||||
def test_known_platforms_present(self) -> None:
|
||||
names = {p["name"] for p in PLATFORMS}
|
||||
assert "ubuntu-2204" in names
|
||||
assert "ubuntu-2404" in names
|
||||
assert "debian-12" in names
|
||||
assert "archlinux" in names
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Unit tests for scripts/ci/post_merge.py."""
|
||||
|
||||
import http
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
@@ -9,6 +10,8 @@ from click.testing import CliRunner
|
||||
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from scripts.ci.post_merge import (
|
||||
_get_git_commit_message,
|
||||
_get_git_commit_sha,
|
||||
build_comment,
|
||||
extract_conventional_msg,
|
||||
extract_task_id,
|
||||
@@ -130,12 +133,39 @@ class TestMain:
|
||||
assert "VIKUNJA_TOKEN" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_no_task_id_skips(self) -> None:
|
||||
def test_no_task_id_non_release_warns(self) -> None:
|
||||
"""Non-release commits without GRM-N prefix should warn, not fail."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["fix: resolve bug"])
|
||||
assert result.exit_code == 0
|
||||
assert "No task ID" in result.output
|
||||
assert "Skipping" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_release_commit_without_task_id_skips(self) -> None:
|
||||
"""Release commits without GRM-N prefix should skip gracefully."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["release: v0.3.2"])
|
||||
assert result.exit_code == 0
|
||||
assert "skipping" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_revert_commit_skips(self) -> None:
|
||||
"""Revert commits without GRM-N prefix should skip gracefully."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["revert: remove v0.6.0 release"])
|
||||
assert result.exit_code == 0
|
||||
assert "Infrastructure commit" in result.output
|
||||
assert "skipping" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_merge_commit_skips(self) -> None:
|
||||
"""Merge commits without GRM-N prefix should skip gracefully."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["Merge pull request #42"])
|
||||
assert result.exit_code == 0
|
||||
assert "Infrastructure commit" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("scripts.ci.post_merge.VikunjaClient")
|
||||
def test_resolve_failure_propagates(self, mock_client_cls: MagicMock) -> None:
|
||||
@@ -149,7 +179,8 @@ class TestMain:
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("scripts.ci.post_merge.VikunjaClient")
|
||||
def test_post_comment_failure_raises_click(self, mock_client_cls: MagicMock) -> None:
|
||||
def test_post_comment_failure_warns(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Vikunja API errors should warn, not fail — the merge already succeeded."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
{"id": 267, "identifier": "GRM-20"},
|
||||
@@ -158,12 +189,14 @@ class TestMain:
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["GRM-20: fix: bug"])
|
||||
assert result.exit_code == 1
|
||||
assert "HTTP" in result.output
|
||||
assert result.exit_code == 0
|
||||
assert "Warning" in result.output
|
||||
assert "not updated" in result.output.lower()
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("scripts.ci.post_merge.VikunjaClient")
|
||||
def test_mark_done_failure_raises_click(self, mock_client_cls: MagicMock) -> None:
|
||||
def test_mark_done_failure_warns(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Vikunja API errors should warn, not fail — the merge already succeeded."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
{"id": 267, "identifier": "GRM-20"},
|
||||
@@ -173,5 +206,123 @@ class TestMain:
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["GRM-20: fix: bug"])
|
||||
assert result.exit_code == 1
|
||||
assert "HTTP" in result.output
|
||||
assert result.exit_code == 0
|
||||
assert "Warning" in result.output
|
||||
assert "not updated" in result.output.lower()
|
||||
|
||||
|
||||
class TestGetGitCommitMessage:
|
||||
def test_success(self) -> None:
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="GRM-20: fix: bug\n", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
assert _get_git_commit_message() == "GRM-20: fix: bug"
|
||||
|
||||
def test_failure(self) -> None:
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="git error")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
with pytest.raises(click.ClickException, match="git log failed"):
|
||||
_get_git_commit_message()
|
||||
|
||||
|
||||
class TestGetGitCommitSha:
|
||||
def test_success(self) -> None:
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="abc123\n", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
assert _get_git_commit_sha() == "abc123"
|
||||
|
||||
def test_failure(self) -> None:
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="git error")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
with pytest.raises(click.ClickException, match="git rev-parse failed"):
|
||||
_get_git_commit_sha()
|
||||
|
||||
|
||||
class TestFromGit:
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("scripts.ci.post_merge.VikunjaClient")
|
||||
@patch("scripts.ci.post_merge._get_git_commit_sha", return_value="abc123")
|
||||
@patch("scripts.ci.post_merge._get_git_commit_message", return_value="GRM-20: fix: bug")
|
||||
def test_from_git(self, mock_msg: MagicMock, mock_sha: MagicMock, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
{"id": 267, "identifier": "GRM-20"},
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--from-git"])
|
||||
assert result.exit_code == 0
|
||||
assert "updated and marked done" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("scripts.ci.post_merge.VikunjaClient")
|
||||
@patch("scripts.ci.post_merge._get_git_commit_sha", return_value="abc123")
|
||||
@patch("scripts.ci.post_merge._get_git_commit_message", return_value="GRM-20: fix: bug")
|
||||
def test_from_git_with_explicit_sha(
|
||||
self, mock_msg: MagicMock, mock_sha: MagicMock, mock_client_cls: MagicMock
|
||||
) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
{"id": 267, "identifier": "GRM-20"},
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--from-git", "--commit-sha", "explicit_sha"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_no_msg_and_no_from_git(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code != 0
|
||||
assert "commit_msg" in result.output
|
||||
|
||||
|
||||
class TestGitSha:
|
||||
"""Tests for the --git-sha option (race condition fix)."""
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("scripts.ci.post_merge.VikunjaClient")
|
||||
def test_git_sha_reads_commit_from_specific_sha(self, mock_client_cls: MagicMock) -> None:
|
||||
"""--git-sha reads commit message from a specific SHA, not HEAD."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
{"id": 267, "identifier": "GRM-20"},
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="GRM-20: fix: bug\n", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--git-sha", "abc123"])
|
||||
assert result.exit_code == 0
|
||||
assert "updated and marked done" in result.output
|
||||
mock_client.post_comment.assert_called_once()
|
||||
# Verify the SHA was passed to the comment
|
||||
args, _ = mock_client.post_comment.call_args
|
||||
assert "abc123" in args[1]
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_git_sha_failure_raises(self) -> None:
|
||||
"""--git-sha with invalid SHA should raise."""
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="bad sha")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--git-sha", "badsha"])
|
||||
assert result.exit_code != 0
|
||||
assert "git log failed" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("scripts.ci.post_merge.VikunjaClient")
|
||||
def test_git_sha_with_explicit_commit_sha(self, mock_client_cls: MagicMock) -> None:
|
||||
"""--git-sha with --commit-sha uses the explicit SHA for the comment."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
{"id": 267, "identifier": "GRM-20"},
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="GRM-20: fix: bug\n", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--git-sha", "abc123", "--commit-sha", "explicit_sha"])
|
||||
assert result.exit_code == 0
|
||||
args, _ = mock_client.post_comment.call_args
|
||||
assert "explicit_sha" in args[1]
|
||||
|
||||
@@ -0,0 +1,553 @@
|
||||
"""Unit tests for scripts/ci/pr_review.py."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from scripts.ci.pr_review import (
|
||||
ReviewResult,
|
||||
build_review_body,
|
||||
check_architecture_compliance,
|
||||
check_best_practices,
|
||||
check_commit_conventions,
|
||||
check_documentation,
|
||||
check_function_length,
|
||||
check_security,
|
||||
check_test_coverage,
|
||||
is_python_file,
|
||||
is_workflow_only,
|
||||
main,
|
||||
post_review,
|
||||
run_review,
|
||||
)
|
||||
|
||||
|
||||
class TestIsPythonFile:
|
||||
def test_python_file_in_src(self) -> None:
|
||||
assert is_python_file("src/gitea_runner_manager/cli.py") is True
|
||||
|
||||
def test_python_file_in_scripts(self) -> None:
|
||||
assert is_python_file("scripts/ci/release.py") is True
|
||||
|
||||
def test_test_file_excluded(self) -> None:
|
||||
assert is_python_file("tests/unit/test_cli.py") is False
|
||||
|
||||
def test_non_python_file(self) -> None:
|
||||
assert is_python_file("README.md") is False
|
||||
|
||||
def test_yaml_file(self) -> None:
|
||||
assert is_python_file(".gitea/workflows/ci.yml") is False
|
||||
|
||||
|
||||
class TestIsWorkflowOnly:
|
||||
def test_yaml_is_workflow(self) -> None:
|
||||
assert is_workflow_only(".gitea/workflows/ci.yml") is True
|
||||
|
||||
def test_md_is_workflow(self) -> None:
|
||||
assert is_workflow_only("README.md") is True
|
||||
|
||||
def test_python_is_not_workflow(self) -> None:
|
||||
assert is_workflow_only("src/gitea_runner_manager/cli.py") is False
|
||||
|
||||
def test_ansible_is_workflow(self) -> None:
|
||||
assert is_workflow_only("ansible/tasks/main.yml") is True
|
||||
|
||||
|
||||
class TestReviewResult:
|
||||
def test_empty_result_has_no_issues(self) -> None:
|
||||
result = ReviewResult()
|
||||
assert result.has_issues is False
|
||||
|
||||
def test_add_issue_makes_has_issues_true(self) -> None:
|
||||
result = ReviewResult()
|
||||
result.add_issue("src/foo.py", 10, "bad code")
|
||||
assert result.has_issues is True
|
||||
assert len(result.issues) == 1
|
||||
assert result.issues[0]["path"] == "src/foo.py"
|
||||
assert result.issues[0]["new_position"] == 10
|
||||
|
||||
def test_add_summary(self) -> None:
|
||||
result = ReviewResult()
|
||||
result.add_summary("all good")
|
||||
assert "all good" in result.summary
|
||||
|
||||
|
||||
class TestCheckArchitectureCompliance:
|
||||
def test_subprocess_in_cli_triggers_issue(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [
|
||||
{
|
||||
"filename": "src/gitea_runner_manager/cli.py",
|
||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ subprocess.run(['ls'])\n",
|
||||
}
|
||||
]
|
||||
check_architecture_compliance(files, result)
|
||||
assert result.has_issues
|
||||
assert "subprocess" in result.issues[0]["body"].lower()
|
||||
|
||||
def test_subprocess_in_other_file_ok(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [
|
||||
{
|
||||
"filename": "src/gitea_runner_manager/executor.py",
|
||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ subprocess.run(['ls'])\n",
|
||||
}
|
||||
]
|
||||
check_architecture_compliance(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_no_changes_adds_ok_summary(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": ""}]
|
||||
check_architecture_compliance(files, result)
|
||||
assert any("Architecture compliance: OK" in s for s in result.summary)
|
||||
|
||||
def test_non_python_file_skipped(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "README.md", "patch": "@@ -1,1 +1,2 @@\n+subprocess.run(['ls'])\n"}]
|
||||
check_architecture_compliance(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_empty_patch_skipped(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": ""}]
|
||||
check_architecture_compliance(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_os_system_in_cli_triggers_issue(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [
|
||||
{
|
||||
"filename": "src/gitea_runner_manager/cli.py",
|
||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ os.system('ls')\n",
|
||||
}
|
||||
]
|
||||
check_architecture_compliance(files, result)
|
||||
assert result.has_issues
|
||||
assert "os.system" in result.issues[0]["body"]
|
||||
|
||||
|
||||
class TestCheckBestPractices:
|
||||
def test_print_triggers_warning(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [
|
||||
{
|
||||
"filename": "src/gitea_runner_manager/cli.py",
|
||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ print('hello')\n",
|
||||
}
|
||||
]
|
||||
check_best_practices(files, result)
|
||||
assert result.has_issues
|
||||
assert "print()" in result.issues[0]["body"]
|
||||
|
||||
def test_bare_except_triggers_warning(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [
|
||||
{
|
||||
"filename": "src/gitea_runner_manager/runner_manager.py",
|
||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ except:\n pass\n",
|
||||
}
|
||||
]
|
||||
check_best_practices(files, result)
|
||||
assert result.has_issues
|
||||
assert "bare except" in result.issues[0]["body"]
|
||||
|
||||
def test_todo_triggers_warning(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [
|
||||
{
|
||||
"filename": "src/gitea_runner_manager/cli.py",
|
||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ # TODO: fix this\n",
|
||||
}
|
||||
]
|
||||
check_best_practices(files, result)
|
||||
assert result.has_issues
|
||||
assert "TODO" in result.issues[0]["body"]
|
||||
|
||||
def test_clean_code_no_issues(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [
|
||||
{
|
||||
"filename": "src/gitea_runner_manager/cli.py",
|
||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ click.echo('hello')\n",
|
||||
}
|
||||
]
|
||||
check_best_practices(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_empty_patch_skipped(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": ""}]
|
||||
check_best_practices(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_non_python_file_skipped(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "README.md", "patch": "@@ -1,1 +1,2 @@\n+print('hello')\n"}]
|
||||
check_best_practices(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
|
||||
class TestCheckSecurity:
|
||||
def test_hardcoded_secret_triggers_error(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [
|
||||
{
|
||||
"filename": "src/gitea_runner_manager/config.py",
|
||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ token = 'abc123secrettoken456'\n",
|
||||
}
|
||||
]
|
||||
check_security(files, result)
|
||||
assert result.has_issues
|
||||
assert "secret" in result.issues[0]["body"].lower()
|
||||
|
||||
def test_example_token_not_flagged(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [
|
||||
{
|
||||
"filename": ".env.example",
|
||||
"patch": "@@ -1,1 +1,2 @@\n+token = your-example-token\n",
|
||||
}
|
||||
]
|
||||
check_security(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_shell_true_triggers_warning(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [
|
||||
{
|
||||
"filename": "src/gitea_runner_manager/executor.py",
|
||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ subprocess.run('ls', shell=True)\n",
|
||||
}
|
||||
]
|
||||
check_best_practices(files, result)
|
||||
assert result.has_issues
|
||||
assert "shell=True" in result.issues[0]["body"]
|
||||
|
||||
def test_empty_patch_skipped(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "src/gitea_runner_manager/config.py", "patch": ""}]
|
||||
check_security(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_non_python_file_skipped(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "docs/config.md", "patch": "@@ -1,1 +1,2 @@\n+token = 'abc123secrettoken456'\n"}]
|
||||
check_security(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
|
||||
class TestCheckFunctionLength:
|
||||
def test_long_function_triggers_warning(self) -> None:
|
||||
result = ReviewResult()
|
||||
# Create a patch with a function that adds > 50 lines
|
||||
added_lines = "\n".join(f"+ x = {i}" for i in range(55))
|
||||
patch = f"@@ -10,3 +10,59 @@\n+def foo():\n+ pass\n{added_lines}\n"
|
||||
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": patch}]
|
||||
check_function_length(files, result)
|
||||
assert result.has_issues
|
||||
assert "foo" in result.issues[0]["body"]
|
||||
|
||||
def test_short_function_no_warning(self) -> None:
|
||||
result = ReviewResult()
|
||||
patch = "@@ -10,3 +10,8 @@\n def foo():\n pass\n+ x = 1\n+ y = 2\n+ z = 3\n"
|
||||
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": patch}]
|
||||
check_function_length(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_empty_patch_skipped(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": ""}]
|
||||
check_function_length(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_non_python_file_skipped(self) -> None:
|
||||
result = ReviewResult()
|
||||
added_lines = "\n".join(f"+ x = {i}" for i in range(55))
|
||||
patch = f"@@ -10,3 +10,59 @@\n+def foo():\n+ pass\n{added_lines}\n"
|
||||
files = [{"filename": "README.md", "patch": patch}]
|
||||
check_function_length(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_multiple_functions_resets_count(self) -> None:
|
||||
"""Two short functions back-to-back should not trigger the length warning."""
|
||||
result = ReviewResult()
|
||||
patch = "@@ -10,3 +10,15 @@\n+def foo():\n+ x = 1\n+def bar():\n+ y = 2\n"
|
||||
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": patch}]
|
||||
check_function_length(files, result)
|
||||
assert not result.has_issues
|
||||
|
||||
def test_long_function_followed_by_new_hunk(self) -> None:
|
||||
"""Long function followed by @@ header triggers the warning at hunk boundary."""
|
||||
result = ReviewResult()
|
||||
added_lines = "\n".join(f"+ x = {i}" for i in range(55))
|
||||
patch = (
|
||||
f"@@ -10,3 +10,59 @@\n+def foo():\n+ pass\n{added_lines}\n@@ -100,3 +100,5 @@\n+def bar():\n+ pass\n"
|
||||
)
|
||||
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": patch}]
|
||||
check_function_length(files, result)
|
||||
assert result.has_issues
|
||||
assert "foo" in result.issues[0]["body"]
|
||||
|
||||
def test_long_function_followed_by_new_def(self) -> None:
|
||||
"""Long function followed by another def triggers the warning at def boundary."""
|
||||
result = ReviewResult()
|
||||
added_lines = "\n".join(f"+ x = {i}" for i in range(55))
|
||||
patch = f"@@ -10,3 +10,60 @@\n+def foo():\n+ pass\n{added_lines}\n+def bar():\n+ pass\n"
|
||||
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": patch}]
|
||||
check_function_length(files, result)
|
||||
assert result.has_issues
|
||||
assert "foo" in result.issues[0]["body"]
|
||||
|
||||
|
||||
class TestCheckDocumentation:
|
||||
def test_src_changes_without_docs_warns(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "src/gitea_runner_manager/cli.py"}]
|
||||
check_documentation(files, result)
|
||||
assert any("WARNING" in s for s in result.summary)
|
||||
|
||||
def test_src_changes_with_docs_ok(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "src/gitea_runner_manager/cli.py"}, {"filename": "docs/user/cli-commands.md"}]
|
||||
check_documentation(files, result)
|
||||
assert any("Documentation: OK" in s for s in result.summary)
|
||||
|
||||
def test_ansible_changes_without_docs_warns(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "ansible/roles/gitea-runner/tasks/main.yml"}]
|
||||
check_documentation(files, result)
|
||||
assert any("WARNING" in s for s in result.summary)
|
||||
|
||||
def test_only_doc_changes_ok(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "README.md"}]
|
||||
check_documentation(files, result)
|
||||
assert any("Documentation: OK" in s for s in result.summary)
|
||||
|
||||
|
||||
class TestCheckTestCoverage:
|
||||
def test_src_changes_without_tests_warns(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "src/gitea_runner_manager/cli.py"}]
|
||||
check_test_coverage(files, result)
|
||||
assert any("WARNING" in s for s in result.summary)
|
||||
|
||||
def test_src_changes_with_tests_ok(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "src/gitea_runner_manager/cli.py"}, {"filename": "tests/unit/test_cli.py"}]
|
||||
check_test_coverage(files, result)
|
||||
assert any("Tests: OK" in s for s in result.summary)
|
||||
|
||||
def test_only_test_changes_ok(self) -> None:
|
||||
result = ReviewResult()
|
||||
files = [{"filename": "tests/unit/test_cli.py"}]
|
||||
check_test_coverage(files, result)
|
||||
assert any("Tests: OK" in s for s in result.summary)
|
||||
|
||||
|
||||
class TestBuildReviewBody:
|
||||
def test_body_contains_summary(self) -> None:
|
||||
result = ReviewResult()
|
||||
result.add_summary("- Architecture compliance: OK")
|
||||
body = build_review_body(result)
|
||||
assert "Architecture compliance: OK" in body
|
||||
assert "Automated PR Review" in body
|
||||
|
||||
def test_body_contains_issues(self) -> None:
|
||||
result = ReviewResult()
|
||||
result.add_issue("src/foo.py", 10, "bad code")
|
||||
body = build_review_body(result)
|
||||
assert "1 issue(s) found" in body
|
||||
assert "src/foo.py:10" in body
|
||||
assert "bad code" in body
|
||||
|
||||
def test_body_contains_no_issues_message(self) -> None:
|
||||
result = ReviewResult()
|
||||
body = build_review_body(result)
|
||||
assert "No issues found" in body
|
||||
|
||||
def test_body_contains_checklist_reference(self) -> None:
|
||||
"""Review body must reference REVIEW_CHECKLIST.md for manual review."""
|
||||
result = ReviewResult()
|
||||
body = build_review_body(result)
|
||||
assert "REVIEW_CHECKLIST.md" in body
|
||||
assert "--checklist-confirmed" in body
|
||||
|
||||
|
||||
class TestRunReview:
|
||||
@patch("scripts.ci.pr_review.GiteaClient")
|
||||
def test_run_review_with_no_files(self, mock_client_class: MagicMock) -> None:
|
||||
mock_client = mock_client_class.return_value
|
||||
mock_client.get_pr_files.return_value = []
|
||||
result = run_review(mock_client, "42")
|
||||
assert "No files changed" in result.summary[0]
|
||||
|
||||
@patch("scripts.ci.pr_review.GiteaClient")
|
||||
def test_run_review_finds_issues(self, mock_client_class: MagicMock) -> None:
|
||||
mock_client = mock_client_class.return_value
|
||||
mock_client.get_pr_files.return_value = [
|
||||
{
|
||||
"filename": "src/gitea_runner_manager/cli.py",
|
||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ print('hello')\n",
|
||||
}
|
||||
]
|
||||
mock_client.get_pr_commits.return_value = [{"commit": {"message": "fix: resolve print issue"}}]
|
||||
result = run_review(mock_client, "42")
|
||||
assert result.has_issues
|
||||
|
||||
def test_run_review_handles_api_error(self) -> None:
|
||||
client = MagicMock()
|
||||
client.get_pr_files.side_effect = APIError(404, "Not found")
|
||||
result = run_review(client, "42")
|
||||
assert any("ERROR" in s for s in result.summary)
|
||||
|
||||
|
||||
class TestCheckCommitConventions:
|
||||
def test_conventional_commit_found(self) -> None:
|
||||
"""Should report OK when at least one commit is conventional."""
|
||||
client = MagicMock()
|
||||
client.get_pr_commits.return_value = [
|
||||
{"commit": {"message": "fix: resolve bug\n\nDetails"}},
|
||||
{"commit": {"message": "wip: testing"}},
|
||||
]
|
||||
result = ReviewResult()
|
||||
check_commit_conventions(client, "42", result)
|
||||
assert any("OK" in s for s in result.summary)
|
||||
|
||||
def test_no_conventional_commit(self) -> None:
|
||||
"""Should warn when no commits are conventional."""
|
||||
client = MagicMock()
|
||||
client.get_pr_commits.return_value = [
|
||||
{"commit": {"message": "updated stuff"}},
|
||||
{"commit": {"message": "wip: testing"}},
|
||||
]
|
||||
result = ReviewResult()
|
||||
check_commit_conventions(client, "42", result)
|
||||
assert any("WARNING" in s for s in result.summary)
|
||||
|
||||
def test_merge_commits_excluded(self) -> None:
|
||||
"""Merge commits should be excluded from the check."""
|
||||
client = MagicMock()
|
||||
client.get_pr_commits.return_value = [
|
||||
{"commit": {"message": "Merge branch 'feature' into master"}},
|
||||
{"commit": {"message": "fix: resolve bug"}},
|
||||
]
|
||||
result = ReviewResult()
|
||||
check_commit_conventions(client, "42", result)
|
||||
assert any("OK" in s for s in result.summary)
|
||||
|
||||
def test_all_merges_and_reverts(self) -> None:
|
||||
"""Should report OK when all commits are merges/reverts."""
|
||||
client = MagicMock()
|
||||
client.get_pr_commits.return_value = [
|
||||
{"commit": {"message": "Merge branch 'feature' into master"}},
|
||||
{"commit": {"message": "Revert: bad commit"}},
|
||||
]
|
||||
result = ReviewResult()
|
||||
check_commit_conventions(client, "42", result)
|
||||
assert any("merges/reverts" in s for s in result.summary)
|
||||
|
||||
def test_no_commits(self) -> None:
|
||||
"""Should report OK when there are no commits."""
|
||||
client = MagicMock()
|
||||
client.get_pr_commits.return_value = []
|
||||
result = ReviewResult()
|
||||
check_commit_conventions(client, "42", result)
|
||||
assert any("no commits" in s for s in result.summary)
|
||||
|
||||
def test_api_error(self) -> None:
|
||||
"""Should report ERROR when API call fails."""
|
||||
client = MagicMock()
|
||||
client.get_pr_commits.side_effect = APIError(500, "server error")
|
||||
result = ReviewResult()
|
||||
check_commit_conventions(client, "42", result)
|
||||
assert any("ERROR" in s for s in result.summary)
|
||||
|
||||
|
||||
class TestPostReview:
|
||||
def test_post_review_with_issues(self) -> None:
|
||||
client = MagicMock()
|
||||
result = ReviewResult()
|
||||
result.add_issue("src/foo.py", 10, "bad code")
|
||||
post_review(client, "42", result)
|
||||
client.create_review.assert_called_once()
|
||||
call_args = client.create_review.call_args
|
||||
assert call_args[1]["event"] == "REQUEST_CHANGES"
|
||||
assert call_args[1]["comments"] == result.issues
|
||||
|
||||
def test_post_review_without_issues_uses_comment_not_approve(self) -> None:
|
||||
"""Automated review posts COMMENT, not APPROVE (self-approval not allowed)."""
|
||||
client = MagicMock()
|
||||
result = ReviewResult()
|
||||
post_review(client, "42", result)
|
||||
client.create_review.assert_called_once()
|
||||
call_args = client.create_review.call_args
|
||||
assert call_args[1]["event"] == "COMMENT"
|
||||
assert call_args[1]["comments"] == []
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch("scripts.ci.pr_review.run_review")
|
||||
@patch("scripts.ci.pr_review.GiteaClient")
|
||||
def test_dry_run_does_not_post(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = ReviewResult()
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["42", "oblachno-oss/grm", "--dry-run"], env={"REPO_TOKEN": "fake"})
|
||||
assert result.exit_code == 0
|
||||
assert "[dry-run]" in result.output
|
||||
mock_client_class.return_value.create_review.assert_not_called()
|
||||
|
||||
@patch("scripts.ci.pr_review.run_review")
|
||||
@patch("scripts.ci.pr_review.GiteaClient")
|
||||
def test_post_review_on_success(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = ReviewResult()
|
||||
mock_client_class.return_value.create_review.return_value = {"id": 123}
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": "fake"})
|
||||
assert result.exit_code == 0
|
||||
assert "Review #123" in result.output
|
||||
mock_client_class.return_value.create_review.assert_called_once()
|
||||
|
||||
@patch("scripts.ci.pr_review.run_review")
|
||||
@patch("scripts.ci.pr_review.GiteaClient")
|
||||
def test_self_approval_falls_back_to_comment(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
|
||||
"""If REQUEST_CHANGES fails with 422 (self-approval), fall back to COMMENT."""
|
||||
mock_run.return_value = ReviewResult()
|
||||
client = mock_client_class.return_value
|
||||
client.create_review.side_effect = [
|
||||
APIError(422, "approve your own pull is not allowed"),
|
||||
{"id": 124},
|
||||
]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": "fake"})
|
||||
assert result.exit_code == 0
|
||||
assert "Review #124" in result.output
|
||||
assert client.create_review.call_count == 2
|
||||
|
||||
@patch("scripts.ci.pr_review.run_review")
|
||||
@patch("scripts.ci.pr_review.GiteaClient")
|
||||
def test_other_api_error_re_raises(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
|
||||
"""Non-approval API errors should re-raise, not fall back."""
|
||||
mock_run.return_value = ReviewResult()
|
||||
client = mock_client_class.return_value
|
||||
client.create_review.side_effect = APIError(500, "Internal server error")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": "fake"})
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_no_token_raises(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": ""})
|
||||
assert result.exit_code != 0
|
||||
assert "REPO_TOKEN" in result.output
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
import scripts.ci.pr_review as pr
|
||||
|
||||
with patch.object(pr, "main") as mock_main:
|
||||
with patch.object(pr, "__name__", "__main__"):
|
||||
pr.main([])
|
||||
mock_main.assert_called_once_with([])
|
||||
+23
-40
@@ -1,6 +1,5 @@
|
||||
"""Unit tests for scripts/ci/publish.py."""
|
||||
|
||||
import http
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
@@ -13,6 +12,7 @@ from scripts.ci.publish import (
|
||||
main,
|
||||
publish_to_pypi,
|
||||
)
|
||||
from scripts.gitea_cli import TeaCLIError
|
||||
|
||||
|
||||
class TestGenerateReleaseNotes:
|
||||
@@ -97,42 +97,45 @@ class TestPublishToPypi:
|
||||
class TestMain:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.ci.publish.GiteaClient")
|
||||
@patch("scripts.ci.publish.TeaCLI")
|
||||
@patch("scripts.ci.publish.publish_to_pypi")
|
||||
@patch("scripts.ci.publish.build_package")
|
||||
def test_full_flow_with_pypi(
|
||||
self,
|
||||
mock_build: MagicMock,
|
||||
mock_publish: MagicMock,
|
||||
mock_client_cls: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
) -> None:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
assert "Gitea release v1.0.0 created" in result.output
|
||||
mock_build.assert_called_once()
|
||||
mock_publish.assert_called_once_with("pypi-tok")
|
||||
mock_client_cls.return_value.create_release.assert_called_once()
|
||||
# Verify release body uses git-cliff notes
|
||||
call_args = mock_client_cls.return_value.create_release.call_args
|
||||
assert call_args.kwargs["body"] == "Release notes"
|
||||
mock_tea.create_release.assert_called_once_with(
|
||||
"owner/repo", tag="v1.0.0", title="v1.0.0", body="Release notes"
|
||||
)
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True)
|
||||
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.ci.publish.GiteaClient")
|
||||
@patch("scripts.ci.publish.TeaCLI")
|
||||
@patch("scripts.ci.publish.build_package")
|
||||
def test_without_pypi(
|
||||
self,
|
||||
mock_build: MagicMock,
|
||||
mock_client_cls: MagicMock,
|
||||
mock_tea_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
) -> None:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
mock_build.assert_called_once()
|
||||
mock_client_cls.return_value.create_release.assert_called_once()
|
||||
mock_tea.create_release.assert_called_once()
|
||||
assert "PYPI_TOKEN not set" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
|
||||
@@ -144,11 +147,11 @@ class TestMain:
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.ci.publish.GiteaClient")
|
||||
@patch("scripts.ci.publish.TeaCLI")
|
||||
@patch("scripts.ci.publish.publish_to_pypi")
|
||||
@patch("scripts.ci.publish.build_package")
|
||||
def test_build_failure_raises_click(
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock, mock_notes: MagicMock
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
||||
) -> None:
|
||||
mock_build.side_effect = click.ClickException("build failed")
|
||||
runner = CliRunner()
|
||||
@@ -158,11 +161,11 @@ class TestMain:
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.ci.publish.GiteaClient")
|
||||
@patch("scripts.ci.publish.TeaCLI")
|
||||
@patch("scripts.ci.publish.publish_to_pypi")
|
||||
@patch("scripts.ci.publish.build_package")
|
||||
def test_publish_failure_raises_click(
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock, mock_notes: MagicMock
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
||||
) -> None:
|
||||
mock_publish.side_effect = click.ClickException("publish failed")
|
||||
runner = CliRunner()
|
||||
@@ -172,36 +175,16 @@ class TestMain:
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.ci.publish.GiteaClient")
|
||||
@patch("scripts.ci.publish.TeaCLI")
|
||||
@patch("scripts.ci.publish.publish_to_pypi")
|
||||
@patch("scripts.ci.publish.build_package")
|
||||
def test_release_failure_raises_click(
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock, mock_notes: MagicMock
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
||||
) -> None:
|
||||
mock_client = MagicMock()
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
|
||||
mock_client.create_release.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.create_release.side_effect = TeaCLIError("server error")
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 1
|
||||
assert "HTTP" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.ci.publish.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:
|
||||
mock_client = MagicMock()
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
|
||||
mock_client.create_release.side_effect = APIError(http.HTTPStatus.BAD_GATEWAY, "bad gateway")
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 1
|
||||
assert str(http.HTTPStatus.BAD_GATEWAY) in result.output
|
||||
assert "Release creation failed" in result.output
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from click import ClickException
|
||||
from click.testing import CliRunner
|
||||
|
||||
import scripts.ci.push_badges as push_badges
|
||||
|
||||
|
||||
class TestFetchLatestMaster:
|
||||
def test_fetch_and_reset(self) -> None:
|
||||
with patch("subprocess.run") as mock_run:
|
||||
push_badges.fetch_latest_master("master")
|
||||
# Should call git fetch and git reset --hard
|
||||
calls = [str(c.args[0]) for c in mock_run.call_args_list]
|
||||
assert any("fetch" in c for c in calls)
|
||||
assert any("reset" in c for c in calls)
|
||||
|
||||
def test_custom_branch(self) -> None:
|
||||
with patch("subprocess.run") as mock_run:
|
||||
push_badges.fetch_latest_master("develop")
|
||||
calls = [list(c.args[0]) for c in mock_run.call_args_list]
|
||||
# fetch call should include the branch name
|
||||
fetch_call = [c for c in calls if "fetch" in c][0]
|
||||
assert "develop" in fetch_call
|
||||
# reset call should include origin/develop
|
||||
reset_call = [c for c in calls if "reset" in c][0]
|
||||
assert "origin/develop" in reset_call
|
||||
|
||||
def test_fetch_failure_raises(self) -> None:
|
||||
import subprocess
|
||||
|
||||
with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")):
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
push_badges.fetch_latest_master()
|
||||
|
||||
|
||||
class TestGenerateBadges:
|
||||
def test_success(self, tmp_path: Path) -> None:
|
||||
output_dir = tmp_path / ".badges"
|
||||
output_dir.mkdir()
|
||||
(output_dir / "badge1.svg").touch()
|
||||
|
||||
with patch("subprocess.run") as mock_run:
|
||||
push_badges.generate_badges(str(output_dir))
|
||||
mock_run.assert_called_once()
|
||||
|
||||
def test_no_badges_generated(self, tmp_path: Path) -> None:
|
||||
output_dir = tmp_path / ".badges"
|
||||
output_dir.mkdir()
|
||||
|
||||
with patch("subprocess.run"):
|
||||
with pytest.raises(ClickException, match="No badge SVG files generated"):
|
||||
push_badges.generate_badges(str(output_dir))
|
||||
|
||||
|
||||
class TestPushToBadgesBranch:
|
||||
def test_success(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
badges_dir = tmp_path / ".badges"
|
||||
badges_dir.mkdir()
|
||||
svg = badges_dir / "badge1.svg"
|
||||
svg.write_text("<svg></svg>")
|
||||
|
||||
with patch("subprocess.run") as mock_run:
|
||||
push_badges.push_to_badges_branch(str(badges_dir))
|
||||
|
||||
# Should have called git config, checkout, rm, add, commit, push
|
||||
assert mock_run.call_count >= 6
|
||||
# Verify the SVG was copied to cwd
|
||||
assert (tmp_path / "badge1.svg").exists()
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_success(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
badges_dir = tmp_path / ".badges"
|
||||
badges_dir.mkdir()
|
||||
(badges_dir / "badge1.svg").touch()
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("subprocess.run"):
|
||||
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir)])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_no_badges(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
badges_dir = tmp_path / ".badges"
|
||||
badges_dir.mkdir()
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("subprocess.run"):
|
||||
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir)])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_custom_branch(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
badges_dir = tmp_path / ".badges"
|
||||
badges_dir.mkdir()
|
||||
(badges_dir / "badge1.svg").touch()
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("subprocess.run") as mock_run:
|
||||
result = runner.invoke(
|
||||
push_badges.main,
|
||||
["--output-dir", str(badges_dir), "--branch", "develop"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
# Verify fetch was called with the custom branch
|
||||
calls = [list(c.args[0]) for c in mock_run.call_args_list]
|
||||
fetch_call = [c for c in calls if "fetch" in c][0]
|
||||
assert "develop" in fetch_call
|
||||
|
||||
def test_fetch_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
import subprocess
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")):
|
||||
result = runner.invoke(push_badges.main, [])
|
||||
assert result.exit_code != 0
|
||||
+43
-20
@@ -99,37 +99,29 @@ class TestGetChangelog:
|
||||
|
||||
class TestHasUnreleasedChanges:
|
||||
@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.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.ci.release.get_latest_tag")
|
||||
def test_with_bumped_version_no_tags(self, mock_latest: MagicMock) -> None:
|
||||
def test_no_tags_has_changes(self, mock_latest: MagicMock) -> None:
|
||||
mock_latest.return_value = ""
|
||||
assert has_unreleased_changes(bumped_version="0.1.0") is True
|
||||
assert has_unreleased_changes() is True
|
||||
|
||||
@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")
|
||||
def test_no_commits_since_tag(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="")
|
||||
mock_latest.return_value = "v0.2.0"
|
||||
assert has_unreleased_changes() is False
|
||||
|
||||
@patch("scripts.ci.release.get_latest_tag")
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_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")
|
||||
def test_commits_since_tag(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="abc123 fix: bug\n")
|
||||
mock_latest.return_value = "v0.2.0"
|
||||
assert has_unreleased_changes() is True
|
||||
|
||||
@patch("scripts.ci.release.get_latest_tag")
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_cliff_fails_returns_false(self, mock_run_cmd: MagicMock) -> None:
|
||||
def test_git_log_fails_returns_false(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="")
|
||||
mock_latest.return_value = "v0.2.0"
|
||||
assert has_unreleased_changes() is False
|
||||
|
||||
|
||||
@@ -221,7 +213,7 @@ class TestCommitReleaseChanges:
|
||||
assert result is True
|
||||
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
|
||||
assert ["git", "add", "src/gitea_runner_manager/__init__.py", "CHANGELOG.md"] in calls
|
||||
assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0"] in calls
|
||||
assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0 [skip ci]"] in calls
|
||||
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_skips_when_no_changes(self, mock_run_cmd: MagicMock) -> None:
|
||||
@@ -302,6 +294,33 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
assert "master" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("scripts.ci.release.has_user_facing_changes", return_value=False)
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_dry_run_on_non_master_warns(self, mock_run_cmd: MagicMock, mock_uf: MagicMock) -> None:
|
||||
"""Dry-run mode should not fail on non-master branches."""
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="feature-branch\n", stderr="")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--dry-run"])
|
||||
assert result.exit_code == 0
|
||||
assert "Dry-run mode" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("scripts.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("scripts.ci.release.run_cmd")
|
||||
def test_release_lock_skips_when_head_is_release_commit(self, mock_run_cmd: MagicMock, mock_uf: MagicMock) -> None:
|
||||
"""If HEAD is already a release commit, should skip to prevent duplicate releases."""
|
||||
# First call: git rev-parse (master), second: git log -1 (release commit)
|
||||
mock_run_cmd.side_effect = [
|
||||
MagicMock(returncode=0, stdout="master\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""),
|
||||
]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "already a release commit" in result.output
|
||||
assert "Skipping" in result.output
|
||||
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("scripts.ci.release.has_user_facing_changes", return_value=True)
|
||||
@patch("scripts.ci.release.has_unreleased_changes", return_value=False)
|
||||
@@ -532,10 +551,11 @@ class TestMain:
|
||||
mock_user: MagicMock,
|
||||
) -> None:
|
||||
"""If tests fail, release aborts — no commit, no tag."""
|
||||
# First call: git rev-parse (master), then make lint-ruff (success),
|
||||
# then make pytest-cov (failure)
|
||||
# Calls: git rev-parse (master), git log -1 (release lock check),
|
||||
# make lint-ruff (success), make pytest-cov (failure)
|
||||
mock_run_cmd.side_effect = [
|
||||
MagicMock(returncode=0, stdout="master\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="GRM-50 fix: something\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="", stderr=""),
|
||||
MagicMock(returncode=1, stdout="", stderr="test failure"),
|
||||
]
|
||||
@@ -571,8 +591,11 @@ class TestMain:
|
||||
mock_user: MagicMock,
|
||||
) -> None:
|
||||
"""If lint fails, release aborts — no commit, no tag."""
|
||||
# Calls: git rev-parse (master), git log -1 (release lock check),
|
||||
# make lint-ruff (failure)
|
||||
mock_run_cmd.side_effect = [
|
||||
MagicMock(returncode=0, stdout="master\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="GRM-50 fix: something\n", stderr=""),
|
||||
MagicMock(returncode=1, stdout="", stderr="lint error"),
|
||||
]
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -77,14 +77,61 @@ class TestMain:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.review_pr.GiteaClient")
|
||||
def test_successful_approve_review(self, mock_client_cls: MagicMock) -> None:
|
||||
"""APPROVE requires --checklist-confirmed and substantive body."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.create_review.return_value = {"id": 7}
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["5", "owner/repo", "--event", "APPROVE"])
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"5",
|
||||
"owner/repo",
|
||||
"--event",
|
||||
"APPROVE",
|
||||
"--checklist-confirmed",
|
||||
"--body",
|
||||
"All 10 checklist categories verified. Architecture OK, tests pass.",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Review #7" in result.output
|
||||
mock_client.create_review.assert_called_once_with("5", event="APPROVE", body="", comments=[])
|
||||
mock_client.create_review.assert_called_once_with(
|
||||
"5",
|
||||
event="APPROVE",
|
||||
body="All 10 checklist categories verified. Architecture OK, tests pass.",
|
||||
comments=[],
|
||||
)
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.review_pr.GiteaClient")
|
||||
def test_approve_without_checklist_confirmed_fails(self, mock_client_cls: MagicMock) -> None:
|
||||
"""APPROVE without --checklist-confirmed is rejected."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["5", "owner/repo", "--event", "APPROVE", "--body", "Looks good to me"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "checklist" in result.output.lower()
|
||||
mock_client.create_review.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.review_pr.GiteaClient")
|
||||
def test_approve_with_trivial_body_fails(self, mock_client_cls: MagicMock) -> None:
|
||||
"""APPROVE with trivial body (< 20 chars) and no comments is rejected."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["5", "owner/repo", "--event", "APPROVE", "--checklist-confirmed", "--body", "LGTM"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "substantive" in result.output.lower()
|
||||
mock_client.create_review.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.ci.review_pr.GiteaClient")
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
import scripts.setup as setup
|
||||
|
||||
|
||||
class TestRun:
|
||||
def test_run_success(self) -> None:
|
||||
with patch("subprocess.run") as mock_run:
|
||||
setup._run(["echo", "hello"], ".venv/bin")
|
||||
mock_run.assert_called_once_with(["echo", "hello"], check=True)
|
||||
|
||||
def test_run_failure(self) -> None:
|
||||
import subprocess
|
||||
|
||||
with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, ["echo"])):
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
setup._run(["echo", "hello"], ".venv/bin")
|
||||
|
||||
|
||||
class TestInstallPythonDeps:
|
||||
def test_install(self) -> None:
|
||||
with patch("scripts.setup._run") as mock_run:
|
||||
setup._install_python_deps(".venv/bin")
|
||||
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[dev]"], ".venv/bin")
|
||||
|
||||
|
||||
class TestInstallAnsibleCollections:
|
||||
def test_install(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
(tmp_path / "ansible").mkdir()
|
||||
(tmp_path / "ansible/requirements.yml").write_text("collections: []")
|
||||
with patch("scripts.setup._run") as mock_run:
|
||||
setup._install_ansible_collections(".venv/bin")
|
||||
mock_run.assert_called_once()
|
||||
|
||||
def test_no_requirements(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with patch("scripts.setup._run") as mock_run:
|
||||
setup._install_ansible_collections(".venv/bin")
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
class TestInstallPreCommitHooks:
|
||||
def test_install(self) -> None:
|
||||
with patch("scripts.setup._run") as mock_run:
|
||||
setup._install_pre_commit_hooks(".venv/bin")
|
||||
assert mock_run.call_count == 3
|
||||
calls = [c.args[0] for c in mock_run.call_args_list]
|
||||
# Each call should have the pre-commit binary and --hook-type flag
|
||||
for call in calls:
|
||||
assert ".venv/bin/pre-commit" in call[0]
|
||||
assert "--hook-type" in call
|
||||
|
||||
|
||||
class TestVerify:
|
||||
def test_verify_success(self) -> None:
|
||||
import subprocess
|
||||
|
||||
mock_result = subprocess.CompletedProcess(
|
||||
args=["grm", "--version"], returncode=0, stdout="grm 1.0.0", stderr=""
|
||||
)
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
setup._verify(".venv/bin")
|
||||
|
||||
def test_verify_not_found(self) -> None:
|
||||
|
||||
with patch("subprocess.run", side_effect=FileNotFoundError()):
|
||||
setup._verify(".venv/bin")
|
||||
|
||||
def test_verify_timeout(self) -> None:
|
||||
import subprocess
|
||||
|
||||
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd=["grm", "--version"], timeout=10)):
|
||||
setup._verify(".venv/bin")
|
||||
|
||||
|
||||
class TestConfigureTeaLogin:
|
||||
def test_tea_not_installed(self) -> None:
|
||||
with patch("shutil.which", return_value=None):
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
setup._configure_tea_login()
|
||||
|
||||
def test_no_repo_token(self) -> None:
|
||||
with patch("shutil.which", return_value="/usr/bin/tea"):
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
setup._configure_tea_login()
|
||||
|
||||
def test_login_already_exists(self) -> None:
|
||||
import subprocess
|
||||
|
||||
mock_result = subprocess.CompletedProcess(
|
||||
args=["tea", "login", "list"], returncode=0, stdout="grm https://git.example.com", stderr=""
|
||||
)
|
||||
with patch("shutil.which", return_value="/usr/bin/tea"):
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
setup._configure_tea_login()
|
||||
|
||||
def test_login_added_successfully(self) -> None:
|
||||
import subprocess
|
||||
|
||||
list_result = subprocess.CompletedProcess(args=["tea", "login", "list"], returncode=0, stdout="", stderr="")
|
||||
add_result = subprocess.CompletedProcess(
|
||||
args=["tea", "login", "add"], returncode=0, stdout="Login added", stderr=""
|
||||
)
|
||||
default_result = subprocess.CompletedProcess(
|
||||
args=["tea", "login", "default"], returncode=0, stdout="", stderr=""
|
||||
)
|
||||
with patch("shutil.which", return_value="/usr/bin/tea"):
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
with patch("subprocess.run", side_effect=[list_result, add_result, default_result]):
|
||||
setup._configure_tea_login()
|
||||
|
||||
def test_login_add_failure(self) -> None:
|
||||
import subprocess
|
||||
|
||||
list_result = subprocess.CompletedProcess(args=["tea", "login", "list"], returncode=0, stdout="", stderr="")
|
||||
add_result = subprocess.CompletedProcess(
|
||||
args=["tea", "login", "add"], returncode=1, stdout="", stderr="auth failed"
|
||||
)
|
||||
with patch("shutil.which", return_value="/usr/bin/tea"):
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
with patch("subprocess.run", side_effect=[list_result, add_result]):
|
||||
setup._configure_tea_login()
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_bin_not_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(setup.main, ["--bin", "nonexistent/bin"])
|
||||
assert result.exit_code != 0
|
||||
assert "Bin directory not found" in result.output
|
||||
|
||||
def test_success(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.chdir(tmp_path)
|
||||
bin_dir = tmp_path / ".venv" / "bin"
|
||||
bin_dir.mkdir(parents=True)
|
||||
(bin_dir / "grm").touch()
|
||||
(bin_dir / "pre-commit").touch()
|
||||
(tmp_path / "ansible").mkdir()
|
||||
(tmp_path / "ansible/requirements.yml").write_text("collections: []")
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("scripts.setup._install_python_deps"):
|
||||
with patch("scripts.setup._install_ansible_collections"):
|
||||
with patch("scripts.setup._install_pre_commit_hooks"):
|
||||
with patch("scripts.setup._configure_tea_login"):
|
||||
with patch("scripts.setup._verify"):
|
||||
result = runner.invoke(setup.main, ["--bin", str(bin_dir)])
|
||||
assert result.exit_code == 0
|
||||
assert "Setup complete" in result.output
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Unit tests for scripts/ci/sync_wiki.py."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
@@ -8,14 +9,47 @@ import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from scripts.ci.sync_wiki import (
|
||||
decode_content,
|
||||
encode_content,
|
||||
fetch_page_content,
|
||||
list_wiki_pages,
|
||||
load_mapping,
|
||||
main,
|
||||
read_doc_content,
|
||||
sync_page,
|
||||
verify_wiki_integrity,
|
||||
verify_wiki_page,
|
||||
)
|
||||
|
||||
|
||||
class TestEncodeContent:
|
||||
def test_encodes_utf8_to_base64(self) -> None:
|
||||
result = encode_content("# Hello World")
|
||||
assert result == base64.b64encode(b"# Hello World").decode("ascii")
|
||||
|
||||
def test_encodes_empty_string(self) -> None:
|
||||
assert encode_content("") == ""
|
||||
|
||||
def test_encodes_unicode(self) -> None:
|
||||
result = encode_content("# Café — résumé")
|
||||
decoded = base64.b64decode(result).decode("utf-8")
|
||||
assert decoded == "# Café — résumé"
|
||||
|
||||
|
||||
class TestDecodeContent:
|
||||
def test_decodes_base64_to_utf8(self) -> None:
|
||||
encoded = base64.b64encode(b"# Hello").decode("ascii")
|
||||
assert decode_content(encoded) == "# Hello"
|
||||
|
||||
def test_empty_string_returns_empty(self) -> None:
|
||||
assert decode_content("") == ""
|
||||
|
||||
def test_roundtrip(self) -> None:
|
||||
original = "# Wiki Page\n\nContent with **markdown**."
|
||||
encoded = encode_content(original)
|
||||
assert decode_content(encoded) == original
|
||||
|
||||
|
||||
class TestLoadMapping:
|
||||
def test_loads_mapping(self, tmp_path: Path) -> None:
|
||||
mapping_file = tmp_path / "mapping.json"
|
||||
@@ -64,6 +98,27 @@ class TestListWikiPages:
|
||||
assert result == {"Home": "Home", "Getting-Started": "Getting-Started.-"}
|
||||
|
||||
|
||||
class TestFetchPageContent:
|
||||
def test_fetches_and_decodes_content(self) -> None:
|
||||
client = MagicMock()
|
||||
encoded = base64.b64encode(b"# Hello Wiki").decode("ascii")
|
||||
client._request.return_value.json.return_value = {"content_base64": encoded}
|
||||
result = fetch_page_content(client, "Home")
|
||||
assert result == "# Hello Wiki"
|
||||
|
||||
def test_returns_empty_on_api_error(self) -> None:
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
|
||||
client = MagicMock()
|
||||
client._request.side_effect = APIError(404, "not found")
|
||||
assert fetch_page_content(client, "Missing") == ""
|
||||
|
||||
def test_returns_empty_for_empty_content(self) -> None:
|
||||
client = MagicMock()
|
||||
client._request.return_value.json.return_value = {"content_base64": ""}
|
||||
assert fetch_page_content(client, "Home") == ""
|
||||
|
||||
|
||||
class TestSyncPage:
|
||||
def test_dry_run_skips(self) -> None:
|
||||
client = MagicMock()
|
||||
@@ -71,7 +126,7 @@ class TestSyncPage:
|
||||
assert result == "skipped"
|
||||
client._request.assert_not_called()
|
||||
|
||||
def test_creates_new_page(self) -> None:
|
||||
def test_creates_new_page_with_base64(self) -> None:
|
||||
client = MagicMock()
|
||||
result = sync_page(client, "New-Page", "# Content", {}, dry_run=False)
|
||||
assert result == "created"
|
||||
@@ -79,8 +134,13 @@ class TestSyncPage:
|
||||
call_args = client._request.call_args
|
||||
assert call_args.args[0] == "POST"
|
||||
assert call_args.args[1] == "/wiki/new"
|
||||
# Verify content_base64 is used, not content
|
||||
payload = call_args.kwargs["json"]
|
||||
assert "content_base64" in payload
|
||||
assert "content" not in payload
|
||||
assert base64.b64decode(payload["content_base64"]).decode("utf-8") == "# Content"
|
||||
|
||||
def test_updates_existing_page(self) -> None:
|
||||
def test_updates_existing_page_with_base64(self) -> None:
|
||||
client = MagicMock()
|
||||
existing = {"Existing-Page": "Existing-Page.-"}
|
||||
result = sync_page(client, "Existing-Page", "# Updated", existing, dry_run=False)
|
||||
@@ -89,6 +149,123 @@ class TestSyncPage:
|
||||
call_args = client._request.call_args
|
||||
assert call_args.args[0] == "PATCH"
|
||||
assert "/wiki/page/Existing-Page.-" in call_args.args[1]
|
||||
# Verify content_base64 is used
|
||||
payload = call_args.kwargs["json"]
|
||||
assert "content_base64" in payload
|
||||
assert "content" not in payload
|
||||
assert base64.b64decode(payload["content_base64"]).decode("utf-8") == "# Updated"
|
||||
|
||||
|
||||
class TestVerifyWikiPage:
|
||||
def test_verifies_matching_content(self) -> None:
|
||||
client = MagicMock()
|
||||
encoded = base64.b64encode(b"# Hello Wiki").decode("ascii")
|
||||
client._request.return_value.json.return_value = {"content_base64": encoded}
|
||||
existing = {"Home": "Home"}
|
||||
assert verify_wiki_page(client, "Home", "# Hello Wiki", existing) is True
|
||||
|
||||
def test_fails_on_mismatch(self) -> None:
|
||||
client = MagicMock()
|
||||
encoded = base64.b64encode(b"# Old Content").decode("ascii")
|
||||
client._request.return_value.json.return_value = {"content_base64": encoded}
|
||||
existing = {"Home": "Home"}
|
||||
assert verify_wiki_page(client, "Home", "# New Content", existing) is False
|
||||
|
||||
def test_fails_on_empty_wiki_content(self) -> None:
|
||||
client = MagicMock()
|
||||
client._request.return_value.json.return_value = {"content_base64": ""}
|
||||
existing = {"Home": "Home"}
|
||||
assert verify_wiki_page(client, "Home", "# Expected", existing) is False
|
||||
|
||||
def test_fails_when_page_not_in_existing(self) -> None:
|
||||
client = MagicMock()
|
||||
assert verify_wiki_page(client, "Missing", "# Content", {}) is False
|
||||
|
||||
|
||||
class TestVerifyWikiIntegrity:
|
||||
def _make_client(self, pages: dict[str, str], contents: dict[str, str]) -> MagicMock:
|
||||
"""Create a mock client that returns the given pages and contents."""
|
||||
client = MagicMock()
|
||||
# list_wiki_pages calls GET /wiki/pages
|
||||
page_list = [{"title": t, "sub_url": s} for t, s in pages.items()]
|
||||
|
||||
# fetch_page_content calls GET /wiki/page/{sub_url}
|
||||
def mock_request(method, path, **kwargs):
|
||||
resp = MagicMock()
|
||||
if path == "/wiki/pages":
|
||||
resp.json.return_value = page_list
|
||||
elif path.startswith("/wiki/page/"):
|
||||
sub_url = path.replace("/wiki/page/", "")
|
||||
content = contents.get(sub_url, "")
|
||||
encoded = base64.b64encode(content.encode()).decode("ascii") if content else ""
|
||||
resp.json.return_value = {"content_base64": encoded}
|
||||
return resp
|
||||
|
||||
client._request.side_effect = mock_request
|
||||
return client
|
||||
|
||||
def test_all_good_no_failures(self) -> None:
|
||||
pages = {"Home": "Home", "FAQ": "FAQ"}
|
||||
contents = {"Home": "# Home", "FAQ": "# FAQ"}
|
||||
client = self._make_client(pages, contents)
|
||||
mapping = {"index.md": "Home", "faq.md": "FAQ"}
|
||||
synced = {"Home": "# Home", "FAQ": "# FAQ"}
|
||||
failures = verify_wiki_integrity(client, mapping, synced)
|
||||
assert failures == []
|
||||
|
||||
def test_missing_page_detected(self) -> None:
|
||||
pages = {"Home": "Home"} # FAQ missing from wiki
|
||||
contents = {"Home": "# Home"}
|
||||
client = self._make_client(pages, contents)
|
||||
mapping = {"index.md": "Home", "faq.md": "FAQ"}
|
||||
synced = {"Home": "# Home"}
|
||||
failures = verify_wiki_integrity(client, mapping, synced)
|
||||
assert any("Missing page: FAQ" in f for f in failures)
|
||||
|
||||
def test_stale_page_detected(self) -> None:
|
||||
pages = {"Home": "Home", "Old-Page": "Old-Page"} # Old-Page not in mapping
|
||||
contents = {"Home": "# Home", "Old-Page": "# Old"}
|
||||
client = self._make_client(pages, contents)
|
||||
mapping = {"index.md": "Home"}
|
||||
synced = {"Home": "# Home"}
|
||||
failures = verify_wiki_integrity(client, mapping, synced)
|
||||
assert any("Stale page" in f and "Old-Page" in f for f in failures)
|
||||
|
||||
def test_page_count_mismatch_detected(self) -> None:
|
||||
pages = {"Home": "Home", "Extra": "Extra"}
|
||||
contents = {"Home": "# Home", "Extra": "# Extra"}
|
||||
client = self._make_client(pages, contents)
|
||||
mapping = {"index.md": "Home"}
|
||||
synced = {"Home": "# Home"}
|
||||
failures = verify_wiki_integrity(client, mapping, synced)
|
||||
assert any("Page count mismatch" in f for f in failures)
|
||||
|
||||
def test_empty_content_detected(self) -> None:
|
||||
pages = {"Home": "Home"}
|
||||
contents = {"Home": ""} # Empty content
|
||||
client = self._make_client(pages, contents)
|
||||
mapping = {"index.md": "Home"}
|
||||
synced = {"Home": "# Expected Content"}
|
||||
failures = verify_wiki_integrity(client, mapping, synced)
|
||||
assert any("Empty content: Home" in f for f in failures)
|
||||
|
||||
def test_content_mismatch_detected(self) -> None:
|
||||
pages = {"Home": "Home"}
|
||||
contents = {"Home": "# Wrong Content"}
|
||||
client = self._make_client(pages, contents)
|
||||
mapping = {"index.md": "Home"}
|
||||
synced = {"Home": "# Correct Content"}
|
||||
failures = verify_wiki_integrity(client, mapping, synced)
|
||||
assert any("Content mismatch: Home" in f for f in failures)
|
||||
|
||||
def test_multiple_failures_all_reported(self) -> None:
|
||||
pages = {"Home": "Home", "Stale": "Stale"}
|
||||
contents = {"Home": "", "Stale": "# Stale"}
|
||||
client = self._make_client(pages, contents)
|
||||
mapping = {"index.md": "Home", "faq.md": "FAQ"} # FAQ missing
|
||||
synced = {"Home": "# Home Content"}
|
||||
failures = verify_wiki_integrity(client, mapping, synced)
|
||||
assert len(failures) >= 3 # count mismatch, missing FAQ, stale Stale, empty Home
|
||||
|
||||
|
||||
class TestMain:
|
||||
@@ -168,6 +345,21 @@ class TestMain:
|
||||
assert "not found" in result.output
|
||||
assert "Skipped: 1" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
||||
def test_empty_doc_file_skipped(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that empty doc files are skipped with a warning."""
|
||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"empty.md": "Empty-Page"}):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value=" \n "):
|
||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={}):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
assert "empty" in result.output.lower()
|
||||
assert "Skipped: 1" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
||||
def test_create_and_update(self, mock_client_cls: MagicMock) -> None:
|
||||
@@ -185,3 +377,107 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
assert "Created: 1" in result.output
|
||||
assert "Updated: 1" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
||||
def test_verify_passes(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that --verify passes when content matches."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
encoded = base64.b64encode(b"# Home Content").decode("ascii")
|
||||
# list_wiki_pages returns {"Home": "Home"}, fetch returns encoded content
|
||||
mock_client._request.return_value.json.return_value = {"content_base64": encoded}
|
||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home Content"):
|
||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
|
||||
with patch("scripts.ci.sync_wiki.verify_wiki_page", return_value=True):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--repo", "owner/repo", "--verify"])
|
||||
assert result.exit_code == 0
|
||||
assert "Verification passed" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
||||
def test_verify_fails_on_empty_content(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that --verify fails when wiki pages have empty content."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home Content"):
|
||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
|
||||
with patch("scripts.ci.sync_wiki.verify_wiki_page", return_value=False):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--repo", "owner/repo", "--verify"])
|
||||
assert result.exit_code == 1
|
||||
assert "FAIL" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
||||
def test_verify_skipped_in_dry_run(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that --verify is skipped during dry-run."""
|
||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={}):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--dry-run", "--verify", "--repo", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
assert "Verification" not in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
||||
def test_strict_passes(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that --strict passes when integrity check succeeds."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
|
||||
with patch("scripts.ci.sync_wiki.verify_wiki_integrity", return_value=[]):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--repo", "owner/repo", "--strict"])
|
||||
assert result.exit_code == 0
|
||||
assert "Integrity check passed" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
||||
def test_strict_fails_on_integrity_issues(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that --strict fails when integrity check finds issues."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
|
||||
with patch(
|
||||
"scripts.ci.sync_wiki.verify_wiki_integrity",
|
||||
return_value=["Missing page: FAQ", "Stale page: Old-Page"],
|
||||
):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--repo", "owner/repo", "--strict"])
|
||||
assert result.exit_code == 1
|
||||
assert "Integrity check FAILED" in result.output
|
||||
assert "Missing page: FAQ" in result.output
|
||||
assert "Stale page: Old-Page" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
||||
def test_strict_skipped_in_dry_run(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that --strict verification is skipped during dry-run."""
|
||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||
mock_mapping.exists.return_value = True
|
||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={}):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--dry-run", "--strict", "--repo", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
assert "Integrity check" not in result.output
|
||||
|
||||
Reference in New Issue
Block a user