GRM-64: refactor: migrate from scripts/ to devx package
This commit is contained in:
@@ -36,3 +36,11 @@ GITEA_REGISTRATION_TOKEN=your-registration-token
|
|||||||
# UI language for GRM console messages (optional, default: en)
|
# UI language for GRM console messages (optional, default: en)
|
||||||
# Supported: en, bg, de, ru, zh
|
# Supported: en, bg, de, ru, zh
|
||||||
# GRM_LANG=en
|
# GRM_LANG=en
|
||||||
|
|
||||||
|
# devx configuration (GRM-specific overrides)
|
||||||
|
# Task prefix for Vikunja task IDs
|
||||||
|
DEVX_TASK_PREFIX=GRM
|
||||||
|
# Vikunja project ID for GRM
|
||||||
|
DEVX_VIKUNJA_PROJECT_ID=6
|
||||||
|
# Version file path (relative to repo root)
|
||||||
|
DEVX_VERSION_FILE=src/gitea_runner_manager/__init__.py
|
||||||
|
|||||||
+38
-32
@@ -12,6 +12,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Set up environment
|
- name: Set up environment
|
||||||
|
env:
|
||||||
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
run: make setup-quality
|
run: make setup-quality
|
||||||
- name: Lint all
|
- name: Lint all
|
||||||
run: |
|
run: |
|
||||||
@@ -24,22 +26,10 @@ jobs:
|
|||||||
make pytest-cov
|
make pytest-cov
|
||||||
- name: Check unit test speed
|
- name: Check unit test speed
|
||||||
env:
|
env:
|
||||||
PYTHONPATH: .:src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate
|
||||||
python3 scripts/check_test_speed.py --max-seconds 10
|
python3 -m devx.tools.check_test_speed --max-seconds 10
|
||||||
- name: Documentation coverage check
|
|
||||||
env:
|
|
||||||
PYTHONPATH: .:src
|
|
||||||
run: |
|
|
||||||
. .venv/bin/activate
|
|
||||||
python3 scripts/ci/doc_coverage.py --fail-on-missing
|
|
||||||
- name: Translation completeness check
|
|
||||||
env:
|
|
||||||
PYTHONPATH: .:src
|
|
||||||
run: |
|
|
||||||
. .venv/bin/activate
|
|
||||||
python3 scripts/ci/check_translations.py
|
|
||||||
- name: Dependency security scan
|
- name: Dependency security scan
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate
|
||||||
@@ -68,14 +58,18 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Set up environment
|
- name: Set up environment
|
||||||
|
env:
|
||||||
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
run: make setup-release
|
run: make setup-release
|
||||||
- name: Release dry-run validation
|
- name: Release dry-run validation
|
||||||
env:
|
env:
|
||||||
PYTHONPATH: .:src
|
PYTHONPATH: src
|
||||||
|
DEVX_VERSION_FILE: src/gitea_runner_manager/__init__.py
|
||||||
|
DEVX_TASK_PREFIX: GRM
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
python3 scripts/ci/release.py --dry-run || true
|
python3 -m devx.ci.release --dry-run || true
|
||||||
|
|
||||||
detect-changes:
|
detect-changes:
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
@@ -88,14 +82,17 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Set up environment
|
- name: Set up environment
|
||||||
|
env:
|
||||||
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
run: make setup-ci
|
run: make setup-ci
|
||||||
- name: Detect changed paths
|
- name: Detect changed paths
|
||||||
id: detect
|
id: detect
|
||||||
env:
|
env:
|
||||||
PYTHONPATH: .:src
|
PYTHONPATH: src
|
||||||
|
DEVX_TASK_PREFIX: GRM
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate
|
||||||
python3 scripts/ci/classify_changes.py \
|
python3 -m devx.ci.classify_changes \
|
||||||
--base "origin/master" \
|
--base "origin/master" \
|
||||||
--head "${{ github.event.pull_request.head.sha || github.sha }}" \
|
--head "${{ github.event.pull_request.head.sha || github.sha }}" \
|
||||||
--github-output
|
--github-output
|
||||||
@@ -111,16 +108,18 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Set up environment
|
- name: Set up environment
|
||||||
|
env:
|
||||||
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
run: make setup-ci
|
run: make setup-ci
|
||||||
- name: Discover available runners
|
- name: Discover available runners
|
||||||
id: discover
|
id: discover
|
||||||
env:
|
env:
|
||||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
MOLECULE_RUNNERS: ${{ vars.MOLECULE_RUNNERS }}
|
MOLECULE_RUNNERS: ${{ vars.MOLECULE_RUNNERS }}
|
||||||
PYTHONPATH: .:src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate
|
||||||
python3 scripts/ci/discover_runners.py \
|
python3 -m devx.molecule.discover_runners \
|
||||||
--owner "${{ github.repository_owner }}" \
|
--owner "${{ github.repository_owner }}" \
|
||||||
--repo "${{ github.event.repository.name }}" \
|
--repo "${{ github.event.repository.name }}" \
|
||||||
--github-output
|
--github-output
|
||||||
@@ -136,15 +135,17 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Set up environment
|
- name: Set up environment
|
||||||
|
env:
|
||||||
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
run: make setup-molecule
|
run: make setup-molecule
|
||||||
- name: Discover assigned test pairs
|
- name: Discover assigned test pairs
|
||||||
env:
|
env:
|
||||||
RUNNER_INDEX: ${{ matrix.runner-index }}
|
RUNNER_INDEX: ${{ matrix.runner-index }}
|
||||||
MAX_RUNNERS: ${{ needs.discover-runners.outputs.runner-count }}
|
MAX_RUNNERS: ${{ needs.discover-runners.outputs.runner-count }}
|
||||||
PYTHONPATH: .:src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate
|
||||||
python3 scripts/ci/distribute_molecule.py \
|
python3 -m devx.molecule.distribute_molecule \
|
||||||
--runner-index "$RUNNER_INDEX" \
|
--runner-index "$RUNNER_INDEX" \
|
||||||
--max-runners "$MAX_RUNNERS" \
|
--max-runners "$MAX_RUNNERS" \
|
||||||
--github-env --skip-if-excess
|
--github-env --skip-if-excess
|
||||||
@@ -154,7 +155,7 @@ jobs:
|
|||||||
. .venv/bin/activate
|
. .venv/bin/activate
|
||||||
if [ -z "$TEST_PAIRS" ]; then exit 0; fi
|
if [ -z "$TEST_PAIRS" ]; then exit 0; fi
|
||||||
# shellcheck disable=SC2086 # intentional word splitting for argument expansion
|
# shellcheck disable=SC2086 # intentional word splitting for argument expansion
|
||||||
python3 scripts/ci/molecule_ci_guard.py $TEST_PAIRS
|
python3 -m devx.molecule.molecule_ci_guard $TEST_PAIRS
|
||||||
env:
|
env:
|
||||||
GITEA_URL: ${{ github.server_url }}
|
GITEA_URL: ${{ github.server_url }}
|
||||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
@@ -162,7 +163,7 @@ jobs:
|
|||||||
JOB_NAME: ${{ github.job }}
|
JOB_NAME: ${{ github.job }}
|
||||||
MATRIX_INDEX: ${{ matrix.runner-index }}
|
MATRIX_INDEX: ${{ matrix.runner-index }}
|
||||||
GITEA_REPOSITORY: ${{ github.repository }}
|
GITEA_REPOSITORY: ${{ github.repository }}
|
||||||
PYTHONPATH: .:src
|
PYTHONPATH: src
|
||||||
|
|
||||||
pr-review:
|
pr-review:
|
||||||
if: github.event_name == 'pull_request'
|
if: github.event_name == 'pull_request'
|
||||||
@@ -170,16 +171,17 @@ jobs:
|
|||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Set up environment
|
- name: Install dependencies
|
||||||
run: make setup-ci
|
run: |
|
||||||
|
. .env 2>/dev/null || true
|
||||||
|
python3 -m pip install --break-system-packages "git+https://emil:${{ secrets.REPO_TOKEN }}@git.oblachno.oblachno.fyi/oblachno-oss/devx.git@master"
|
||||||
- name: Run automated PR review
|
- name: Run automated PR review
|
||||||
env:
|
env:
|
||||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
PYTHONPATH: .:src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
. .venv/bin/activate
|
python3 -m devx.ci.pr_review \
|
||||||
python3 scripts/ci/pr_review.py \
|
|
||||||
"${{ github.event.number }}" \
|
"${{ github.event.number }}" \
|
||||||
"${{ github.repository }}"
|
"${{ github.repository }}"
|
||||||
|
|
||||||
@@ -197,18 +199,22 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.REPO_TOKEN }}
|
token: ${{ secrets.REPO_TOKEN }}
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: python3 -m pip install --break-system-packages requests python-dotenv click
|
run: |
|
||||||
|
. .env 2>/dev/null || true
|
||||||
|
python3 -m pip install --break-system-packages "git+https://emil:${{ secrets.REPO_TOKEN }}@git.oblachno.oblachno.fyi/oblachno-oss/devx.git@master"
|
||||||
- name: Squash merge with task ID
|
- name: Squash merge with task ID
|
||||||
env:
|
env:
|
||||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
||||||
PYTHONPATH: .:src
|
PYTHONPATH: src
|
||||||
|
DEVX_TASK_PREFIX: GRM
|
||||||
|
DEVX_VIKUNJA_PROJECT_ID: 6
|
||||||
HEAD_REF: ${{ github.head_ref }}
|
HEAD_REF: ${{ github.head_ref }}
|
||||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||||
REPOSITORY: ${{ github.repository }}
|
REPOSITORY: ${{ github.repository }}
|
||||||
PR_NUMBER: ${{ github.event.number }}
|
PR_NUMBER: ${{ github.event.number }}
|
||||||
run: |
|
run: |
|
||||||
python3 scripts/ci/auto_merge.py \
|
python3 -m devx.ci.auto_merge \
|
||||||
"$HEAD_REF" \
|
"$HEAD_REF" \
|
||||||
"$PR_TITLE" \
|
"$PR_TITLE" \
|
||||||
"$REPOSITORY" \
|
"$REPOSITORY" \
|
||||||
|
|||||||
@@ -40,11 +40,15 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
. .env 2>/dev/null || true
|
||||||
|
python3 -m pip install --break-system-packages "git+https://emil:${{ secrets.REPO_TOKEN }}@git.oblachno.oblachno.fyi/oblachno-oss/devx.git@master"
|
||||||
- name: Check if this is a release commit
|
- name: Check if this is a release commit
|
||||||
id: check
|
id: check
|
||||||
env:
|
env:
|
||||||
PYTHONPATH: .:src
|
PYTHONPATH: src
|
||||||
run: python3 scripts/ci/detect_release_commit.py
|
run: python3 -m devx.ci.detect_release_commit
|
||||||
|
|
||||||
validate-commit-msg:
|
validate-commit-msg:
|
||||||
needs: [detect-type]
|
needs: [detect-type]
|
||||||
@@ -56,13 +60,15 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: python3 -m pip install --break-system-packages click python-dotenv
|
run: |
|
||||||
|
. .env 2>/dev/null || true
|
||||||
|
python3 -m pip install --break-system-packages "git+https://emil:${{ secrets.REPO_TOKEN }}@git.oblachno.oblachno.fyi/oblachno-oss/devx.git@master"
|
||||||
- name: Validate latest commit message
|
- name: Validate latest commit message
|
||||||
env:
|
env:
|
||||||
PYTHONPATH: .:src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
git log -1 --format=%B > commit-msg.txt
|
git log -1 --format=%B > commit-msg.txt
|
||||||
python3 scripts/ci/validate_commit_msg.py commit-msg.txt --branch master
|
python3 -m devx.ci.validate_commit_msg commit-msg.txt --branch master
|
||||||
rm -f commit-msg.txt
|
rm -f commit-msg.txt
|
||||||
|
|
||||||
release:
|
release:
|
||||||
@@ -76,6 +82,8 @@ jobs:
|
|||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.REPO_TOKEN }}
|
token: ${{ secrets.REPO_TOKEN }}
|
||||||
- name: Set up environment
|
- name: Set up environment
|
||||||
|
env:
|
||||||
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
run: make setup-release
|
run: make setup-release
|
||||||
- name: Configure git
|
- name: Configure git
|
||||||
run: |
|
run: |
|
||||||
@@ -83,19 +91,25 @@ jobs:
|
|||||||
git config user.email "grm-ci-bot@oblachno.fyi"
|
git config user.email "grm-ci-bot@oblachno.fyi"
|
||||||
- name: Run release
|
- name: Run release
|
||||||
env:
|
env:
|
||||||
PYTHONPATH: .:src
|
PYTHONPATH: src
|
||||||
|
DEVX_VERSION_FILE: src/gitea_runner_manager/__init__.py
|
||||||
|
DEVX_TASK_PREFIX: GRM
|
||||||
|
DEVX_VIKUNJA_PROJECT_ID: 6
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
python3 scripts/ci/release.py
|
python3 -m devx.ci.release
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
env:
|
env:
|
||||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
PYTHONPATH: .:src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
python3 scripts/ci/notify_failure.py \
|
python3 -m devx.tools.install_tools --tool tea
|
||||||
|
tea login add --name grm --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
|
||||||
|
tea login default grm || true
|
||||||
|
python3 -m devx.ci.notify_failure \
|
||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
--run-id "${{ github.run_id }}" \
|
--run-id "${{ github.run_id }}" \
|
||||||
--workflow "post-merge/release" \
|
--workflow "post-merge/release" \
|
||||||
@@ -111,22 +125,27 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Set up environment
|
- name: Set up environment
|
||||||
|
env:
|
||||||
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
run: make setup-ci
|
run: make setup-ci
|
||||||
- name: Sync documentation to wiki
|
- name: Sync documentation to wiki
|
||||||
env:
|
env:
|
||||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
PYTHONPATH: .:src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate
|
||||||
python3 scripts/ci/sync_wiki.py --repo "${{ github.repository }}" --strict
|
python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --strict
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
env:
|
env:
|
||||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
PYTHONPATH: .:src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
python3 scripts/ci/notify_failure.py \
|
python3 -m devx.tools.install_tools --tool tea
|
||||||
|
tea login add --name grm --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
|
||||||
|
tea login default grm || true
|
||||||
|
python3 -m devx.ci.notify_failure \
|
||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
--run-id "${{ github.run_id }}" \
|
--run-id "${{ github.run_id }}" \
|
||||||
--workflow "post-merge/sync-wiki" \
|
--workflow "post-merge/sync-wiki" \
|
||||||
@@ -148,21 +167,26 @@ jobs:
|
|||||||
git fetch origin master
|
git fetch origin master
|
||||||
git reset --hard origin/master
|
git reset --hard origin/master
|
||||||
- name: Set up environment
|
- name: Set up environment
|
||||||
|
env:
|
||||||
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
run: make setup-ci
|
run: make setup-ci
|
||||||
- name: Generate and push badges
|
- name: Generate and push badges
|
||||||
env:
|
env:
|
||||||
PRE_COMMIT_ALLOW_NO_CONFIG: "1"
|
PRE_COMMIT_ALLOW_NO_CONFIG: "1"
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate
|
||||||
python3 scripts/ci/push_badges.py
|
python3 -m devx.ci.push_badges
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
env:
|
env:
|
||||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
PYTHONPATH: .:src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
python3 scripts/ci/notify_failure.py \
|
python3 -m devx.tools.install_tools --tool tea
|
||||||
|
tea login add --name grm --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
|
||||||
|
tea login default grm || true
|
||||||
|
python3 -m devx.ci.notify_failure \
|
||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
--run-id "${{ github.run_id }}" \
|
--run-id "${{ github.run_id }}" \
|
||||||
--workflow "post-merge/badges" \
|
--workflow "post-merge/badges" \
|
||||||
@@ -178,23 +202,27 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: python3 -m pip install --break-system-packages requests python-dotenv click
|
run: |
|
||||||
|
. .env 2>/dev/null || true
|
||||||
|
python3 -m pip install --break-system-packages "git+https://emil:${{ secrets.REPO_TOKEN }}@git.oblachno.oblachno.fyi/oblachno-oss/devx.git@master"
|
||||||
- name: Update Vikunja task
|
- name: Update Vikunja task
|
||||||
env:
|
env:
|
||||||
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
||||||
PYTHONPATH: .:src
|
PYTHONPATH: src
|
||||||
run: python3 scripts/ci/post_merge.py --git-sha "${{ github.sha }}"
|
DEVX_TASK_PREFIX: GRM
|
||||||
|
DEVX_VIKUNJA_PROJECT_ID: 6
|
||||||
|
run: python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
env:
|
env:
|
||||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
PYTHONPATH: .:src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
python3 scripts/install_tools.py --tool tea
|
python3 -m devx.tools.install_tools --tool tea
|
||||||
tea login add --name grm --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
|
tea login add --name grm --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
|
||||||
tea login default grm || true
|
tea login default grm || true
|
||||||
python3 scripts/ci/notify_failure.py \
|
python3 -m devx.ci.notify_failure \
|
||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
--run-id "${{ github.run_id }}" \
|
--run-id "${{ github.run_id }}" \
|
||||||
--workflow "post-merge/vikunja" \
|
--workflow "post-merge/vikunja" \
|
||||||
@@ -208,23 +236,26 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: python3 -m pip install --break-system-packages requests python-dotenv click
|
run: |
|
||||||
|
. .env 2>/dev/null || true
|
||||||
|
python3 -m pip install --break-system-packages "git+https://emil:${{ secrets.REPO_TOKEN }}@git.oblachno.oblachno.fyi/oblachno-oss/devx.git@master"
|
||||||
- name: Ensure branch protection and labels
|
- name: Ensure branch protection and labels
|
||||||
env:
|
env:
|
||||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
PYTHONPATH: .:src
|
PYTHONPATH: src
|
||||||
run: python3 scripts/configure_repo.py
|
DEVX_STATUS_CHECKS: "CI / quality (pull_request),CI / molecule-tests (1) (pull_request),CI / molecule-tests (2) (pull_request),CI / molecule-tests (3) (pull_request)"
|
||||||
|
run: python3 -m devx.tools.configure_repo
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
env:
|
env:
|
||||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
PYTHONPATH: .:src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
python3 scripts/install_tools.py --tool tea
|
python3 -m devx.tools.install_tools --tool tea
|
||||||
tea login add --name grm --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
|
tea login add --name grm --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
|
||||||
tea login default grm || true
|
tea login default grm || true
|
||||||
python3 scripts/ci/notify_failure.py \
|
python3 -m devx.ci.notify_failure \
|
||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
--run-id "${{ github.run_id }}" \
|
--run-id "${{ github.run_id }}" \
|
||||||
--workflow "post-merge/configure-repo" \
|
--workflow "post-merge/configure-repo" \
|
||||||
|
|||||||
@@ -14,9 +14,12 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Install CI tools
|
- name: Install CI tools
|
||||||
run: python3 scripts/install_tools.py --tool git-cliff --tool tea
|
run: |
|
||||||
|
. .env 2>/dev/null || true
|
||||||
|
python3 -m pip install --break-system-packages "git+https://emil:${{ secrets.REPO_TOKEN }}@git.oblachno.oblachno.fyi/oblachno-oss/devx.git@master"
|
||||||
|
python3 -m devx.tools.install_tools --tool git-cliff --tool tea
|
||||||
- name: Install build tools
|
- name: Install build tools
|
||||||
run: python3 -m pip install --break-system-packages build twine requests python-dotenv click
|
run: python3 -m pip install --break-system-packages build twine
|
||||||
- name: Configure tea login
|
- name: Configure tea login
|
||||||
env:
|
env:
|
||||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
@@ -28,20 +31,20 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
||||||
PYTHONPATH: .:src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
python3 scripts/ci/publish.py \
|
python3 -m devx.ci.publish \
|
||||||
"${{ github.ref_name }}" \
|
"${{ github.ref_name }}" \
|
||||||
"${{ github.repository }}"
|
"${{ github.repository }}"
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
env:
|
env:
|
||||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
PYTHONPATH: .:src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
python3 scripts/ci/notify_failure.py \
|
python3 -m devx.ci.notify_failure \
|
||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
--run-id "${{ github.run_id }}" \
|
--run-id "${{ github.run_id }}" \
|
||||||
--workflow "publish" \
|
--workflow "publish" \
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ repos:
|
|||||||
hooks:
|
hooks:
|
||||||
- id: validate-commit-msg
|
- id: validate-commit-msg
|
||||||
name: validate commit message
|
name: validate commit message
|
||||||
entry: env PYTHONPATH=. .venv/bin/python scripts/ci/validate_commit_msg.py
|
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.validate_commit_msg
|
||||||
language: system
|
language: system
|
||||||
stages: [commit-msg]
|
stages: [commit-msg]
|
||||||
pass_filenames: true
|
pass_filenames: true
|
||||||
@@ -67,7 +67,7 @@ repos:
|
|||||||
|
|
||||||
- id: commit-msg
|
- id: commit-msg
|
||||||
name: validate commit message
|
name: validate commit message
|
||||||
entry: env PYTHONPATH=. .venv/bin/python scripts/ci/validate_commit_msg.py
|
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.validate_commit_msg
|
||||||
language: system
|
language: system
|
||||||
stages: [commit-msg]
|
stages: [commit-msg]
|
||||||
pass_filenames: true
|
pass_filenames: true
|
||||||
|
|||||||
@@ -17,10 +17,11 @@ make workflow-check # workflow-lint + workflow-dryrun
|
|||||||
```
|
```
|
||||||
|
|
||||||
`make setup` automatically installs all development tools:
|
`make setup` automatically installs all development tools:
|
||||||
- **Python deps** via `scripts/setup.py` (pip install -e .[dev], ansible-galaxy, pre-commit hooks)
|
- **Python deps** via `devx.tools.setup` (pip install -e .[dev], ansible-galaxy, pre-commit hooks)
|
||||||
- **checkmake** via `scripts/install_checkmake.py` (Makefile linter)
|
- **devx package** via `make install-devx` (installs the devx package from git, providing all CI/CD tools)
|
||||||
- **actionlint, git-cliff, act_runner, tea** via `scripts/install_tools.py` (CI/CD tools to ~/.local/bin)
|
- **checkmake** via `devx.tools.install_checkmake` (Makefile linter)
|
||||||
- **tea CLI login** via `scripts/setup.py` (configures `tea login` from `.env` `REPO_TOKEN`)
|
- **actionlint, git-cliff, act_runner, tea** via `devx.tools.install_tools` (CI/CD tools to ~/.local/bin)
|
||||||
|
- **tea CLI login** via `devx.tools.setup` (configures `tea login` from `.env` `REPO_TOKEN`)
|
||||||
|
|
||||||
## Workflow Verification (Before Push)
|
## Workflow Verification (Before Push)
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ Workflow YAML files (`.gitea/workflows/*.yml`) are verified with two tools:
|
|||||||
1. **actionlint** — Static linter that catches syntax errors, invalid
|
1. **actionlint** — Static linter that catches syntax errors, invalid
|
||||||
expressions, unknown keys, type mismatches, and shellcheck issues.
|
expressions, unknown keys, type mismatches, and shellcheck issues.
|
||||||
Config: `.gitea/actionlint.yaml` (registers custom `docker` runner label).
|
Config: `.gitea/actionlint.yaml` (registers custom `docker` runner label).
|
||||||
Installed automatically by `make setup` via `scripts/install_tools.py`.
|
Installed automatically by `make setup` via `devx.tools.install_tools`.
|
||||||
|
|
||||||
2. **act_runner exec --dryrun** — Gitea's own runner in dry-run mode.
|
2. **act_runner exec --dryrun** — Gitea's own runner in dry-run mode.
|
||||||
Validates job dependencies, step ordering, and Docker image selection
|
Validates job dependencies, step ordering, and Docker image selection
|
||||||
@@ -44,7 +45,7 @@ CI also runs a best-effort `make workflow-dryrun` step (skipped if act_runner is
|
|||||||
|
|
||||||
- **Python CLI** (`src/gitea_runner_manager/`) — Click-based CLI that delegates to Ansible
|
- **Python CLI** (`src/gitea_runner_manager/`) — Click-based CLI that delegates to Ansible
|
||||||
- **Ansible Role** (`ansible/roles/gitea-runner/`) — Idempotent role for rootless Docker runner setup
|
- **Ansible Role** (`ansible/roles/gitea-runner/`) — Idempotent role for rootless Docker runner setup
|
||||||
- **CI Scripts** (`scripts/`) — Automation for auto-merge, post-merge, release, publishing, molecule distribution, PR reviews, failure notifications
|
- **devx package** (installed from git) — Reusable CI/CD tools: auto-merge, post-merge, release, publishing, molecule distribution, PR reviews, failure notifications
|
||||||
- **Versioning** (`cliff.toml`) — git-cliff configuration for automated semver versioning from conventional commits
|
- **Versioning** (`cliff.toml`) — git-cliff configuration for automated semver versioning from conventional commits
|
||||||
|
|
||||||
## PR Workflow (Mandatory)
|
## PR Workflow (Mandatory)
|
||||||
@@ -54,7 +55,8 @@ Every change to master goes through this workflow. No exceptions.
|
|||||||
### Branch Protection (Required Gitea Settings)
|
### Branch Protection (Required Gitea Settings)
|
||||||
|
|
||||||
Branch protection and labels are automatically configured by
|
Branch protection and labels are automatically configured by
|
||||||
`scripts/configure_repo.py`, which runs as a `configure-repo` job in
|
`devx.tools.configure_repo` (run as `python -m devx.tools.configure_repo`),
|
||||||
|
which runs as a `configure-repo` job in
|
||||||
the post-merge workflow on every push to master.
|
the post-merge workflow on every push to master.
|
||||||
|
|
||||||
The following rules are enforced for `master`:
|
The following rules are enforced for `master`:
|
||||||
@@ -102,7 +104,7 @@ UX, documentation, workflow compliance, maintainability, resource
|
|||||||
management, backwards compatibility, and logging.
|
management, backwards compatibility, and logging.
|
||||||
|
|
||||||
**Automated review (CI `pr-review` job):** Every PR triggers an automated
|
**Automated review (CI `pr-review` job):** Every PR triggers an automated
|
||||||
review via `scripts/ci/pr_review.py`. This job posts a review with
|
review via `devx.ci.pr_review` (run as `python -m devx.ci.pr_review`). This job posts a review with
|
||||||
`COMMENT` (no issues) or `REQUEST_CHANGES` (issues found) based on
|
`COMMENT` (no issues) or `REQUEST_CHANGES` (issues found) based on
|
||||||
the **[auto]** items in the checklist:
|
the **[auto]** items in the checklist:
|
||||||
|
|
||||||
@@ -125,9 +127,9 @@ must go through **every category** in `REVIEW_CHECKLIST.md` and verify
|
|||||||
the **[manual]** items by reviewing the full diff
|
the **[manual]** items by reviewing the full diff
|
||||||
(`git diff master...HEAD`).
|
(`git diff master...HEAD`).
|
||||||
|
|
||||||
Post review comments using `scripts/ci/review_pr.py`:
|
Post review comments using `devx.ci.review_pr` (run as `python -m devx.ci.review_pr`):
|
||||||
```bash
|
```bash
|
||||||
REPO_TOKEN=<token> python3 scripts/ci/review_pr.py <pr_number> <owner/repo> \
|
REPO_TOKEN=<token> python -m devx.ci.review_pr <pr_number> <owner/repo> \
|
||||||
--event REQUEST_CHANGES \
|
--event REQUEST_CHANGES \
|
||||||
--body "Review summary" \
|
--body "Review summary" \
|
||||||
--comments-json comments.json
|
--comments-json comments.json
|
||||||
@@ -140,7 +142,7 @@ Fix each comment one by one, commit, and push. Re-review until satisfied.
|
|||||||
Once all checklist items are verified and comments are addressed, post
|
Once all checklist items are verified and comments are addressed, post
|
||||||
an approval review with `--checklist-confirmed` and `--checklist-categories`:
|
an approval review with `--checklist-confirmed` and `--checklist-categories`:
|
||||||
```bash
|
```bash
|
||||||
REPO_TOKEN=<token> python3 scripts/ci/review_pr.py <pr_number> <owner/repo> \
|
REPO_TOKEN=<token> python -m devx.ci.review_pr <pr_number> <owner/repo> \
|
||||||
--event APPROVE --checklist-confirmed \
|
--event APPROVE --checklist-confirmed \
|
||||||
--checklist-categories 1,2,3,4,5,6,7,8,9,10,11,12,13 \
|
--checklist-categories 1,2,3,4,5,6,7,8,9,10,11,12,13 \
|
||||||
--body "All 13 REVIEW_CHECKLIST.md categories verified. Architecture: <summary>. Security: <summary>. Tests: <summary>. Docs: <summary>."
|
--body "All 13 REVIEW_CHECKLIST.md categories verified. Architecture: <summary>. Security: <summary>. Tests: <summary>. Docs: <summary>."
|
||||||
@@ -179,7 +181,7 @@ test infrastructure flakiness.
|
|||||||
### Dynamic Runner Discovery
|
### Dynamic Runner Discovery
|
||||||
|
|
||||||
Molecule tests are distributed across available Gitea Actions runners
|
Molecule tests are distributed across available Gitea Actions runners
|
||||||
dynamically via `scripts/ci/discover_runners.py`. The `discover-runners`
|
dynamically via `devx.molecule.discover_runners`. The `discover-runners`
|
||||||
job queries the Gitea API for runners at all levels (repo, org, instance)
|
job queries the Gitea API for runners at all levels (repo, org, instance)
|
||||||
and generates a dynamic matrix. If the API can't see instance-level runners
|
and generates a dynamic matrix. If the API can't see instance-level runners
|
||||||
(no admin scope), it falls back to the `MOLECULE_RUNNERS` repo variable,
|
(no admin scope), it falls back to the `MOLECULE_RUNNERS` repo variable,
|
||||||
@@ -201,9 +203,9 @@ Vikunja task updates:
|
|||||||
release commit (`release: vX.Y.Z`). All subsequent jobs skip for
|
release commit (`release: vX.Y.Z`). All subsequent jobs skip for
|
||||||
release commits (the `[skip ci]` tag also prevents re-triggering).
|
release commits (the `[skip ci]` tag also prevents re-triggering).
|
||||||
|
|
||||||
2. **release** — Runs `scripts/ci/release.py` which:
|
2. **release** — Runs `devx.ci.release` which:
|
||||||
- **Checks for user-facing changes** via `scripts/ci/classify_changes.py` — if only
|
- **Checks for user-facing changes** via `devx.ci.classify_changes` — if only
|
||||||
workflow/infrastructure files changed (`.gitea/`, `scripts/`, `docs/`, `tests/`,
|
workflow/infrastructure files changed (`.gitea/`, `docs/`, `tests/`,
|
||||||
`AGENTS.md`, `Makefile`, etc.), the release is **skipped entirely** — no version
|
`AGENTS.md`, `Makefile`, etc.), the release is **skipped entirely** — no version
|
||||||
bump, no tag, no publish. This prevents unnecessary releases for CI/docs-only changes.
|
bump, no tag, no publish. This prevents unnecessary releases for CI/docs-only changes.
|
||||||
- Uses **git-cliff** to calculate the next semver version from conventional commits
|
- Uses **git-cliff** to calculate the next semver version from conventional commits
|
||||||
@@ -233,16 +235,15 @@ which builds and publishes the package to PyPI.
|
|||||||
### Smart CI: User-Facing vs Workflow-Only Changes
|
### Smart CI: User-Facing vs Workflow-Only Changes
|
||||||
|
|
||||||
Not all changes require the full CI pipeline or a new release. The project
|
Not all changes require the full CI pipeline or a new release. The project
|
||||||
classifies changes into two categories using `scripts/ci/classify_changes.py`:
|
classifies changes into two categories using `devx.ci.classify_changes`:
|
||||||
|
|
||||||
**Classification strategy (safe-by-default):** Any file NOT in the explicit
|
**Classification strategy (safe-by-default):** Any file NOT in the explicit
|
||||||
workflow-only allowlist is treated as user-facing. This prevents new file
|
workflow-only allowlist is treated as user-facing. This prevents new file
|
||||||
types from accidentally skipping releases.
|
types from accidentally skipping releases. Classification is config-driven
|
||||||
|
via `[tool.devx.classify]` in `pyproject.toml`.
|
||||||
|
|
||||||
**Workflow-only paths** (infrastructure → no release needed):
|
**Workflow-only paths** (infrastructure → no release needed):
|
||||||
- `.gitea/**` — Gitea Actions workflows
|
- `.gitea/**` — Gitea Actions workflows
|
||||||
- `scripts/ci/**` — CI/CD automation scripts
|
|
||||||
- `scripts/setup.py`, `scripts/molecule_all.py`, `scripts/install_tools.py`, `scripts/__init__.py` — Dev tooling and package init
|
|
||||||
- `docs/**` — Documentation
|
- `docs/**` — Documentation
|
||||||
- `tests/**` — Test files
|
- `tests/**` — Test files
|
||||||
- `AGENTS.md`, `README.md`, `CHANGELOG.md`, `TROUBLESHOOTING.md` — Project docs
|
- `AGENTS.md`, `README.md`, `CHANGELOG.md`, `TROUBLESHOOTING.md` — Project docs
|
||||||
@@ -256,9 +257,15 @@ types from accidentally skipping releases.
|
|||||||
- `pyproject.toml` — Package metadata
|
- `pyproject.toml` — Package metadata
|
||||||
- Any new file type not in the allowlist
|
- Any new file type not in the allowlist
|
||||||
|
|
||||||
**Script directory structure:**
|
**devx module structure** (installed from git, not in this repo):
|
||||||
- `scripts/` — Dev tools (run locally by developers): `check_test_speed.py`, `configure_repo.py`, `install_checkmake.py`, `install_tools.py`, `setup.py`, `molecule_all.py`, `generate_badges.py`, `gitea_cli.py`
|
- `devx.ci.*` — CI/CD automation (run by workflows): release, publish, auto_merge, classify_changes, detect_release_commit, push_badges, doc_coverage, sync_wiki, distribute_molecule, molecule_ci_guard, discover_runners, notify_failure, post_merge, pr_review, review_pr, validate_commit_msg
|
||||||
- `scripts/ci/` — CI/CD automation (run by workflows): `release.py`, `publish.py`, `auto_merge.py`, `classify_changes.py`, `detect_release_commit.py`, `push_badges.py`, `doc_coverage.py`, `sync_wiki.py`, `distribute_molecule.py`, `molecule_ci_guard.py`, `discover_runners.py`, `notify_failure.py`, `post_merge.py`, `pr_review.py`, `review_pr.py`, `validate_commit_msg.py`, `platforms.py`
|
- `devx.tools.*` — Dev tools (run locally): check_test_speed, configure_repo, install_checkmake, install_tools, setup, generate_badges
|
||||||
|
- `devx.molecule.*` — Molecule helpers: molecule_all, platforms, discover_runners, distribute_molecule, molecule_ci_guard
|
||||||
|
- `devx.gitea_cli` — Tea CLI wrapper
|
||||||
|
- `devx.i18n` — i18n translation system
|
||||||
|
- `devx.config` — Shared configuration (DEVX_* env vars)
|
||||||
|
- `devx.api_clients` — GiteaClient, VikunjaClient
|
||||||
|
- `devx.exceptions` — APIError and other exceptions
|
||||||
|
|
||||||
**CI behavior based on classification:**
|
**CI behavior based on classification:**
|
||||||
- **Molecule tests**: Only run when `ansible/` or `.ansible-lint` files change
|
- **Molecule tests**: Only run when `ansible/` or `.ansible-lint` files change
|
||||||
@@ -269,80 +276,76 @@ types from accidentally skipping releases.
|
|||||||
**AI agents must follow these rules:**
|
**AI agents must follow these rules:**
|
||||||
- When working on workflow/CI/docs-only changes, use `ci:` or `docs:` commit prefixes
|
- When working on workflow/CI/docs-only changes, use `ci:` or `docs:` commit prefixes
|
||||||
- Do NOT bump the version or create tags for workflow-only changes
|
- Do NOT bump the version or create tags for workflow-only changes
|
||||||
- The `classify_changes.py` script enforces this automatically — no manual intervention needed
|
- The `classify_changes` module enforces this automatically — no manual intervention needed
|
||||||
- When adding a new CI script, place it in `scripts/ci/`. Dev tools go in `scripts/`.
|
|
||||||
|
|
||||||
## Script Separation and Import Rules
|
## Source Code Separation and devx Integration
|
||||||
|
|
||||||
The codebase enforces strict separation between the GRM tool and CI/dev scripts:
|
The codebase enforces strict separation between the GRM tool and the devx package:
|
||||||
|
|
||||||
### Directory Layout
|
### Directory Layout
|
||||||
|
|
||||||
| Directory | Purpose | Release impact |
|
| Directory | Purpose | Release impact |
|
||||||
|-----------|---------|----------------|
|
|-----------|---------|----------------|
|
||||||
| `src/gitea_runner_manager/` | User-facing GRM CLI tool | Changes trigger release |
|
| `src/gitea_runner_manager/` | User-facing GRM CLI tool | Changes trigger release |
|
||||||
| `scripts/` | Dev tools (run locally) | Workflow-only (no release) |
|
| `devx` package (installed from git) | Reusable CI/CD and dev tools | Not in this repo (no release impact) |
|
||||||
| `scripts/ci/` | CI/CD automation (run by workflows) | Workflow-only (no release) |
|
|
||||||
| `ansible/` | Ansible role for runner setup | Changes trigger release |
|
| `ansible/` | Ansible role for runner setup | Changes trigger release |
|
||||||
|
|
||||||
### Import Rules
|
### Import Rules
|
||||||
|
|
||||||
1. **`src/gitea_runner_manager/` NEVER imports from `scripts/`** — the tool is self-contained
|
1. **`src/gitea_runner_manager/` NEVER imports from devx** — the GRM tool is self-contained
|
||||||
2. **Scripts MAY import from `gitea_runner_manager`** — one-way dependency (scripts use the tool's API clients, config, i18n)
|
2. **devx MAY import from `gitea_runner_manager`** — one-way dependency (devx uses the tool's API clients, config, i18n)
|
||||||
3. **Cross-script imports** (scripts importing from other scripts) are allowed within `scripts/ci/` but must be documented
|
3. **Cross-module imports within devx** are allowed (devx modules importing from other devx modules) and must be documented
|
||||||
4. **`scripts/gitea_cli.py`** is a shared wrapper around the `tea` CLI — CI scripts import from it for Gitea API operations (issues, labels, PRs, releases, reviews)
|
4. **`devx.gitea_cli`** is a shared wrapper around the `tea` CLI — devx modules import from it for Gitea API operations (issues, labels, PRs, releases, reviews)
|
||||||
|
|
||||||
### tea CLI Integration
|
### tea CLI Integration
|
||||||
|
|
||||||
The `tea` Gitea CLI tool is used for Gitea API interactions in CI scripts. It is installed by `scripts/install_tools.py` and configured by `scripts/setup.py` (login profile from `.env` `REPO_TOKEN`).
|
The `tea` Gitea CLI tool is used for Gitea API interactions in devx. It is installed by `devx.tools.install_tools` and configured by `devx.tools.setup` (login profile from `.env` `REPO_TOKEN`).
|
||||||
|
|
||||||
**`scripts/gitea_cli.py`** — Python wrapper around `tea` CLI with JSON output parsing:
|
**`devx.gitea_cli`** — Python wrapper around `tea` CLI with JSON output parsing:
|
||||||
- `TeaCLI.create_issue()` — Create issues with labels
|
- `TeaCLI.create_issue()` — Create issues with labels
|
||||||
- `TeaCLI.list_labels()` / `TeaCLI.create_label()` / `TeaCLI.add_label()` — Label management
|
- `TeaCLI.list_labels()` / `TeaCLI.create_label()` / `TeaCLI.add_label()` — Label management
|
||||||
- `TeaCLI.create_pr()` / `TeaCLI.merge_pr()` / `TeaCLI.review_pr()` — Pull request operations
|
- `TeaCLI.create_pr()` / `TeaCLI.merge_pr()` / `TeaCLI.review_pr()` — Pull request operations
|
||||||
- `TeaCLI.create_release()` / `TeaCLI.list_releases()` — Release management
|
- `TeaCLI.create_release()` / `TeaCLI.list_releases()` — Release management
|
||||||
- `TeaCLI.list_branches()` — Branch listing
|
- `TeaCLI.list_branches()` — Branch listing
|
||||||
|
|
||||||
**Scripts using tea (via `gitea_cli.py`):**
|
**Modules using tea (via `devx.gitea_cli`):**
|
||||||
- `scripts/ci/publish.py` — Creates Gitea releases via `tea releases create`
|
- `devx.ci.publish` — Creates Gitea releases via `tea releases create`
|
||||||
- `scripts/ci/notify_failure.py` — Creates issues via `tea issues create` (falls back to `GiteaClient` if tea not installed)
|
- `devx.ci.notify_failure` — Creates issues via `tea issues create` (falls back to `GiteaClient` if tea not installed)
|
||||||
- `scripts/configure_repo.py` — Creates labels via `tea labels create` (falls back to `GiteaClient` if tea fails; branch protection still uses `GiteaClient` since tea only supports basic protect/unprotect)
|
- `devx.tools.configure_repo` — Creates labels via `tea labels create` (falls back to `GiteaClient` if tea fails; branch protection still uses `GiteaClient` since tea only supports basic protect/unprotect)
|
||||||
|
|
||||||
**Operations still using `GiteaClient` (not supported by tea):**
|
**Operations still using `GiteaClient` (not supported by tea):**
|
||||||
- PR reviews (`review_pr.py`) — tea v0.14.1 only supports interactive reviews
|
- PR reviews (`devx.ci.review_pr`) — tea v0.14.1 only supports interactive reviews
|
||||||
- Wiki page management (`sync_wiki.py`)
|
- Wiki page management (`devx.ci.sync_wiki`)
|
||||||
- Commit status checks (`auto_merge.py`)
|
- Commit status checks (`devx.ci.auto_merge`)
|
||||||
- Runner discovery (`discover_runners.py`)
|
- Runner discovery (`devx.molecule.discover_runners`)
|
||||||
- Branch protection with detailed config (`configure_repo.py`)
|
- Branch protection with detailed config (`devx.tools.configure_repo`)
|
||||||
- PR file/commit listing (`pr_review.py`)
|
- PR file/commit listing (`devx.ci.pr_review`)
|
||||||
|
|
||||||
### PYTHONPATH Configuration
|
### PYTHONPATH Configuration
|
||||||
|
|
||||||
Scripts have different import requirements. Workflows must set `PYTHONPATH` accordingly:
|
Since devx is installed as a package (via `pip install` from git), it is importable directly. Workflows only need `PYTHONPATH=src` when a devx module imports from `gitea_runner_manager`:
|
||||||
|
|
||||||
| PYTHONPATH | When to use | Example scripts |
|
| PYTHONPATH | When to use | Example modules |
|
||||||
|------------|-------------|-----------------|
|
|------------|-------------|-----------------|
|
||||||
| `src` | Script imports from `gitea_runner_manager` | `auto_merge.py`, `pr_review.py`, `review_pr.py`, `sync_wiki.py`, `post_merge.py`, `classify_changes.py`, `discover_runners.py`, `doc_coverage.py` |
|
| `src` | Module imports from `gitea_runner_manager` | `devx.ci.auto_merge`, `devx.ci.pr_review`, `devx.ci.review_pr`, `devx.ci.sync_wiki`, `devx.ci.post_merge`, `devx.ci.classify_changes`, `devx.molecule.discover_runners`, `devx.ci.doc_coverage` |
|
||||||
| `.:src` | Script imports from both `gitea_runner_manager` and `scripts.gitea_cli` | `publish.py`, `notify_failure.py`, `configure_repo.py` |
|
| (none) | Module has no GRM imports | `devx.ci.detect_release_commit`, `devx.molecule.distribute_molecule`, `devx.molecule.molecule_ci_guard`, `devx.ci.push_badges`, `devx.ci.validate_commit_msg` |
|
||||||
| `.` | Script imports from other `scripts.ci.*` modules | `release.py` (imports `classify_changes.has_user_facing_changes`) |
|
|
||||||
| (none) | Script has no GRM or cross-script imports | `detect_release_commit.py`, `distribute_molecule.py`, `molecule_ci_guard.py`, `push_badges.py`, `validate_commit_msg.py` |
|
|
||||||
|
|
||||||
**In workflows**, always use `env:` blocks (not inline `PYTHONPATH=value`):
|
**In workflows**, always use `env:` blocks (not inline `PYTHONPATH=value`):
|
||||||
```yaml
|
```yaml
|
||||||
- name: Run script
|
- name: Run module
|
||||||
env:
|
env:
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: python3 scripts/ci/example.py
|
run: python -m devx.ci.example
|
||||||
```
|
```
|
||||||
|
|
||||||
**Locally**, the current directory is in `sys.path` by default, so `PYTHONPATH` is usually not needed.
|
**Locally**, devx is installed as a package, so only `PYTHONPATH=src` is needed if importing from `gitea_runner_manager`.
|
||||||
|
|
||||||
### Shared Constants
|
### Shared Constants
|
||||||
|
|
||||||
`scripts/ci/platforms.py` is the single source of truth for the molecule
|
`devx.molecule.platforms` is the single source of truth for the molecule
|
||||||
platform matrix. Both `scripts/ci/distribute_molecule.py` (CI) and
|
platform matrix. Both `devx.molecule.distribute_molecule` (CI) and
|
||||||
`scripts/molecule_all.py` (dev tool) import `PLATFORMS` from it — this
|
`devx.molecule.molecule_all` (dev tool) import `PLATFORMS` from it — this
|
||||||
avoids dev tools importing directly from CI scripts.
|
avoids dev tools importing directly from CI modules.
|
||||||
|
|
||||||
2. **Publish workflow** (`.gitea/workflows/publish.yml`):
|
2. **Publish workflow** (`.gitea/workflows/publish.yml`):
|
||||||
- Triggers on tag push (`v*`)
|
- Triggers on tag push (`v*`)
|
||||||
@@ -350,7 +353,7 @@ avoids dev tools importing directly from CI scripts.
|
|||||||
- Builds the Python package
|
- Builds the Python package
|
||||||
- Optionally publishes to PyPI (if `PYPI_TOKEN` is set)
|
- Optionally publishes to PyPI (if `PYPI_TOKEN` is set)
|
||||||
- Creates a Gitea release with git-cliff-generated release notes
|
- Creates a Gitea release with git-cliff-generated release notes
|
||||||
- On failure, creates a Gitea issue via `scripts/ci/notify_failure.py`
|
- On failure, creates a Gitea issue via `devx.ci.notify_failure`
|
||||||
|
|
||||||
### git-cliff Commit Preprocessing
|
### git-cliff Commit Preprocessing
|
||||||
|
|
||||||
@@ -379,6 +382,15 @@ The version source is `__version__` in `src/gitea_runner_manager/__init__.py`, r
|
|||||||
| PR title | `GRM-N: <vikunja task title>` | `GRM-33: Add mandatory PR review step` |
|
| PR title | `GRM-N: <vikunja task title>` | `GRM-33: Add mandatory PR review step` |
|
||||||
| Merge commit | `GRM-N <conventional commit>` | `GRM-33 feat: add review script` |
|
| Merge commit | `GRM-N <conventional commit>` | `GRM-33 feat: add review script` |
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
The devx package is configured via `DEVX_*` environment variables:
|
||||||
|
- `DEVX_TASK_PREFIX=GRM` — Prefix for Vikunja task identifiers
|
||||||
|
- `DEVX_VIKUNJA_PROJECT_ID=6` — Vikunja project ID for task tracking
|
||||||
|
- `DEVX_VERSION_FILE=src/gitea_runner_manager/__init__.py` — Path to the version source file
|
||||||
|
|
||||||
|
Change classification is config-driven via `[tool.devx.classify]` in `pyproject.toml`, which defines the workflow-only and user-facing path patterns.
|
||||||
|
|
||||||
## Key Conventions
|
## Key Conventions
|
||||||
|
|
||||||
- Python 3.12+ required (ruff/pyright target `py312`)
|
- Python 3.12+ required (ruff/pyright target `py312`)
|
||||||
@@ -404,7 +416,7 @@ main.yml → systemd_check → user_setup → rootless_docker → install_runner
|
|||||||
|
|
||||||
6 scenarios: `default`, `multi-instance`, `lifecycle`, `template-content`, `deregister`, `update`
|
6 scenarios: `default`, `multi-instance`, `lifecycle`, `template-content`, `deregister`, `update`
|
||||||
4 platforms: `ubuntu-2204`, `ubuntu-2404`, `debian-12`, `archlinux`
|
4 platforms: `ubuntu-2204`, `ubuntu-2404`, `debian-12`, `archlinux`
|
||||||
Platform list is defined in `scripts/ci/platforms.py` (single source of truth)
|
Platform list is defined in `devx.molecule.platforms` (single source of truth)
|
||||||
|
|
||||||
## Known Issues
|
## Known Issues
|
||||||
|
|
||||||
@@ -438,14 +450,14 @@ docs/
|
|||||||
|
|
||||||
### Wiki Sync
|
### Wiki Sync
|
||||||
|
|
||||||
- **On merge to master**: `sync-wiki.yml` workflow runs `scripts/ci/sync_wiki.py` which pushes all `/docs/` content to the Gitea wiki via API
|
- **On merge to master**: `sync-wiki.yml` workflow runs `devx.ci.sync_wiki` which pushes all `/docs/` content to the Gitea wiki via API
|
||||||
- **On release tag**: Same sync runs, plus the wiki is tagged with the release version
|
- **On release tag**: Same sync runs, plus the wiki is tagged with the release version
|
||||||
- `mapping.json` maps each file path to a wiki page title (e.g., `user/getting-started.md` → `Getting-Started`)
|
- `mapping.json` maps each file path to a wiki page title (e.g., `user/getting-started.md` → `Getting-Started`)
|
||||||
- README.md is a lean entry point with links to the wiki — no detailed content
|
- README.md is a lean entry point with links to the wiki — no detailed content
|
||||||
|
|
||||||
### Documentation Coverage
|
### Documentation Coverage
|
||||||
|
|
||||||
- `scripts/ci/doc_coverage.py` checks that all CLI commands, Python modules, and CI scripts are documented
|
- `devx.ci.doc_coverage` checks that all CLI commands, Python modules, and CI scripts are documented
|
||||||
- Runs as a CI step in the quality job with `--fail-on-missing` (blocks CI if docs are missing)
|
- Runs as a CI step in the quality job with `--fail-on-missing` (blocks CI if docs are missing)
|
||||||
- Enforced: 100% coverage for public CLI commands and major architectural components
|
- Enforced: 100% coverage for public CLI commands and major architectural components
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
.PHONY: all setup setup-ci setup-quality setup-molecule setup-release install update lint ansible-lint makefile-lint lint-all test test-unit pytest-cov molecule molecule-all test-all clean workflow-lint workflow-dryrun workflow-check install-tools
|
.PHONY: all setup setup-ci setup-quality setup-molecule setup-release install-devx install update lint ansible-lint makefile-lint lint-all test test-unit pytest-cov molecule molecule-all test-all clean workflow-lint workflow-dryrun workflow-check install-tools
|
||||||
|
|
||||||
PYTHON := python3
|
PYTHON := python3
|
||||||
VENV := .venv
|
VENV := .venv
|
||||||
@@ -7,32 +7,40 @@ CHECKMAKE := $(shell command -v checkmake 2>/dev/null || echo $(HOME)/go/bin/che
|
|||||||
|
|
||||||
all: setup
|
all: setup
|
||||||
|
|
||||||
|
install-devx: $(VENV)/bin/activate
|
||||||
|
@# REPO_TOKEN may come from .env (local) or environment (CI secrets)
|
||||||
|
@if [ -z "$$REPO_TOKEN" ]; then . .env 2>/dev/null; fi; \
|
||||||
|
if [ -z "$$REPO_TOKEN" ]; then echo "REPO_TOKEN not set (check .env or environment)"; exit 1; fi; \
|
||||||
|
$(BIN)/pip install "git+https://emil:$$REPO_TOKEN@git.oblachno.oblachno.fyi/oblachno-oss/devx.git@master"
|
||||||
|
|
||||||
# Full setup for local development (all deps, tools, collections, hooks)
|
# Full setup for local development (all deps, tools, collections, hooks)
|
||||||
setup: $(VENV)/bin/activate .env activate-scripts checkmake install-tools
|
# install-devx must run before install-tools (which uses devx modules)
|
||||||
|
setup: $(VENV)/bin/activate .env activate-scripts install-devx checkmake install-tools
|
||||||
@export PATH="$(HOME)/.local/bin:$$PATH"; \
|
@export PATH="$(HOME)/.local/bin:$$PATH"; \
|
||||||
$(PYTHON) scripts/setup.py --bin "$(BIN)"
|
$(BIN)/python -m devx.tools.setup --bin "$(BIN)"
|
||||||
|
|
||||||
# Lean setup for CI jobs that need pytest + lint tools + runtime deps
|
# Lean setup for CI jobs that need pytest + lint tools + runtime deps
|
||||||
# (detect-changes, discover-runners, pr-review, sync-wiki, badges)
|
# (detect-changes, discover-runners, pr-review, sync-wiki, badges)
|
||||||
# badges job runs generate_badges.py which needs ruff, pyright, bandit
|
# badges job runs generate_badges.py which needs ruff, pyright, bandit
|
||||||
setup-ci: $(VENV)/bin/activate .env
|
setup-ci: $(VENV)/bin/activate .env install-devx
|
||||||
@$(PYTHON) scripts/setup.py --bin "$(BIN)" --extras "ci,lint" --no-ansible-collections --no-pre-commit --no-tea-login
|
@$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,lint" --no-ansible-collections --no-pre-commit --no-tea-login
|
||||||
|
|
||||||
# Setup for the quality job (lint + test deps, actionlint tool)
|
# Setup for the quality job (lint + test deps, actionlint tool)
|
||||||
setup-quality: $(VENV)/bin/activate .env install-tools
|
# install-devx must run before install-tools (which uses devx modules)
|
||||||
|
setup-quality: $(VENV)/bin/activate .env install-devx install-tools
|
||||||
@export PATH="$(HOME)/.local/bin:$$PATH"; \
|
@export PATH="$(HOME)/.local/bin:$$PATH"; \
|
||||||
$(PYTHON) scripts/setup.py --bin "$(BIN)" --extras "ci,lint" --no-ansible-collections --no-pre-commit --no-tea-login
|
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,lint" --no-ansible-collections --no-pre-commit --no-tea-login
|
||||||
|
|
||||||
# Full setup for molecule testing (needs ansible, molecule, collections)
|
# Full setup for molecule testing (needs ansible, molecule, collections)
|
||||||
setup-molecule: $(VENV)/bin/activate .env install-tools
|
setup-molecule: $(VENV)/bin/activate .env install-devx install-tools
|
||||||
@export PATH="$(HOME)/.local/bin:$$PATH"; \
|
@export PATH="$(HOME)/.local/bin:$$PATH"; \
|
||||||
$(PYTHON) scripts/setup.py --bin "$(BIN)" --extras "ci,molecule" --no-pre-commit --no-tea-login
|
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,molecule" --no-pre-commit --no-tea-login
|
||||||
|
|
||||||
# Setup for release jobs (needs git-cliff, tea, and lint tools for release.py)
|
# Setup for release jobs (needs git-cliff, tea, and lint tools for release.py)
|
||||||
setup-release: $(VENV)/bin/activate .env
|
setup-release: $(VENV)/bin/activate .env install-devx
|
||||||
@$(PYTHON) scripts/install_tools.py --tool git-cliff --tool tea
|
@$(BIN)/python -m devx.tools.install_tools --tool git-cliff --tool tea
|
||||||
@export PATH="$(HOME)/.local/bin:$$PATH"; \
|
@export PATH="$(HOME)/.local/bin:$$PATH"; \
|
||||||
$(PYTHON) scripts/setup.py --bin "$(BIN)" --extras "ci,lint" --no-ansible-collections --no-pre-commit
|
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,lint" --no-ansible-collections --no-pre-commit
|
||||||
|
|
||||||
.env:
|
.env:
|
||||||
@if [ ! -f .env ]; then \
|
@if [ ! -f .env ]; then \
|
||||||
@@ -55,11 +63,11 @@ install-hooks:
|
|||||||
@cp hooks/pre-push .git/hooks/pre-push && chmod +x .git/hooks/pre-push
|
@cp hooks/pre-push .git/hooks/pre-push && chmod +x .git/hooks/pre-push
|
||||||
@echo "Git hooks installed."
|
@echo "Git hooks installed."
|
||||||
|
|
||||||
checkmake:
|
checkmake: install-devx
|
||||||
@python3 scripts/install_checkmake.py
|
@$(BIN)/python -m devx.tools.install_checkmake
|
||||||
|
|
||||||
install-tools:
|
install-tools: install-devx
|
||||||
@$(PYTHON) scripts/install_tools.py
|
@$(BIN)/python -m devx.tools.install_tools
|
||||||
|
|
||||||
install:
|
install:
|
||||||
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make install HOST=192.168.1.10"; exit 1; fi
|
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make install HOST=192.168.1.10"; exit 1; fi
|
||||||
@@ -94,10 +102,10 @@ remove:
|
|||||||
$(BIN)/grm remove $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(TOKEN),--token $(TOKEN),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
|
$(BIN)/grm remove $(NAME) $(if $(HOST),--host $(HOST),) $(if $(USER),--user $(USER),) $(if $(TOKEN),--token $(TOKEN),) $(if $(ASK_BECOME_PASS),--ask-become-pass,)
|
||||||
|
|
||||||
lint-ruff:
|
lint-ruff:
|
||||||
$(BIN)/ruff check src/ tests/ scripts/
|
$(BIN)/ruff check src/ tests/
|
||||||
|
|
||||||
lint-format:
|
lint-format:
|
||||||
$(BIN)/ruff format --check src/ tests/ scripts/
|
$(BIN)/ruff format --check src/ tests/
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
$(BIN)/pyright
|
$(BIN)/pyright
|
||||||
@@ -105,7 +113,7 @@ typecheck:
|
|||||||
lint: lint-ruff lint-format typecheck lint-bandit
|
lint: lint-ruff lint-format typecheck lint-bandit
|
||||||
|
|
||||||
lint-bandit:
|
lint-bandit:
|
||||||
$(BIN)/bandit -r src/ scripts/
|
$(BIN)/bandit -r src/
|
||||||
|
|
||||||
lint-deps:
|
lint-deps:
|
||||||
@echo "Checking dependencies for known vulnerabilities..."
|
@echo "Checking dependencies for known vulnerabilities..."
|
||||||
@@ -143,7 +151,7 @@ test-integration:
|
|||||||
$(BIN)/pytest tests/integration/ -v --no-cov
|
$(BIN)/pytest tests/integration/ -v --no-cov
|
||||||
|
|
||||||
pytest-cov:
|
pytest-cov:
|
||||||
$(BIN)/pytest tests/ -v --cov=src/gitea_runner_manager --cov=scripts --cov-report=term-missing --cov-fail-under=100
|
$(BIN)/pytest tests/ -v --cov=src/gitea_runner_manager --cov-report=term-missing --cov-fail-under=100
|
||||||
|
|
||||||
MOLECULE := $(realpath $(BIN))/molecule
|
MOLECULE := $(realpath $(BIN))/molecule
|
||||||
MOLECULE_BASE := cd $(CURDIR)/ansible/roles/gitea-runner && ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true ANSIBLE_INJECT_INVOCATION=1 $(MOLECULE)
|
MOLECULE_BASE := cd $(CURDIR)/ansible/roles/gitea-runner && ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true ANSIBLE_INJECT_INVOCATION=1 $(MOLECULE)
|
||||||
@@ -154,7 +162,7 @@ molecule:
|
|||||||
|
|
||||||
# All scenarios on all supported platforms (sequential; use CI matrix for parallel execution)
|
# All scenarios on all supported platforms (sequential; use CI matrix for parallel execution)
|
||||||
molecule-all:
|
molecule-all:
|
||||||
@$(PYTHON) scripts/molecule_all.py --bin "$(BIN)"
|
@$(BIN)/python -m devx.molecule.molecule_all --bin "$(BIN)"
|
||||||
|
|
||||||
test: test-all
|
test: test-all
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -9,8 +9,8 @@
|
|||||||
| Vikunja task not updated after merge | VIKUNJA_TOKEN expired or task ID missing from commit | Regenerate token; verify merge commit has `GRM-N:` prefix |
|
| Vikunja task not updated after merge | VIKUNJA_TOKEN expired or task ID missing from commit | Regenerate token; verify merge commit has `GRM-N:` prefix |
|
||||||
| Post-merge can't find Vikunja task | Task not in project 6 or identifier mismatch | Verify task exists in Vikunja project 6 with correct identifier |
|
| Post-merge can't find Vikunja task | Task not in project 6 or identifier mismatch | Verify task exists in Vikunja project 6 with correct identifier |
|
||||||
| `make pytest-cov` fails | Coverage below 100% | Add tests for new code paths |
|
| `make pytest-cov` fails | Coverage below 100% | Add tests for new code paths |
|
||||||
| `scripts/configure_repo.py` fails | REPO_TOKEN missing or invalid | Set token with repo admin scope and re-run |
|
| `devx.tools.configure_repo` fails | REPO_TOKEN missing or invalid | Set token with repo admin scope and re-run |
|
||||||
| `configure_repo.py` sets wrong status checks | Stale `BRANCH_PROTECTION_CONFIG` | Updated to include `(pull_request)` suffix; re-run `configure_repo.py` |
|
| `configure_repo` sets wrong status checks | Stale `BRANCH_PROTECTION_CONFIG` | Updated to include `(pull_request)` suffix; re-run `configure_repo` |
|
||||||
| Token visible in `ps aux` during install | Old version passed tokens via command line | Fixed: tokens now passed via temp file with `0600` permissions |
|
| Token visible in `ps aux` during install | Old version passed tokens via command line | Fixed: tokens now passed via temp file with `0600` permissions |
|
||||||
| `remove-runner.yml` leaves lingering enabled | Old version didn't disable lingering | Fixed: now runs `loginctl disable-linger` and removes subuid/subgid |
|
| `remove-runner.yml` leaves lingering enabled | Old version didn't disable lingering | Fixed: now runs `loginctl disable-linger` and removes subuid/subgid |
|
||||||
| apt cache update always reports `changed` | `cache_valid_time: 0` forced update every run | Fixed: changed to `cache_valid_time: 3600` |
|
| apt cache update always reports `changed` | `cache_valid_time: 0` forced update every run | Fixed: changed to `cache_valid_time: 3600` |
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ flowchart TD
|
|||||||
|
|
||||||
From `AGENTS.md`, the project also includes:
|
From `AGENTS.md`, the project also includes:
|
||||||
|
|
||||||
- **CI Scripts** (`scripts/`) — Automation for auto-merge, post-merge, release, publishing, molecule distribution, PR reviews, failure notifications
|
- **devx package** (installed from git) — Reusable CI/CD tools: auto-merge, post-merge, release, publishing, molecule distribution, PR reviews, failure notifications
|
||||||
- **Versioning** (`cliff.toml`) — git-cliff configuration for automated semver versioning from conventional commits
|
- **Versioning** (`cliff.toml`) — git-cliff configuration for automated semver versioning from conventional commits
|
||||||
|
|
||||||
## Python Modules
|
## Python Modules
|
||||||
|
|||||||
+24
-24
@@ -60,10 +60,10 @@ Review the full diff (`git diff master...HEAD`) focusing on:
|
|||||||
- **User experience**: Clear error messages, intuitive CLI flags, helpful output
|
- **User experience**: Clear error messages, intuitive CLI flags, helpful output
|
||||||
- **Documentation**: Completeness and relevance of docs, CHANGELOG entries, AGENTS.md updates
|
- **Documentation**: Completeness and relevance of docs, CHANGELOG entries, AGENTS.md updates
|
||||||
|
|
||||||
Post review comments using `scripts/ci/review_pr.py`:
|
Post review comments using `devx.ci.review_pr`:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
REPO_TOKEN=<token> python3 scripts/ci/review_pr.py <pr_number> <owner/repo> \
|
REPO_TOKEN=<token> python -m devx.ci.review_pr <pr_number> <owner/repo> \
|
||||||
--event REQUEST_CHANGES \
|
--event REQUEST_CHANGES \
|
||||||
--body "Review summary" \
|
--body "Review summary" \
|
||||||
--comments-json comments.json
|
--comments-json comments.json
|
||||||
@@ -78,7 +78,7 @@ Fix each comment one by one, commit, and push. Re-review until satisfied.
|
|||||||
Once all comments are addressed:
|
Once all comments are addressed:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
REPO_TOKEN=<token> python3 scripts/ci/review_pr.py <pr_number> <owner/repo> \
|
REPO_TOKEN=<token> python -m devx.ci.review_pr <pr_number> <owner/repo> \
|
||||||
--event APPROVE \
|
--event APPROVE \
|
||||||
--body "All comments addressed. LGTM."
|
--body "All comments addressed. LGTM."
|
||||||
```
|
```
|
||||||
@@ -96,7 +96,7 @@ Then add the `ready-to-merge` label. The auto-merge workflow will:
|
|||||||
|
|
||||||
After the squash-merge:
|
After the squash-merge:
|
||||||
|
|
||||||
- The **post-merge workflow** (`.gitea/workflows/post-merge.yml`) triggers on push to `master` and runs `scripts/ci/post_merge.py` to mark the Vikunja task as done, extracting the task ID from the merge commit message.
|
- The **post-merge workflow** (`.gitea/workflows/post-merge.yml`) triggers on push to `master` and runs `devx.ci.post_merge` to mark the Vikunja task as done, extracting the task ID from the merge commit message.
|
||||||
- The **release workflow** (`.gitea/workflows/release.yml`) triggers on push to `master` and automatically versions, tags, and publishes (see below).
|
- The **release workflow** (`.gitea/workflows/release.yml`) triggers on push to `master` and automatically versions, tags, and publishes (see below).
|
||||||
|
|
||||||
## Branch Protection (Required Gitea Settings)
|
## Branch Protection (Required Gitea Settings)
|
||||||
@@ -131,8 +131,8 @@ The `quality` job in `.gitea/workflows/ci.yml` runs:
|
|||||||
1. `make setup` — full environment setup
|
1. `make setup` — full environment setup
|
||||||
2. `make lint-all` — ruff + pyright + bandit + ansible-lint + checkmake
|
2. `make lint-all` — ruff + pyright + bandit + ansible-lint + checkmake
|
||||||
3. `make pytest-cov` — unit tests with 100% coverage enforcement
|
3. `make pytest-cov` — unit tests with 100% coverage enforcement
|
||||||
4. `python3 scripts/check_test_speed.py --max-seconds 10` — verify unit tests run fast
|
4. `python -m devx.tools.check_test_speed --max-seconds 10` — verify unit tests run fast
|
||||||
5. `PYTHONPATH=src python3 scripts/ci/release.py --dry-run` — release dry-run validation
|
5. `PYTHONPATH=src python -m devx.ci.release --dry-run` — release dry-run validation
|
||||||
|
|
||||||
## Automated Release Pipeline
|
## Automated Release Pipeline
|
||||||
|
|
||||||
@@ -144,7 +144,7 @@ After a PR is merged to master, the release pipeline runs automatically.
|
|||||||
- Sets up full dev environment (`make setup`) so lint and tests can run
|
- Sets up full dev environment (`make setup`) so lint and tests can run
|
||||||
- Installs git-cliff (version 2.13.0)
|
- Installs git-cliff (version 2.13.0)
|
||||||
- Configures git as `grm-ci-bot`
|
- Configures git as `grm-ci-bot`
|
||||||
- Runs `scripts/ci/release.py` which uses **git-cliff** to:
|
- Runs `devx.ci.release` which uses **git-cliff** to:
|
||||||
- Calculate the next semver version from conventional commits since the last tag
|
- Calculate the next semver version from conventional commits since the last tag
|
||||||
- Update `__version__` in `src/gitea_runner_manager/__init__.py` (single source of truth)
|
- Update `__version__` in `src/gitea_runner_manager/__init__.py` (single source of truth)
|
||||||
- Update `CHANGELOG.md` with the new version section
|
- Update `CHANGELOG.md` with the new version section
|
||||||
@@ -155,7 +155,7 @@ After a PR is merged to master, the release pipeline runs automatically.
|
|||||||
- Push both the commit and tag to master
|
- Push both the commit and tag to master
|
||||||
- `--skip-tests` flag bypasses test verification (emergency use only, not recommended)
|
- `--skip-tests` flag bypasses test verification (emergency use only, not recommended)
|
||||||
- Loops are prevented by `has_unreleased_changes` — after a release commit is tagged, the next run finds no unreleased changes and exits
|
- Loops are prevented by `has_unreleased_changes` — after a release commit is tagged, the next run finds no unreleased changes and exits
|
||||||
- On failure, creates a Gitea issue via `scripts/ci/notify_failure.py`
|
- On failure, creates a Gitea issue via `devx.ci.notify_failure`
|
||||||
|
|
||||||
### Publish Workflow (`.gitea/workflows/publish.yml`)
|
### Publish Workflow (`.gitea/workflows/publish.yml`)
|
||||||
|
|
||||||
@@ -166,25 +166,25 @@ After a PR is merged to master, the release pipeline runs automatically.
|
|||||||
- Builds the Python package
|
- Builds the Python package
|
||||||
- Optionally publishes to PyPI (if `PYPI_TOKEN` is set)
|
- Optionally publishes to PyPI (if `PYPI_TOKEN` is set)
|
||||||
- Creates a Gitea release with git-cliff-generated release notes
|
- Creates a Gitea release with git-cliff-generated release notes
|
||||||
- Uses `scripts/ci/publish.py` for build and publish orchestration
|
- Uses `devx.ci.publish` for build and publish orchestration
|
||||||
- On failure, creates a Gitea issue via `scripts/ci/notify_failure.py`
|
- On failure, creates a Gitea issue via `devx.ci.notify_failure`
|
||||||
|
|
||||||
### Auto-Merge Workflow (`.gitea/workflows/auto-merge.yml`)
|
### Auto-Merge Workflow (`.gitea/workflows/auto-merge.yml`)
|
||||||
|
|
||||||
- Triggers on `pull_request` labeled events
|
- Triggers on `pull_request` labeled events
|
||||||
- Runs `scripts/ci/auto_merge.py` with the branch name, PR title, repository, PR number, and label name
|
- Runs `devx.ci.auto_merge` with the branch name, PR title, repository, PR number, and label name
|
||||||
- Validates PR title format, checks for APPROVE review, waits for CI, and squash-merges
|
- Validates PR title format, checks for APPROVE review, waits for CI, and squash-merges
|
||||||
|
|
||||||
### Post-Merge Workflow (`.gitea/workflows/post-merge.yml`)
|
### Post-Merge Workflow (`.gitea/workflows/post-merge.yml`)
|
||||||
|
|
||||||
- Triggers on push to `master`
|
- Triggers on push to `master`
|
||||||
- Runs `scripts/ci/post_merge.py` with the latest commit message and commit SHA
|
- Runs `devx.ci.post_merge` with the latest commit message and commit SHA
|
||||||
- Marks the corresponding Vikunja task as done
|
- Marks the corresponding Vikunja task as done
|
||||||
|
|
||||||
### Smart CI: User-Facing vs Workflow-Only Changes
|
### Smart CI: User-Facing vs Workflow-Only Changes
|
||||||
|
|
||||||
Not all changes require the full CI pipeline or a new release. The project uses
|
Not all changes require the full CI pipeline or a new release. The project uses
|
||||||
`scripts/ci/classify_changes.py` to classify changed files into two categories:
|
`devx.ci.classify_changes` to classify changed files into two categories:
|
||||||
|
|
||||||
**User-facing paths** (tool changes → release needed):
|
**User-facing paths** (tool changes → release needed):
|
||||||
- `src/gitea_runner_manager/**` — Python CLI source
|
- `src/gitea_runner_manager/**` — Python CLI source
|
||||||
@@ -192,28 +192,28 @@ Not all changes require the full CI pipeline or a new release. The project uses
|
|||||||
- `pyproject.toml` — Package metadata
|
- `pyproject.toml` — Package metadata
|
||||||
|
|
||||||
**Workflow-only paths** (infrastructure → no release needed):
|
**Workflow-only paths** (infrastructure → no release needed):
|
||||||
- `.gitea/workflows/**`, `scripts/**`, `docs/**`, `tests/**`
|
- `.gitea/workflows/**`, `docs/**`, `tests/**`
|
||||||
- `AGENTS.md`, `README.md`, `CHANGELOG.md`, `Makefile`, `cliff.toml`, etc.
|
- `AGENTS.md`, `README.md`, `CHANGELOG.md`, `Makefile`, `cliff.toml`, etc.
|
||||||
|
|
||||||
**CI behavior based on classification:**
|
**CI behavior based on classification:**
|
||||||
- **Molecule tests**: Only run when `ansible/` or `.ansible-lint` files change
|
- **Molecule tests**: Only run when `ansible/` or `.ansible-lint` files change
|
||||||
- **Release dry-run**: Only runs when user-facing files change (separate `release-dry-run` job)
|
- **Release dry-run**: Only runs when user-facing files change (separate `release-dry-run` job)
|
||||||
- **Quality job** (lint, unit tests, coverage, doc-coverage): Always runs
|
- **Quality job** (lint, unit tests, coverage, doc-coverage): Always runs
|
||||||
- **Release workflow**: `release.py` calls `classify_changes.py` to check if any
|
- **Release workflow**: `release.py` calls `classify_changes` to check if any
|
||||||
user-facing files changed since the last tag. If not, the release is skipped
|
user-facing files changed since the last tag. If not, the release is skipped
|
||||||
entirely — no version bump, no tag, no publish.
|
entirely — no version bump, no tag, no publish.
|
||||||
|
|
||||||
### Dynamic Runner Discovery
|
### Dynamic Runner Discovery
|
||||||
|
|
||||||
Molecule tests are distributed across available Gitea Actions runners
|
Molecule tests are distributed across available Gitea Actions runners
|
||||||
dynamically. The `discover-runners` job runs `scripts/ci/discover_runners.py` which queries the Gitea API for
|
dynamically. The `discover-runners` job runs `devx.molecule.discover_runners` which queries the Gitea API for
|
||||||
registered runners at three levels (repo, org, instance) and generates
|
registered runners at three levels (repo, org, instance) and generates
|
||||||
a matrix of runner indices. If the API query fails (e.g., no admin
|
a matrix of runner indices. If the API query fails (e.g., no admin
|
||||||
access for instance-level runners), it falls back to the
|
access for instance-level runners), it falls back to the
|
||||||
`MOLECULE_RUNNERS` repo variable, then to a default of 3.
|
`MOLECULE_RUNNERS` repo variable, then to a default of 3.
|
||||||
|
|
||||||
The `molecule-tests` job uses `fromJSON()` to consume the dynamic
|
The `molecule-tests` job uses `fromJSON()` to consume the dynamic
|
||||||
matrix, and passes the runner count to `distribute_molecule.py
|
matrix, and passes the runner count to `python -m devx.molecule.distribute_molecule
|
||||||
--max-runners` so test pairs are evenly distributed.
|
--max-runners` so test pairs are evenly distributed.
|
||||||
|
|
||||||
When adding or removing Gitea runners:
|
When adding or removing Gitea runners:
|
||||||
@@ -223,19 +223,19 @@ When adding or removing Gitea runners:
|
|||||||
|
|
||||||
### Molecule Test Distribution
|
### Molecule Test Distribution
|
||||||
|
|
||||||
`scripts/ci/distribute_molecule.py` discovers all molecule scenarios
|
`devx.molecule.distribute_molecule` discovers all molecule scenarios
|
||||||
under `ansible/roles/*/molecule/` and crosses them with the supported
|
under `ansible/roles/*/molecule/` and crosses them with the supported
|
||||||
OS platform matrix (defined in `scripts/ci/platforms.py`), then splits
|
OS platform matrix (defined in `devx.molecule.platforms`), then splits
|
||||||
the resulting test pairs evenly across the requested number of runners.
|
the resulting test pairs evenly across the requested number of runners.
|
||||||
Each pair is encoded as `scenario|platform_name|platform_image|platform_command`.
|
Each pair is encoded as `scenario|platform_name|platform_image|platform_command`.
|
||||||
|
|
||||||
`scripts/ci/molecule_ci_guard.py` runs the actual molecule test for a
|
`devx.molecule.molecule_ci_guard` runs the actual molecule test for a
|
||||||
given test pair, with CI context (Gitea URL, token, run ID) for
|
given test pair, with CI context (Gitea URL, token, run ID) for
|
||||||
reporting results back to the commit status API.
|
reporting results back to the commit status API.
|
||||||
|
|
||||||
### Commit Message Validation
|
### Commit Message Validation
|
||||||
|
|
||||||
`scripts/ci/validate_commit_msg.py` validates that commit messages
|
`devx.ci.validate_commit_msg` validates that commit messages
|
||||||
follow the conventional commit format (`feat:`, `fix:`, `docs:`, etc.).
|
follow the conventional commit format (`feat:`, `fix:`, `docs:`, etc.).
|
||||||
It is used by the pre-commit hook to enforce conventional commits on
|
It is used by the pre-commit hook to enforce conventional commits on
|
||||||
feature branches.
|
feature branches.
|
||||||
@@ -243,7 +243,7 @@ feature branches.
|
|||||||
### Release Commit Detection
|
### Release Commit Detection
|
||||||
|
|
||||||
The `detect-type` job in the post-merge workflow runs
|
The `detect-type` job in the post-merge workflow runs
|
||||||
`scripts/ci/detect_release_commit.py` to check whether the latest commit
|
`devx.ci.detect_release_commit` to check whether the latest commit
|
||||||
is a release commit (format: `release: vX.Y.Z`). When a release commit
|
is a release commit (format: `release: vX.Y.Z`). When a release commit
|
||||||
is detected, all post-merge jobs (release, sync-wiki, badges, vikunja)
|
is detected, all post-merge jobs (release, sync-wiki, badges, vikunja)
|
||||||
are skipped — the tag push triggers the publish workflow instead.
|
are skipped — the tag push triggers the publish workflow instead.
|
||||||
@@ -251,9 +251,9 @@ are skipped — the tag push triggers the publish workflow instead.
|
|||||||
### Badge Generation and Push
|
### Badge Generation and Push
|
||||||
|
|
||||||
The `badges` job in the post-merge workflow runs
|
The `badges` job in the post-merge workflow runs
|
||||||
`scripts/ci/push_badges.py` which:
|
`devx.ci.push_badges` which:
|
||||||
1. Fetches the latest master and hard-resets to it (picks up release commits)
|
1. Fetches the latest master and hard-resets to it (picks up release commits)
|
||||||
2. Generates quality badge SVG files via `scripts/generate_badges.py`
|
2. Generates quality badge SVG files via `devx.tools.generate_badges`
|
||||||
3. Creates an orphan `badges` branch
|
3. Creates an orphan `badges` branch
|
||||||
4. Copies SVG files to the branch root
|
4. Copies SVG files to the branch root
|
||||||
5. Force-pushes the branch to the remote
|
5. Force-pushes the branch to the remote
|
||||||
|
|||||||
@@ -55,9 +55,9 @@ Every change to master goes through this workflow. No exceptions.
|
|||||||
3. **Implement** — write code, tests (100% coverage), update docs
|
3. **Implement** — write code, tests (100% coverage), update docs
|
||||||
4. **Commit** — conventional commits (no `GRM-N:` prefix on branch)
|
4. **Commit** — conventional commits (no `GRM-N:` prefix on branch)
|
||||||
5. **Push & create PR** — title: `GRM-N: <vikunja task title>`, body: summary + `Closes GRM-N`
|
5. **Push & create PR** — title: `GRM-N: <vikunja task title>`, body: summary + `Closes GRM-N`
|
||||||
6. **Review** — review the full diff focusing on: functional completeness, edge cases, technical excellence (architecture, SRP, deduplication, code smells, best practices, code quality, reusability, clean code, readability, maintainability, extensibility), performance, security, UX, documentation completeness/relevance. Post review comments via `scripts/review_pr.py`.
|
6. **Review** — review the full diff focusing on: functional completeness, edge cases, technical excellence (architecture, SRP, deduplication, code smells, best practices, code quality, reusability, clean code, readability, maintainability, extensibility), performance, security, UX, documentation completeness/relevance. Post review comments via `devx.ci.review_pr`.
|
||||||
7. **Address comments** — fix each comment, commit, push, re-review
|
7. **Address comments** — fix each comment, commit, push, re-review
|
||||||
8. **Approve** — post an `APPROVE` review via `scripts/review_pr.py`
|
8. **Approve** — post an `APPROVE` review via `devx.ci.review_pr`
|
||||||
9. **Add `ready-to-merge` label** — auto-merge workflow squash-merges with title `GRM-N <conventional commit message>`, post-merge workflow marks the Vikunja task as done, release workflow automatically versions and tags
|
9. **Add `ready-to-merge` label** — auto-merge workflow squash-merges with title `GRM-N <conventional commit message>`, post-merge workflow marks the Vikunja task as done, release workflow automatically versions and tags
|
||||||
|
|
||||||
### Branch Protection (Required Gitea Settings)
|
### Branch Protection (Required Gitea Settings)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ Key technical decisions for the GRM project, extracted from `CHANGELOG.md` and `
|
|||||||
|
|
||||||
**Decision:** Use `dynamic = ["version"]` in `pyproject.toml` with setuptools `attr` to source the version from `__version__` in `src/gitea_runner_manager/__init__.py`.
|
**Decision:** Use `dynamic = ["version"]` in `pyproject.toml` with setuptools `attr` to source the version from `__version__` in `src/gitea_runner_manager/__init__.py`.
|
||||||
|
|
||||||
**Rationale:** `__init__.py` is the single source of truth for the version. The release script (`scripts/release.py`) only updates `__init__.py` — there is no need to touch `pyproject.toml`. `grm --version` reports this version directly. This eliminates version duplication across files and ensures the runtime version always matches the tagged release.
|
**Rationale:** `__init__.py` is the single source of truth for the version. The release script (`devx.ci.release`) only updates `__init__.py` — there is no need to touch `pyproject.toml`. `grm --version` reports this version directly. This eliminates version duplication across files and ensures the runtime version always matches the tagged release.
|
||||||
|
|
||||||
**Source:** `CHANGELOG.md` (Unreleased — Added), `AGENTS.md` (Version Bumping Rules)
|
**Source:** `CHANGELOG.md` (Unreleased — Added), `AGENTS.md` (Version Bumping Rules)
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ Key technical decisions for the GRM project, extracted from `CHANGELOG.md` and `
|
|||||||
|
|
||||||
**Decision:** Use conventional commits on feature branches and git-cliff (`cliff.toml`) to calculate the next semver version from commit history, generate the changelog, and automate releases.
|
**Decision:** Use conventional commits on feature branches and git-cliff (`cliff.toml`) to calculate the next semver version from commit history, generate the changelog, and automate releases.
|
||||||
|
|
||||||
**Rationale:** `scripts/release.py` uses git-cliff to calculate the next version from conventional commits since the last tag. Merge commits on master have the format `GRM-N <conventional commit>`, so `cliff.toml` includes a `commit_preprocessors` entry that strips the `GRM-N ` prefix before parsing. Version bumping rules: `feat:` → minor, `fix:` → patch, `feat!:`/`BREAKING CHANGE` → minor (pre-1.0), `chore:`/`ci:`/`docs:` → no bump. This fully automates versioning and changelog generation.
|
**Rationale:** `devx.ci.release` uses git-cliff to calculate the next version from conventional commits since the last tag. Merge commits on master have the format `GRM-N <conventional commit>`, so `cliff.toml` includes a `commit_preprocessors` entry that strips the `GRM-N ` prefix before parsing. Version bumping rules: `feat:` → minor, `fix:` → patch, `feat!:`/`BREAKING CHANGE` → minor (pre-1.0), `chore:`/`ci:`/`docs:` → no bump. This fully automates versioning and changelog generation.
|
||||||
|
|
||||||
**Source:** `CHANGELOG.md` (Unreleased — Added), `AGENTS.md` (Automated Release Pipeline, git-cliff Commit Preprocessing, Version Bumping Rules), `cliff.toml`
|
**Source:** `CHANGELOG.md` (Unreleased — Added), `AGENTS.md` (Automated Release Pipeline, git-cliff Commit Preprocessing, Version Bumping Rules), `cliff.toml`
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ Key technical decisions for the GRM project, extracted from `CHANGELOG.md` and `
|
|||||||
|
|
||||||
**Decision:** Require branch protection on `master` (require pull request, require approval review, require status checks, block force pushes) and use an auto-merge workflow that programmatically enforces the APPROVE review check.
|
**Decision:** Require branch protection on `master` (require pull request, require approval review, require status checks, block force pushes) and use an auto-merge workflow that programmatically enforces the APPROVE review check.
|
||||||
|
|
||||||
**Rationale:** Branch protection is the primary gate — no direct pushes to master, at least 1 APPROVE review before merge, CI quality + molecule tests must pass, and no history rewriting. The auto-merge workflow (`scripts/auto_merge.py`) enforces the APPROVE review check programmatically as a defense-in-depth measure. When the `ready-to-merge` label is added, the workflow validates PR title format, checks for APPROVE review, waits for CI, and squash-merges with title `GRM-N <conventional commit message>`. The post-merge workflow then marks the Vikunja task as done.
|
**Rationale:** Branch protection is the primary gate — no direct pushes to master, at least 1 APPROVE review before merge, CI quality + molecule tests must pass, and no history rewriting. The auto-merge workflow (`devx.ci.auto_merge`) enforces the APPROVE review check programmatically as a defense-in-depth measure. When the `ready-to-merge` label is added, the workflow validates PR title format, checks for APPROVE review, waits for CI, and squash-merges with title `GRM-N <conventional commit message>`. The post-merge workflow then marks the Vikunja task as done.
|
||||||
|
|
||||||
**Source:** `CHANGELOG.md` (Unreleased — Added: mandatory PR review step, auto_merge.py), `AGENTS.md` (Branch Protection, PR Workflow step 8)
|
**Source:** `CHANGELOG.md` (Unreleased — Added: mandatory PR review step, auto_merge.py), `AGENTS.md` (Branch Protection, PR Workflow step 8)
|
||||||
|
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ The `make setup` target (from the `Makefile`):
|
|||||||
- Installs/updates `pip`, `setuptools`, and `wheel`
|
- Installs/updates `pip`, `setuptools`, and `wheel`
|
||||||
- Creates `.env` from `.env.example` if not present
|
- Creates `.env` from `.env.example` if not present
|
||||||
- Generates shell activation scripts (`activate.sh`, `activate.fish`, `activate.zsh`)
|
- Generates shell activation scripts (`activate.sh`, `activate.fish`, `activate.zsh`)
|
||||||
- Installs `checkmake` via `scripts/install_checkmake.py`
|
- Installs `checkmake` via `devx.tools.install_checkmake`
|
||||||
- Runs `scripts/setup.sh` to install dependencies and hooks
|
- Runs `python -m devx.tools.setup` to install dependencies and hooks
|
||||||
|
|
||||||
### Developer Quick Start
|
### Developer Quick Start
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ Individual lint targets from the `Makefile`:
|
|||||||
| `lint-ruff` | `ruff check src/ tests/` |
|
| `lint-ruff` | `ruff check src/ tests/` |
|
||||||
| `lint-format` | `ruff format --check src/ tests/` |
|
| `lint-format` | `ruff format --check src/ tests/` |
|
||||||
| `typecheck` | `pyright` |
|
| `typecheck` | `pyright` |
|
||||||
| `lint-bandit` | `bandit -r src/ scripts/` |
|
| `lint-bandit` | `bandit -r src/` |
|
||||||
| `ansible-lint` | `ansible-lint ansible/` |
|
| `ansible-lint` | `ansible-lint ansible/` |
|
||||||
| `makefile-lint` | `checkmake Makefile` |
|
| `makefile-lint` | `checkmake Makefile` |
|
||||||
| `lint` | ruff + format check + pyright + bandit |
|
| `lint` | ruff + format check + pyright + bandit |
|
||||||
|
|||||||
@@ -37,13 +37,13 @@ All scenarios test idempotence (second run produces zero changes).
|
|||||||
|
|
||||||
4 platforms are tested: `ubuntu-2204`, `ubuntu-2404`, `debian-12`, `archlinux`.
|
4 platforms are tested: `ubuntu-2204`, `ubuntu-2404`, `debian-12`, `archlinux`.
|
||||||
|
|
||||||
The platform list is defined in `scripts/distribute_molecule.py` (single source of truth).
|
The platform list is defined in `devx.molecule.distribute_molecule` (single source of truth).
|
||||||
|
|
||||||
### CI Test Distribution
|
### CI Test Distribution
|
||||||
|
|
||||||
CI runs all 6 scenarios × 4 platforms (24 test pairs) distributed across 3 parallel runners.
|
CI runs all 6 scenarios × 4 platforms (24 test pairs) distributed across 3 parallel runners.
|
||||||
|
|
||||||
From `.gitea/workflows/ci.yml`, the `molecule-tests` job uses a matrix of `runner-index: [0, 1, 2]` and calls `scripts/distribute_molecule.py --runner-index <index> --max-runners 3` to discover assigned test pairs, then runs `scripts/molecule_ci_guard.py` with those pairs.
|
From `.gitea/workflows/ci.yml`, the `molecule-tests` job uses a matrix of `runner-index: [0, 1, 2]` and calls `python -m devx.molecule.distribute_molecule --runner-index <index> --max-runners 3` to discover assigned test pairs, then runs `python -m devx.molecule.molecule_ci_guard` with those pairs.
|
||||||
|
|
||||||
## Integration Tests
|
## Integration Tests
|
||||||
|
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ The test checks two things:
|
|||||||
| Vikunja task not updated after merge | VIKUNJA_TOKEN expired or task ID missing from commit | Regenerate token; verify merge commit has `GRM-N:` prefix |
|
| Vikunja task not updated after merge | VIKUNJA_TOKEN expired or task ID missing from commit | Regenerate token; verify merge commit has `GRM-N:` prefix |
|
||||||
| Post-merge can't find Vikunja task | Task not in project 6 or identifier mismatch | Verify task exists in Vikunja project 6 with correct identifier |
|
| Post-merge can't find Vikunja task | Task not in project 6 or identifier mismatch | Verify task exists in Vikunja project 6 with correct identifier |
|
||||||
| `make pytest-cov` fails | Coverage below 100% | Add tests for new code paths |
|
| `make pytest-cov` fails | Coverage below 100% | Add tests for new code paths |
|
||||||
| `scripts/configure_repo.py` fails | REPO_TOKEN missing or invalid | Set token with repo admin scope and re-run |
|
| `devx.tools.configure_repo` fails | REPO_TOKEN missing or invalid | Set token with repo admin scope and re-run |
|
||||||
| `configure_repo.py` sets wrong status checks | Stale `BRANCH_PROTECTION_CONFIG` | Updated to include `(pull_request)` suffix; re-run `configure_repo.py` |
|
| `configure_repo` sets wrong status checks | Stale `BRANCH_PROTECTION_CONFIG` | Updated to include `(pull_request)` suffix; re-run `configure_repo` |
|
||||||
| Token visible in `ps aux` during install | Old version passed tokens via command line | Fixed: tokens now passed via temp file with `0600` permissions |
|
| Token visible in `ps aux` during install | Old version passed tokens via command line | Fixed: tokens now passed via temp file with `0600` permissions |
|
||||||
| `remove-runner.yml` leaves lingering enabled | Old version didn't disable lingering | Fixed: now runs `loginctl disable-linger` and removes subuid/subgid |
|
| `remove-runner.yml` leaves lingering enabled | Old version didn't disable lingering | Fixed: now runs `loginctl disable-linger` and removes subuid/subgid |
|
||||||
| apt cache update always reports `changed` | `cache_valid_time: 0` forced update every run | Fixed: changed to `cache_valid_time: 3600` |
|
| apt cache update always reports `changed` | `cache_valid_time: 0` forced update every run | Fixed: changed to `cache_valid_time: 3600` |
|
||||||
|
|||||||
+1
-1
@@ -2,4 +2,4 @@
|
|||||||
# pre-commit hook: fail if unit tests take longer than 10 seconds.
|
# pre-commit hook: fail if unit tests take longer than 10 seconds.
|
||||||
# Aligned with CI timeout (ci.yml uses --max-seconds 10).
|
# Aligned with CI timeout (ci.yml uses --max-seconds 10).
|
||||||
set -e
|
set -e
|
||||||
python3 scripts/check_test_speed.py --max-seconds 10
|
python3 -m devx.tools.check_test_speed --max-seconds 10
|
||||||
|
|||||||
+1
-1
@@ -2,4 +2,4 @@
|
|||||||
# pre-push hook: fail if unit tests take longer than 10 seconds.
|
# pre-push hook: fail if unit tests take longer than 10 seconds.
|
||||||
# Aligned with CI timeout (ci.yml uses --max-seconds 10).
|
# Aligned with CI timeout (ci.yml uses --max-seconds 10).
|
||||||
set -e
|
set -e
|
||||||
python3 scripts/check_test_speed.py --max-seconds 10
|
python3 -m devx.tools.check_test_speed --max-seconds 10
|
||||||
|
|||||||
+34
-3
@@ -62,8 +62,8 @@ gitea_runner_manager = ["translations.json"]
|
|||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
pythonpath = ["src", "."]
|
pythonpath = ["src"]
|
||||||
addopts = "--cov=src/gitea_runner_manager --cov=scripts --cov-report=term-missing --cov-fail-under=100"
|
addopts = "--cov=src/gitea_runner_manager --cov-report=term-missing --cov-fail-under=100"
|
||||||
markers = [
|
markers = [
|
||||||
"integration: marks tests as integration tests (not counted in coverage)",
|
"integration: marks tests as integration tests (not counted in coverage)",
|
||||||
]
|
]
|
||||||
@@ -81,6 +81,37 @@ quote-style = "double"
|
|||||||
indent-style = "space"
|
indent-style = "space"
|
||||||
|
|
||||||
[tool.pyright]
|
[tool.pyright]
|
||||||
include = ["src", "scripts"]
|
include = ["src"]
|
||||||
pythonVersion = "3.12"
|
pythonVersion = "3.12"
|
||||||
strict = ["src/gitea_runner_manager"]
|
strict = ["src/gitea_runner_manager"]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Change classification — determines which changes trigger a release
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The framework provides DEFAULT_INFRASTRUCTURE (CI workflows, tests, docs,
|
||||||
|
# lint config, etc.) that applies to any Python project. We only specify
|
||||||
|
# what's different about GRM.
|
||||||
|
[tool.devx.classify]
|
||||||
|
# use_defaults = true # (default) merge with DEFAULT_INFRASTRUCTURE
|
||||||
|
|
||||||
|
# Project-specific infrastructure paths (merged with defaults).
|
||||||
|
# GRM has no additional infrastructure paths beyond the defaults.
|
||||||
|
infrastructure = []
|
||||||
|
|
||||||
|
# Infrastructure overrides — files that would default to user-facing
|
||||||
|
# but are actually infrastructure:
|
||||||
|
# - __init__.py: only contains __version__ (set by release.py, not user code)
|
||||||
|
# - api_clients.py: used only by tests and legacy CI scripts (now in devx),
|
||||||
|
# not by the grm CLI tool itself
|
||||||
|
infrastructure_overrides = [
|
||||||
|
"src/gitea_runner_manager/__init__.py",
|
||||||
|
"src/gitea_runner_manager/api_clients.py",
|
||||||
|
]
|
||||||
|
|
||||||
|
# User-facing overrides — safety override for broad infrastructure patterns
|
||||||
|
user_facing_overrides = []
|
||||||
|
|
||||||
|
# Tag patterns — additional categories for CI conditional execution
|
||||||
|
# Orthogonal to release impact (user-facing vs infrastructure)
|
||||||
|
[tool.devx.classify.tags]
|
||||||
|
ansible = ["ansible/**", ".ansible-lint"]
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Run unit tests and enforce a maximum execution-time budget.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python3 scripts/check_test_speed.py [--max-seconds N]
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
import subprocess # nosec B404
|
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
from scripts.i18n import _
|
|
||||||
|
|
||||||
DEFAULT_MAX_SECONDS = 2.0
|
|
||||||
TEST_COMMAND = ["make", "test-unit"]
|
|
||||||
_TIMING_RE = re.compile(r"(\d+) passed.* in ([0-9.]+)s")
|
|
||||||
|
|
||||||
|
|
||||||
def run_tests() -> tuple[str, str]:
|
|
||||||
"""Execute the unit-test suite and return (stdout, stderr)."""
|
|
||||||
result = subprocess.run( # nosec B603
|
|
||||||
TEST_COMMAND,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
return result.stdout, result.stderr
|
|
||||||
|
|
||||||
|
|
||||||
def parse_duration(output: str) -> float:
|
|
||||||
"""Extract elapsed seconds from pytest summary line.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
click.ClickException: when the timing line cannot be found.
|
|
||||||
"""
|
|
||||||
for line in output.splitlines():
|
|
||||||
match = _TIMING_RE.search(line)
|
|
||||||
if match:
|
|
||||||
return float(match.group(2))
|
|
||||||
raise click.ClickException(_("Could not parse test execution time from output."))
|
|
||||||
|
|
||||||
|
|
||||||
def check_speed(duration: float, max_seconds: float) -> None:
|
|
||||||
"""Validate duration is within budget; raise on violation."""
|
|
||||||
if duration > max_seconds:
|
|
||||||
raise click.ClickException(
|
|
||||||
_(
|
|
||||||
"Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n"
|
|
||||||
" Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n"
|
|
||||||
" Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.",
|
|
||||||
duration=duration,
|
|
||||||
max=max_seconds,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main(max_seconds: float) -> None:
|
|
||||||
"""Run tests, parse timing, and enforce the budget."""
|
|
||||||
stdout, stderr = run_tests()
|
|
||||||
combined = stdout + "\n" + stderr
|
|
||||||
click.echo(combined, err=False)
|
|
||||||
|
|
||||||
duration = parse_duration(combined)
|
|
||||||
check_speed(duration, max_seconds)
|
|
||||||
click.echo(
|
|
||||||
_(
|
|
||||||
"Unit tests passed in {duration:.2f}s (under {max}s limit).",
|
|
||||||
duration=duration,
|
|
||||||
max=max_seconds,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
|
||||||
@click.option(
|
|
||||||
"--max-seconds",
|
|
||||||
type=float,
|
|
||||||
default=DEFAULT_MAX_SECONDS,
|
|
||||||
show_default=True,
|
|
||||||
help="Maximum allowed execution time in seconds.",
|
|
||||||
)
|
|
||||||
def cli(max_seconds: float) -> None:
|
|
||||||
main(max_seconds)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
cli() # pragma: no cover
|
|
||||||
@@ -1,253 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Auto-merge PR when all CI checks pass.
|
|
||||||
|
|
||||||
Runs as the final job in ci.yml. Reads the task ID from ``.taskid`` file
|
|
||||||
(falling back to branch name extraction for backwards compatibility),
|
|
||||||
validates the PR title, and squash-merges with a conventional commit
|
|
||||||
message prefixed by the task ID.
|
|
||||||
|
|
||||||
PR title format: ``GRM-N: <vikunja task title>``
|
|
||||||
Merge commit format: ``GRM-N: <conventional commit message>``
|
|
||||||
|
|
||||||
The conventional commit message is extracted from the PR commits.
|
|
||||||
This allows the PR title to be a human-friendly Vikunja task title
|
|
||||||
while the squashed commit follows conventional commits.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
REPO_TOKEN=<token> python3 scripts/ci/auto_merge.py <branch> <pr_title> <repo> <pr_number>
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess # nosec B404
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import click
|
|
||||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
|
||||||
|
|
||||||
from gitea_runner_manager.api_clients import GiteaClient, VikunjaClient
|
|
||||||
from gitea_runner_manager.config import (
|
|
||||||
CONVENTIONAL_RE,
|
|
||||||
DEFAULT_PER_PAGE,
|
|
||||||
GITEA_API_URL,
|
|
||||||
TASK_ID_RE,
|
|
||||||
VIKUNJA_API_URL,
|
|
||||||
VIKUNJA_PROJECT_ID,
|
|
||||||
)
|
|
||||||
from gitea_runner_manager.exceptions import APIError
|
|
||||||
from scripts.i18n import _
|
|
||||||
|
|
||||||
TASKID_FILE = ".taskid"
|
|
||||||
PR_TITLE_RE = re.compile(r"^GRM-\d+:\s+.+")
|
|
||||||
|
|
||||||
load_dotenv(override=True)
|
|
||||||
|
|
||||||
|
|
||||||
def run_cmd(args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]:
|
|
||||||
"""Run a command and return the completed process."""
|
|
||||||
result = subprocess.run(args, capture_output=True, text=True, check=False) # nosec B603
|
|
||||||
if check and result.returncode != 0:
|
|
||||||
raise click.ClickException(
|
|
||||||
_(
|
|
||||||
"Command failed ({cmd}): {stderr}",
|
|
||||||
cmd=" ".join(args),
|
|
||||||
stderr=result.stderr.strip() or result.stdout.strip(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def read_taskid(branch: str) -> str:
|
|
||||||
"""Read task ID from .taskid file, falling back to branch name extraction.
|
|
||||||
|
|
||||||
The .taskid file is a simple text file containing just the task ID
|
|
||||||
(e.g., ``GRM-60``). If the file doesn't exist, extract from the
|
|
||||||
branch name as a backwards-compatibility fallback.
|
|
||||||
"""
|
|
||||||
path = Path(TASKID_FILE)
|
|
||||||
if path.exists():
|
|
||||||
task_id = path.read_text(encoding="utf-8").strip()
|
|
||||||
if task_id:
|
|
||||||
return task_id
|
|
||||||
# Fallback: extract from branch name
|
|
||||||
match = TASK_ID_RE.search(branch)
|
|
||||||
return match.group(0) if match else ""
|
|
||||||
|
|
||||||
|
|
||||||
def extract_task_id(branch: str) -> str:
|
|
||||||
"""Extract GRM-N task identifier from branch name (legacy fallback)."""
|
|
||||||
match = TASK_ID_RE.search(branch)
|
|
||||||
return match.group(0) if match else ""
|
|
||||||
|
|
||||||
|
|
||||||
def validate_pr_title(pr_title: str, task_id: str) -> None:
|
|
||||||
"""Raise ClickException if PR title does not follow the required format.
|
|
||||||
|
|
||||||
Expected: ``GRM-N: <vikunja task title>``
|
|
||||||
"""
|
|
||||||
if not PR_TITLE_RE.match(pr_title):
|
|
||||||
raise click.ClickException(
|
|
||||||
_(
|
|
||||||
"Oops! PR title must follow format 'GRM-N: <task title>'.\n"
|
|
||||||
" Expected: {task_id}: <task title>\n"
|
|
||||||
" Got: {pr_title}",
|
|
||||||
task_id=task_id,
|
|
||||||
pr_title=pr_title,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if not pr_title.startswith(f"{task_id}:"):
|
|
||||||
raise click.ClickException(
|
|
||||||
_(
|
|
||||||
"Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}",
|
|
||||||
task_id=task_id,
|
|
||||||
pr_title=pr_title,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_vikunja_task_title(task_id: str) -> str:
|
|
||||||
"""Fetch the Vikunja task title for the given GRM-N identifier.
|
|
||||||
|
|
||||||
Returns empty string if VIKUNJA_TOKEN is not set (local dev without token).
|
|
||||||
Raises ClickException if the token is set but the task is not found.
|
|
||||||
"""
|
|
||||||
token = os.environ.get("VIKUNJA_TOKEN", "")
|
|
||||||
if not token:
|
|
||||||
return ""
|
|
||||||
client = VikunjaClient(VIKUNJA_API_URL, token)
|
|
||||||
page = 1
|
|
||||||
while True:
|
|
||||||
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
|
|
||||||
if not tasks:
|
|
||||||
break
|
|
||||||
matches = [t for t in tasks if t.get("identifier") == task_id]
|
|
||||||
if matches:
|
|
||||||
return str(matches[0].get("title", ""))
|
|
||||||
if len(tasks) < DEFAULT_PER_PAGE:
|
|
||||||
break
|
|
||||||
page += 1
|
|
||||||
raise click.ClickException(
|
|
||||||
_(
|
|
||||||
"Could not find Vikunja task {task_id} in project {project_id}. "
|
|
||||||
"Every PR must have a corresponding Vikunja task.",
|
|
||||||
task_id=task_id,
|
|
||||||
project_id=VIKUNJA_PROJECT_ID,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_pr_title_matches_vikunja(pr_title: str, task_id: str) -> None:
|
|
||||||
"""Validate that PR title matches the Vikunja task title.
|
|
||||||
|
|
||||||
Skips validation if VIKUNJA_TOKEN is not set (local dev).
|
|
||||||
Raises ClickException if the task is not found or the title doesn't match.
|
|
||||||
"""
|
|
||||||
vikunja_title = get_vikunja_task_title(task_id)
|
|
||||||
if not vikunja_title:
|
|
||||||
# VIKUNJA_TOKEN not set — skip validation (local dev)
|
|
||||||
click.echo(_("Warning: VIKUNJA_TOKEN not set, skipping title match validation."))
|
|
||||||
return
|
|
||||||
expected = f"{task_id}: {vikunja_title}"
|
|
||||||
if pr_title != expected:
|
|
||||||
raise click.ClickException(
|
|
||||||
_(
|
|
||||||
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
|
|
||||||
expected=expected,
|
|
||||||
pr_title=pr_title,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
|
|
||||||
"""Extract the conventional commit message from PR commits.
|
|
||||||
|
|
||||||
Iterates commits in reverse order (newest first) to find the first
|
|
||||||
message matching the conventional commit format. Falls back to the
|
|
||||||
newest commit message if none match.
|
|
||||||
"""
|
|
||||||
for commit in reversed(commits):
|
|
||||||
commit_info = commit.get("commit", {})
|
|
||||||
message = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
|
|
||||||
if CONVENTIONAL_RE.match(message):
|
|
||||||
return message
|
|
||||||
# Fallback: use the newest commit's first line
|
|
||||||
if commits:
|
|
||||||
commit_info = commits[-1].get("commit", {})
|
|
||||||
return str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
|
||||||
@click.argument("branch")
|
|
||||||
@click.argument("pr_title")
|
|
||||||
@click.argument("repo")
|
|
||||||
@click.argument("pr_number")
|
|
||||||
def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
|
|
||||||
token = os.environ.get("REPO_TOKEN", "")
|
|
||||||
if not token:
|
|
||||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
|
||||||
|
|
||||||
owner, repo_name = repo.split("/")
|
|
||||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
|
||||||
|
|
||||||
task_id = read_taskid(branch)
|
|
||||||
if not task_id:
|
|
||||||
raise click.ClickException(
|
|
||||||
_(
|
|
||||||
"Oops! No task ID found in .taskid file or branch name '{branch}'.",
|
|
||||||
branch=branch,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
click.echo(_("Task ID: {task_id}", task_id=task_id))
|
|
||||||
|
|
||||||
validate_pr_title(pr_title, task_id)
|
|
||||||
validate_pr_title_matches_vikunja(pr_title, task_id)
|
|
||||||
|
|
||||||
# Build merge title: GRM-N: <conventional commit message>
|
|
||||||
commits = client.get_pr_commits(pr_number)
|
|
||||||
conv_msg = extract_conventional_msg(commits)
|
|
||||||
if not conv_msg:
|
|
||||||
raise click.ClickException(_("Could not extract conventional commit message from PR commits."))
|
|
||||||
merge_title = f"{task_id}: {conv_msg}"
|
|
||||||
|
|
||||||
try:
|
|
||||||
client.merge_pr(pr_number, merge_title)
|
|
||||||
except APIError as e:
|
|
||||||
if e.status == 405 and "behind" in e.message.lower():
|
|
||||||
# Head branch is behind master — pull master and rebase, then retry
|
|
||||||
click.echo(_("Head branch is behind master. Pulling and rebasing..."))
|
|
||||||
try:
|
|
||||||
run_cmd(["git", "fetch", "origin", "master"])
|
|
||||||
run_cmd(["git", "rebase", "origin/master"])
|
|
||||||
run_cmd(["git", "push", "--force-with-lease"])
|
|
||||||
click.echo(_("Rebased and pushed. Retrying merge..."))
|
|
||||||
client.merge_pr(pr_number, merge_title)
|
|
||||||
except (APIError, Exception) as retry_err:
|
|
||||||
raise click.ClickException(
|
|
||||||
_(
|
|
||||||
"Merge failed after rebase retry: {error}\nPlease rebase the PR manually.",
|
|
||||||
error=str(retry_err),
|
|
||||||
)
|
|
||||||
) from None
|
|
||||||
else:
|
|
||||||
raise click.ClickException(
|
|
||||||
_(
|
|
||||||
"Merge failed with HTTP {status}: {message}\n"
|
|
||||||
"Please check the PR is ready and you have merge rights.",
|
|
||||||
status=e.status,
|
|
||||||
message=e.message,
|
|
||||||
)
|
|
||||||
) from None
|
|
||||||
|
|
||||||
click.echo(
|
|
||||||
_(
|
|
||||||
"Nice! PR #{pr_number} squash-merged with title: {merge_title}",
|
|
||||||
pr_number=pr_number,
|
|
||||||
merge_title=merge_title,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
main()
|
|
||||||
@@ -1,198 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Check translation files for gaps, dead keys, and missing languages.
|
|
||||||
|
|
||||||
Validates two separate translation sets:
|
|
||||||
1. GRM tool translations: ``src/gitea_runner_manager/translations.json``
|
|
||||||
— keys used by ``src/gitea_runner_manager/*.py``
|
|
||||||
2. CI/dev tool translations: ``scripts/translations.json``
|
|
||||||
— keys used by ``scripts/**/*.py``
|
|
||||||
|
|
||||||
Checks performed (all fail with exit code 1 on error):
|
|
||||||
- **Missing keys**: a ``_()`` call in code has no entry in the corresponding
|
|
||||||
translations file.
|
|
||||||
- **Dead keys**: a key in a translations file is not used in any code.
|
|
||||||
- **Missing languages**: a key exists but is missing one of the 5 supported
|
|
||||||
languages (en, bg, de, ru, zh). Reported as a warning, not an error.
|
|
||||||
|
|
||||||
Usage::
|
|
||||||
|
|
||||||
python3 scripts/ci/check_translations.py
|
|
||||||
python3 scripts/ci/check_translations.py --strict # warnings are errors
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import ast
|
|
||||||
import json
|
|
||||||
import sys
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
||||||
|
|
||||||
SUPPORTED_LANGS = ("en", "bg", "de", "ru", "zh")
|
|
||||||
|
|
||||||
GRM_SRC_DIR = REPO_ROOT / "src" / "gitea_runner_manager"
|
|
||||||
GRM_TRANS_FILE = GRM_SRC_DIR / "translations.json"
|
|
||||||
|
|
||||||
CI_SRC_DIR = REPO_ROOT / "scripts"
|
|
||||||
CI_TRANS_FILE = CI_SRC_DIR / "translations.json"
|
|
||||||
|
|
||||||
# Functions that wrap _() and receive a translation key as first arg.
|
|
||||||
# Their string-literal arguments should be treated as translation keys.
|
|
||||||
_I18N_WRAPPERS = {"_handle_errors"}
|
|
||||||
|
|
||||||
# Known dynamic keys used via _(variable) that can't be detected by AST.
|
|
||||||
# These are status strings set as variable values and passed to _().
|
|
||||||
DYNAMIC_KEYS = {"completed", "pending", "in_progress", "failed", "active", "inactive", "unknown"}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class TranslationCheckResult:
|
|
||||||
"""Result of a translation check for one translation set."""
|
|
||||||
|
|
||||||
name: str
|
|
||||||
src_dir: Path
|
|
||||||
trans_file: Path
|
|
||||||
used_keys: set[str] = field(default_factory=set)
|
|
||||||
defined_keys: set[str] = field(default_factory=set)
|
|
||||||
missing_keys: set[str] = field(default_factory=set)
|
|
||||||
dead_keys: set[str] = field(default_factory=set)
|
|
||||||
missing_langs: dict[str, list[str]] = field(default_factory=dict)
|
|
||||||
errors: list[str] = field(default_factory=list)
|
|
||||||
warnings: list[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
|
|
||||||
def extract_keys(filepath: Path) -> set[str]:
|
|
||||||
"""Extract translation keys from a Python file using AST.
|
|
||||||
|
|
||||||
Detects:
|
|
||||||
- ``_("key")`` calls with string-literal first argument
|
|
||||||
- ``_handle_errors("key")`` and other wrapper calls (see ``_I18N_WRAPPERS``)
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
tree = ast.parse(filepath.read_text(encoding="utf-8"), filename=str(filepath))
|
|
||||||
except SyntaxError:
|
|
||||||
return set()
|
|
||||||
keys: set[str] = set()
|
|
||||||
for node in ast.walk(tree):
|
|
||||||
if isinstance(node, ast.Call):
|
|
||||||
func = node.func
|
|
||||||
if (
|
|
||||||
isinstance(func, ast.Name)
|
|
||||||
and func.id in ("_", *_I18N_WRAPPERS)
|
|
||||||
and node.args
|
|
||||||
and isinstance(node.args[0], ast.Constant)
|
|
||||||
and isinstance(node.args[0].value, str)
|
|
||||||
):
|
|
||||||
keys.add(node.args[0].value)
|
|
||||||
return keys
|
|
||||||
|
|
||||||
|
|
||||||
def collect_keys(src_dir: Path) -> set[str]:
|
|
||||||
"""Collect all translation keys from .py files in a directory tree.
|
|
||||||
|
|
||||||
Also includes known dynamic keys (see ``DYNAMIC_KEYS``) that are used
|
|
||||||
via ``_(variable)`` and can't be detected by AST scanning.
|
|
||||||
"""
|
|
||||||
keys: set[str] = set()
|
|
||||||
for pyfile in src_dir.rglob("*.py"):
|
|
||||||
if pyfile.name == "i18n.py":
|
|
||||||
continue
|
|
||||||
keys |= extract_keys(pyfile)
|
|
||||||
# Add dynamic keys for the GRM source directory
|
|
||||||
if src_dir == GRM_SRC_DIR:
|
|
||||||
keys |= DYNAMIC_KEYS
|
|
||||||
return keys
|
|
||||||
|
|
||||||
|
|
||||||
def check_translation_set(name: str, src_dir: Path, trans_file: Path) -> TranslationCheckResult:
|
|
||||||
"""Check one translation set for gaps and dead keys."""
|
|
||||||
result = TranslationCheckResult(name=name, src_dir=src_dir, trans_file=trans_file)
|
|
||||||
|
|
||||||
# Collect used keys from source code
|
|
||||||
result.used_keys = collect_keys(src_dir)
|
|
||||||
|
|
||||||
# Load defined keys from translations file
|
|
||||||
if not trans_file.exists():
|
|
||||||
result.errors.append(f"Translations file not found: {trans_file}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
translations = json.loads(trans_file.read_text(encoding="utf-8"))
|
|
||||||
result.defined_keys = set(translations.keys())
|
|
||||||
|
|
||||||
# Check for missing keys (used in code but not in translations)
|
|
||||||
result.missing_keys = result.used_keys - result.defined_keys
|
|
||||||
for key in sorted(result.missing_keys):
|
|
||||||
result.errors.append(f"Missing key in {name}: {key!r}")
|
|
||||||
|
|
||||||
# Check for dead keys (in translations but not used in code)
|
|
||||||
result.dead_keys = result.defined_keys - result.used_keys
|
|
||||||
for key in sorted(result.dead_keys):
|
|
||||||
result.warnings.append(f"Dead key in {name}: {key!r}")
|
|
||||||
|
|
||||||
# Check for missing languages
|
|
||||||
for key, langs in translations.items():
|
|
||||||
missing = [lang for lang in SUPPORTED_LANGS if lang not in langs]
|
|
||||||
if missing:
|
|
||||||
result.missing_langs[key] = missing
|
|
||||||
result.warnings.append(f"Missing languages {missing} for key {key!r} in {name}")
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def print_result(result: TranslationCheckResult) -> None:
|
|
||||||
"""Print check results in a human-readable format."""
|
|
||||||
click.echo(f"\n=== {result.name} ===")
|
|
||||||
click.echo(f" Source dir: {result.src_dir}")
|
|
||||||
click.echo(f" Translations: {result.trans_file}")
|
|
||||||
click.echo(f" Used keys: {len(result.used_keys)}")
|
|
||||||
click.echo(f" Defined keys: {len(result.defined_keys)}")
|
|
||||||
click.echo(f" Missing keys: {len(result.missing_keys)}")
|
|
||||||
click.echo(f" Dead keys: {len(result.dead_keys)}")
|
|
||||||
click.echo(f" Missing langs: {len(result.missing_langs)} keys")
|
|
||||||
|
|
||||||
for err in result.errors:
|
|
||||||
click.echo(f" ERROR: {err}", err=True)
|
|
||||||
for warn in result.warnings:
|
|
||||||
click.echo(f" WARN: {warn}", err=True)
|
|
||||||
|
|
||||||
if not result.errors and not result.warnings:
|
|
||||||
click.echo(" All good!")
|
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
|
||||||
@click.option("--strict", is_flag=True, default=False, help="Treat warnings as errors.")
|
|
||||||
def main(strict: bool) -> None:
|
|
||||||
"""Check translation files for gaps, dead keys, and missing languages."""
|
|
||||||
results = [
|
|
||||||
check_translation_set("GRM tool", GRM_SRC_DIR, GRM_TRANS_FILE),
|
|
||||||
check_translation_set("CI/dev tools", CI_SRC_DIR, CI_TRANS_FILE),
|
|
||||||
]
|
|
||||||
|
|
||||||
has_errors = False
|
|
||||||
has_warnings = False
|
|
||||||
for result in results:
|
|
||||||
print_result(result)
|
|
||||||
if result.errors:
|
|
||||||
has_errors = True
|
|
||||||
if result.warnings:
|
|
||||||
has_warnings = True
|
|
||||||
|
|
||||||
click.echo()
|
|
||||||
if has_errors:
|
|
||||||
click.echo("FAIL: Translation check found errors.", err=True)
|
|
||||||
sys.exit(1)
|
|
||||||
if strict and has_warnings:
|
|
||||||
click.echo("FAIL: Translation check found warnings (--strict mode).", err=True)
|
|
||||||
sys.exit(1)
|
|
||||||
if has_warnings:
|
|
||||||
click.echo("PASS with warnings: Translation check passed (warnings present).")
|
|
||||||
else:
|
|
||||||
click.echo("PASS: All translations are complete and up to date.")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
main() # pragma: no cover
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Classify git changes as user-facing or workflow-only.
|
|
||||||
|
|
||||||
Determines whether changes between two git refs (e.g., last tag and HEAD)
|
|
||||||
affect the GRM tool itself (user-facing) or only the CI/CD infrastructure
|
|
||||||
(workflow-only). This is used by:
|
|
||||||
|
|
||||||
- **release.py** — skips release when only workflow files changed
|
|
||||||
- **CI workflow** — skips molecule tests and release dry-run when only
|
|
||||||
workflow files changed
|
|
||||||
|
|
||||||
Classification strategy (safe-by-default):
|
|
||||||
|
|
||||||
Any file that is NOT in the explicit workflow-only allowlist is treated
|
|
||||||
as user-facing. This ensures new file types default to requiring a
|
|
||||||
release rather than silently skipping it.
|
|
||||||
|
|
||||||
Workflow-only paths (infrastructure → no release needed):
|
|
||||||
- .gitea/workflows/** — Gitea Actions workflows
|
|
||||||
- scripts/** — All scripts (CI/CD, dev tools, setup)
|
|
||||||
- src/gitea_runner_manager/__init__.py — Version file (release artifact)
|
|
||||||
- src/gitea_runner_manager/api_clients.py — Gitea API client (CI/CD only, not used by CLI)
|
|
||||||
- docs/** — Documentation
|
|
||||||
- tests/** — Test files
|
|
||||||
- hooks/** — Git hooks
|
|
||||||
- AGENTS.md — Agent conventions
|
|
||||||
- README.md — README (lean, links to wiki)
|
|
||||||
- CHANGELOG.md — Changelog (generated)
|
|
||||||
- TROUBLESHOOTING.md — Troubleshooting guide
|
|
||||||
- cliff.toml — git-cliff config
|
|
||||||
- Makefile — Build automation
|
|
||||||
- .pre-commit-config.yaml — Pre-commit config
|
|
||||||
- .ansible-lint — Ansible lint config
|
|
||||||
- .env.example — Environment template
|
|
||||||
- .gitignore — Git ignore rules
|
|
||||||
- .ruff.toml — Ruff config (if separate)
|
|
||||||
- .github/** — GitHub config (if present)
|
|
||||||
|
|
||||||
Everything else is user-facing (tool changes → release needed),
|
|
||||||
including but not limited to:
|
|
||||||
- src/gitea_runner_manager/*.py — Python CLI source (except __init__.py)
|
|
||||||
- ansible/** — Ansible role
|
|
||||||
- pyproject.toml — Package metadata
|
|
||||||
- Any new file type not in the allowlist
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python3 scripts/ci/classify_changes.py [--base <ref>] [--head <ref>]
|
|
||||||
python3 scripts/ci/classify_changes.py --base v0.3.0 --head HEAD
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import subprocess # nosec B404
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
from scripts.i18n import _
|
|
||||||
|
|
||||||
# Explicit allowlist of workflow-only path patterns.
|
|
||||||
# Anything NOT matching these is treated as user-facing (safe default).
|
|
||||||
WORKFLOW_ONLY_PATTERNS = frozenset(
|
|
||||||
[
|
|
||||||
# CI/CD infrastructure
|
|
||||||
".gitea/",
|
|
||||||
# All scripts are infrastructure (CI/CD, dev tools, setup)
|
|
||||||
# User-facing code lives in src/gitea_runner_manager/
|
|
||||||
"scripts/",
|
|
||||||
# Version file — only contains __version__, not user-facing code.
|
|
||||||
# Version bumps are a release artifact, not a feature.
|
|
||||||
"src/gitea_runner_manager/__init__.py",
|
|
||||||
# Gitea API client — used only by CI/CD scripts, not by the GRM CLI.
|
|
||||||
"src/gitea_runner_manager/api_clients.py",
|
|
||||||
# Documentation
|
|
||||||
"docs/",
|
|
||||||
"AGENTS.md",
|
|
||||||
"README.md",
|
|
||||||
"CHANGELOG.md",
|
|
||||||
"TROUBLESHOOTING.md",
|
|
||||||
# Tests
|
|
||||||
"tests/",
|
|
||||||
# Config / build automation
|
|
||||||
"cliff.toml",
|
|
||||||
"Makefile",
|
|
||||||
".pre-commit-config.yaml",
|
|
||||||
".ansible-lint",
|
|
||||||
".env.example",
|
|
||||||
".gitignore",
|
|
||||||
".ruff.toml",
|
|
||||||
# Hooks
|
|
||||||
"hooks/",
|
|
||||||
# GitHub (if ever added)
|
|
||||||
".github/",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def run_git(args: list[str]) -> str:
|
|
||||||
"""Run a git command and return stdout."""
|
|
||||||
result = subprocess.run( # nosec B603
|
|
||||||
args,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise click.ClickException(
|
|
||||||
_("git command failed ({cmd}): {stderr}", cmd=" ".join(args), stderr=result.stderr.strip())
|
|
||||||
)
|
|
||||||
return result.stdout.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def get_changed_files(base: str, head: str) -> list[str]:
|
|
||||||
"""Get list of files changed between base and head refs."""
|
|
||||||
output = run_git(["git", "diff", "--name-only", base, head])
|
|
||||||
if not output:
|
|
||||||
return []
|
|
||||||
return output.split("\n")
|
|
||||||
|
|
||||||
|
|
||||||
def is_workflow_only(file_path: str) -> bool:
|
|
||||||
"""Check if a file path is workflow-only (infrastructure, not the tool itself).
|
|
||||||
|
|
||||||
Uses an explicit allowlist — anything not in the list is treated as
|
|
||||||
user-facing (safe default that prevents accidental release skips).
|
|
||||||
"""
|
|
||||||
return any(file_path.startswith(pattern) or file_path == pattern for pattern in WORKFLOW_ONLY_PATTERNS)
|
|
||||||
|
|
||||||
|
|
||||||
def is_user_facing(file_path: str) -> bool:
|
|
||||||
"""Check if a file path is user-facing (affects the GRM tool).
|
|
||||||
|
|
||||||
Inverse of is_workflow_only — anything not explicitly workflow-only
|
|
||||||
is treated as user-facing.
|
|
||||||
"""
|
|
||||||
return not is_workflow_only(file_path)
|
|
||||||
|
|
||||||
|
|
||||||
def classify_changes(files: list[str]) -> dict[str, list[str]]:
|
|
||||||
"""Classify changed files into user-facing and workflow-only.
|
|
||||||
|
|
||||||
Returns a dict with keys "user_facing" and "workflow_only".
|
|
||||||
"""
|
|
||||||
user_facing: list[str] = []
|
|
||||||
workflow_only: list[str] = []
|
|
||||||
for f in files:
|
|
||||||
if is_user_facing(f):
|
|
||||||
user_facing.append(f)
|
|
||||||
else:
|
|
||||||
workflow_only.append(f)
|
|
||||||
return {"user_facing": user_facing, "workflow_only": workflow_only}
|
|
||||||
|
|
||||||
|
|
||||||
def has_user_facing_changes(base: str, head: str) -> bool:
|
|
||||||
"""Check if any user-facing files changed between base and head.
|
|
||||||
|
|
||||||
Imported by ``scripts/ci/release.py`` to decide whether a release
|
|
||||||
is needed. This is a cross-CI import that requires ``PYTHONPATH=.``.
|
|
||||||
"""
|
|
||||||
files = get_changed_files(base, head)
|
|
||||||
return any(is_user_facing(f) for f in files)
|
|
||||||
|
|
||||||
|
|
||||||
def get_latest_tag() -> str:
|
|
||||||
"""Get the latest git tag, or empty string if none exists."""
|
|
||||||
result = subprocess.run( # nosec B603 B607
|
|
||||||
["git", "describe", "--tags", "--abbrev=0"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return ""
|
|
||||||
return result.stdout.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _write_github_output(key: str, value: str) -> None:
|
|
||||||
"""Append a key=value line to the $GITHUB_OUTPUT file."""
|
|
||||||
import os
|
|
||||||
|
|
||||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
|
||||||
if not gh_output:
|
|
||||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
|
||||||
with open(gh_output, "a") as f: # noqa: PTH123
|
|
||||||
f.write(f"{key}={value}\n")
|
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
|
||||||
@click.option("--base", default=None, help="Base ref (default: latest tag).")
|
|
||||||
@click.option("--head", default="HEAD", help="Head ref (default: HEAD).")
|
|
||||||
@click.option("--quiet", is_flag=True, default=False, help="Only output true/false.")
|
|
||||||
@click.option(
|
|
||||||
"--check",
|
|
||||||
type=click.Choice(["all", "ansible", "user-facing"]),
|
|
||||||
default="all",
|
|
||||||
help="Check specific category: all (default), ansible, or user-facing.",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--github-output",
|
|
||||||
"github_output",
|
|
||||||
is_flag=True,
|
|
||||||
default=False,
|
|
||||||
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps).",
|
|
||||||
)
|
|
||||||
def main(base: str | None, head: str, quiet: bool, check: str, github_output: bool) -> None:
|
|
||||||
if base is None:
|
|
||||||
base = get_latest_tag()
|
|
||||||
if not base:
|
|
||||||
if github_output:
|
|
||||||
_write_github_output("ansible-changed", "true")
|
|
||||||
_write_github_output("user-facing-changed", "true")
|
|
||||||
click.echo("No tags found — treating all changes as user-facing.")
|
|
||||||
return
|
|
||||||
if quiet:
|
|
||||||
click.echo("true")
|
|
||||||
else:
|
|
||||||
click.echo(_("No tags found — treating all changes as user-facing."))
|
|
||||||
return
|
|
||||||
|
|
||||||
files = get_changed_files(base, head)
|
|
||||||
if not files:
|
|
||||||
if github_output:
|
|
||||||
_write_github_output("ansible-changed", "false")
|
|
||||||
_write_github_output("user-facing-changed", "false")
|
|
||||||
click.echo(f"No changes between {base} and {head}.")
|
|
||||||
return
|
|
||||||
if quiet:
|
|
||||||
click.echo("false")
|
|
||||||
else:
|
|
||||||
click.echo(_("No changes between {base} and {head}.", base=base, head=head))
|
|
||||||
return
|
|
||||||
|
|
||||||
if github_output:
|
|
||||||
ansible_files = [f for f in files if f.startswith("ansible/") or f == ".ansible-lint"]
|
|
||||||
user_files = [f for f in files if is_user_facing(f)]
|
|
||||||
_write_github_output("ansible-changed", "true" if ansible_files else "false")
|
|
||||||
_write_github_output("user-facing-changed", "true" if user_files else "false")
|
|
||||||
click.echo(f"Ansible files changed: {bool(ansible_files)}")
|
|
||||||
click.echo(f"User-facing files changed: {bool(user_files)}")
|
|
||||||
return
|
|
||||||
|
|
||||||
if check == "ansible":
|
|
||||||
# Check only for Ansible-related file changes
|
|
||||||
ansible_files = [f for f in files if f.startswith("ansible/") or f == ".ansible-lint"]
|
|
||||||
has_ansible = bool(ansible_files)
|
|
||||||
if quiet:
|
|
||||||
click.echo("true" if has_ansible else "false")
|
|
||||||
return
|
|
||||||
click.echo(_("\nAnsible files changed ({count}):", count=len(ansible_files)))
|
|
||||||
for f in ansible_files:
|
|
||||||
click.echo(f" {f}")
|
|
||||||
click.echo(_("\nResult: {status}", status="Ansible changes detected" if has_ansible else "No Ansible changes"))
|
|
||||||
return
|
|
||||||
|
|
||||||
if check == "user-facing":
|
|
||||||
# Check only for user-facing file changes (inverse of workflow-only)
|
|
||||||
user_files = [f for f in files if is_user_facing(f)]
|
|
||||||
has_user = bool(user_files)
|
|
||||||
if quiet:
|
|
||||||
click.echo("true" if has_user else "false")
|
|
||||||
return
|
|
||||||
click.echo(_("\nUser-facing files changed ({count}):", count=len(user_files)))
|
|
||||||
for f in user_files:
|
|
||||||
click.echo(f" {f}")
|
|
||||||
click.echo(
|
|
||||||
_("\nResult: {status}", status="User-facing changes detected" if has_user else "No user-facing changes")
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
result = classify_changes(files)
|
|
||||||
has_user = bool(result["user_facing"])
|
|
||||||
|
|
||||||
if quiet:
|
|
||||||
click.echo("true" if has_user else "false")
|
|
||||||
return
|
|
||||||
|
|
||||||
click.echo(_("Comparing {base}..{head} ({count} files changed)", base=base, head=head, count=len(files)))
|
|
||||||
click.echo(_("\nUser-facing changes ({count}):", count=len(result["user_facing"])))
|
|
||||||
for f in result["user_facing"]:
|
|
||||||
click.echo(f" {f}")
|
|
||||||
click.echo(_("\nWorkflow-only changes ({count}):", count=len(result["workflow_only"])))
|
|
||||||
for f in result["workflow_only"]:
|
|
||||||
click.echo(f" {f}")
|
|
||||||
if has_user:
|
|
||||||
status = "USER-FACING changes detected — release needed"
|
|
||||||
else:
|
|
||||||
status = "Workflow-only changes — no release needed"
|
|
||||||
click.echo(_("\nResult: {status}", status=status))
|
|
||||||
|
|
||||||
if not has_user:
|
|
||||||
sys.exit(2) # Exit code 2 = workflow-only (used by CI to skip release)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
main()
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Detect whether the latest git commit is a release commit.
|
|
||||||
|
|
||||||
Release commits have the format ``release: vX.Y.Z [skip ci]``.
|
|
||||||
This script writes ``is-release=true`` or ``is-release=false`` to
|
|
||||||
``$GITHUB_OUTPUT`` for use in CI workflow conditionals.
|
|
||||||
|
|
||||||
Usage::
|
|
||||||
|
|
||||||
python3 scripts/ci/detect_release_commit.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess # nosec B404
|
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
RELEASE_RE = re.compile(r"^release: v\d+\.\d+\.\d+")
|
|
||||||
|
|
||||||
|
|
||||||
def get_commit_message() -> str:
|
|
||||||
"""Get the subject of the latest git commit."""
|
|
||||||
result = subprocess.run( # nosec B603 B607
|
|
||||||
["git", "log", "-1", "--pretty=%s"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise click.ClickException(f"git log failed: {result.stderr.strip()}")
|
|
||||||
return result.stdout.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def is_release_commit(message: str) -> bool:
|
|
||||||
"""Check if a commit message matches the release commit format."""
|
|
||||||
return bool(RELEASE_RE.match(message))
|
|
||||||
|
|
||||||
|
|
||||||
def write_github_output(key: str, value: str) -> None:
|
|
||||||
"""Append a key=value line to the $GITHUB_OUTPUT file."""
|
|
||||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
|
||||||
if not gh_output:
|
|
||||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
|
||||||
with open(gh_output, "a") as f: # noqa: PTH123
|
|
||||||
f.write(f"{key}={value}\n")
|
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
|
||||||
def main() -> None:
|
|
||||||
"""Detect if the latest commit is a release commit and set GITHUB_OUTPUT."""
|
|
||||||
msg = get_commit_message()
|
|
||||||
click.echo(f"Commit message: {msg}")
|
|
||||||
is_release = is_release_commit(msg)
|
|
||||||
write_github_output("is-release", "true" if is_release else "false")
|
|
||||||
if is_release:
|
|
||||||
click.echo("Release commit — skipping all post-merge jobs.")
|
|
||||||
else:
|
|
||||||
click.echo("Regular merge commit — running all post-merge jobs.")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
main() # pragma: no cover
|
|
||||||
@@ -1,180 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Discover available Gitea Actions runners for dynamic job distribution.
|
|
||||||
|
|
||||||
Queries the Gitea API for registered runners at three levels:
|
|
||||||
1. Repository level: GET /repos/{owner}/{repo}/actions/runners
|
|
||||||
2. Organization level: GET /orgs/{org}/actions/runners
|
|
||||||
3. Instance (admin) level: GET /admin/actions/runners
|
|
||||||
|
|
||||||
Falls back to the ``MOLECULE_RUNNERS`` repo variable or environment
|
|
||||||
variable, then to ``DEFAULT_MAX_RUNNERS`` (3).
|
|
||||||
|
|
||||||
Outputs:
|
|
||||||
- ``--count``: prints the number of available runners
|
|
||||||
- ``--indices``: prints a JSON array [0, 1, ..., N-1] for use as a
|
|
||||||
dynamic matrix in Gitea Actions
|
|
||||||
- (default): prints both as ``count=N`` and ``indices=[0,1,...]``
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python3 scripts/ci/discover_runners.py --owner oblachno-oss --repo grm
|
|
||||||
python3 scripts/ci/discover_runners.py --indices
|
|
||||||
python3 scripts/ci/discover_runners.py --count
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
|
|
||||||
import click
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from gitea_runner_manager.config import GITEA_API_URL
|
|
||||||
|
|
||||||
DEFAULT_MAX_RUNNERS = 3
|
|
||||||
|
|
||||||
|
|
||||||
def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
|
|
||||||
"""Query the Gitea API for registered runners at all levels.
|
|
||||||
|
|
||||||
Returns the total count of active runners. If the API call fails
|
|
||||||
(e.g., no admin access for instance-level runners), falls back to
|
|
||||||
what we can see.
|
|
||||||
"""
|
|
||||||
headers = {"Authorization": f"token {token}"}
|
|
||||||
total = 0
|
|
||||||
|
|
||||||
# 1. Repository-level runners
|
|
||||||
try:
|
|
||||||
r = requests.get(
|
|
||||||
f"{api_url}/repos/{owner}/{repo}/actions/runners",
|
|
||||||
headers=headers,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
if r.status_code == 200:
|
|
||||||
data = r.json()
|
|
||||||
total += data.get("total_count", 0)
|
|
||||||
except (requests.RequestException, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 2. Organization-level runners
|
|
||||||
try:
|
|
||||||
r = requests.get(
|
|
||||||
f"{api_url}/orgs/{owner}/actions/runners",
|
|
||||||
headers=headers,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
if r.status_code == 200:
|
|
||||||
data = r.json()
|
|
||||||
total += data.get("total_count", 0)
|
|
||||||
except (requests.RequestException, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
# 3. Instance-level runners (requires admin scope)
|
|
||||||
try:
|
|
||||||
r = requests.get(
|
|
||||||
f"{api_url}/admin/actions/runners",
|
|
||||||
headers=headers,
|
|
||||||
timeout=10,
|
|
||||||
)
|
|
||||||
if r.status_code == 200:
|
|
||||||
data = r.json()
|
|
||||||
total += data.get("total_count", 0)
|
|
||||||
except (requests.RequestException, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return total
|
|
||||||
|
|
||||||
|
|
||||||
def get_runner_count(api_url: str, token: str, owner: str, repo: str) -> int:
|
|
||||||
"""Determine the number of available runners.
|
|
||||||
|
|
||||||
Tries the Gitea API first, then falls back to env vars, then default.
|
|
||||||
"""
|
|
||||||
# Try API query if we have a token
|
|
||||||
if token:
|
|
||||||
api_count = query_runners(api_url, token, owner, repo)
|
|
||||||
if api_count > 0:
|
|
||||||
return api_count
|
|
||||||
|
|
||||||
# Fall back to MOLECULE_RUNNERS env var (set by CI from repo variable)
|
|
||||||
env_count = os.environ.get("MOLECULE_RUNNERS")
|
|
||||||
if env_count:
|
|
||||||
try:
|
|
||||||
count = int(env_count)
|
|
||||||
if count > 0:
|
|
||||||
return count
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Fall back to default
|
|
||||||
return DEFAULT_MAX_RUNNERS
|
|
||||||
|
|
||||||
|
|
||||||
def generate_indices(count: int) -> list[str]:
|
|
||||||
"""Generate a list of runner indices ["1", "2", ..., "N"].
|
|
||||||
|
|
||||||
Uses 1-based string indices because Gitea Actions renders
|
|
||||||
integer 0 and string "0" as empty in ${{ matrix.runner-index }}
|
|
||||||
expressions, causing --runner-index to be passed without a value.
|
|
||||||
The distribute_molecule.py script converts these back to 0-based
|
|
||||||
internally.
|
|
||||||
"""
|
|
||||||
return [str(i + 1) for i in range(count)]
|
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
|
||||||
@click.option("--owner", default=None, help="Repository owner (for API query).")
|
|
||||||
@click.option("--repo", default=None, help="Repository name (for API query).")
|
|
||||||
@click.option("--count", "output_count", is_flag=True, help="Output only the count.")
|
|
||||||
@click.option("--indices", "output_indices", is_flag=True, help="Output only the JSON indices array.")
|
|
||||||
@click.option(
|
|
||||||
"--github-output",
|
|
||||||
"github_output",
|
|
||||||
is_flag=True,
|
|
||||||
default=False,
|
|
||||||
help="Write results to $GITHUB_OUTPUT file (for CI workflow steps).",
|
|
||||||
)
|
|
||||||
def main(
|
|
||||||
owner: str | None,
|
|
||||||
repo: str | None,
|
|
||||||
output_count: bool,
|
|
||||||
output_indices: bool,
|
|
||||||
github_output: bool,
|
|
||||||
) -> None:
|
|
||||||
token = os.environ.get("REPO_TOKEN", "")
|
|
||||||
|
|
||||||
if owner is None:
|
|
||||||
owner = os.environ.get("GRM_REPO_OWNER", "oblachno-oss")
|
|
||||||
if repo is None:
|
|
||||||
repo = os.environ.get("GRM_REPO_NAME", "grm")
|
|
||||||
|
|
||||||
count = get_runner_count(GITEA_API_URL, token, owner, repo)
|
|
||||||
indices = generate_indices(count)
|
|
||||||
|
|
||||||
if github_output:
|
|
||||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
|
||||||
if not gh_output:
|
|
||||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
|
||||||
with open(gh_output, "a") as f: # noqa: PTH123
|
|
||||||
f.write(f"runner-count={count}\n")
|
|
||||||
f.write(f"runner-indices={json.dumps(indices)}\n")
|
|
||||||
click.echo(f"Runner count: {count}")
|
|
||||||
click.echo(f"Runner indices: {indices}")
|
|
||||||
return
|
|
||||||
|
|
||||||
if output_count:
|
|
||||||
click.echo(str(count))
|
|
||||||
return
|
|
||||||
|
|
||||||
if output_indices:
|
|
||||||
click.echo(json.dumps(indices))
|
|
||||||
return
|
|
||||||
|
|
||||||
# Default: output both as key=value pairs for CI consumption
|
|
||||||
click.echo(f"count={count}")
|
|
||||||
click.echo(f"indices={json.dumps(indices)}")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
main()
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Distribute molecule (scenario, platform) pairs across N parallel runners.
|
|
||||||
|
|
||||||
Discovers all molecule scenarios under ansible/roles/*/molecule/ and
|
|
||||||
crosses them with the supported OS platform matrix, then splits the
|
|
||||||
resulting test pairs evenly across the requested number of runners.
|
|
||||||
|
|
||||||
Each pair is printed as ``scenario|platform_name|platform_image|platform_command``
|
|
||||||
so the CI workflow can set the appropriate environment variables.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python3 scripts/distribute_molecule.py --runner-index 1 --max-runners 3
|
|
||||||
# prints: default|ubuntu-2204|ubuntu:22.04| lifecycle|ubuntu-2204|ubuntu:22.04| ...
|
|
||||||
python3 scripts/distribute_molecule.py --list
|
|
||||||
# prints all scenarios, one per line
|
|
||||||
python3 scripts/distribute_molecule.py --list-platforms
|
|
||||||
# prints all platforms, one per line
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
from scripts.ci.platforms import PLATFORMS
|
|
||||||
from scripts.i18n import _
|
|
||||||
|
|
||||||
DEFAULT_MAX_RUNNERS = 3
|
|
||||||
MOLECULE_ROOT = Path("ansible/roles/gitea-runner/molecule")
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class TestPair:
|
|
||||||
"""A (scenario, platform) combination to test."""
|
|
||||||
|
|
||||||
scenario: str
|
|
||||||
platform: dict[str, str]
|
|
||||||
|
|
||||||
def encode(self) -> str:
|
|
||||||
"""Serialize to a pipe-delimited string for CI consumption."""
|
|
||||||
return f"{self.scenario}|{self.platform['name']}|{self.platform['image']}|{self.platform['command']}"
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def decode(encoded: str) -> TestPair:
|
|
||||||
"""Deserialize from a pipe-delimited string."""
|
|
||||||
parts = encoded.split("|")
|
|
||||||
return TestPair(
|
|
||||||
scenario=parts[0],
|
|
||||||
platform={"name": parts[1], "image": parts[2], "command": parts[3]},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def discover_scenarios(root: Path | None = None) -> list[str]:
|
|
||||||
"""Return sorted list of molecule scenario directory names."""
|
|
||||||
if root is None:
|
|
||||||
root = MOLECULE_ROOT
|
|
||||||
if not root.is_dir():
|
|
||||||
raise click.ClickException(_("Molecule directory not found: {path}", path=str(root)))
|
|
||||||
scenarios = [d.name for d in root.iterdir() if d.is_dir() and not d.name.startswith("_") and d.name != "common"]
|
|
||||||
return sorted(scenarios)
|
|
||||||
|
|
||||||
|
|
||||||
def build_pairs(scenarios: list[str], platforms: list[dict[str, str]] | None = None) -> list[TestPair]:
|
|
||||||
"""Build the full cross-product of scenarios and platforms."""
|
|
||||||
if platforms is None:
|
|
||||||
platforms = PLATFORMS
|
|
||||||
return [TestPair(s, p) for s in scenarios for p in platforms]
|
|
||||||
|
|
||||||
|
|
||||||
def distribute(pairs: list[TestPair], max_runners: int) -> list[list[TestPair]]:
|
|
||||||
"""Split *pairs* into *max_runners* balanced groups (round-robin)."""
|
|
||||||
groups: list[list[TestPair]] = [[] for _ in range(max_runners)]
|
|
||||||
for i, pair in enumerate(pairs):
|
|
||||||
groups[i % max_runners].append(pair)
|
|
||||||
return groups
|
|
||||||
|
|
||||||
|
|
||||||
def pairs_for_runner(pairs: list[TestPair], runner_index: int, max_runners: int) -> list[TestPair]:
|
|
||||||
"""Return the subset of pairs assigned to *runner_index*."""
|
|
||||||
groups = distribute(pairs, max_runners)
|
|
||||||
if runner_index < 0 or runner_index >= len(groups):
|
|
||||||
raise click.ClickException(
|
|
||||||
_(
|
|
||||||
"Runner index {index} out of range (0..{max})",
|
|
||||||
index=runner_index,
|
|
||||||
max=max_runners - 1,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return groups[runner_index]
|
|
||||||
|
|
||||||
|
|
||||||
def _write_github_env(key: str, value: str) -> None:
|
|
||||||
"""Append a key=value line to the $GITHUB_ENV file."""
|
|
||||||
import os
|
|
||||||
|
|
||||||
gh_env = os.environ.get("GITHUB_ENV")
|
|
||||||
if not gh_env:
|
|
||||||
raise click.ClickException("GITHUB_ENV environment variable is not set")
|
|
||||||
with open(gh_env, "a") as f: # noqa: PTH123
|
|
||||||
f.write(f"{key}={value}\n")
|
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
|
||||||
@click.option(
|
|
||||||
"--runner-index",
|
|
||||||
type=int,
|
|
||||||
default=None,
|
|
||||||
help="One-based runner index (Gitea Actions renders 0 as empty). "
|
|
||||||
"Converted to zero-based internally. If omitted, prints all groups.",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--max-runners",
|
|
||||||
type=int,
|
|
||||||
default=DEFAULT_MAX_RUNNERS,
|
|
||||||
show_default=True,
|
|
||||||
help="Total number of parallel runners.",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--list",
|
|
||||||
"list_all",
|
|
||||||
is_flag=True,
|
|
||||||
help="List all discovered scenarios, one per line.",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--list-platforms",
|
|
||||||
"list_platforms",
|
|
||||||
is_flag=True,
|
|
||||||
help="List all supported platforms, one per line.",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--github-env",
|
|
||||||
"github_env",
|
|
||||||
is_flag=True,
|
|
||||||
default=False,
|
|
||||||
help="Write TEST_PAIRS and SKIP to $GITHUB_ENV (for CI workflow steps).",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--skip-if-excess",
|
|
||||||
is_flag=True,
|
|
||||||
default=False,
|
|
||||||
help="With --github-env: write SKIP=true when runner-index exceeds max-runners.",
|
|
||||||
)
|
|
||||||
def cli(
|
|
||||||
runner_index: int | None,
|
|
||||||
max_runners: int,
|
|
||||||
list_all: bool,
|
|
||||||
list_platforms: bool,
|
|
||||||
github_env: bool,
|
|
||||||
skip_if_excess: bool,
|
|
||||||
) -> None:
|
|
||||||
scenarios = discover_scenarios()
|
|
||||||
if list_all:
|
|
||||||
for s in scenarios:
|
|
||||||
click.echo(s)
|
|
||||||
return
|
|
||||||
if list_platforms:
|
|
||||||
for p in PLATFORMS:
|
|
||||||
click.echo(f"{p['name']}|{p['image']}|{p['command']}")
|
|
||||||
return
|
|
||||||
pairs = build_pairs(scenarios)
|
|
||||||
if runner_index is None:
|
|
||||||
groups = distribute(pairs, max_runners)
|
|
||||||
for i, group in enumerate(groups):
|
|
||||||
labels = " ".join(p.encode() for p in group) if group else "(none)"
|
|
||||||
click.echo(f"Runner {i}: {labels}")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Skip if runner index exceeds available runners (CI static matrix has 3 slots)
|
|
||||||
if skip_if_excess and github_env and runner_index > max_runners:
|
|
||||||
click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}")
|
|
||||||
_write_github_env("TEST_PAIRS", "")
|
|
||||||
_write_github_env("SKIP", "true")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Convert 1-based CLI index to 0-based internal index
|
|
||||||
zero_based = runner_index - 1
|
|
||||||
assigned = pairs_for_runner(pairs, zero_based, max_runners)
|
|
||||||
encoded = " ".join(p.encode() for p in assigned)
|
|
||||||
|
|
||||||
if github_env:
|
|
||||||
_write_github_env("TEST_PAIRS", encoded)
|
|
||||||
_write_github_env("SKIP", "false")
|
|
||||||
click.echo(f"Assigned pairs: {encoded}")
|
|
||||||
return
|
|
||||||
|
|
||||||
click.echo(encoded)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
cli() # pragma: no cover
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Check documentation coverage for CLI commands and major modules.
|
|
||||||
|
|
||||||
Parses Click commands from the CLI source code and checks if each command
|
|
||||||
has corresponding documentation in the wiki/docs. Reports missing
|
|
||||||
documentation as warnings and exits with non-zero if coverage is below 100%.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python3 scripts/ci/doc_coverage.py [--docs-dir docs/] [--fail-on-missing]
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
from scripts.i18n import _
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
||||||
DOCS_DIR = REPO_ROOT / "docs"
|
|
||||||
CLI_FILE = REPO_ROOT / "src" / "gitea_runner_manager" / "cli.py"
|
|
||||||
|
|
||||||
# Major modules that should be documented in tech/architecture.md
|
|
||||||
REQUIRED_MODULES = [
|
|
||||||
"cli.py",
|
|
||||||
"runner_manager.py",
|
|
||||||
"executor.py",
|
|
||||||
"registry.py",
|
|
||||||
"i18n.py",
|
|
||||||
"exceptions.py",
|
|
||||||
"api_clients.py",
|
|
||||||
"config.py",
|
|
||||||
]
|
|
||||||
|
|
||||||
# CI scripts that should be documented in tech/ci-cd-workflow.md
|
|
||||||
REQUIRED_SCRIPTS = [
|
|
||||||
"auto_merge.py",
|
|
||||||
"release.py",
|
|
||||||
"publish.py",
|
|
||||||
"review_pr.py",
|
|
||||||
"notify_failure.py",
|
|
||||||
"post_merge.py",
|
|
||||||
"classify_changes.py",
|
|
||||||
"discover_runners.py",
|
|
||||||
"detect_release_commit.py",
|
|
||||||
"push_badges.py",
|
|
||||||
"distribute_molecule.py",
|
|
||||||
"molecule_ci_guard.py",
|
|
||||||
"validate_commit_msg.py",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def extract_cli_commands() -> list[str]:
|
|
||||||
"""Extract command names from the CLI source file."""
|
|
||||||
content = CLI_FILE.read_text()
|
|
||||||
commands: list[str] = []
|
|
||||||
# Find all @cli.command(...) occurrences, then the next def statement
|
|
||||||
for match in re.finditer(r"@cli\.command\b", content):
|
|
||||||
# Check for explicit name="..." in the decorator arguments
|
|
||||||
decorator_end = content.find(")", match.start())
|
|
||||||
decorator_text = content[match.start() : decorator_end + 1]
|
|
||||||
name_match = re.search(r'name\s*=\s*"([^"]+)"', decorator_text)
|
|
||||||
if name_match:
|
|
||||||
commands.append(name_match.group(1))
|
|
||||||
continue
|
|
||||||
# Find the next def statement after this decorator
|
|
||||||
after = content[decorator_end:]
|
|
||||||
def_match = re.search(r"def\s+(\w+)\s*\(", after)
|
|
||||||
if def_match:
|
|
||||||
commands.append(def_match.group(1))
|
|
||||||
return commands
|
|
||||||
|
|
||||||
|
|
||||||
def check_command_documented(command: str, docs_content: str) -> bool:
|
|
||||||
"""Check if a CLI command is documented in the docs content."""
|
|
||||||
# Look for the command name as a heading or in code blocks
|
|
||||||
patterns = [
|
|
||||||
rf"##.*\b{re.escape(command)}\b",
|
|
||||||
rf"`grm\s+{re.escape(command)}\b",
|
|
||||||
rf"\bgrm\s+{re.escape(command)}\b",
|
|
||||||
rf"###.*\b{re.escape(command)}\b",
|
|
||||||
]
|
|
||||||
return any(re.search(p, docs_content, re.IGNORECASE) for p in patterns)
|
|
||||||
|
|
||||||
|
|
||||||
def check_module_documented(module: str, docs_content: str) -> bool:
|
|
||||||
"""Check if a module is mentioned in the docs content."""
|
|
||||||
return module in docs_content
|
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
|
||||||
@click.option("--docs-dir", default=str(DOCS_DIR), help="Path to the docs directory.")
|
|
||||||
@click.option(
|
|
||||||
"--fail-on-missing",
|
|
||||||
is_flag=True,
|
|
||||||
default=False,
|
|
||||||
help="Exit with non-zero status if any documentation is missing.",
|
|
||||||
)
|
|
||||||
def main(docs_dir: str, fail_on_missing: bool) -> None:
|
|
||||||
docs_path = Path(docs_dir)
|
|
||||||
cli_commands_file = docs_path / "user" / "cli-commands.md"
|
|
||||||
architecture_file = docs_path / "tech" / "architecture.md"
|
|
||||||
ci_cd_file = docs_path / "tech" / "ci-cd-workflow.md"
|
|
||||||
|
|
||||||
missing: list[str] = []
|
|
||||||
total = 0
|
|
||||||
|
|
||||||
# Check CLI commands
|
|
||||||
click.echo(_("Checking CLI command documentation..."))
|
|
||||||
commands = extract_cli_commands()
|
|
||||||
total += len(commands)
|
|
||||||
cli_docs = cli_commands_file.read_text() if cli_commands_file.exists() else ""
|
|
||||||
for cmd in commands:
|
|
||||||
if check_command_documented(cmd, cli_docs):
|
|
||||||
click.echo(_(" OK: grm {cmd}", cmd=cmd))
|
|
||||||
else:
|
|
||||||
click.echo(_(" MISSING: grm {cmd}", cmd=cmd))
|
|
||||||
missing.append(f"CLI command: grm {cmd}")
|
|
||||||
|
|
||||||
# Check modules in architecture.md
|
|
||||||
click.echo(_("\nChecking module documentation in architecture.md..."))
|
|
||||||
total += len(REQUIRED_MODULES)
|
|
||||||
arch_docs = architecture_file.read_text() if architecture_file.exists() else ""
|
|
||||||
for module in REQUIRED_MODULES:
|
|
||||||
if check_module_documented(module, arch_docs):
|
|
||||||
click.echo(_(" OK: {module}", module=module))
|
|
||||||
else:
|
|
||||||
click.echo(_(" MISSING: {module}", module=module))
|
|
||||||
missing.append(f"Module: {module}")
|
|
||||||
|
|
||||||
# Check CI scripts in ci-cd-workflow.md
|
|
||||||
click.echo(_("\nChecking CI script documentation in ci-cd-workflow.md..."))
|
|
||||||
total += len(REQUIRED_SCRIPTS)
|
|
||||||
ci_docs = ci_cd_file.read_text() if ci_cd_file.exists() else ""
|
|
||||||
for script in REQUIRED_SCRIPTS:
|
|
||||||
if check_module_documented(script, ci_docs):
|
|
||||||
click.echo(_(" OK: {script}", script=script))
|
|
||||||
else:
|
|
||||||
click.echo(_(" MISSING: {script}", script=script))
|
|
||||||
missing.append(f"CI script: {script}")
|
|
||||||
|
|
||||||
# Report
|
|
||||||
covered = total - len(missing)
|
|
||||||
percentage = (covered / total * 100) if total > 0 else 100.0
|
|
||||||
click.echo(
|
|
||||||
_(
|
|
||||||
"\nDoc coverage: {covered}/{total} ({pct}%)",
|
|
||||||
covered=covered,
|
|
||||||
total=total,
|
|
||||||
pct=f"{percentage:.0f}",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if missing:
|
|
||||||
click.echo(_("\nMissing documentation:"))
|
|
||||||
for item in missing:
|
|
||||||
click.echo(f" - {item}")
|
|
||||||
|
|
||||||
if missing and fail_on_missing:
|
|
||||||
click.echo(_("\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce."))
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if not missing:
|
|
||||||
click.echo(_("\nAll documentation coverage checks passed!"))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
main()
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Run molecule tests sequentially while polling Gitea for other runner failures.
|
|
||||||
|
|
||||||
Each pair is encoded as ``scenario|platform_name|platform_image|platform_command``.
|
|
||||||
Pairs are executed one at a time (molecule scenarios share temp directories and
|
|
||||||
Docker networks, so parallel execution within a single runner is unsafe).
|
|
||||||
|
|
||||||
A background thread polls the Gitea API. If any other molecule matrix runner
|
|
||||||
reports failure, the current molecule subprocess is killed and this runner
|
|
||||||
exits early with code 1.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python3 scripts/molecule_ci_guard.py <pair1> <pair2> ...
|
|
||||||
|
|
||||||
Environment variables:
|
|
||||||
GITEA_URL Base URL of the Gitea instance.
|
|
||||||
REPO_TOKEN API token with repo access.
|
|
||||||
RUN_ID Workflow run ID (GITHUB_RUN_ID).
|
|
||||||
JOB_NAME Base job name (GITHUB_JOB), e.g. "molecule-tests".
|
|
||||||
MATRIX_INDEX Current matrix index (runner-index).
|
|
||||||
GITEA_REPOSITORY Repository in "owner/repo" format.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
import subprocess # nosec B404
|
|
||||||
import sys
|
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import click
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from scripts.i18n import _
|
|
||||||
|
|
||||||
POLL_INTERVAL = 10
|
|
||||||
|
|
||||||
|
|
||||||
def get_running_jobs(gitea_url: str, owner: str, repo: str, token: str, run_id: int) -> list[dict]:
|
|
||||||
"""Return jobs for the given workflow run."""
|
|
||||||
url = f"{gitea_url}/api/v1/repos/{owner}/{repo}/actions/runs/{run_id}/jobs"
|
|
||||||
headers = {"Authorization": f"token {token}"}
|
|
||||||
response = requests.get(url, headers=headers, timeout=30)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
return data.get("jobs", [])
|
|
||||||
|
|
||||||
|
|
||||||
def any_other_runner_failed(jobs: list[dict], current_job_name: str, current_index: int) -> bool:
|
|
||||||
"""Return True if any other molecule matrix job has failed."""
|
|
||||||
for job in jobs:
|
|
||||||
name = job.get("name", "")
|
|
||||||
if not name.startswith(current_job_name):
|
|
||||||
continue
|
|
||||||
if name == f"{current_job_name} ({current_index})" or name == current_job_name:
|
|
||||||
continue
|
|
||||||
if job.get("conclusion") == "failure":
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def poll_for_other_failures(
|
|
||||||
gitea_url: str,
|
|
||||||
owner: str,
|
|
||||||
repo: str,
|
|
||||||
token: str,
|
|
||||||
run_id: int,
|
|
||||||
job_name: str,
|
|
||||||
current_index: int,
|
|
||||||
stop_event: threading.Event,
|
|
||||||
failed_event: threading.Event,
|
|
||||||
) -> None:
|
|
||||||
"""Background thread: poll API and signal if another runner fails."""
|
|
||||||
while not stop_event.is_set():
|
|
||||||
try:
|
|
||||||
jobs = get_running_jobs(gitea_url, owner, repo, token, run_id)
|
|
||||||
if any_other_runner_failed(jobs, job_name, current_index):
|
|
||||||
click.echo(_("Another molecule runner failed. Stopping this runner early."))
|
|
||||||
failed_event.set()
|
|
||||||
return
|
|
||||||
except requests.RequestException as exc:
|
|
||||||
click.echo(_("API poll warning: {exc}", exc=exc))
|
|
||||||
stop_event.wait(POLL_INTERVAL)
|
|
||||||
|
|
||||||
|
|
||||||
def build_molecule_cmd(scenario: str) -> list[str]:
|
|
||||||
"""Build the molecule command for a scenario."""
|
|
||||||
cmd = ["molecule", "test"]
|
|
||||||
if scenario != "default":
|
|
||||||
cmd.extend(["-s", scenario])
|
|
||||||
return cmd
|
|
||||||
|
|
||||||
|
|
||||||
def build_env_for_pair(pair: str, base_env: dict[str, str]) -> dict[str, str]:
|
|
||||||
"""Build environment for a single molecule pair."""
|
|
||||||
scenario, platform_name, platform_image, platform_command = pair.split("|")
|
|
||||||
env = base_env.copy()
|
|
||||||
env["MOLECULE_PLATFORM_NAME"] = platform_name
|
|
||||||
env["MOLECULE_PLATFORM_IMAGE"] = platform_image
|
|
||||||
if platform_command:
|
|
||||||
env["MOLECULE_PLATFORM_COMMAND"] = platform_command
|
|
||||||
elif "MOLECULE_PLATFORM_COMMAND" in env:
|
|
||||||
del env["MOLECULE_PLATFORM_COMMAND"]
|
|
||||||
env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] = "true"
|
|
||||||
return env
|
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
|
||||||
@click.argument("pairs", nargs=-1, required=True)
|
|
||||||
def cli(pairs: tuple[str, ...]) -> None:
|
|
||||||
"""Run molecule pairs sequentially, stop if another CI runner fails."""
|
|
||||||
gitea_url = os.environ.get("GITEA_URL", "")
|
|
||||||
token = os.environ.get("REPO_TOKEN", "")
|
|
||||||
run_id = int(os.environ.get("RUN_ID", "0"))
|
|
||||||
job_name = os.environ.get("JOB_NAME", "molecule-tests")
|
|
||||||
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
|
|
||||||
repository = os.environ.get("GITEA_REPOSITORY", "oblachno-oss/grm")
|
|
||||||
owner, sep, repo = repository.partition("/")
|
|
||||||
if not owner or not repo:
|
|
||||||
owner, repo = "oblachno-oss", "grm"
|
|
||||||
|
|
||||||
if not all([gitea_url, token, run_id]):
|
|
||||||
click.echo(_("GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
|
|
||||||
|
|
||||||
repo_root = Path(__file__).resolve().parent.parent.parent
|
|
||||||
role_dir = repo_root / "ansible" / "roles" / "gitea-runner"
|
|
||||||
|
|
||||||
base_env = os.environ.copy()
|
|
||||||
base_env.setdefault("DOCKER_HOST", f"unix:///run/user/{os.getuid()}/docker.sock")
|
|
||||||
base_env.setdefault("ANSIBLE_INJECT_INVOCATION", "1")
|
|
||||||
|
|
||||||
stop_event = threading.Event()
|
|
||||||
failed_event = threading.Event()
|
|
||||||
|
|
||||||
if gitea_url and token and run_id:
|
|
||||||
poller = threading.Thread(
|
|
||||||
target=poll_for_other_failures,
|
|
||||||
args=(
|
|
||||||
gitea_url,
|
|
||||||
owner,
|
|
||||||
repo,
|
|
||||||
token,
|
|
||||||
run_id,
|
|
||||||
job_name,
|
|
||||||
current_index,
|
|
||||||
stop_event,
|
|
||||||
failed_event,
|
|
||||||
),
|
|
||||||
daemon=True,
|
|
||||||
)
|
|
||||||
poller.start()
|
|
||||||
|
|
||||||
try:
|
|
||||||
for pair in pairs:
|
|
||||||
if failed_event.is_set():
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
scenario = pair.split("|")[0]
|
|
||||||
platform_name = pair.split("|")[1]
|
|
||||||
click.echo(_("Running: {scenario} on {platform}", scenario=scenario, platform=platform_name))
|
|
||||||
|
|
||||||
cmd = build_molecule_cmd(scenario)
|
|
||||||
env = build_env_for_pair(pair, base_env)
|
|
||||||
|
|
||||||
process = subprocess.Popen( # nosec B603
|
|
||||||
cmd,
|
|
||||||
cwd=str(role_dir),
|
|
||||||
env=env,
|
|
||||||
preexec_fn=os.setsid,
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
while process.poll() is None:
|
|
||||||
if failed_event.is_set():
|
|
||||||
with contextlib.suppress(ProcessLookupError):
|
|
||||||
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
|
||||||
try:
|
|
||||||
process.wait(timeout=10)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
with contextlib.suppress(ProcessLookupError):
|
|
||||||
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
|
||||||
process.wait()
|
|
||||||
sys.exit(1)
|
|
||||||
time.sleep(1)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
with contextlib.suppress(ProcessLookupError):
|
|
||||||
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
|
||||||
process.wait()
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
rc = process.returncode
|
|
||||||
if rc != 0:
|
|
||||||
click.echo(_("FAILED: {pair} exited with code {code}", pair=pair, code=rc))
|
|
||||||
sys.exit(rc)
|
|
||||||
|
|
||||||
click.echo(_("PASSED: {pair}", pair=pair))
|
|
||||||
|
|
||||||
click.echo(_("All molecule tests passed."))
|
|
||||||
finally:
|
|
||||||
stop_event.set()
|
|
||||||
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
cli()
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Create a Gitea issue when a CI workflow fails.
|
|
||||||
|
|
||||||
Used by the release and publish workflows to alert on failures that would
|
|
||||||
otherwise go unnoticed in the Actions tab. Uses the ``tea`` Gitea CLI
|
|
||||||
for issue creation — tea must be installed and configured.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
REPO_TOKEN=<token> python3 scripts/notify_failure.py \
|
|
||||||
--repo <owner/repo> \
|
|
||||||
--run-id <run_id> \
|
|
||||||
--workflow <workflow_name> \
|
|
||||||
--commit <commit_sha>
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import contextlib
|
|
||||||
import os
|
|
||||||
|
|
||||||
import click
|
|
||||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
|
||||||
|
|
||||||
from gitea_runner_manager.config import GITEA_API_URL
|
|
||||||
from scripts.gitea_cli import TeaCLI, TeaCLIError
|
|
||||||
from scripts.i18n import _
|
|
||||||
|
|
||||||
load_dotenv(override=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _create_issue_via_tea(repo: str, title: str, body: str) -> int:
|
|
||||||
"""Create issue via tea CLI. Returns issue index.
|
|
||||||
|
|
||||||
Raises TeaCLIError if tea is not installed or the command fails.
|
|
||||||
"""
|
|
||||||
tea = TeaCLI(repo=repo)
|
|
||||||
|
|
||||||
# Check if "bug" label exists
|
|
||||||
labels: list[str] = []
|
|
||||||
with contextlib.suppress(TeaCLIError):
|
|
||||||
existing_labels = tea.list_labels(repo)
|
|
||||||
if any(label.get("name") == "bug" for label in existing_labels):
|
|
||||||
labels = ["bug"]
|
|
||||||
|
|
||||||
issue = tea.create_issue(repo, title=title, body=body, labels=labels if labels else None)
|
|
||||||
if labels:
|
|
||||||
with contextlib.suppress(TeaCLIError):
|
|
||||||
tea.add_label(repo, issue["index"], labels)
|
|
||||||
return int(issue.get("index", 0))
|
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
|
||||||
@click.option("--repo", required=True, help="Repository in owner/name format.")
|
|
||||||
@click.option("--run-id", required=True, help="CI run ID.")
|
|
||||||
@click.option("--workflow", required=True, help="Workflow name.")
|
|
||||||
@click.option("--commit", required=True, help="Commit SHA.")
|
|
||||||
def main(repo: str, run_id: str, workflow: str, commit: str) -> None:
|
|
||||||
token = os.environ.get("REPO_TOKEN", "")
|
|
||||||
if not token:
|
|
||||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
|
||||||
|
|
||||||
title = f"[CI] {workflow} workflow failed (run #{run_id})"
|
|
||||||
body = (
|
|
||||||
f"The **{workflow}** workflow failed.\n\n"
|
|
||||||
f"- **Run ID**: #{run_id}\n"
|
|
||||||
f"- **Commit**: `{commit[:8]}`\n"
|
|
||||||
f"- **Check the logs**: {GITEA_API_URL.replace('/api/v1', '')}/"
|
|
||||||
f"{repo}/actions/runs/{run_id}\n\n"
|
|
||||||
f"Please investigate and fix the issue."
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
issue_id = _create_issue_via_tea(repo, title, body)
|
|
||||||
except TeaCLIError as e:
|
|
||||||
raise click.ClickException(_("Failed to create issue via tea: {error}", error=str(e))) from None
|
|
||||||
|
|
||||||
click.echo(
|
|
||||||
_(
|
|
||||||
"Created issue #{issue_id}: {title}",
|
|
||||||
issue_id=issue_id or "?",
|
|
||||||
title=title,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
main()
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
"""Supported OS platform matrix for molecule tests.
|
|
||||||
|
|
||||||
Single source of truth for the platform list used by both:
|
|
||||||
- ``scripts/ci/distribute_molecule.py`` (CI parallel matrix)
|
|
||||||
- ``scripts/molecule_all.py`` (local sequential runner)
|
|
||||||
|
|
||||||
Keeping this in a dedicated module avoids cross-imports between
|
|
||||||
dev tools and CI scripts.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
#: Supported OS platform matrix.
|
|
||||||
#: Each entry maps a short name to (image, command).
|
|
||||||
#: The command must be systemd since rootless Docker requires
|
|
||||||
#: loginctl/systemctl --user.
|
|
||||||
PLATFORMS: list[dict[str, str]] = [
|
|
||||||
{"name": "ubuntu-2204", "image": "geerlingguy/docker-ubuntu2204-ansible:latest", "command": "/lib/systemd/systemd"},
|
|
||||||
{"name": "ubuntu-2404", "image": "geerlingguy/docker-ubuntu2404-ansible:latest", "command": "/lib/systemd/systemd"},
|
|
||||||
{"name": "debian-12", "image": "geerlingguy/docker-debian12-ansible:latest", "command": "/lib/systemd/systemd"},
|
|
||||||
{"name": "archlinux", "image": "marcstraube/archlinux-ansible:latest", "command": "/usr/lib/systemd/systemd"},
|
|
||||||
]
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Update Vikunja task after a merge to master.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
VIKUNJA_TOKEN=<token> python3 scripts/post_merge.py <commit_msg> [--commit-sha <sha>]
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import subprocess # nosec B404
|
|
||||||
|
|
||||||
import click
|
|
||||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
|
||||||
|
|
||||||
from gitea_runner_manager.api_clients import VikunjaClient
|
|
||||||
from gitea_runner_manager.config import DEFAULT_PER_PAGE, TASK_ID_RE, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
|
|
||||||
from gitea_runner_manager.exceptions import APIError
|
|
||||||
from scripts.i18n import _
|
|
||||||
|
|
||||||
load_dotenv(override=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_git_commit_message() -> str:
|
|
||||||
"""Get the full commit message of the latest commit."""
|
|
||||||
result = subprocess.run( # nosec B603 B607
|
|
||||||
["git", "log", "-1", "--pretty=%B"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise click.ClickException(f"git log failed: {result.stderr.strip()}")
|
|
||||||
return result.stdout.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def _get_git_commit_sha() -> str:
|
|
||||||
"""Get the SHA of the latest commit."""
|
|
||||||
result = subprocess.run( # nosec B603 B607
|
|
||||||
["git", "rev-parse", "HEAD"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise click.ClickException(f"git rev-parse failed: {result.stderr.strip()}")
|
|
||||||
return result.stdout.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def extract_task_id(commit_msg: str) -> str:
|
|
||||||
"""Extract GRM-N task identifier from the first line of commit message."""
|
|
||||||
first_line = commit_msg.split("\n")[0]
|
|
||||||
match = TASK_ID_RE.search(first_line)
|
|
||||||
return match.group(0) if match else ""
|
|
||||||
|
|
||||||
|
|
||||||
def extract_conventional_msg(commit_msg: str) -> str:
|
|
||||||
"""Strip the GRM-N prefix from the commit subject.
|
|
||||||
|
|
||||||
Handles both formats:
|
|
||||||
- ``GRM-N: <message>`` (legacy, colon-separated)
|
|
||||||
- ``GRM-N <message>`` (current, space-separated)
|
|
||||||
"""
|
|
||||||
first_line = commit_msg.split("\n")[0]
|
|
||||||
return re.sub(r"^GRM-\d+[:\s]\s*", "", first_line)
|
|
||||||
|
|
||||||
|
|
||||||
def resolve_task_id(client: VikunjaClient, task_id: str) -> int:
|
|
||||||
"""Resolve GRM-N identifier to Vikunja numeric task ID.
|
|
||||||
|
|
||||||
Paginates through the project's tasks to handle projects with more
|
|
||||||
than 50 tasks. Raises ClickException if the task is not found.
|
|
||||||
"""
|
|
||||||
page = 1
|
|
||||||
while True:
|
|
||||||
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
|
|
||||||
if not tasks:
|
|
||||||
break
|
|
||||||
matches = [t for t in tasks if t.get("identifier") == task_id]
|
|
||||||
if matches:
|
|
||||||
return int(matches[0]["id"])
|
|
||||||
if len(tasks) < DEFAULT_PER_PAGE:
|
|
||||||
break
|
|
||||||
page += 1
|
|
||||||
raise click.ClickException(
|
|
||||||
_(
|
|
||||||
"Could not find Vikunja task {task_id} in project {project_id}. "
|
|
||||||
"Every PR must have a corresponding Vikunja task.",
|
|
||||||
task_id=task_id,
|
|
||||||
project_id=VIKUNJA_PROJECT_ID,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def build_comment(task_id: str, conv_msg: str, commit_sha: str) -> str:
|
|
||||||
"""Build HTML comment body for Vikunja."""
|
|
||||||
return f"<p><strong>{task_id}</strong>: {conv_msg}</p><p>Commit: <code>{commit_sha}</code></p>"
|
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
|
||||||
@click.argument("commit_msg", required=False)
|
|
||||||
@click.option("--commit-sha", default="", help="Commit SHA")
|
|
||||||
@click.option("--from-git", is_flag=True, default=False, help="Read commit message and SHA from git.")
|
|
||||||
@click.option(
|
|
||||||
"--git-sha",
|
|
||||||
default="",
|
|
||||||
help="Read commit message from a specific git SHA (avoids race condition with parallel jobs).",
|
|
||||||
)
|
|
||||||
def main(commit_msg: str | None, commit_sha: str, from_git: bool, git_sha: str) -> None:
|
|
||||||
if git_sha:
|
|
||||||
# Read commit message from a specific SHA — this avoids the race
|
|
||||||
# condition where a parallel job (e.g., release) pushes a new commit
|
|
||||||
# to master before this job reads HEAD.
|
|
||||||
result = subprocess.run( # nosec B603 B607
|
|
||||||
["git", "log", "-1", "--pretty=%B", git_sha],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise click.ClickException(f"git log failed for SHA {git_sha}: {result.stderr.strip()}")
|
|
||||||
commit_msg = result.stdout.strip()
|
|
||||||
if not commit_sha:
|
|
||||||
commit_sha = git_sha
|
|
||||||
elif from_git:
|
|
||||||
commit_msg = _get_git_commit_message()
|
|
||||||
if not commit_sha:
|
|
||||||
commit_sha = _get_git_commit_sha()
|
|
||||||
if not commit_msg:
|
|
||||||
raise click.ClickException("commit_msg argument is required (or use --from-git or --git-sha)")
|
|
||||||
token = os.environ.get("VIKUNJA_TOKEN", "")
|
|
||||||
if not token:
|
|
||||||
raise click.ClickException(_("ERROR: VIKUNJA_TOKEN is not set."))
|
|
||||||
|
|
||||||
task_id = extract_task_id(commit_msg)
|
|
||||||
if not task_id:
|
|
||||||
first_line = commit_msg.split("\n")[0]
|
|
||||||
# Skip gracefully for infrastructure commits that don't follow
|
|
||||||
# the GRM-N convention: release commits, reverts, bot commits, etc.
|
|
||||||
infra_patterns = [
|
|
||||||
r"^release: v\d+\.\d+\.\d+", # release commits
|
|
||||||
r"^revert: ", # git revert commits
|
|
||||||
r"^Merge ", # merge commits
|
|
||||||
r"^\[skip ci\]", # skip-ci commits
|
|
||||||
]
|
|
||||||
for pattern in infra_patterns:
|
|
||||||
if re.match(pattern, first_line):
|
|
||||||
click.echo(
|
|
||||||
_(
|
|
||||||
"Infrastructure commit (no GRM-N task ID), skipping Vikunja update: {msg}",
|
|
||||||
msg=first_line,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
# Non-infrastructure commits without GRM-N prefix — warn but don't fail
|
|
||||||
click.echo(
|
|
||||||
_(
|
|
||||||
"Warning: No task ID (GRM-N) found in commit message: {msg}. Skipping Vikunja update.",
|
|
||||||
msg=first_line,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
client = VikunjaClient(VIKUNJA_API_URL, token)
|
|
||||||
vikunja_task_id = resolve_task_id(client, task_id)
|
|
||||||
conv_msg = extract_conventional_msg(commit_msg)
|
|
||||||
sha = commit_sha or "unknown"
|
|
||||||
html = build_comment(task_id, conv_msg, sha)
|
|
||||||
|
|
||||||
try:
|
|
||||||
client.post_comment(vikunja_task_id, html)
|
|
||||||
client.update_task(vikunja_task_id, done=True)
|
|
||||||
except APIError as e:
|
|
||||||
# Vikunja is a project management tool — if it's down, the merge
|
|
||||||
# still succeeded. Warn but don't fail the post-merge workflow.
|
|
||||||
click.echo(
|
|
||||||
_(
|
|
||||||
"Warning: Vikunja API error (HTTP {status}): {message}. "
|
|
||||||
"Task {task_id} was NOT updated. The merge succeeded — "
|
|
||||||
"please update the Vikunja task manually.",
|
|
||||||
status=e.status,
|
|
||||||
message=e.message,
|
|
||||||
task_id=task_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
click.echo(
|
|
||||||
_(
|
|
||||||
"Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.",
|
|
||||||
task_id=task_id,
|
|
||||||
vikunja_id=vikunja_task_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
main()
|
|
||||||
@@ -1,571 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Automated PR review: check architecture compliance, best practices, and quality.
|
|
||||||
|
|
||||||
Fetches the PR diff via the Gitea API, runs a series of automated checks,
|
|
||||||
and posts a structured review using GiteaClient.create_review.
|
|
||||||
|
|
||||||
Checks performed:
|
|
||||||
1. Architecture compliance — no business logic in CLI, no direct subprocess
|
|
||||||
calls outside executor, no hardcoded config that should be in config.py
|
|
||||||
2. Best practices — no bare except, no print() (use click.echo), no TODO/FIXME
|
|
||||||
left in merged code, no functions > 50 lines
|
|
||||||
3. Security — no secrets in code, no shell=True, no eval/exec
|
|
||||||
4. i18n — no raw English strings in click.echo() without _() wrapper
|
|
||||||
5. Resource management — no open() without with statement, no subprocess without cleanup
|
|
||||||
6. Documentation — new CLI commands documented, new modules in architecture.md
|
|
||||||
7. Test coverage — 100% enforced by pytest-cov (checked in quality job)
|
|
||||||
8. Commit conventions — conventional commit format on branch commits
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
REPO_TOKEN=<token> python3 scripts/ci/pr_review.py <pr_number> <owner/repo>
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import click
|
|
||||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
|
||||||
|
|
||||||
from gitea_runner_manager.api_clients import GiteaClient
|
|
||||||
from gitea_runner_manager.config import GITEA_API_URL
|
|
||||||
from gitea_runner_manager.exceptions import APIError
|
|
||||||
from scripts.i18n import _
|
|
||||||
|
|
||||||
load_dotenv(override=True)
|
|
||||||
|
|
||||||
# Files that are exempt from certain checks
|
|
||||||
WORKFLOW_ONLY_SUFFIXES = (".yml", ".yaml", ".md", ".json", ".toml", ".cfg", ".ini", ".txt")
|
|
||||||
PYTHON_SUFFIX = ".py"
|
|
||||||
|
|
||||||
# Architecture rules
|
|
||||||
CLI_FILE = "src/gitea_runner_manager/cli.py"
|
|
||||||
EXECUTOR_FILE = "src/gitea_runner_manager/executor.py"
|
|
||||||
CONFIG_FILE = "src/gitea_runner_manager/config.py"
|
|
||||||
|
|
||||||
# Patterns that indicate business logic in CLI (should be in runner_manager.py)
|
|
||||||
BUSINESS_LOGIC_IN_CLI = [
|
|
||||||
(r"subprocess\.(run|call|Popen|check_output|check_call)", "subprocess call in CLI — delegate to executor.py"),
|
|
||||||
(r"\bos\.system\b", "os.system call in CLI — delegate to executor.py"),
|
|
||||||
(r"\bansible-playbook\b", "ansible-playbook reference in CLI — delegate to executor.py"),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Patterns that indicate bad practices
|
|
||||||
BAD_PRACTICES = [
|
|
||||||
(r"\bprint\s*\(", "print() found — use click.echo() for user output"),
|
|
||||||
(r"\beval\s*\(", "eval() found — security risk, avoid dynamic code execution"),
|
|
||||||
(r"\bexec\s*\(", "exec() found — security risk, avoid dynamic code execution"),
|
|
||||||
(r"shell\s*=\s*True", "shell=True found — security risk, use shell=False with list args"),
|
|
||||||
(r"except\s*:", "bare except found — catch specific exceptions"),
|
|
||||||
(r"except\s+Exception\s*:", "broad Exception catch — catch specific exceptions"),
|
|
||||||
(r"#\s*(TODO|FIXME|HACK|XXX)", "TODO/FIXME found — resolve before merging"),
|
|
||||||
]
|
|
||||||
|
|
||||||
# Patterns for hardcoded config values that should be in config.py
|
|
||||||
HARDCODED_CONFIG = [
|
|
||||||
(r"https?://[a-z]+\.[a-z]+\.[a-z]+", "hardcoded URL — move to config.py with env var override"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ReviewResult:
|
|
||||||
"""Result of automated review checks."""
|
|
||||||
|
|
||||||
issues: list[dict[str, Any]] = field(default_factory=list)
|
|
||||||
summary: list[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def has_issues(self) -> bool:
|
|
||||||
return bool(self.issues)
|
|
||||||
|
|
||||||
def add_issue(self, file_path: str, line: int, message: str, severity: str = "warning") -> None:
|
|
||||||
self.issues.append(
|
|
||||||
{
|
|
||||||
"path": file_path,
|
|
||||||
"body": f"[{severity}] {message}",
|
|
||||||
"new_position": line,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
def add_summary(self, text: str) -> None:
|
|
||||||
self.summary.append(text)
|
|
||||||
|
|
||||||
|
|
||||||
def is_python_file(path: str) -> bool:
|
|
||||||
"""Check if a file is a Python source file."""
|
|
||||||
return path.endswith(PYTHON_SUFFIX) and not path.startswith("tests/")
|
|
||||||
|
|
||||||
|
|
||||||
def is_workflow_only(path: str) -> bool:
|
|
||||||
"""Check if a file is workflow/config/docs only (not Python source)."""
|
|
||||||
return path.endswith(WORKFLOW_ONLY_SUFFIXES) or path.startswith((".gitea/", "docs/", "ansible/"))
|
|
||||||
|
|
||||||
|
|
||||||
def check_architecture_compliance(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
|
||||||
"""Check that changes follow the documented architecture."""
|
|
||||||
for f in files:
|
|
||||||
path = f.get("filename", "")
|
|
||||||
if not is_python_file(path):
|
|
||||||
continue
|
|
||||||
|
|
||||||
patch = f.get("patch", "")
|
|
||||||
if not patch:
|
|
||||||
continue
|
|
||||||
|
|
||||||
lines = patch.split("\n")
|
|
||||||
current_line = 0
|
|
||||||
for line in lines:
|
|
||||||
if line.startswith("@@"):
|
|
||||||
match = re.search(r"\+(\d+)", line)
|
|
||||||
if match:
|
|
||||||
current_line = int(match.group(1)) - 1
|
|
||||||
continue
|
|
||||||
if line.startswith("+") and not line.startswith("+++"):
|
|
||||||
current_line += 1
|
|
||||||
content = line[1:]
|
|
||||||
|
|
||||||
# Check for business logic in CLI
|
|
||||||
if path == CLI_FILE:
|
|
||||||
for pattern, msg in BUSINESS_LOGIC_IN_CLI:
|
|
||||||
if re.search(pattern, content):
|
|
||||||
result.add_issue(path, current_line, msg, "error")
|
|
||||||
|
|
||||||
if not result.issues:
|
|
||||||
result.add_summary("- Architecture compliance: OK")
|
|
||||||
|
|
||||||
|
|
||||||
def check_best_practices(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
|
||||||
"""Check for common code quality issues."""
|
|
||||||
for f in files:
|
|
||||||
path = f.get("filename", "")
|
|
||||||
if not is_python_file(path):
|
|
||||||
continue
|
|
||||||
|
|
||||||
patch = f.get("patch", "")
|
|
||||||
if not patch:
|
|
||||||
continue
|
|
||||||
|
|
||||||
lines = patch.split("\n")
|
|
||||||
current_line = 0
|
|
||||||
for line in lines:
|
|
||||||
if line.startswith("@@"):
|
|
||||||
match = re.search(r"\+(\d+)", line)
|
|
||||||
if match:
|
|
||||||
current_line = int(match.group(1)) - 1
|
|
||||||
continue
|
|
||||||
if line.startswith("+") and not line.startswith("+++"):
|
|
||||||
current_line += 1
|
|
||||||
content = line[1:]
|
|
||||||
|
|
||||||
for pattern, msg in BAD_PRACTICES:
|
|
||||||
if re.search(pattern, content):
|
|
||||||
result.add_issue(path, current_line, msg, "warning")
|
|
||||||
|
|
||||||
if not any(i["body"].startswith("[warning]") for i in result.issues):
|
|
||||||
result.add_summary("- Best practices: OK")
|
|
||||||
|
|
||||||
|
|
||||||
def check_security(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
|
||||||
"""Check for security issues in changed files."""
|
|
||||||
for f in files:
|
|
||||||
path = f.get("filename", "")
|
|
||||||
if not is_python_file(path):
|
|
||||||
continue
|
|
||||||
|
|
||||||
patch = f.get("patch", "")
|
|
||||||
if not patch:
|
|
||||||
continue
|
|
||||||
|
|
||||||
lines = patch.split("\n")
|
|
||||||
current_line = 0
|
|
||||||
for line in lines:
|
|
||||||
if line.startswith("@@"):
|
|
||||||
match = re.search(r"\+(\d+)", line)
|
|
||||||
if match:
|
|
||||||
current_line = int(match.group(1)) - 1
|
|
||||||
continue
|
|
||||||
if line.startswith("+") and not line.startswith("+++"):
|
|
||||||
current_line += 1
|
|
||||||
content = line[1:]
|
|
||||||
|
|
||||||
# Check for hardcoded secrets
|
|
||||||
secret_re = r'(token|password|secret|key)\s*=\s*["\'][^"\']{8,}["\']' # nosec B105
|
|
||||||
is_secret = re.search(secret_re, content, re.IGNORECASE)
|
|
||||||
is_comment = content.strip().startswith("#")
|
|
||||||
is_example = "your-" in content or "example" in content
|
|
||||||
if is_secret and not is_comment and not is_example:
|
|
||||||
result.add_issue(
|
|
||||||
path,
|
|
||||||
current_line,
|
|
||||||
"potential hardcoded secret — use environment variable",
|
|
||||||
"error",
|
|
||||||
)
|
|
||||||
|
|
||||||
if not any(i["body"].startswith("[error]") and "secret" in i["body"] for i in result.issues):
|
|
||||||
result.add_summary("- Security: OK")
|
|
||||||
|
|
||||||
|
|
||||||
def check_i18n(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
|
||||||
"""Check that user-facing strings are wrapped in _().
|
|
||||||
|
|
||||||
Detects ``click.echo()`` calls with raw string literals that are not
|
|
||||||
wrapped in ``_()``. Only checks ``src/`` files, not tests or scripts.
|
|
||||||
"""
|
|
||||||
# Pattern: click.echo("...") or click.echo(f"...") without _() wrapper
|
|
||||||
raw_echo_re = re.compile(r'click\.echo\s*\(\s*["\']([^"\']+)["\']')
|
|
||||||
raw_fstring_re = re.compile(r'click\.echo\s*\(\s*f["\']')
|
|
||||||
# Also check click.ClickException and raise with string
|
|
||||||
raw_exception_re = re.compile(r'click\.ClickException\s*\(\s*["\']([^"\']+)["\']')
|
|
||||||
|
|
||||||
for f in files:
|
|
||||||
path = f.get("filename", "")
|
|
||||||
if not is_python_file(path) or not path.startswith("src/"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
patch = f.get("patch", "")
|
|
||||||
if not patch:
|
|
||||||
continue
|
|
||||||
|
|
||||||
lines = patch.split("\n")
|
|
||||||
current_line = 0
|
|
||||||
for line in lines:
|
|
||||||
if line.startswith("@@"):
|
|
||||||
match = re.search(r"\+(\d+)", line)
|
|
||||||
if match:
|
|
||||||
current_line = int(match.group(1)) - 1
|
|
||||||
continue
|
|
||||||
if line.startswith("+") and not line.startswith("+++"):
|
|
||||||
current_line += 1
|
|
||||||
content = line[1:]
|
|
||||||
|
|
||||||
# Skip comments and docstrings
|
|
||||||
stripped = content.strip()
|
|
||||||
if stripped.startswith("#") or stripped.startswith('"""') or stripped.startswith("'''"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check for raw strings in click.echo without _()
|
|
||||||
for regex, msg in [
|
|
||||||
(raw_echo_re, "click.echo() with raw string — wrap in _() for i18n"),
|
|
||||||
(raw_fstring_re, "click.echo() with f-string — wrap in _() for i18n"),
|
|
||||||
(raw_exception_re, "ClickException with raw string — wrap in _() for i18n"),
|
|
||||||
]:
|
|
||||||
if regex.search(content):
|
|
||||||
result.add_issue(path, current_line, msg, "warning")
|
|
||||||
|
|
||||||
if not any("i18n" in i["body"] for i in result.issues):
|
|
||||||
result.add_summary("- i18n: OK")
|
|
||||||
|
|
||||||
|
|
||||||
def check_resource_management(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
|
||||||
"""Check for resource leaks: open() without with, subprocess without cleanup.
|
|
||||||
|
|
||||||
Detects:
|
|
||||||
- ``open()`` calls not in a ``with`` statement
|
|
||||||
- ``subprocess.Popen()`` without ``.wait()`` or ``.communicate()``
|
|
||||||
"""
|
|
||||||
# Pattern: open("...") not preceded by "with" on the same line
|
|
||||||
open_re = re.compile(r"(?<!with\s)\bopen\s*\(")
|
|
||||||
popen_re = re.compile(r"subprocess\.Popen\s*\(")
|
|
||||||
|
|
||||||
for f in files:
|
|
||||||
path = f.get("filename", "")
|
|
||||||
if not is_python_file(path):
|
|
||||||
continue
|
|
||||||
|
|
||||||
patch = f.get("patch", "")
|
|
||||||
if not patch:
|
|
||||||
continue
|
|
||||||
|
|
||||||
lines = patch.split("\n")
|
|
||||||
current_line = 0
|
|
||||||
for line in lines:
|
|
||||||
if line.startswith("@@"):
|
|
||||||
match = re.search(r"\+(\d+)", line)
|
|
||||||
if match:
|
|
||||||
current_line = int(match.group(1)) - 1
|
|
||||||
continue
|
|
||||||
if line.startswith("+") and not line.startswith("+++"):
|
|
||||||
current_line += 1
|
|
||||||
content = line[1:]
|
|
||||||
|
|
||||||
# Skip comments
|
|
||||||
if content.strip().startswith("#"):
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Check for open() without with
|
|
||||||
if open_re.search(content) and "with " not in content:
|
|
||||||
result.add_issue(
|
|
||||||
path, current_line, "open() without with statement — potential resource leak", "warning"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check for Popen without communicate/wait on same line
|
|
||||||
if popen_re.search(content) and ".communicate" not in content and ".wait" not in content:
|
|
||||||
result.add_issue(
|
|
||||||
path,
|
|
||||||
current_line,
|
|
||||||
"subprocess.Popen() without immediate .communicate() or .wait() — ensure cleanup",
|
|
||||||
"warning",
|
|
||||||
)
|
|
||||||
|
|
||||||
if not any("resource" in i["body"].lower() for i in result.issues):
|
|
||||||
result.add_summary("- Resource management: OK")
|
|
||||||
|
|
||||||
|
|
||||||
def check_function_length(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
|
||||||
"""Check that no new function is excessively long (> 50 lines)."""
|
|
||||||
for f in files:
|
|
||||||
path = f.get("filename", "")
|
|
||||||
if not is_python_file(path):
|
|
||||||
continue
|
|
||||||
|
|
||||||
patch = f.get("patch", "")
|
|
||||||
if not patch:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Count consecutive added lines within a function
|
|
||||||
lines = patch.split("\n")
|
|
||||||
current_line = 0
|
|
||||||
func_start = 0
|
|
||||||
func_name = ""
|
|
||||||
added_in_func = 0
|
|
||||||
|
|
||||||
for line in lines:
|
|
||||||
if line.startswith("@@"):
|
|
||||||
if func_name and added_in_func > 50:
|
|
||||||
result.add_issue(
|
|
||||||
path,
|
|
||||||
func_start,
|
|
||||||
f"function '{func_name}' adds {added_in_func} lines — consider splitting (> 50 lines)",
|
|
||||||
"warning",
|
|
||||||
)
|
|
||||||
match = re.search(r"\+(\d+)", line)
|
|
||||||
if match:
|
|
||||||
current_line = int(match.group(1)) - 1
|
|
||||||
func_name = ""
|
|
||||||
added_in_func = 0
|
|
||||||
continue
|
|
||||||
if line.startswith("+") and not line.startswith("+++"):
|
|
||||||
current_line += 1
|
|
||||||
content = line[1:]
|
|
||||||
func_match = re.match(r"\s*def\s+(\w+)\s*\(", content)
|
|
||||||
if func_match:
|
|
||||||
if func_name and added_in_func > 50:
|
|
||||||
result.add_issue(
|
|
||||||
path,
|
|
||||||
func_start,
|
|
||||||
f"function '{func_name}' adds {added_in_func} lines — consider splitting (> 50 lines)",
|
|
||||||
"warning",
|
|
||||||
)
|
|
||||||
func_name = func_match.group(1)
|
|
||||||
func_start = current_line
|
|
||||||
added_in_func = 0
|
|
||||||
else:
|
|
||||||
added_in_func += 1
|
|
||||||
elif line.startswith(" ") or line.startswith("-"):
|
|
||||||
pass # context or removed line
|
|
||||||
|
|
||||||
# Check last function
|
|
||||||
if func_name and added_in_func > 50:
|
|
||||||
result.add_issue(
|
|
||||||
path,
|
|
||||||
func_start,
|
|
||||||
f"function '{func_name}' adds {added_in_func} lines — consider splitting (> 50 lines)",
|
|
||||||
"warning",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def check_documentation(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
|
||||||
"""Check that documentation is updated for relevant changes."""
|
|
||||||
has_src_changes = any(
|
|
||||||
is_python_file(f.get("filename", "")) and f.get("filename", "").startswith("src/") for f in files
|
|
||||||
)
|
|
||||||
has_doc_changes = any(
|
|
||||||
f.get("filename", "").startswith("docs/") or f.get("filename", "") in ("README.md", "AGENTS.md", "CHANGELOG.md")
|
|
||||||
for f in files
|
|
||||||
)
|
|
||||||
has_ansible_changes = any(f.get("filename", "").startswith("ansible/") for f in files)
|
|
||||||
|
|
||||||
if has_src_changes and not has_doc_changes:
|
|
||||||
result.add_summary("- Documentation: WARNING — source files changed but no docs updated")
|
|
||||||
elif has_ansible_changes and not has_doc_changes:
|
|
||||||
result.add_summary("- Documentation: WARNING — Ansible role changed but no docs updated")
|
|
||||||
else:
|
|
||||||
result.add_summary("- Documentation: OK")
|
|
||||||
|
|
||||||
|
|
||||||
def check_test_coverage(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
|
||||||
"""Check that tests are updated for source changes."""
|
|
||||||
has_src_changes = any(
|
|
||||||
is_python_file(f.get("filename", "")) and f.get("filename", "").startswith("src/") for f in files
|
|
||||||
)
|
|
||||||
has_test_changes = any(f.get("filename", "").startswith("tests/") for f in files)
|
|
||||||
|
|
||||||
if has_src_changes and not has_test_changes:
|
|
||||||
result.add_summary("- Tests: WARNING — source files changed but no test files updated")
|
|
||||||
else:
|
|
||||||
result.add_summary("- Tests: OK")
|
|
||||||
|
|
||||||
|
|
||||||
def check_commit_conventions(client: GiteaClient, pr_number: str, result: ReviewResult) -> None:
|
|
||||||
"""Check that PR commits follow conventional commit format.
|
|
||||||
|
|
||||||
Verifies that at least one commit on the PR branch matches the
|
|
||||||
conventional commit pattern (type: description). Merge commits
|
|
||||||
and revert commits are exempt.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
commits = client.get_pr_commits(pr_number)
|
|
||||||
except APIError as e:
|
|
||||||
result.add_summary(f"- Commit conventions: ERROR — could not fetch commits: {e.message}")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not commits:
|
|
||||||
result.add_summary("- Commit conventions: OK (no commits to check)")
|
|
||||||
return
|
|
||||||
|
|
||||||
from gitea_runner_manager.config import CONVENTIONAL_RE
|
|
||||||
|
|
||||||
has_conventional = False
|
|
||||||
non_conventional: list[str] = []
|
|
||||||
|
|
||||||
for commit in commits:
|
|
||||||
commit_info = commit.get("commit", {})
|
|
||||||
message = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
|
|
||||||
# Skip merge commits and revert commits
|
|
||||||
if message.startswith(("Merge", "Revert")):
|
|
||||||
continue
|
|
||||||
if CONVENTIONAL_RE.match(message):
|
|
||||||
has_conventional = True
|
|
||||||
else:
|
|
||||||
non_conventional.append(message[:60])
|
|
||||||
|
|
||||||
if has_conventional:
|
|
||||||
result.add_summary("- Commit conventions: OK")
|
|
||||||
elif non_conventional:
|
|
||||||
result.add_summary(
|
|
||||||
f"- Commit conventions: WARNING — no conventional commit found. "
|
|
||||||
f"Non-conventional commits: {', '.join(non_conventional[:3])}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
result.add_summary("- Commit conventions: OK (all commits are merges/reverts)")
|
|
||||||
|
|
||||||
|
|
||||||
def run_review(client: GiteaClient, pr_number: str) -> ReviewResult:
|
|
||||||
"""Run all review checks and return the result."""
|
|
||||||
result = ReviewResult()
|
|
||||||
|
|
||||||
try:
|
|
||||||
files = client.get_pr_files(pr_number)
|
|
||||||
except APIError as e:
|
|
||||||
result.add_summary(f"- ERROR: Could not fetch PR files: {e.message}")
|
|
||||||
return result
|
|
||||||
|
|
||||||
if not files:
|
|
||||||
result.add_summary("- No files changed in this PR")
|
|
||||||
return result
|
|
||||||
|
|
||||||
# Run all checks
|
|
||||||
check_architecture_compliance(files, result)
|
|
||||||
check_best_practices(files, result)
|
|
||||||
check_security(files, result)
|
|
||||||
check_i18n(files, result)
|
|
||||||
check_resource_management(files, result)
|
|
||||||
check_function_length(files, result)
|
|
||||||
check_documentation(files, result)
|
|
||||||
check_test_coverage(files, result)
|
|
||||||
check_commit_conventions(client, pr_number, result)
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def build_review_body(result: ReviewResult) -> str:
|
|
||||||
"""Build the review body text from the review result."""
|
|
||||||
lines = ["## Automated PR Review", ""]
|
|
||||||
|
|
||||||
for item in result.summary:
|
|
||||||
lines.append(item)
|
|
||||||
|
|
||||||
if result.issues:
|
|
||||||
lines.append("")
|
|
||||||
lines.append(f"**{len(result.issues)} issue(s) found:**")
|
|
||||||
lines.append("")
|
|
||||||
for issue in result.issues:
|
|
||||||
lines.append(f"- `{issue['path']}:{issue['new_position']}` — {issue['body']}")
|
|
||||||
else:
|
|
||||||
lines.append("")
|
|
||||||
lines.append("No issues found by automated checks.")
|
|
||||||
|
|
||||||
lines.append("")
|
|
||||||
lines.append("---")
|
|
||||||
lines.append("**Auto-merge:** If all CI checks pass, this PR will be merged automatically.")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
|
|
||||||
def post_review(client: GiteaClient, pr_number: str, result: ReviewResult) -> dict[str, Any]:
|
|
||||||
"""Post the review to the PR.
|
|
||||||
|
|
||||||
Uses REQUEST_CHANGES when issues are found, COMMENT otherwise.
|
|
||||||
Never uses APPROVE — the bot shares the PR author's token, so
|
|
||||||
Gitea rejects self-approval. The actual APPROVE must come from
|
|
||||||
the manual review step.
|
|
||||||
"""
|
|
||||||
body = build_review_body(result)
|
|
||||||
event = "REQUEST_CHANGES" if result.has_issues else "COMMENT"
|
|
||||||
comments = result.issues if result.has_issues else []
|
|
||||||
|
|
||||||
return client.create_review(pr_number, event=event, body=body, comments=comments)
|
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
|
||||||
@click.argument("pr_number")
|
|
||||||
@click.argument("repo")
|
|
||||||
@click.option("--dry-run", is_flag=True, default=False, help="Print review without posting.")
|
|
||||||
def main(pr_number: str, repo: str, dry_run: bool) -> None:
|
|
||||||
"""Run automated PR review and post results to Gitea."""
|
|
||||||
token = os.environ.get("REPO_TOKEN", "")
|
|
||||||
if not token:
|
|
||||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
|
||||||
|
|
||||||
owner, repo_name = repo.split("/")
|
|
||||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
|
||||||
|
|
||||||
result = run_review(client, pr_number)
|
|
||||||
|
|
||||||
body = build_review_body(result)
|
|
||||||
event = "REQUEST_CHANGES" if result.has_issues else "COMMENT"
|
|
||||||
|
|
||||||
click.echo(f"Review event: {event}")
|
|
||||||
click.echo(f"Issues found: {len(result.issues)}")
|
|
||||||
click.echo("")
|
|
||||||
click.echo(body)
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
click.echo("\n[dry-run] Review not posted.")
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
review = post_review(client, pr_number, result)
|
|
||||||
except APIError as e:
|
|
||||||
if "approve" in e.message.lower() or "422" in str(e.status):
|
|
||||||
click.echo(_("Note: Self-approval not allowed. Posting COMMENT instead."))
|
|
||||||
review = client.create_review(pr_number, event="COMMENT", body=body)
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
review_id = review.get("id", "?")
|
|
||||||
click.echo(
|
|
||||||
_(
|
|
||||||
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
|
|
||||||
review_id=review_id,
|
|
||||||
pr_number=pr_number,
|
|
||||||
event=event,
|
|
||||||
num_comments=len(result.issues),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
main() # pragma: no cover
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Build package, optionally publish to PyPI, and create Gitea release.
|
|
||||||
|
|
||||||
Uses git-cliff to generate the release notes from conventional commits.
|
|
||||||
Uses the ``tea`` Gitea CLI for release creation.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
REPO_TOKEN=<token> [PYPI_TOKEN=<token>] python3 scripts/publish.py <tag> <repo>
|
|
||||||
"""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import subprocess # nosec B404
|
|
||||||
import sys
|
|
||||||
|
|
||||||
import click
|
|
||||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
|
||||||
|
|
||||||
from scripts.gitea_cli import TeaCLI, TeaCLIError
|
|
||||||
from scripts.i18n import _
|
|
||||||
|
|
||||||
load_dotenv(override=True)
|
|
||||||
|
|
||||||
CLIFF_CONFIG = "cliff.toml"
|
|
||||||
|
|
||||||
|
|
||||||
def generate_release_notes(tag: str) -> str:
|
|
||||||
"""Generate release notes for the given tag using git-cliff.
|
|
||||||
|
|
||||||
Falls back to a generic message if git-cliff is not available.
|
|
||||||
"""
|
|
||||||
cliff_bin = shutil.which("git-cliff")
|
|
||||||
if not cliff_bin:
|
|
||||||
return f"Release {tag}\n\nSee CHANGELOG.md for details."
|
|
||||||
try:
|
|
||||||
result = subprocess.run( # nosec B603
|
|
||||||
[cliff_bin, "--config", CLIFF_CONFIG, "--latest", "--strip", "header"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode == 0 and result.stdout.strip():
|
|
||||||
return result.stdout.strip()
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
return f"Release {tag}\n\nSee CHANGELOG.md for details."
|
|
||||||
|
|
||||||
|
|
||||||
def build_package() -> None:
|
|
||||||
"""Build the Python package using python -m build."""
|
|
||||||
result = subprocess.run( # nosec B603
|
|
||||||
[sys.executable, "-m", "build"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise click.ClickException(
|
|
||||||
_(
|
|
||||||
"Oops! Package build failed:\n{stderr}",
|
|
||||||
stderr=result.stderr.strip(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def publish_to_pypi(token: str) -> None:
|
|
||||||
"""Publish built packages to PyPI using twine."""
|
|
||||||
result = subprocess.run( # nosec B603
|
|
||||||
[
|
|
||||||
sys.executable,
|
|
||||||
"-m",
|
|
||||||
"twine",
|
|
||||||
"upload",
|
|
||||||
"dist/*",
|
|
||||||
"-u",
|
|
||||||
"__token__",
|
|
||||||
"-p",
|
|
||||||
token,
|
|
||||||
],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise click.ClickException(
|
|
||||||
_(
|
|
||||||
"Oops! PyPI publish failed:\n{stderr}",
|
|
||||||
stderr=result.stderr.strip(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
click.echo(_("Published to PyPI."))
|
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
|
||||||
@click.argument("tag")
|
|
||||||
@click.argument("repo")
|
|
||||||
def main(tag: str, repo: str) -> None:
|
|
||||||
gitea_token = os.environ.get("REPO_TOKEN", "")
|
|
||||||
if not gitea_token:
|
|
||||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
|
||||||
|
|
||||||
pypi_token = os.environ.get("PYPI_TOKEN", "")
|
|
||||||
|
|
||||||
build_package()
|
|
||||||
|
|
||||||
if pypi_token:
|
|
||||||
publish_to_pypi(pypi_token)
|
|
||||||
else:
|
|
||||||
click.echo(_("PYPI_TOKEN not set — skipping PyPI publish. No worries, we'll just create the Gitea release."))
|
|
||||||
|
|
||||||
tea = TeaCLI(repo=repo)
|
|
||||||
release_body = generate_release_notes(tag)
|
|
||||||
|
|
||||||
try:
|
|
||||||
tea.create_release(repo, tag=tag, title=tag, body=release_body)
|
|
||||||
except TeaCLIError as e:
|
|
||||||
raise click.ClickException(_("Release creation failed: {error}", error=str(e))) from None
|
|
||||||
|
|
||||||
click.echo(
|
|
||||||
_(
|
|
||||||
"Nice! Gitea release {tag} created.",
|
|
||||||
tag=tag,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
main()
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Generate badge SVG files and push them to the ``badges`` branch.
|
|
||||||
|
|
||||||
Also updates README.md and docs/index.md on master with cache-busting
|
|
||||||
``raw/commit/<sha>/badge.svg`` URLs so that browsers always fetch the
|
|
||||||
latest badge version (Gitea caches ``raw/branch/`` URLs for 6 hours).
|
|
||||||
|
|
||||||
The script fetches the latest master before generating badges so that
|
|
||||||
the version badge always reflects the current state of the repository
|
|
||||||
(even if a release commit was pushed moments before by the parallel
|
|
||||||
release job).
|
|
||||||
|
|
||||||
Usage::
|
|
||||||
|
|
||||||
python3 scripts/ci/push_badges.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
import subprocess # nosec B404
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
||||||
|
|
||||||
# Badge filenames that get pushed to the badges branch
|
|
||||||
BADGE_FILES = ["coverage.svg", "tests.svg", "docs.svg", "quality.svg", "version.svg", "python.svg"]
|
|
||||||
|
|
||||||
# Files that contain badge URLs and need to be updated
|
|
||||||
FILES_WITH_BADGE_URLS = ["README.md", "docs/index.md"]
|
|
||||||
|
|
||||||
# Pattern to match raw/branch/badges/<name>.svg URLs
|
|
||||||
_BADGE_URL_RE = re.compile(r"(https://[^/]+/[^/]+/[^/]+/raw/)(?:branch/badges|commit/[0-9a-f]{40})/([a-z_]+\.svg)")
|
|
||||||
|
|
||||||
|
|
||||||
def _run(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
|
||||||
"""Run a command and return the result."""
|
|
||||||
return subprocess.run(cmd, check=True, text=True, **kwargs) # nosec B603
|
|
||||||
|
|
||||||
|
|
||||||
def _run_capture(cmd: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
|
||||||
"""Run a command and capture stdout."""
|
|
||||||
return subprocess.run(cmd, check=True, text=True, capture_output=True, **kwargs) # nosec B603
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_latest_master(branch: str = "master") -> None:
|
|
||||||
"""Fetch and hard-reset to the latest remote branch.
|
|
||||||
|
|
||||||
Ensures the working tree reflects the absolute latest state of the
|
|
||||||
remote, which is critical when the release job may have just pushed
|
|
||||||
a new version commit.
|
|
||||||
"""
|
|
||||||
_run(["git", "fetch", "origin", branch]) # nosec B607
|
|
||||||
_run(["git", "reset", "--hard", f"origin/{branch}"]) # nosec B607
|
|
||||||
click.echo(f"Synced to latest origin/{branch}")
|
|
||||||
|
|
||||||
|
|
||||||
def generate_badges(output_dir: str) -> None:
|
|
||||||
"""Generate badge SVG files using generate_badges.py."""
|
|
||||||
_run([sys.executable, "scripts/generate_badges.py", "--output-dir", output_dir])
|
|
||||||
badges = list(Path(output_dir).glob("*.svg"))
|
|
||||||
if not badges:
|
|
||||||
raise click.ClickException("No badge SVG files generated")
|
|
||||||
click.echo(f"Generated {len(badges)} badge files")
|
|
||||||
|
|
||||||
|
|
||||||
def push_to_badges_branch(badges_dir: str) -> str:
|
|
||||||
"""Push generated badges to the orphan ``badges`` branch.
|
|
||||||
|
|
||||||
Returns the commit SHA of the pushed badges branch.
|
|
||||||
"""
|
|
||||||
_run(["git", "config", "user.name", "gitea-actions-bot"]) # nosec B607
|
|
||||||
_run(["git", "config", "user.email", "actions@oblachno.fyi"]) # nosec B607
|
|
||||||
_run(["git", "checkout", "--orphan", "badges"]) # nosec B607
|
|
||||||
_run(["git", "rm", "-rf", "."]) # nosec B607
|
|
||||||
|
|
||||||
# Copy badge files to root
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
for svg in Path(badges_dir).glob("*.svg"):
|
|
||||||
shutil.copy2(svg, Path.cwd() / svg.name)
|
|
||||||
|
|
||||||
_run(["git", "add", "./*.svg"]) # nosec B607
|
|
||||||
_run(["git", "commit", "--no-verify", "-m", "Update badges [skip ci]"]) # nosec B607
|
|
||||||
_run(["git", "push", "origin", "badges", "--force"]) # nosec B607
|
|
||||||
click.echo("Badges pushed to badges branch")
|
|
||||||
|
|
||||||
# Get the commit SHA of the badges branch
|
|
||||||
result = _run_capture(["git", "rev-parse", "HEAD"]) # nosec B607
|
|
||||||
sha = result.stdout.strip()
|
|
||||||
click.echo(f"Badges commit SHA: {sha}")
|
|
||||||
return sha
|
|
||||||
|
|
||||||
|
|
||||||
def update_badge_urls(content: str, badges_sha: str) -> str:
|
|
||||||
"""Replace raw/branch/badges/<name>.svg URLs with raw/commit/<sha>/<name>.svg.
|
|
||||||
|
|
||||||
This bypasses Gitea's 6-hour cache on raw/branch/ URLs by using a
|
|
||||||
URL that changes each time the badges branch is updated.
|
|
||||||
"""
|
|
||||||
return _BADGE_URL_RE.sub(
|
|
||||||
lambda m: f"{m.group(1)}commit/{badges_sha}/{m.group(2)}",
|
|
||||||
content,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None) -> None:
|
|
||||||
"""Update README.md and docs/index.md with cache-busting badge URLs.
|
|
||||||
|
|
||||||
Switches back to master, replaces ``raw/branch/badges/`` URLs with
|
|
||||||
``raw/commit/<sha>/`` URLs, commits and pushes.
|
|
||||||
"""
|
|
||||||
root = repo_root or REPO_ROOT
|
|
||||||
|
|
||||||
# Switch back to master
|
|
||||||
_run(["git", "checkout", "master"]) # nosec B607
|
|
||||||
_run(["git", "fetch", "origin", "master"]) # nosec B607
|
|
||||||
_run(["git", "reset", "--hard", "origin/master"]) # nosec B607
|
|
||||||
|
|
||||||
updated_any = False
|
|
||||||
for filename in FILES_WITH_BADGE_URLS:
|
|
||||||
filepath = root / filename
|
|
||||||
if not filepath.exists():
|
|
||||||
continue
|
|
||||||
content = filepath.read_text()
|
|
||||||
new_content = update_badge_urls(content, badges_sha)
|
|
||||||
if new_content != content:
|
|
||||||
filepath.write_text(new_content)
|
|
||||||
click.echo(f"Updated badge URLs in {filename}")
|
|
||||||
updated_any = True
|
|
||||||
|
|
||||||
if not updated_any:
|
|
||||||
click.echo("No badge URLs found to update — README already up to date")
|
|
||||||
return
|
|
||||||
|
|
||||||
_run(["git", "add", "README.md", "docs/index.md"]) # nosec B607
|
|
||||||
_run(
|
|
||||||
[
|
|
||||||
"git",
|
|
||||||
"commit",
|
|
||||||
"--no-verify",
|
|
||||||
"-m",
|
|
||||||
f"chore: update badge URLs to commit {badges_sha[:8]} [skip ci]",
|
|
||||||
]
|
|
||||||
) # nosec B607
|
|
||||||
_run(["git", "push", "origin", "master"]) # nosec B607
|
|
||||||
click.echo(f"Pushed README update with badge SHA {badges_sha[:8]}")
|
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
|
||||||
@click.option("--output-dir", default=".badges/", help="Temporary directory for badge files.")
|
|
||||||
@click.option("--branch", default="master", help="Branch to sync before generating badges.")
|
|
||||||
@click.option(
|
|
||||||
"--no-readme-update",
|
|
||||||
is_flag=True,
|
|
||||||
default=False,
|
|
||||||
help="Skip updating README with cache-busting URLs (for local testing).",
|
|
||||||
)
|
|
||||||
def main(output_dir: str, branch: str, no_readme_update: bool) -> None:
|
|
||||||
"""Generate badges and push them to the badges branch."""
|
|
||||||
fetch_latest_master(branch)
|
|
||||||
generate_badges(output_dir)
|
|
||||||
badges_sha = push_to_badges_branch(output_dir)
|
|
||||||
if not no_readme_update:
|
|
||||||
update_readme_with_badge_sha(badges_sha)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
main() # pragma: no cover
|
|
||||||
@@ -1,369 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Automated release: calculate next version, update files, tag, and push.
|
|
||||||
|
|
||||||
Uses git-cliff to determine the next semver version from conventional commits
|
|
||||||
since the last tag. Updates ``__version__`` in ``__init__.py`` (the single
|
|
||||||
source of truth, read by setuptools via ``dynamic = ["version"]``) and
|
|
||||||
``CHANGELOG.md``, commits them with a ``release:`` prefix, tags the commit
|
|
||||||
with the changelog as the tag message, and pushes both to trigger the publish
|
|
||||||
workflow.
|
|
||||||
|
|
||||||
**Test enforcement**: Before committing or tagging, the script runs
|
|
||||||
``make lint-ruff`` and ``make pytest-cov`` to verify the release is healthy.
|
|
||||||
If either fails, the release is aborted — no commit, no tag. This ensures
|
|
||||||
we never release a version that fails tests. Use ``--skip-tests`` only for
|
|
||||||
emergency releases (not recommended).
|
|
||||||
|
|
||||||
The ``release:`` prefix (instead of ``chore(release):``) keeps the history
|
|
||||||
clean while still being descriptive. Loops are prevented by the
|
|
||||||
``has_unreleased_changes`` check — after a release commit is tagged, the next
|
|
||||||
run finds no unreleased changes and exits.
|
|
||||||
|
|
||||||
This script is idempotent: if there are no new conventional commits since the
|
|
||||||
last tag, it exits with a message and does nothing. If the tag already exists
|
|
||||||
(e.g., from a partial previous run), it skips tag creation and only pushes.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
REPO_TOKEN=<token> python3 scripts/release.py [--dry-run] [--skip-tests]
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
import subprocess # nosec B404
|
|
||||||
|
|
||||||
import click
|
|
||||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
|
||||||
|
|
||||||
from scripts.ci.classify_changes import has_user_facing_changes # cross-CI import, needs PYTHONPATH=.
|
|
||||||
from scripts.i18n import _
|
|
||||||
|
|
||||||
load_dotenv(override=True)
|
|
||||||
|
|
||||||
INIT_FILE = "src/gitea_runner_manager/__init__.py"
|
|
||||||
CHANGELOG_FILE = "CHANGELOG.md"
|
|
||||||
CLIFF_CONFIG = "cliff.toml"
|
|
||||||
|
|
||||||
|
|
||||||
def run_cmd(args: list[str], check: bool = True, capture: bool = True) -> subprocess.CompletedProcess[str]:
|
|
||||||
"""Run a command and return the completed process."""
|
|
||||||
result = subprocess.run( # nosec B603
|
|
||||||
args,
|
|
||||||
capture_output=capture,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if check and result.returncode != 0:
|
|
||||||
raise click.ClickException(
|
|
||||||
_(
|
|
||||||
"Command failed ({cmd}): {stderr}",
|
|
||||||
cmd=" ".join(args),
|
|
||||||
stderr=result.stderr.strip() if result.stderr else result.stdout.strip(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
def get_latest_tag() -> str:
|
|
||||||
"""Get the latest git tag, or empty string if none exists."""
|
|
||||||
result = run_cmd(["git", "describe", "--tags", "--abbrev=0"], check=False)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return ""
|
|
||||||
return result.stdout.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def tag_exists(tag: str) -> bool:
|
|
||||||
"""Check if a git tag already exists."""
|
|
||||||
result = run_cmd(["git", "tag", "-l", tag], check=False)
|
|
||||||
return bool(result.stdout.strip())
|
|
||||||
|
|
||||||
|
|
||||||
def get_bumped_version() -> str:
|
|
||||||
"""Use git-cliff to calculate the next version from conventional commits."""
|
|
||||||
result = run_cmd(["git-cliff", "--bumped-version", "--config", CLIFF_CONFIG])
|
|
||||||
version = result.stdout.strip()
|
|
||||||
if not version:
|
|
||||||
raise click.ClickException(_("git-cliff returned empty version."))
|
|
||||||
# git-cliff may return with or without 'v' prefix
|
|
||||||
return version.lstrip("v")
|
|
||||||
|
|
||||||
|
|
||||||
def get_changelog(new_version: str) -> str:
|
|
||||||
"""Generate changelog content for the new version using git-cliff."""
|
|
||||||
result = run_cmd(
|
|
||||||
[
|
|
||||||
"git-cliff",
|
|
||||||
"--config",
|
|
||||||
CLIFF_CONFIG,
|
|
||||||
"--tag",
|
|
||||||
f"v{new_version}",
|
|
||||||
"--unreleased",
|
|
||||||
"--bump",
|
|
||||||
]
|
|
||||||
)
|
|
||||||
return result.stdout.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def has_unreleased_changes(bumped_version: str | None = None) -> bool:
|
|
||||||
"""Check if there are unreleased conventional commits since the last tag.
|
|
||||||
|
|
||||||
Uses ``git log`` to check for commits between the last tag and HEAD.
|
|
||||||
This is more reliable than comparing version strings — if git-cliff
|
|
||||||
bumps to the same version (e.g., two fix commits between tags), the
|
|
||||||
version comparison would incorrectly report "no unreleased changes"
|
|
||||||
even though there are commits that haven't been released yet.
|
|
||||||
"""
|
|
||||||
latest = get_latest_tag()
|
|
||||||
if not latest:
|
|
||||||
return True
|
|
||||||
# Check for any commits since the last tag
|
|
||||||
result = run_cmd(
|
|
||||||
["git", "log", f"{latest}..HEAD", "--oneline"],
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
return False
|
|
||||||
return bool(result.stdout.strip())
|
|
||||||
|
|
||||||
|
|
||||||
def update_init_version(new_version: str) -> None:
|
|
||||||
"""Update __version__ in __init__.py."""
|
|
||||||
with open(INIT_FILE) as f:
|
|
||||||
content = f.read()
|
|
||||||
if not re.search(r'^__version__\s*=\s*"[^"]*"', content, flags=re.MULTILINE):
|
|
||||||
raise click.ClickException(_("Could not find __version__ in {file}", file=INIT_FILE))
|
|
||||||
updated = re.sub(
|
|
||||||
r'^__version__\s*=\s*"[^"]*"',
|
|
||||||
f'__version__ = "{new_version}"',
|
|
||||||
content,
|
|
||||||
count=1,
|
|
||||||
flags=re.MULTILINE,
|
|
||||||
)
|
|
||||||
with open(INIT_FILE, "w") as f:
|
|
||||||
f.write(updated)
|
|
||||||
|
|
||||||
|
|
||||||
def update_changelog(changelog: str) -> None:
|
|
||||||
"""Prepend the new changelog section to CHANGELOG.md.
|
|
||||||
|
|
||||||
The changelog from git-cliff may include a header (e.g., "# Changelog").
|
|
||||||
This function strips everything before the first ``## [`` version section
|
|
||||||
before inserting, to avoid duplicating the header.
|
|
||||||
"""
|
|
||||||
# Strip git-cliff header — keep only from the first version section
|
|
||||||
section_match = re.search(r"^## \[", changelog, flags=re.MULTILINE)
|
|
||||||
if section_match:
|
|
||||||
changelog = changelog[section_match.start() :]
|
|
||||||
|
|
||||||
try:
|
|
||||||
with open(CHANGELOG_FILE) as f:
|
|
||||||
existing = f.read()
|
|
||||||
except FileNotFoundError:
|
|
||||||
with open(CHANGELOG_FILE, "w") as f:
|
|
||||||
f.write(changelog + "\n")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Find the first version section header (## [...] or ## [unreleased])
|
|
||||||
match = re.search(r"^## \[", existing, flags=re.MULTILINE)
|
|
||||||
if match:
|
|
||||||
# Insert before the first version section
|
|
||||||
pos = match.start()
|
|
||||||
updated = existing[:pos] + changelog + "\n\n" + existing[pos:]
|
|
||||||
else:
|
|
||||||
# No version sections found — append
|
|
||||||
updated = existing.rstrip() + "\n\n" + changelog + "\n"
|
|
||||||
with open(CHANGELOG_FILE, "w") as f:
|
|
||||||
f.write(updated)
|
|
||||||
|
|
||||||
|
|
||||||
def commit_release_changes(new_version: str) -> bool:
|
|
||||||
"""Stage version file and changelog, then create a release commit.
|
|
||||||
|
|
||||||
Uses ``release:`` prefix (not ``chore(release):``) for clarity.
|
|
||||||
The commit is created with ``--no-verify`` to bypass the commit-msg hook
|
|
||||||
(which requires ``GRM-N:`` prefix for master commits) since release
|
|
||||||
commits are a special case generated by the release script.
|
|
||||||
Returns True if a commit was created, False if there were no staged changes.
|
|
||||||
"""
|
|
||||||
run_cmd(["git", "add", INIT_FILE, CHANGELOG_FILE])
|
|
||||||
status = run_cmd(["git", "diff", "--cached", "--quiet"], check=False)
|
|
||||||
if status.returncode == 0:
|
|
||||||
click.echo(_("No staged changes — version and changelog already up to date."))
|
|
||||||
return False
|
|
||||||
run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version} [skip ci]"])
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def run_tests() -> None:
|
|
||||||
"""Run lint and tests to verify the release is healthy.
|
|
||||||
|
|
||||||
This is called *after* version files are updated but *before* the tag is
|
|
||||||
created, ensuring we never tag a release that fails tests.
|
|
||||||
"""
|
|
||||||
click.echo(_("Running lint checks..."))
|
|
||||||
lint = run_cmd(["make", "lint-ruff"], check=False)
|
|
||||||
if lint.returncode != 0:
|
|
||||||
raise click.ClickException(
|
|
||||||
_(
|
|
||||||
"Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
|
|
||||||
stderr=lint.stderr.strip() if lint.stderr else lint.stdout.strip(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
click.echo(_("Lint passed."))
|
|
||||||
|
|
||||||
click.echo(_("Running tests..."))
|
|
||||||
tests = run_cmd(["make", "pytest-cov"], check=False)
|
|
||||||
if tests.returncode != 0:
|
|
||||||
raise click.ClickException(
|
|
||||||
_(
|
|
||||||
"Tests failed — refusing to release. Fix test failures first.\n{stderr}",
|
|
||||||
stderr=tests.stderr.strip() if tests.stderr else tests.stdout.strip(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
click.echo(_("Tests passed."))
|
|
||||||
|
|
||||||
|
|
||||||
def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool:
|
|
||||||
"""Create an annotated tag with the changelog as message and push it.
|
|
||||||
|
|
||||||
Returns True if the tag was created/pushed, False if it already existed.
|
|
||||||
"""
|
|
||||||
tag = f"v{new_version}"
|
|
||||||
if tag_exists(tag):
|
|
||||||
click.echo(_("Tag {tag} already exists, skipping creation.", tag=tag))
|
|
||||||
if not dry_run:
|
|
||||||
# Ensure the existing tag is pushed
|
|
||||||
run_cmd(["git", "push", "origin", tag], check=False)
|
|
||||||
return False
|
|
||||||
tag_msg = f"Release v{new_version}\n\n{changelog}"
|
|
||||||
if dry_run:
|
|
||||||
click.echo(_("[dry-run] Would create tag: {tag}", tag=tag))
|
|
||||||
return True
|
|
||||||
run_cmd(["git", "tag", "-a", tag, "-m", tag_msg])
|
|
||||||
run_cmd(["git", "push", "origin", tag])
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
|
||||||
@click.option("--dry-run", is_flag=True, default=False, help="Show what would happen without making changes.")
|
|
||||||
@click.option(
|
|
||||||
"--skip-tests",
|
|
||||||
is_flag=True,
|
|
||||||
default=False,
|
|
||||||
help="Skip lint and test verification (NOT recommended — only for emergency releases).",
|
|
||||||
)
|
|
||||||
def main(dry_run: bool, skip_tests: bool) -> None:
|
|
||||||
# Ensure we're on master (skip this check in dry-run mode for PR validation)
|
|
||||||
branch = run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip()
|
|
||||||
if branch != "master" and not dry_run:
|
|
||||||
raise click.ClickException(_("Release must be run on master, currently on '{branch}'.", branch=branch))
|
|
||||||
if branch != "master" and dry_run:
|
|
||||||
click.echo(
|
|
||||||
_(
|
|
||||||
"Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
|
|
||||||
branch=branch,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Release lock: if HEAD is already a release commit, another release
|
|
||||||
# run is in progress (or already completed). Skip to prevent duplicate tags.
|
|
||||||
head_msg = run_cmd(["git", "log", "-1", "--pretty=%s"]).stdout.strip()
|
|
||||||
if re.match(r"^release: v\d+\.\d+\.\d+", head_msg):
|
|
||||||
click.echo(
|
|
||||||
_(
|
|
||||||
"HEAD is already a release commit ('{msg}'). Another release may have just completed. Skipping.",
|
|
||||||
msg=head_msg,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check if any user-facing files changed since the last tag.
|
|
||||||
# If only workflow/infra files changed, skip the release entirely.
|
|
||||||
latest_tag = get_latest_tag()
|
|
||||||
if latest_tag and not has_user_facing_changes(latest_tag, "HEAD"):
|
|
||||||
click.echo(
|
|
||||||
_(
|
|
||||||
"No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
|
|
||||||
tag=latest_tag,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
# Calculate next version (single git-cliff call — Gap 7 fix)
|
|
||||||
new_version = get_bumped_version()
|
|
||||||
|
|
||||||
# Check for unreleased changes (reuses the version we just calculated)
|
|
||||||
if not has_unreleased_changes(bumped_version=new_version):
|
|
||||||
click.echo(_("No unreleased changes found. Nothing to release."))
|
|
||||||
return
|
|
||||||
|
|
||||||
current_tag = get_latest_tag()
|
|
||||||
click.echo(
|
|
||||||
_(
|
|
||||||
"Bumping version: {current} -> v{new_version}",
|
|
||||||
current=current_tag or "(none)",
|
|
||||||
new_version=new_version,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# Generate changelog
|
|
||||||
changelog = get_changelog(new_version)
|
|
||||||
if not changelog:
|
|
||||||
click.echo(_("Warning: git-cliff generated empty changelog."))
|
|
||||||
|
|
||||||
if dry_run:
|
|
||||||
click.echo(_("\n[dry-run] Changelog:\n{changelog}", changelog=changelog))
|
|
||||||
click.echo(_("[dry-run] Would update {init}", init=INIT_FILE))
|
|
||||||
click.echo(_("[dry-run] Would update {changelog_file}", changelog_file=CHANGELOG_FILE))
|
|
||||||
click.echo(_("[dry-run] Would commit: release: v{version}", version=new_version))
|
|
||||||
click.echo(_("[dry-run] Would push commit to master"))
|
|
||||||
click.echo(_("[dry-run] Would create tag: v{version}", version=new_version))
|
|
||||||
return
|
|
||||||
|
|
||||||
# Update version file
|
|
||||||
update_init_version(new_version)
|
|
||||||
click.echo(_("Updated version in {init}", init=INIT_FILE))
|
|
||||||
|
|
||||||
# Update CHANGELOG.md (Gap 3 fix)
|
|
||||||
update_changelog(changelog)
|
|
||||||
click.echo(_("Updated {changelog_file}", changelog_file=CHANGELOG_FILE))
|
|
||||||
|
|
||||||
# Verify tests pass BEFORE committing or tagging.
|
|
||||||
# This ensures we never release a version that fails tests.
|
|
||||||
if skip_tests:
|
|
||||||
click.echo(_("WARNING: --skip-tests passed — skipping test verification."))
|
|
||||||
else:
|
|
||||||
run_tests()
|
|
||||||
|
|
||||||
# Commit version + changelog (Gap 11: use 'release:' prefix, not 'chore(release):')
|
|
||||||
committed = commit_release_changes(new_version)
|
|
||||||
if committed:
|
|
||||||
click.echo(_("Created release commit."))
|
|
||||||
# Pull --rebase before push to handle the case where master
|
|
||||||
# advanced between checkout and commit (e.g., another merge).
|
|
||||||
run_cmd(["git", "pull", "--rebase", "origin", "master"], check=False)
|
|
||||||
run_cmd(["git", "push", "origin", "master"])
|
|
||||||
click.echo(_("Pushed release commit to master."))
|
|
||||||
else:
|
|
||||||
click.echo(_("Skipping commit push — no staged changes."))
|
|
||||||
|
|
||||||
# Create and push tag (Gap 4: handles existing tag)
|
|
||||||
created = create_and_push_tag(new_version, changelog, dry_run)
|
|
||||||
if created:
|
|
||||||
click.echo(
|
|
||||||
_(
|
|
||||||
"Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.",
|
|
||||||
version=new_version,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
click.echo(
|
|
||||||
_(
|
|
||||||
"Tag v{version} already existed. Publish workflow should already have been triggered.",
|
|
||||||
version=new_version,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
main()
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Sync documentation from /docs/ to the Gitea wiki via API.
|
|
||||||
|
|
||||||
Reads markdown files from the ``docs/`` directory, uses ``mapping.json`` to
|
|
||||||
map file paths to wiki page titles, and creates/updates wiki pages via the
|
|
||||||
Gitea API. Pages that exist in the wiki but not in the mapping are left
|
|
||||||
untouched (not deleted).
|
|
||||||
|
|
||||||
Gitea 1.26 wiki API endpoints (all use content_base64, NOT content):
|
|
||||||
- Create: POST /repos/{owner}/{repo}/wiki/new {title, content_base64, message}
|
|
||||||
- Update: PATCH /repos/{owner}/{repo}/wiki/page/{sub_url} {title, content_base64, message}
|
|
||||||
- List: GET /repos/{owner}/{repo}/wiki/pages → [{title, sub_url, ...}]
|
|
||||||
- Fetch: GET /repos/{owner}/{repo}/wiki/page/{sub_url} → {title, content_base64, ...}
|
|
||||||
- Delete: DELETE /repos/{owner}/{repo}/wiki/page/{sub_url}
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
REPO_TOKEN=<token> python3 scripts/ci/sync_wiki.py [--dry-run] [--repo owner/repo]
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import click
|
|
||||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
|
||||||
|
|
||||||
from gitea_runner_manager.api_clients import GiteaClient
|
|
||||||
from gitea_runner_manager.config import GITEA_API_URL
|
|
||||||
from gitea_runner_manager.exceptions import APIError
|
|
||||||
from scripts.i18n import _
|
|
||||||
|
|
||||||
load_dotenv(override=True)
|
|
||||||
|
|
||||||
DOCS_DIR = Path(__file__).resolve().parent.parent.parent / "docs"
|
|
||||||
MAPPING_FILE = DOCS_DIR / "mapping.json"
|
|
||||||
|
|
||||||
|
|
||||||
def load_mapping() -> dict[str, str]:
|
|
||||||
"""Load the file-to-wiki-page mapping from mapping.json."""
|
|
||||||
with open(MAPPING_FILE) as f:
|
|
||||||
return json.load(f)
|
|
||||||
|
|
||||||
|
|
||||||
def read_doc_content(file_path: str) -> str:
|
|
||||||
"""Read markdown content from a docs file."""
|
|
||||||
full_path = DOCS_DIR / file_path
|
|
||||||
with open(full_path) as f:
|
|
||||||
return f.read()
|
|
||||||
|
|
||||||
|
|
||||||
def encode_content(content: str) -> str:
|
|
||||||
"""Encode content as base64 for the Gitea wiki API.
|
|
||||||
|
|
||||||
The Gitea wiki API requires content_base64, not plain content.
|
|
||||||
Sending plain content silently fails (pages are created/updated
|
|
||||||
but with empty content).
|
|
||||||
"""
|
|
||||||
return base64.b64encode(content.encode("utf-8")).decode("ascii")
|
|
||||||
|
|
||||||
|
|
||||||
def decode_content(content_b64: str) -> str:
|
|
||||||
"""Decode base64 content from the Gitea wiki API."""
|
|
||||||
if not content_b64:
|
|
||||||
return ""
|
|
||||||
return base64.b64decode(content_b64).decode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def list_wiki_pages(client: GiteaClient) -> dict[str, str]:
|
|
||||||
"""List existing wiki pages, returning {title: sub_url}."""
|
|
||||||
try:
|
|
||||||
pages = client._request("GET", "/wiki/pages").json()
|
|
||||||
except APIError:
|
|
||||||
return {}
|
|
||||||
return {page.get("title", ""): page.get("sub_url", page.get("title", "")) for page in pages}
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_page_content(client: GiteaClient, sub_url: str) -> str:
|
|
||||||
"""Fetch a wiki page's content by sub_url, decoded from base64."""
|
|
||||||
try:
|
|
||||||
page = client._request("GET", f"/wiki/page/{sub_url}").json()
|
|
||||||
return decode_content(page.get("content_base64", ""))
|
|
||||||
except APIError:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
def sync_page(
|
|
||||||
client: GiteaClient,
|
|
||||||
page_title: str,
|
|
||||||
content: str,
|
|
||||||
existing_pages: dict[str, str],
|
|
||||||
dry_run: bool,
|
|
||||||
) -> str:
|
|
||||||
"""Create or update a single wiki page.
|
|
||||||
|
|
||||||
Returns "created", "updated", or "skipped" (if dry-run).
|
|
||||||
"""
|
|
||||||
if dry_run:
|
|
||||||
click.echo(_("[dry-run] Would sync page: {title} ({chars} chars)", title=page_title, chars=len(content)))
|
|
||||||
return "skipped"
|
|
||||||
|
|
||||||
content_b64 = encode_content(content)
|
|
||||||
|
|
||||||
if page_title in existing_pages:
|
|
||||||
# Update existing page via PATCH
|
|
||||||
sub_url = existing_pages[page_title]
|
|
||||||
client._request(
|
|
||||||
"PATCH",
|
|
||||||
f"/wiki/page/{sub_url}",
|
|
||||||
json={
|
|
||||||
"title": page_title,
|
|
||||||
"content_base64": content_b64,
|
|
||||||
"message": f"Sync from docs/ — update {page_title}",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return "updated"
|
|
||||||
|
|
||||||
# Create new page via POST /wiki/new
|
|
||||||
client._request(
|
|
||||||
"POST",
|
|
||||||
"/wiki/new",
|
|
||||||
json={
|
|
||||||
"title": page_title,
|
|
||||||
"content_base64": content_b64,
|
|
||||||
"message": f"Sync from docs/ — create {page_title}",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
return "created"
|
|
||||||
|
|
||||||
|
|
||||||
def verify_wiki_page(
|
|
||||||
client: GiteaClient, page_title: str, expected_content: str, existing_pages: dict[str, str]
|
|
||||||
) -> bool:
|
|
||||||
"""Verify that a wiki page has non-empty content matching the docs.
|
|
||||||
|
|
||||||
Returns True if the page content matches, False otherwise.
|
|
||||||
"""
|
|
||||||
if page_title not in existing_pages:
|
|
||||||
return False
|
|
||||||
sub_url = existing_pages[page_title]
|
|
||||||
actual = fetch_page_content(client, sub_url)
|
|
||||||
return actual.strip() == expected_content.strip()
|
|
||||||
|
|
||||||
|
|
||||||
def verify_wiki_integrity(
|
|
||||||
client: GiteaClient,
|
|
||||||
mapping: dict[str, str],
|
|
||||||
synced: dict[str, str],
|
|
||||||
) -> list[str]:
|
|
||||||
"""Comprehensive wiki verification.
|
|
||||||
|
|
||||||
Checks:
|
|
||||||
1. Every mapped page exists in the wiki
|
|
||||||
2. Every mapped page has non-empty content
|
|
||||||
3. Every mapped page's content matches the docs
|
|
||||||
4. No stale pages exist in the wiki (pages not in mapping)
|
|
||||||
5. Page count matches
|
|
||||||
|
|
||||||
Returns a list of failure messages (empty if all checks pass).
|
|
||||||
"""
|
|
||||||
failures: list[str] = []
|
|
||||||
existing_pages = list_wiki_pages(client)
|
|
||||||
expected_titles = set(mapping.values())
|
|
||||||
|
|
||||||
# Check 1: Page count
|
|
||||||
if len(existing_pages) != len(expected_titles):
|
|
||||||
failures.append(f"Page count mismatch: wiki has {len(existing_pages)}, mapping has {len(expected_titles)}")
|
|
||||||
|
|
||||||
# Check 2: Missing pages (in mapping but not in wiki)
|
|
||||||
missing = expected_titles - set(existing_pages.keys())
|
|
||||||
for title in sorted(missing):
|
|
||||||
failures.append(f"Missing page: {title}")
|
|
||||||
|
|
||||||
# Check 3: Stale pages (in wiki but not in mapping)
|
|
||||||
stale = set(existing_pages.keys()) - expected_titles
|
|
||||||
for title in sorted(stale):
|
|
||||||
failures.append(f"Stale page (not in mapping): {title}")
|
|
||||||
|
|
||||||
# Check 4: Content verification
|
|
||||||
for page_title, expected_content in sorted(synced.items()):
|
|
||||||
ok = verify_wiki_page(client, page_title, expected_content, existing_pages)
|
|
||||||
if not ok:
|
|
||||||
sub_url = existing_pages.get(page_title, "?")
|
|
||||||
actual = fetch_page_content(client, sub_url)
|
|
||||||
if not actual.strip():
|
|
||||||
failures.append(f"Empty content: {page_title}")
|
|
||||||
else:
|
|
||||||
failures.append(f"Content mismatch: {page_title}")
|
|
||||||
|
|
||||||
return failures
|
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
|
||||||
@click.option("--dry-run", is_flag=True, default=False, help="Show what would happen without making changes.")
|
|
||||||
@click.option("--repo", default=None, help="Repository in owner/name format (auto-detected if omitted).")
|
|
||||||
@click.option(
|
|
||||||
"--verify",
|
|
||||||
is_flag=True,
|
|
||||||
default=False,
|
|
||||||
help="After syncing, verify each page has non-empty content. Exit 1 if any page is empty or mismatched.",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--strict",
|
|
||||||
is_flag=True,
|
|
||||||
default=False,
|
|
||||||
help="Full integrity check: verify page count, missing pages, stale pages, and content. Implies --verify.",
|
|
||||||
)
|
|
||||||
def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
|
|
||||||
token = os.environ.get("REPO_TOKEN", "")
|
|
||||||
if not token:
|
|
||||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
|
||||||
|
|
||||||
if repo is None:
|
|
||||||
owner = os.environ.get("GRM_REPO_OWNER", "oblachno-oss")
|
|
||||||
repo_name = os.environ.get("GRM_REPO_NAME", "grm")
|
|
||||||
else:
|
|
||||||
owner, repo_name = repo.split("/")
|
|
||||||
|
|
||||||
if not MAPPING_FILE.exists():
|
|
||||||
raise click.ClickException(_("ERROR: mapping.json not found at {path}", path=MAPPING_FILE))
|
|
||||||
|
|
||||||
mapping = load_mapping()
|
|
||||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
|
||||||
|
|
||||||
click.echo(_("Syncing {count} documentation pages to wiki...", count=len(mapping)))
|
|
||||||
|
|
||||||
existing_pages = list_wiki_pages(client)
|
|
||||||
if existing_pages:
|
|
||||||
click.echo(_("Found {count} existing wiki pages.", count=len(existing_pages)))
|
|
||||||
|
|
||||||
created = 0
|
|
||||||
updated = 0
|
|
||||||
skipped = 0
|
|
||||||
synced: dict[str, str] = {} # title -> content, for verification
|
|
||||||
|
|
||||||
for file_path, page_title in sorted(mapping.items()):
|
|
||||||
try:
|
|
||||||
content = read_doc_content(file_path)
|
|
||||||
except FileNotFoundError:
|
|
||||||
click.echo(_("WARNING: File {file} not found — skipping.", file=file_path))
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
if not content.strip():
|
|
||||||
click.echo(_("WARNING: File {file} is empty — skipping.", file=file_path))
|
|
||||||
skipped += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
result = sync_page(client, page_title, content, existing_pages, dry_run)
|
|
||||||
if result == "created":
|
|
||||||
created += 1
|
|
||||||
click.echo(_(" Created: {title}", title=page_title))
|
|
||||||
elif result == "updated":
|
|
||||||
updated += 1
|
|
||||||
click.echo(_(" Updated: {title}", title=page_title))
|
|
||||||
else:
|
|
||||||
skipped += 1
|
|
||||||
|
|
||||||
synced[page_title] = content
|
|
||||||
|
|
||||||
click.echo(
|
|
||||||
_(
|
|
||||||
"\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
|
|
||||||
created=created,
|
|
||||||
updated=updated,
|
|
||||||
skipped=skipped,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
# --strict implies --verify
|
|
||||||
do_verify = verify or strict
|
|
||||||
|
|
||||||
if do_verify and not dry_run:
|
|
||||||
if strict:
|
|
||||||
click.echo(_("\nRunning full wiki integrity check..."))
|
|
||||||
failures = verify_wiki_integrity(client, mapping, synced)
|
|
||||||
if failures:
|
|
||||||
click.echo(_("\nIntegrity check FAILED ({count} issues):", count=len(failures)))
|
|
||||||
for f in failures:
|
|
||||||
click.echo(f" - {f}")
|
|
||||||
raise click.ClickException(_("Wiki integrity check failed — {count} issue(s)", count=len(failures)))
|
|
||||||
click.echo(_("\nIntegrity check passed — all {count} pages verified.", count=len(synced)))
|
|
||||||
else:
|
|
||||||
click.echo(_("\nVerifying wiki pages have content..."))
|
|
||||||
# Re-fetch the page list to get updated sub_urls
|
|
||||||
existing_pages = list_wiki_pages(client)
|
|
||||||
failures = 0
|
|
||||||
for page_title, expected_content in sorted(synced.items()):
|
|
||||||
ok = verify_wiki_page(client, page_title, expected_content, existing_pages)
|
|
||||||
if ok:
|
|
||||||
click.echo(_(" OK: {title} ({chars} chars)", title=page_title, chars=len(expected_content)))
|
|
||||||
else:
|
|
||||||
click.echo(_(" FAIL: {title} — content mismatch or empty!", title=page_title))
|
|
||||||
failures += 1
|
|
||||||
if failures > 0:
|
|
||||||
click.echo(
|
|
||||||
_(
|
|
||||||
"\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
|
|
||||||
failures=failures,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
raise click.ClickException(
|
|
||||||
_("Wiki verification failed — {failures} page(s) empty or mismatched", failures=failures)
|
|
||||||
)
|
|
||||||
click.echo(_("\nVerification passed — all wiki pages have correct content."))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
main()
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Validate commit messages for GRM.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- On feature branches: conventional commits ONLY, must NOT include GRM-N prefix.
|
|
||||||
- On master branch: must follow '<task-id>: <conventional commit>' pattern,
|
|
||||||
e.g. 'GRM-24: fix: resolve timeout'.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import re
|
|
||||||
import subprocess # nosec B404
|
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
from gitea_runner_manager.config import CONVENTIONAL_RE
|
|
||||||
from scripts.i18n import _
|
|
||||||
|
|
||||||
MASTER_TASK_ID_RE = re.compile(r"^GRM-\d+:")
|
|
||||||
|
|
||||||
|
|
||||||
def first_line(text: str) -> str:
|
|
||||||
return text.split("\n")[0]
|
|
||||||
|
|
||||||
|
|
||||||
def get_branch() -> str:
|
|
||||||
try:
|
|
||||||
result = subprocess.run( # nosec
|
|
||||||
["git", "symbolic-ref", "--short", "HEAD"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
return result.stdout.strip()
|
|
||||||
except subprocess.CalledProcessError:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
|
||||||
@click.argument("commit_msg_file")
|
|
||||||
@click.option("--branch", default=None, help="Override branch detection (for CI use).")
|
|
||||||
def main(commit_msg_file: str, branch: str | None) -> None:
|
|
||||||
with open(commit_msg_file) as f:
|
|
||||||
msg = f.read().strip()
|
|
||||||
|
|
||||||
if branch is None:
|
|
||||||
branch = get_branch()
|
|
||||||
subject = first_line(msg)
|
|
||||||
|
|
||||||
if branch == "master":
|
|
||||||
if not MASTER_TASK_ID_RE.match(subject):
|
|
||||||
raise click.ClickException(
|
|
||||||
_(
|
|
||||||
"Oops! Master branch commits must start with a task ID.\n"
|
|
||||||
" Expected: GRM-N: <conventional commit message>\n"
|
|
||||||
" Got: {subject}",
|
|
||||||
subject=subject,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
remainder = MASTER_TASK_ID_RE.sub("", subject).strip()
|
|
||||||
if not CONVENTIONAL_RE.match(remainder):
|
|
||||||
raise click.ClickException(
|
|
||||||
_(
|
|
||||||
"Oops! Master branch commit must follow conventional format after task ID.\n"
|
|
||||||
" Expected: GRM-N: <type>: <description>\n"
|
|
||||||
" Got: {subject}",
|
|
||||||
subject=subject,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
if MASTER_TASK_ID_RE.match(subject):
|
|
||||||
raise click.ClickException(
|
|
||||||
_(
|
|
||||||
"Oops! Do not include task ID (GRM-N) in feature branch commits.\n"
|
|
||||||
" The task ID will be added automatically on merge via CI."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
if not CONVENTIONAL_RE.match(subject):
|
|
||||||
raise click.ClickException(
|
|
||||||
_(
|
|
||||||
"Oops! Commit message must follow conventional commit format.\n"
|
|
||||||
" Expected: <type>: <description>\n"
|
|
||||||
" Got: {subject}\n"
|
|
||||||
" Allowed types: feat, fix, chore, docs, style, refactor,\n"
|
|
||||||
" perf, test, ci, build, revert, BREAKING CHANGE",
|
|
||||||
subject=subject,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
main()
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Configure GRM repository: branch protection + repo settings via Gitea REST API.
|
|
||||||
|
|
||||||
Uses ``GiteaClient`` for branch protection and repo settings.
|
|
||||||
The ``tea`` CLI is used for label creation if available, with a
|
|
||||||
fallback to ``GiteaClient`` if tea is not installed.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
REPO_TOKEN=<token> python3 scripts/configure_repo.py
|
|
||||||
"""
|
|
||||||
|
|
||||||
import http
|
|
||||||
import os
|
|
||||||
from typing import cast
|
|
||||||
|
|
||||||
import click
|
|
||||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
|
||||||
|
|
||||||
from gitea_runner_manager.api_clients import GiteaClient
|
|
||||||
from gitea_runner_manager.config import (
|
|
||||||
BRANCH_PROTECTION_CONFIG,
|
|
||||||
GITEA_API_URL,
|
|
||||||
REPO_NAME,
|
|
||||||
REPO_OWNER,
|
|
||||||
REPO_SETTINGS_CONFIG,
|
|
||||||
)
|
|
||||||
from gitea_runner_manager.exceptions import APIError
|
|
||||||
from scripts.i18n import _
|
|
||||||
|
|
||||||
load_dotenv(override=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _handle_http_error(e: APIError) -> None:
|
|
||||||
"""Raise a user-friendly Click exception for HTTP errors."""
|
|
||||||
if e.status == http.HTTPStatus.FORBIDDEN:
|
|
||||||
raise click.ClickException(
|
|
||||||
_(
|
|
||||||
"HTTP {status} Forbidden — your token lacks admin rights.\n"
|
|
||||||
"Make sure the token belongs to a repo owner or organisation admin.\n"
|
|
||||||
"Alternatively, configure branch protection manually in Settings → Branches.",
|
|
||||||
status=e.status,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
raise click.ClickException(_("HTTP error: {status} — {message}", status=e.status, message=e.message))
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
token = os.environ.get("REPO_TOKEN", "")
|
|
||||||
if not token:
|
|
||||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
|
||||||
|
|
||||||
client = GiteaClient(GITEA_API_URL, token, REPO_OWNER, REPO_NAME)
|
|
||||||
|
|
||||||
try:
|
|
||||||
click.echo(_("Configuring branch protection for {branch}...", branch="master"))
|
|
||||||
client.ensure_branch_protection("master", BRANCH_PROTECTION_CONFIG)
|
|
||||||
click.echo(_(" - Direct pushes: BLOCKED (require PR, whitelisted users can push)"))
|
|
||||||
click.echo(
|
|
||||||
_(
|
|
||||||
" - Required approvals: {count}",
|
|
||||||
count=BRANCH_PROTECTION_CONFIG["required_approvals"],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
click.echo(_(" - Dismiss stale approvals: yes"))
|
|
||||||
click.echo(_(" - Block outdated branches: yes"))
|
|
||||||
click.echo(_(" - Block rejected reviews: yes"))
|
|
||||||
checks = ", ".join(cast(list[str], BRANCH_PROTECTION_CONFIG["status_check_contexts"]))
|
|
||||||
click.echo(_(" - Required status checks: {checks}", checks=checks))
|
|
||||||
|
|
||||||
click.echo("")
|
|
||||||
click.echo(_("Configuring repository settings..."))
|
|
||||||
client.update_repo_settings(cast(dict[str, object], REPO_SETTINGS_CONFIG))
|
|
||||||
click.echo(_(" - Auto-delete branch after merge: yes"))
|
|
||||||
|
|
||||||
click.echo("")
|
|
||||||
click.echo(_("Repository configuration complete."))
|
|
||||||
except APIError as e:
|
|
||||||
_handle_http_error(e)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
main() # pragma: no cover
|
|
||||||
@@ -1,251 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Generate self-contained SVG badge files from project metrics.
|
|
||||||
|
|
||||||
Runs pytest-cov, doc-coverage, lint checks, and version extraction,
|
|
||||||
then writes SVG badge files that can be served as static files from
|
|
||||||
the Gitea raw file API.
|
|
||||||
|
|
||||||
Usage:
|
|
||||||
python3 scripts/generate_badges.py --output-dir .badges/
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
import subprocess # nosec B404
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
||||||
INIT_FILE = REPO_ROOT / "src" / "gitea_runner_manager" / "__init__.py"
|
|
||||||
|
|
||||||
_COVERAGE_RE = re.compile(r"TOTAL.*?(\d+(?:\.\d+)?)%")
|
|
||||||
_PASSED_RE = re.compile(r"(\d+) passed")
|
|
||||||
_DOC_COVERAGE_RE = re.compile(r"Doc coverage:\s+\d+/\d+\s+\((\d+)%")
|
|
||||||
|
|
||||||
# shields.io color names to hex values
|
|
||||||
COLOR_HEX: dict[str, str] = {
|
|
||||||
"brightgreen": "#4c1",
|
|
||||||
"green": "#97ca00",
|
|
||||||
"yellowgreen": "#a4a61d",
|
|
||||||
"yellow": "#dfb317",
|
|
||||||
"orange": "#fe7d37",
|
|
||||||
"red": "#e05d44",
|
|
||||||
"blue": "#007ec6",
|
|
||||||
"lightgrey": "#9f9f9f",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def _xml_escape(text: str) -> str:
|
|
||||||
"""Escape XML special characters."""
|
|
||||||
return text.replace("&", "&").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
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Thin Python wrapper around the ``tea`` Gitea CLI for CI/CD scripts.
|
|
||||||
|
|
||||||
This module provides a programmatic interface to the ``tea`` CLI tool,
|
|
||||||
parsing JSON output for structured data. It is used by CI scripts to
|
|
||||||
avoid hand-rolling HTTP requests and to leverage the official Gitea CLI
|
|
||||||
for reliability.
|
|
||||||
|
|
||||||
The wrapper requires ``tea`` to be installed and configured (run
|
|
||||||
``make setup`` which calls ``scripts/install_tools.py`` and
|
|
||||||
``scripts/setup.py``).
|
|
||||||
|
|
||||||
Operations supported via tea:
|
|
||||||
- Creating pull requests
|
|
||||||
- Creating issues
|
|
||||||
- Adding labels to issues/PRs
|
|
||||||
- Creating labels
|
|
||||||
- Merging pull requests
|
|
||||||
- Creating releases
|
|
||||||
- Posting reviews on PRs
|
|
||||||
- Listing branches
|
|
||||||
|
|
||||||
Operations NOT supported via tea (still use GiteaClient):
|
|
||||||
- Wiki page management
|
|
||||||
- Commit status checks
|
|
||||||
- Runner discovery
|
|
||||||
- PR file/commit listing (tea has limited support)
|
|
||||||
- Branch protection with detailed config (tea only has basic protect/unprotect)
|
|
||||||
|
|
||||||
Usage::
|
|
||||||
|
|
||||||
from scripts.gitea_cli import TeaCLI
|
|
||||||
|
|
||||||
tea = TeaCLI()
|
|
||||||
tea.create_issue("owner/repo", title="Bug", body="Description", labels=["bug"])
|
|
||||||
tea.add_label("owner/repo", 42, ["ready-to-merge"])
|
|
||||||
tea.create_release("owner/repo", tag="v1.0.0", title="Release 1.0.0", body="Notes")
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import shutil
|
|
||||||
import subprocess # nosec B404
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
|
|
||||||
class TeaCLIError(Exception):
|
|
||||||
"""Raised when a tea CLI command fails."""
|
|
||||||
|
|
||||||
|
|
||||||
class TeaCLI:
|
|
||||||
"""Wrapper around the ``tea`` Gitea CLI tool.
|
|
||||||
|
|
||||||
All methods parse JSON output from tea for structured access.
|
|
||||||
Commands are run with ``--output json`` where structured data is expected.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, tea_bin: str | None = None, repo: str | None = None) -> None:
|
|
||||||
"""Initialize the tea CLI wrapper.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
tea_bin: Path to the tea binary. If None, auto-detect via shutil.which.
|
|
||||||
repo: Default repo in ``owner/name`` format for commands that need it.
|
|
||||||
"""
|
|
||||||
self._tea = tea_bin or shutil.which("tea") or "tea"
|
|
||||||
self._repo = repo
|
|
||||||
|
|
||||||
def _run(self, args: list[str], json_output: bool = True) -> str:
|
|
||||||
"""Run a tea command and return stdout.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
args: Command arguments (without the leading ``tea``).
|
|
||||||
json_output: If True, append ``--output json`` to the command.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
stdout as a string.
|
|
||||||
|
|
||||||
Raises:
|
|
||||||
TeaCLIError: If the command fails.
|
|
||||||
"""
|
|
||||||
cmd = [self._tea, *args]
|
|
||||||
if json_output:
|
|
||||||
cmd.extend(["--output", "json"])
|
|
||||||
result = subprocess.run( # nosec B603
|
|
||||||
cmd,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode != 0:
|
|
||||||
raise TeaCLIError(
|
|
||||||
f"tea command failed (rc={result.returncode}): {' '.join(args)}\nstderr: {result.stderr.strip()}"
|
|
||||||
)
|
|
||||||
return result.stdout.strip()
|
|
||||||
|
|
||||||
def _run_raw(self, args: list[str]) -> str:
|
|
||||||
"""Run a tea command without JSON output and return stdout."""
|
|
||||||
return self._run(args, json_output=False)
|
|
||||||
|
|
||||||
def _repo_arg(self, repo: str | None = None) -> list[str]:
|
|
||||||
"""Build the --repo argument list."""
|
|
||||||
target = repo or self._repo
|
|
||||||
if target:
|
|
||||||
return ["--repo", target]
|
|
||||||
return []
|
|
||||||
|
|
||||||
# -- Issues --
|
|
||||||
|
|
||||||
def create_issue(
|
|
||||||
self,
|
|
||||||
repo: str,
|
|
||||||
title: str,
|
|
||||||
body: str = "",
|
|
||||||
labels: list[str] | None = None,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Create an issue and return the issue dict.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
repo: Repository in ``owner/name`` format.
|
|
||||||
title: Issue title.
|
|
||||||
body: Issue body (markdown).
|
|
||||||
labels: List of label names to attach.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
The created issue as a dict (parsed from tea JSON output).
|
|
||||||
"""
|
|
||||||
args = ["issues", "create", "--title", title, "--body", body, *self._repo_arg(repo)]
|
|
||||||
output = self._run(args, json_output=False)
|
|
||||||
# tea issues create doesn't output JSON; extract issue number from output
|
|
||||||
# Format: "Created issue #42: <title>"
|
|
||||||
issue_index = _extract_issue_number(output)
|
|
||||||
return {"title": title, "body": body, "index": issue_index, "url": output.strip()}
|
|
||||||
|
|
||||||
# -- Labels --
|
|
||||||
|
|
||||||
def list_labels(self, repo: str) -> list[dict[str, Any]]:
|
|
||||||
"""List all labels for a repository."""
|
|
||||||
output = self._run(["labels", "list", *self._repo_arg(repo)])
|
|
||||||
if not output:
|
|
||||||
return []
|
|
||||||
return json.loads(output)
|
|
||||||
|
|
||||||
def create_label(
|
|
||||||
self,
|
|
||||||
repo: str,
|
|
||||||
name: str,
|
|
||||||
color: str = "",
|
|
||||||
description: str = "",
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Create a label. Returns the label dict.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
repo: Repository in ``owner/name`` format.
|
|
||||||
name: Label name.
|
|
||||||
color: Hex color (without #), e.g. ``2ecc71``.
|
|
||||||
description: Label description.
|
|
||||||
"""
|
|
||||||
args = ["labels", "create", name, *self._repo_arg(repo)]
|
|
||||||
if color:
|
|
||||||
args.extend(["--color", f"#{color}"])
|
|
||||||
if description:
|
|
||||||
args.extend(["--description", description])
|
|
||||||
output = self._run(args, json_output=False)
|
|
||||||
return {"name": name, "color": color, "description": description, "output": output}
|
|
||||||
|
|
||||||
def add_label(self, repo: str, issue_index: int, labels: list[str]) -> None:
|
|
||||||
"""Add labels to an issue or PR.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
repo: Repository in ``owner/name`` format.
|
|
||||||
issue_index: Issue or PR number.
|
|
||||||
labels: List of label names to add.
|
|
||||||
"""
|
|
||||||
for _label in labels:
|
|
||||||
self._run_raw(["issues", "edit", "--add-labels", ",".join(labels), str(issue_index), *self._repo_arg(repo)])
|
|
||||||
return # tea edit handles all labels at once
|
|
||||||
# No labels to add — nothing to do
|
|
||||||
|
|
||||||
# -- Pull Requests --
|
|
||||||
|
|
||||||
def create_pr(
|
|
||||||
self,
|
|
||||||
repo: str,
|
|
||||||
title: str,
|
|
||||||
head: str,
|
|
||||||
base: str,
|
|
||||||
body: str = "",
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Create a pull request and return the PR dict.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
repo: Repository in ``owner/name`` format.
|
|
||||||
title: PR title.
|
|
||||||
head: Head branch name.
|
|
||||||
base: Base branch name.
|
|
||||||
body: PR description (markdown).
|
|
||||||
"""
|
|
||||||
args = [
|
|
||||||
"pulls",
|
|
||||||
"create",
|
|
||||||
"--title",
|
|
||||||
title,
|
|
||||||
"--base",
|
|
||||||
base,
|
|
||||||
"--head",
|
|
||||||
head,
|
|
||||||
*self._repo_arg(repo),
|
|
||||||
]
|
|
||||||
if body:
|
|
||||||
args.extend(["--body", body])
|
|
||||||
output = self._run(args, json_output=False)
|
|
||||||
pr_index = _extract_pr_number(output)
|
|
||||||
return {"title": title, "index": pr_index, "url": output.strip()}
|
|
||||||
|
|
||||||
def merge_pr(self, repo: str, pr_index: int, style: str = "squash") -> None:
|
|
||||||
"""Merge a pull request.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
repo: Repository in ``owner/name`` format.
|
|
||||||
pr_index: PR number.
|
|
||||||
style: Merge style: ``squash``, ``merge``, ``rebase``, ``rebase-edit``.
|
|
||||||
"""
|
|
||||||
self._run_raw(["pulls", "merge", "--style", style, str(pr_index), *self._repo_arg(repo)])
|
|
||||||
|
|
||||||
def review_pr(
|
|
||||||
self,
|
|
||||||
repo: str,
|
|
||||||
pr_index: int,
|
|
||||||
event: str = "COMMENT",
|
|
||||||
body: str = "",
|
|
||||||
) -> None:
|
|
||||||
"""Post a review on a pull request.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
repo: Repository in ``owner/name`` format.
|
|
||||||
pr_index: PR number.
|
|
||||||
event: Review event: ``APPROVE``, ``REQUEST_CHANGES``, ``COMMENT``.
|
|
||||||
body: Review body text.
|
|
||||||
"""
|
|
||||||
args = ["pulls", "review", str(pr_index), *self._repo_arg(repo)]
|
|
||||||
if event == "APPROVE":
|
|
||||||
args.append("--approve")
|
|
||||||
elif event == "REQUEST_CHANGES":
|
|
||||||
args.extend(["--reject"])
|
|
||||||
if body:
|
|
||||||
args.extend(["--comment", body])
|
|
||||||
self._run_raw(args)
|
|
||||||
|
|
||||||
# -- Releases --
|
|
||||||
|
|
||||||
def create_release(
|
|
||||||
self,
|
|
||||||
repo: str,
|
|
||||||
tag: str,
|
|
||||||
title: str = "",
|
|
||||||
body: str = "",
|
|
||||||
target: str = "",
|
|
||||||
draft: bool = False,
|
|
||||||
prerelease: bool = False,
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
"""Create a release and return the release dict.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
repo: Repository in ``owner/name`` format.
|
|
||||||
tag: Tag name (e.g. ``v1.0.0``).
|
|
||||||
title: Release title.
|
|
||||||
body: Release notes (markdown).
|
|
||||||
target: Target branch/commit for the tag.
|
|
||||||
draft: If True, create as draft.
|
|
||||||
prerelease: If True, mark as prerelease.
|
|
||||||
"""
|
|
||||||
args = ["releases", "create", tag, *self._repo_arg(repo)]
|
|
||||||
if title:
|
|
||||||
args.extend(["--title", title])
|
|
||||||
if body:
|
|
||||||
args.extend(["--note", body])
|
|
||||||
if target:
|
|
||||||
args.extend(["--target", target])
|
|
||||||
if draft:
|
|
||||||
args.append("--draft")
|
|
||||||
if prerelease:
|
|
||||||
args.append("--prerelease")
|
|
||||||
output = self._run(args, json_output=False)
|
|
||||||
return {"tag": tag, "title": title, "url": output.strip()}
|
|
||||||
|
|
||||||
def list_releases(self, repo: str) -> list[dict[str, Any]]:
|
|
||||||
"""List all releases for a repository."""
|
|
||||||
output = self._run(["releases", "list", *self._repo_arg(repo)])
|
|
||||||
if not output:
|
|
||||||
return []
|
|
||||||
return json.loads(output)
|
|
||||||
|
|
||||||
# -- Branches --
|
|
||||||
|
|
||||||
def list_branches(self, repo: str) -> list[dict[str, Any]]:
|
|
||||||
"""List all branches for a repository."""
|
|
||||||
output = self._run(["branches", "list", *self._repo_arg(repo)])
|
|
||||||
if not output:
|
|
||||||
return []
|
|
||||||
return json.loads(output)
|
|
||||||
|
|
||||||
# -- Utility --
|
|
||||||
|
|
||||||
def whoami(self) -> str:
|
|
||||||
"""Return the current authenticated user."""
|
|
||||||
return self._run_raw(["whoami"])
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_issue_number(output: str) -> int:
|
|
||||||
"""Extract the issue number from tea output like 'Created issue #42: ...'."""
|
|
||||||
for part in output.split():
|
|
||||||
if part.startswith("#"):
|
|
||||||
try:
|
|
||||||
return int(part[1:].rstrip(":"))
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def _extract_pr_number(output: str) -> int:
|
|
||||||
"""Extract the PR number from tea output like 'Created PR #42: ...'."""
|
|
||||||
return _extract_issue_number(output)
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
"""i18n for CI/dev scripts.
|
|
||||||
|
|
||||||
Separate from the GRM tool's i18n (``gitea_runner_manager.i18n``) so that
|
|
||||||
CI-only translation keys don't bloat the packaged CLI.
|
|
||||||
|
|
||||||
Set GRM_LANG environment variable to override the default English.
|
|
||||||
Supported: en, bg, de, ru, zh.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
TRANSLATIONS: dict[str, dict[str, str]] = json.loads(
|
|
||||||
(Path(__file__).parent / "translations.json").read_text(encoding="utf-8")
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _(key: str, **kwargs: object) -> str:
|
|
||||||
"""Return a translated string for the given key.
|
|
||||||
|
|
||||||
Translation is opt-in via the ``GRM_LANG`` environment variable.
|
|
||||||
If unset, English is always returned regardless of system locale.
|
|
||||||
"""
|
|
||||||
lang = os.getenv("GRM_LANG", "en")
|
|
||||||
if lang not in ("en", "bg", "de", "ru", "zh"):
|
|
||||||
lang = "en"
|
|
||||||
template = TRANSLATIONS.get(key, {}).get(lang, key)
|
|
||||||
return template.format(**kwargs)
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Install checkmake if it is not already present.
|
|
||||||
|
|
||||||
Tries to install via Go if available, otherwise downloads the latest
|
|
||||||
pre-built Linux binary from the official GitHub releases.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import platform
|
|
||||||
import shutil
|
|
||||||
import subprocess # nosec B404
|
|
||||||
import urllib.request
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
CHECKMAKE_VERSION = "0.3.2"
|
|
||||||
RELEASE_URL_TEMPLATE = (
|
|
||||||
"https://github.com/checkmake/checkmake/releases/download/"
|
|
||||||
f"v{CHECKMAKE_VERSION}/checkmake-v{CHECKMAKE_VERSION}.linux.{{arch}}"
|
|
||||||
)
|
|
||||||
TARGET_PATH = Path("/usr/local/bin/checkmake")
|
|
||||||
|
|
||||||
|
|
||||||
def _arch() -> str:
|
|
||||||
"""Return the architecture string used by checkmake releases."""
|
|
||||||
machine = platform.machine().lower()
|
|
||||||
if machine in {"x86_64", "amd64"}:
|
|
||||||
return "amd64"
|
|
||||||
if machine in {"aarch64", "arm64"}:
|
|
||||||
return "arm64"
|
|
||||||
raise click.ClickException(f"Unsupported architecture: {machine}")
|
|
||||||
|
|
||||||
|
|
||||||
def _install_with_go() -> bool:
|
|
||||||
"""Install checkmake using go install if Go is available."""
|
|
||||||
go_bin = shutil.which("go")
|
|
||||||
if go_bin is None:
|
|
||||||
return False
|
|
||||||
subprocess.run( # nosec B603
|
|
||||||
[
|
|
||||||
go_bin,
|
|
||||||
"install",
|
|
||||||
"github.com/checkmake/checkmake/cmd/checkmake@latest",
|
|
||||||
],
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def _download_binary() -> None:
|
|
||||||
"""Download the prebuilt checkmake binary for the current architecture."""
|
|
||||||
url = RELEASE_URL_TEMPLATE.format(arch=_arch())
|
|
||||||
urllib.request.urlretrieve(url, TARGET_PATH) # nosec B310
|
|
||||||
TARGET_PATH.chmod(0o755)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
"""Install checkmake if not already present."""
|
|
||||||
if shutil.which("checkmake") is not None:
|
|
||||||
return
|
|
||||||
|
|
||||||
if not _install_with_go():
|
|
||||||
_download_binary()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
main() # pragma: no cover
|
|
||||||
@@ -1,219 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Install CI/CD development tools that are not Python packages.
|
|
||||||
|
|
||||||
Handles installation of:
|
|
||||||
- actionlint (workflow YAML linter)
|
|
||||||
- git-cliff (changelog generator)
|
|
||||||
- act_runner (Gitea Actions local runner, optional)
|
|
||||||
- tea (Gitea CLI — official command-line tool for Gitea API operations)
|
|
||||||
|
|
||||||
Each tool is installed to ``~/.local/bin`` if not already on PATH.
|
|
||||||
Idempotent: skips tools that are already available.
|
|
||||||
|
|
||||||
Usage::
|
|
||||||
|
|
||||||
python3 scripts/install_tools.py # install all
|
|
||||||
python3 scripts/install_tools.py --tool actionlint # install one
|
|
||||||
python3 scripts/install_tools.py --list # list status
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import platform
|
|
||||||
import shutil
|
|
||||||
import tarfile
|
|
||||||
import tempfile
|
|
||||||
import urllib.request
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
TARGET_DIR = Path.home() / ".local" / "bin"
|
|
||||||
|
|
||||||
ACTIONLINT_VERSION = "1.7.12"
|
|
||||||
|
|
||||||
GIT_CLIFF_VERSION = "2.13.0"
|
|
||||||
|
|
||||||
ACT_RUNNER_VERSION = "0.2.11"
|
|
||||||
|
|
||||||
TEA_VERSION = "0.14.1"
|
|
||||||
|
|
||||||
|
|
||||||
def _arch() -> str:
|
|
||||||
"""Return the architecture string used by release assets."""
|
|
||||||
machine = platform.machine().lower()
|
|
||||||
if machine in {"x86_64", "amd64"}:
|
|
||||||
return "amd64"
|
|
||||||
if machine in {"aarch64", "arm64"}:
|
|
||||||
return "arm64"
|
|
||||||
raise click.ClickException(f"Unsupported architecture: {machine}")
|
|
||||||
|
|
||||||
|
|
||||||
def _ensure_target_dir() -> Path:
|
|
||||||
"""Ensure the target directory exists and return it."""
|
|
||||||
TARGET_DIR.mkdir(parents=True, exist_ok=True)
|
|
||||||
return TARGET_DIR
|
|
||||||
|
|
||||||
|
|
||||||
def _download(url: str, dest: Path) -> None:
|
|
||||||
"""Download a file from ``url`` to ``dest``."""
|
|
||||||
urllib.request.urlretrieve(url, dest) # nosec B310
|
|
||||||
|
|
||||||
|
|
||||||
def _download_and_extract_tarball(url: str, binary_name: str) -> Path:
|
|
||||||
"""Download a tarball, extract the binary, and install it to TARGET_DIR.
|
|
||||||
|
|
||||||
Returns the path to the installed binary.
|
|
||||||
"""
|
|
||||||
target_dir = _ensure_target_dir()
|
|
||||||
dest = target_dir / binary_name
|
|
||||||
with tempfile.TemporaryDirectory() as tmpdir:
|
|
||||||
tarball = Path(tmpdir) / "archive.tar.gz"
|
|
||||||
_download(url, tarball)
|
|
||||||
with tarfile.open(tarball, "r:gz") as tar:
|
|
||||||
tar.extractall(tmpdir) # nosec B202
|
|
||||||
# Find the binary in the extracted tree
|
|
||||||
extracted = Path(tmpdir).rglob(binary_name)
|
|
||||||
found = next(extracted, None)
|
|
||||||
if found is None:
|
|
||||||
raise click.ClickException(f"Binary {binary_name} not found in archive from {url}")
|
|
||||||
shutil.copy2(found, dest)
|
|
||||||
dest.chmod(0o755)
|
|
||||||
return dest
|
|
||||||
|
|
||||||
|
|
||||||
def _download_binary(url: str, binary_name: str) -> Path:
|
|
||||||
"""Download a standalone binary and install it to TARGET_DIR.
|
|
||||||
|
|
||||||
Returns the path to the installed binary.
|
|
||||||
"""
|
|
||||||
target_dir = _ensure_target_dir()
|
|
||||||
dest = target_dir / binary_name
|
|
||||||
_download(url, dest)
|
|
||||||
dest.chmod(0o755)
|
|
||||||
return dest
|
|
||||||
|
|
||||||
|
|
||||||
def _is_installed(name: str) -> bool:
|
|
||||||
"""Check if a tool is already on PATH or in TARGET_DIR."""
|
|
||||||
if shutil.which(name) is not None:
|
|
||||||
return True
|
|
||||||
return (TARGET_DIR / name).exists()
|
|
||||||
|
|
||||||
|
|
||||||
def install_actionlint() -> bool:
|
|
||||||
"""Install actionlint if not already present. Returns True if installed/skipped."""
|
|
||||||
if _is_installed("actionlint"):
|
|
||||||
click.echo("actionlint: already installed")
|
|
||||||
return True
|
|
||||||
arch = _arch()
|
|
||||||
url = (
|
|
||||||
f"https://github.com/rhysd/actionlint/releases/download/"
|
|
||||||
f"v{ACTIONLINT_VERSION}/actionlint_{ACTIONLINT_VERSION}_linux_{arch}.tar.gz"
|
|
||||||
)
|
|
||||||
dest = _download_and_extract_tarball(url, "actionlint")
|
|
||||||
click.echo(f"actionlint: installed to {dest}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def install_git_cliff() -> bool:
|
|
||||||
"""Install git-cliff if not already present. Returns True if installed/skipped."""
|
|
||||||
if _is_installed("git-cliff"):
|
|
||||||
click.echo("git-cliff: already installed")
|
|
||||||
return True
|
|
||||||
arch = _arch()
|
|
||||||
url = (
|
|
||||||
f"https://github.com/orhun/git-cliff/releases/download/"
|
|
||||||
f"v{GIT_CLIFF_VERSION}/git-cliff-{GIT_CLIFF_VERSION}-{arch}-unknown-linux-gnu.tar.gz"
|
|
||||||
)
|
|
||||||
dest = _download_and_extract_tarball(url, "git-cliff")
|
|
||||||
click.echo(f"git-cliff: installed to {dest}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def install_act_runner() -> bool:
|
|
||||||
"""Install act_runner if not already present. Returns True if installed/skipped."""
|
|
||||||
if _is_installed("act_runner"):
|
|
||||||
click.echo("act_runner: already installed")
|
|
||||||
return True
|
|
||||||
arch = _arch()
|
|
||||||
url = (
|
|
||||||
f"https://gitea.com/gitea/act_runner/releases/download/"
|
|
||||||
f"v{ACT_RUNNER_VERSION}/act_runner-{ACT_RUNNER_VERSION}-linux-{arch}"
|
|
||||||
)
|
|
||||||
dest = _download_binary(url, "act_runner")
|
|
||||||
click.echo(f"act_runner: installed to {dest}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def install_tea() -> bool:
|
|
||||||
"""Install tea (Gitea CLI) if not already present. Returns True if installed/skipped."""
|
|
||||||
if _is_installed("tea"):
|
|
||||||
click.echo("tea: already installed")
|
|
||||||
return True
|
|
||||||
arch = _arch()
|
|
||||||
url = f"https://dl.gitea.com/tea/{TEA_VERSION}/tea-{TEA_VERSION}-linux-{arch}"
|
|
||||||
dest = _download_binary(url, "tea")
|
|
||||||
click.echo(f"tea: installed to {dest}")
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea"]
|
|
||||||
|
|
||||||
|
|
||||||
def _install_tool(name: str) -> bool:
|
|
||||||
"""Install a single tool by name."""
|
|
||||||
if name == "actionlint":
|
|
||||||
return install_actionlint()
|
|
||||||
if name == "git-cliff":
|
|
||||||
return install_git_cliff()
|
|
||||||
if name == "act_runner":
|
|
||||||
return install_act_runner()
|
|
||||||
if name == "tea":
|
|
||||||
return install_tea()
|
|
||||||
raise click.ClickException(f"Unknown tool: {name}")
|
|
||||||
|
|
||||||
|
|
||||||
def list_tools() -> None:
|
|
||||||
"""Print the installation status of all tools."""
|
|
||||||
for name in TOOL_NAMES:
|
|
||||||
status = "installed" if _is_installed(name) else "not installed"
|
|
||||||
click.echo(f" {name}: {status}")
|
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
|
||||||
@click.option(
|
|
||||||
"--tool",
|
|
||||||
"tools",
|
|
||||||
multiple=True,
|
|
||||||
type=click.Choice(TOOL_NAMES),
|
|
||||||
help="Install specific tool(s). Can be repeated.",
|
|
||||||
)
|
|
||||||
@click.option("--list", "list_status", is_flag=True, help="List tool installation status.")
|
|
||||||
def main(tools: tuple[str, ...], list_status: bool) -> None:
|
|
||||||
"""Install CI/CD development tools to ~/.local/bin."""
|
|
||||||
if list_status:
|
|
||||||
list_tools()
|
|
||||||
return
|
|
||||||
|
|
||||||
tools_to_install = list(tools) if tools else TOOL_NAMES
|
|
||||||
failed: list[str] = []
|
|
||||||
for name in tools_to_install:
|
|
||||||
try:
|
|
||||||
_install_tool(name)
|
|
||||||
except Exception as exc:
|
|
||||||
click.echo(f" {name}: FAILED — {exc}", err=True)
|
|
||||||
failed.append(name)
|
|
||||||
|
|
||||||
if failed:
|
|
||||||
raise click.ClickException(f"Failed to install: {', '.join(failed)}")
|
|
||||||
|
|
||||||
# Remind user to add ~/.local/bin to PATH if not already there
|
|
||||||
path_env = os.environ.get("PATH", "")
|
|
||||||
if str(TARGET_DIR) not in path_env:
|
|
||||||
click.echo(f"\nAdd {TARGET_DIR} to your PATH to use these tools.")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
main() # pragma: no cover
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Run all molecule scenarios on all supported OS platforms.
|
|
||||||
|
|
||||||
Replaces the previous ``scripts/molecule_all.sh`` with a tested Python equivalent.
|
|
||||||
Sequential execution — CI uses the parallel matrix instead.
|
|
||||||
|
|
||||||
Usage::
|
|
||||||
|
|
||||||
python3 scripts/molecule_all.py
|
|
||||||
python3 scripts/molecule_all.py --bin .venv/bin
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess # nosec B404
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import click
|
|
||||||
|
|
||||||
from scripts.ci.platforms import PLATFORMS
|
|
||||||
|
|
||||||
ROLE_DIR = Path("ansible/roles/gitea-runner")
|
|
||||||
SCENARIOS = ["default", "multi-instance", "lifecycle", "template-content", "deregister", "update"]
|
|
||||||
|
|
||||||
|
|
||||||
def _run_molecule(molecule_bin: str, scenario: str, role_dir: Path, env: dict[str, str]) -> int:
|
|
||||||
"""Run a single molecule scenario. Returns the exit code."""
|
|
||||||
cmd = [molecule_bin, "test"]
|
|
||||||
if scenario != "default":
|
|
||||||
cmd.extend(["-s", scenario])
|
|
||||||
|
|
||||||
click.echo(f"--- Scenario: {scenario} ---")
|
|
||||||
result = subprocess.run( # nosec B603
|
|
||||||
cmd,
|
|
||||||
cwd=str(role_dir),
|
|
||||||
env=env,
|
|
||||||
)
|
|
||||||
return result.returncode
|
|
||||||
|
|
||||||
|
|
||||||
def _run_platform(
|
|
||||||
molecule_bin: str,
|
|
||||||
platform: dict[str, str],
|
|
||||||
role_dir: Path,
|
|
||||||
scenarios: list[str],
|
|
||||||
base_env: dict[str, str],
|
|
||||||
) -> int:
|
|
||||||
"""Run all scenarios for a single platform. Returns the first non-zero exit code."""
|
|
||||||
env = dict(base_env)
|
|
||||||
env["MOLECULE_PLATFORM_NAME"] = platform["name"]
|
|
||||||
env["MOLECULE_PLATFORM_IMAGE"] = platform["image"]
|
|
||||||
if platform.get("command"):
|
|
||||||
env["MOLECULE_PLATFORM_COMMAND"] = platform["command"]
|
|
||||||
else:
|
|
||||||
env.pop("MOLECULE_PLATFORM_COMMAND", None)
|
|
||||||
|
|
||||||
click.echo(f"=== Platform: {platform['name']} ===")
|
|
||||||
for scenario in scenarios:
|
|
||||||
rc = _run_molecule(molecule_bin, scenario, role_dir, env)
|
|
||||||
if rc != 0:
|
|
||||||
return rc
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
|
||||||
@click.option("--bin", "bin_dir", default=".venv/bin", help="Path to the virtualenv bin directory.")
|
|
||||||
def main(bin_dir: str) -> None:
|
|
||||||
"""Run all molecule scenarios on all supported OS platforms sequentially."""
|
|
||||||
molecule_bin = str(Path(bin_dir) / "molecule")
|
|
||||||
if not Path(molecule_bin).exists():
|
|
||||||
raise click.ClickException(f"molecule not found at {molecule_bin}. Run 'make setup' first.")
|
|
||||||
|
|
||||||
if not ROLE_DIR.exists():
|
|
||||||
raise click.ClickException(f"Role directory not found: {ROLE_DIR}")
|
|
||||||
|
|
||||||
base_env = dict(os.environ)
|
|
||||||
base_env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] = "true"
|
|
||||||
base_env["ANSIBLE_INJECT_INVOCATION"] = "1"
|
|
||||||
|
|
||||||
for platform in PLATFORMS:
|
|
||||||
rc = _run_platform(molecule_bin, platform, ROLE_DIR, SCENARIOS, base_env)
|
|
||||||
if rc != 0:
|
|
||||||
click.echo(f"FAILED on platform {platform['name']}", err=True)
|
|
||||||
sys.exit(rc)
|
|
||||||
|
|
||||||
click.echo("All molecule scenarios passed on all platforms.")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
main() # pragma: no cover
|
|
||||||
@@ -1,184 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""Project setup: install Python deps, Ansible collections, and pre-commit hooks.
|
|
||||||
|
|
||||||
Also configures the ``tea`` Gitea CLI login profile from ``.env`` so that
|
|
||||||
CI scripts and dev tools can use ``tea`` for Gitea API operations.
|
|
||||||
|
|
||||||
Usage::
|
|
||||||
|
|
||||||
python3 scripts/setup.py --bin .venv/bin
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import subprocess # nosec B404
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import click
|
|
||||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
|
||||||
|
|
||||||
load_dotenv(override=True)
|
|
||||||
|
|
||||||
|
|
||||||
def _run(cmd: list[str], bin_dir: str) -> None:
|
|
||||||
"""Run a command, streaming output to stdout/stderr."""
|
|
||||||
click.echo(f" $ {' '.join(cmd)}")
|
|
||||||
subprocess.run(cmd, check=True) # nosec B603
|
|
||||||
|
|
||||||
|
|
||||||
def _install_python_deps(bin_dir: str, extras: str = "dev") -> None:
|
|
||||||
"""Install the project with the specified extras in editable mode."""
|
|
||||||
pip = str(Path(bin_dir) / "pip")
|
|
||||||
_run([pip, "install", "-e", f".[{extras}]"], bin_dir)
|
|
||||||
|
|
||||||
|
|
||||||
def _install_ansible_collections(bin_dir: str) -> None:
|
|
||||||
"""Install required Ansible Galaxy collections."""
|
|
||||||
galaxy = str(Path(bin_dir) / "ansible-galaxy")
|
|
||||||
requirements = Path("ansible/requirements.yml")
|
|
||||||
if not requirements.exists():
|
|
||||||
click.echo(" ansible/requirements.yml not found — skipping collections.")
|
|
||||||
return
|
|
||||||
_run([galaxy, "collection", "install", "-r", str(requirements)], bin_dir)
|
|
||||||
|
|
||||||
|
|
||||||
def _install_pre_commit_hooks(bin_dir: str) -> None:
|
|
||||||
"""Install pre-commit hooks for commit-msg, pre-commit, and pre-push."""
|
|
||||||
pre_commit = str(Path(bin_dir) / "pre-commit")
|
|
||||||
for hook_type in ["pre-commit", "commit-msg", "pre-push"]:
|
|
||||||
_run([pre_commit, "install", "--hook-type", hook_type], bin_dir)
|
|
||||||
|
|
||||||
|
|
||||||
def _configure_tea_login() -> None:
|
|
||||||
"""Configure tea CLI login from .env if REPO_TOKEN is set.
|
|
||||||
|
|
||||||
Idempotent: if a login with the same name already exists, it is not re-added.
|
|
||||||
Fails if tea is not installed (run ``make install-tools`` first).
|
|
||||||
Skips if REPO_TOKEN is not set (local dev without token).
|
|
||||||
"""
|
|
||||||
tea_bin = shutil.which("tea")
|
|
||||||
if tea_bin is None:
|
|
||||||
raise click.ClickException("tea: not installed — run 'make install-tools' to install it.")
|
|
||||||
|
|
||||||
token = os.environ.get("REPO_TOKEN", "")
|
|
||||||
if not token:
|
|
||||||
click.echo("tea: REPO_TOKEN not set — skipping login configuration.")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Derive the Gitea URL from the API URL (strip /api/v1 suffix)
|
|
||||||
api_url = os.environ.get("GRM_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
|
|
||||||
gitea_url = api_url.replace("/api/v1", "")
|
|
||||||
login_name = "grm"
|
|
||||||
|
|
||||||
# Check if login already exists
|
|
||||||
result = subprocess.run( # nosec B603
|
|
||||||
[tea_bin, "login", "list", "--output", "simple"],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if result.returncode == 0 and login_name in result.stdout:
|
|
||||||
click.echo(f"tea: login '{login_name}' already configured.")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Add login profile
|
|
||||||
click.echo(f"tea: configuring login '{login_name}' for {gitea_url}...")
|
|
||||||
add_result = subprocess.run( # nosec B603
|
|
||||||
[tea_bin, "login", "add", "--name", login_name, "--url", gitea_url, "--token", token],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
if add_result.returncode != 0:
|
|
||||||
click.echo(f"tea: login configuration failed: {add_result.stderr.strip()}", err=True)
|
|
||||||
else:
|
|
||||||
# Set as default login
|
|
||||||
subprocess.run( # nosec B603
|
|
||||||
[tea_bin, "login", "default", login_name],
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
click.echo(f"tea: login '{login_name}' configured and set as default.")
|
|
||||||
|
|
||||||
|
|
||||||
def _verify(bin_dir: str) -> None:
|
|
||||||
"""Print versions of installed tools for verification."""
|
|
||||||
grm = str(Path(bin_dir) / "grm")
|
|
||||||
pre_commit = str(Path(bin_dir) / "pre-commit")
|
|
||||||
for tool in [grm, pre_commit]:
|
|
||||||
try:
|
|
||||||
result = subprocess.run([tool, "--version"], capture_output=True, text=True, timeout=10) # nosec B603
|
|
||||||
if result.returncode == 0:
|
|
||||||
click.echo(f" {result.stdout.strip()}")
|
|
||||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
@click.command()
|
|
||||||
@click.option("--bin", "bin_dir", default=".venv/bin", help="Path to the virtualenv bin directory.")
|
|
||||||
@click.option(
|
|
||||||
"--extras",
|
|
||||||
default="dev",
|
|
||||||
help="Dependency group to install: ci, lint, molecule, or dev (default: dev).",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--no-ansible-collections",
|
|
||||||
is_flag=True,
|
|
||||||
default=False,
|
|
||||||
help="Skip Ansible Galaxy collections installation.",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--no-pre-commit",
|
|
||||||
is_flag=True,
|
|
||||||
default=False,
|
|
||||||
help="Skip pre-commit hook installation.",
|
|
||||||
)
|
|
||||||
@click.option(
|
|
||||||
"--no-tea-login",
|
|
||||||
is_flag=True,
|
|
||||||
default=False,
|
|
||||||
help="Skip tea CLI login configuration.",
|
|
||||||
)
|
|
||||||
def main(
|
|
||||||
bin_dir: str,
|
|
||||||
extras: str,
|
|
||||||
no_ansible_collections: bool,
|
|
||||||
no_pre_commit: bool,
|
|
||||||
no_tea_login: bool,
|
|
||||||
) -> None:
|
|
||||||
"""Install Python deps, Ansible collections, and pre-commit hooks."""
|
|
||||||
if not Path(bin_dir).exists():
|
|
||||||
raise click.ClickException(f"Bin directory not found: {bin_dir}. Run 'python3 -m venv .venv' first.")
|
|
||||||
|
|
||||||
click.echo(f"Installing Python dependencies (extras: {extras})...")
|
|
||||||
_install_python_deps(bin_dir, extras)
|
|
||||||
|
|
||||||
if not no_ansible_collections:
|
|
||||||
click.echo("Installing Ansible collections...")
|
|
||||||
_install_ansible_collections(bin_dir)
|
|
||||||
|
|
||||||
if not no_pre_commit:
|
|
||||||
click.echo("Installing pre-commit hooks...")
|
|
||||||
_install_pre_commit_hooks(bin_dir)
|
|
||||||
|
|
||||||
if not no_tea_login:
|
|
||||||
click.echo("Configuring tea CLI login...")
|
|
||||||
_configure_tea_login()
|
|
||||||
|
|
||||||
click.echo("")
|
|
||||||
click.echo("Setup complete.")
|
|
||||||
click.echo("Activate the virtual environment with one of:")
|
|
||||||
click.echo(" source .venv/bin/activate (generic)")
|
|
||||||
click.echo(" source activate.sh (bash)")
|
|
||||||
click.echo(" source activate.fish (fish)")
|
|
||||||
click.echo(" source activate.zsh (zsh)")
|
|
||||||
click.echo("")
|
|
||||||
|
|
||||||
_verify(bin_dir)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__": # pragma: no cover
|
|
||||||
main() # pragma: no cover
|
|
||||||
@@ -1,503 +0,0 @@
|
|||||||
{
|
|
||||||
"\nAll documentation coverage checks passed!": {
|
|
||||||
"en": "\nAll documentation coverage checks passed!"
|
|
||||||
},
|
|
||||||
"\nAnsible files changed ({count}):": {
|
|
||||||
"en": "\nAnsible files changed ({count}):"
|
|
||||||
},
|
|
||||||
"\nChecking CI script documentation in ci-cd-workflow.md...": {
|
|
||||||
"en": "\nChecking CI script documentation in ci-cd-workflow.md..."
|
|
||||||
},
|
|
||||||
"\nChecking module documentation in architecture.md...": {
|
|
||||||
"en": "\nChecking module documentation in architecture.md..."
|
|
||||||
},
|
|
||||||
"\nDoc coverage: {covered}/{total} ({pct}%)": {
|
|
||||||
"en": "\nDoc coverage: {covered}/{total} ({pct}%)"
|
|
||||||
},
|
|
||||||
"\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}": {
|
|
||||||
"en": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}"
|
|
||||||
},
|
|
||||||
"\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.": {
|
|
||||||
"en": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce."
|
|
||||||
},
|
|
||||||
"\nIntegrity check FAILED ({count} issues):": {
|
|
||||||
"en": "\nIntegrity check FAILED ({count} issues):"
|
|
||||||
},
|
|
||||||
"\nIntegrity check passed — all {count} pages verified.": {
|
|
||||||
"en": "\nIntegrity check passed — all {count} pages verified."
|
|
||||||
},
|
|
||||||
"\nMissing documentation:": {
|
|
||||||
"en": "\nMissing documentation:"
|
|
||||||
},
|
|
||||||
"\nResult: {status}": {
|
|
||||||
"en": "\nResult: {status}"
|
|
||||||
},
|
|
||||||
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).": {
|
|
||||||
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)."
|
|
||||||
},
|
|
||||||
"\nRunning full wiki integrity check...": {
|
|
||||||
"en": "\nRunning full wiki integrity check..."
|
|
||||||
},
|
|
||||||
"\nUser-facing changes ({count}):": {
|
|
||||||
"en": "\nUser-facing changes ({count}):"
|
|
||||||
},
|
|
||||||
"\nUser-facing files changed ({count}):": {
|
|
||||||
"en": "\nUser-facing files changed ({count}):"
|
|
||||||
},
|
|
||||||
"\nVerification FAILED: {failures} page(s) have empty or mismatched content!": {
|
|
||||||
"en": "\nVerification FAILED: {failures} page(s) have empty or mismatched content!"
|
|
||||||
},
|
|
||||||
"\nVerification passed — all wiki pages have correct content.": {
|
|
||||||
"en": "\nVerification passed — all wiki pages have correct content."
|
|
||||||
},
|
|
||||||
"\nVerifying wiki pages have content...": {
|
|
||||||
"en": "\nVerifying wiki pages have content..."
|
|
||||||
},
|
|
||||||
"\nWorkflow-only changes ({count}):": {
|
|
||||||
"en": "\nWorkflow-only changes ({count}):"
|
|
||||||
},
|
|
||||||
"\n[dry-run] Changelog:\n{changelog}": {
|
|
||||||
"en": "\n[dry-run] Changelog:\n{changelog}"
|
|
||||||
},
|
|
||||||
" - Auto-delete branch after merge: yes": {
|
|
||||||
"en": " - Auto-delete branch after merge: yes",
|
|
||||||
"bg": " - Автоматично изтриване на клон след сливане: да",
|
|
||||||
"de": " - Branch nach Merge automatisch löschen: ja",
|
|
||||||
"ru": " - Автоудаление ветки после слияния: да",
|
|
||||||
"zh": " - 合并后自动删除分支: 是"
|
|
||||||
},
|
|
||||||
" - Block outdated branches: yes": {
|
|
||||||
"en": " - Block outdated branches: yes",
|
|
||||||
"bg": " - Блокиране на остарели клонове: да",
|
|
||||||
"de": " - Veraltete Branches blockieren: ja",
|
|
||||||
"ru": " - Блокировать устаревшие ветки: да",
|
|
||||||
"zh": " - 阻止过时分支: 是"
|
|
||||||
},
|
|
||||||
" - Block rejected reviews: yes": {
|
|
||||||
"en": " - Block rejected reviews: yes",
|
|
||||||
"bg": " - Блокиране на отхвърлени рецензии: да",
|
|
||||||
"de": " - Abgelehnte Reviews blockieren: ja",
|
|
||||||
"ru": " - Блокировать отклонённые ревью: да",
|
|
||||||
"zh": " - 阻止被拒绝的审查: 是"
|
|
||||||
},
|
|
||||||
" - Direct pushes: BLOCKED (require PR, whitelisted users can push)": {
|
|
||||||
"en": " - Direct pushes: BLOCKED (require PR, whitelisted users can push)"
|
|
||||||
},
|
|
||||||
" - Dismiss stale approvals: yes": {
|
|
||||||
"en": " - Dismiss stale approvals: yes",
|
|
||||||
"bg": " - Анулиране на остарели одобрения: да",
|
|
||||||
"de": " - Veraltete Genehmigungen ablehnen: ja",
|
|
||||||
"ru": " - Отклонять устаревшие одобрения: да",
|
|
||||||
"zh": " - 忽略过时审批: 是"
|
|
||||||
},
|
|
||||||
" - Required approvals: {count}": {
|
|
||||||
"en": " - Required approvals: {count}",
|
|
||||||
"bg": " - Необходими одобрения: {count}",
|
|
||||||
"de": " - Erforderliche Genehmigungen: {count}",
|
|
||||||
"ru": " - Требуемые одобрения: {count}",
|
|
||||||
"zh": " - 必需审批数: {count}"
|
|
||||||
},
|
|
||||||
" - Required status checks: {checks}": {
|
|
||||||
"en": " - Required status checks: {checks}",
|
|
||||||
"bg": " - Необходими проверки на състоянието: {checks}",
|
|
||||||
"de": " - Erforderliche Status-Checks: {checks}",
|
|
||||||
"ru": " - Требуемые проверки статуса: {checks}",
|
|
||||||
"zh": " - 必需状态检查: {checks}"
|
|
||||||
},
|
|
||||||
" Created: {title}": {
|
|
||||||
"en": " Created: {title}"
|
|
||||||
},
|
|
||||||
" FAIL: {title} — content mismatch or empty!": {
|
|
||||||
"en": " FAIL: {title} — content mismatch or empty!"
|
|
||||||
},
|
|
||||||
" MISSING: grm {cmd}": {
|
|
||||||
"en": " MISSING: grm {cmd}"
|
|
||||||
},
|
|
||||||
" MISSING: {module}": {
|
|
||||||
"en": " MISSING: {module}"
|
|
||||||
},
|
|
||||||
" MISSING: {script}": {
|
|
||||||
"en": " MISSING: {script}"
|
|
||||||
},
|
|
||||||
" OK: grm {cmd}": {
|
|
||||||
"en": " OK: grm {cmd}"
|
|
||||||
},
|
|
||||||
" OK: {module}": {
|
|
||||||
"en": " OK: {module}"
|
|
||||||
},
|
|
||||||
" OK: {script}": {
|
|
||||||
"en": " OK: {script}"
|
|
||||||
},
|
|
||||||
" OK: {title} ({chars} chars)": {
|
|
||||||
"en": " OK: {title} ({chars} chars)"
|
|
||||||
},
|
|
||||||
" Updated: {title}": {
|
|
||||||
"en": " Updated: {title}"
|
|
||||||
},
|
|
||||||
"API poll warning: {exc}": {
|
|
||||||
"en": "API poll warning: {exc}"
|
|
||||||
},
|
|
||||||
"All molecule tests passed.": {
|
|
||||||
"en": "All molecule tests passed."
|
|
||||||
},
|
|
||||||
"Another molecule runner failed. Stopping this runner early.": {
|
|
||||||
"en": "Another molecule runner failed. Stopping this runner early."
|
|
||||||
},
|
|
||||||
"Bumping version: {current} -> v{new_version}": {
|
|
||||||
"en": "Bumping version: {current} -> v{new_version}"
|
|
||||||
},
|
|
||||||
"Checking CLI command documentation...": {
|
|
||||||
"en": "Checking CLI command documentation..."
|
|
||||||
},
|
|
||||||
"Command failed ({cmd}): {stderr}": {
|
|
||||||
"en": "Command failed ({cmd}): {stderr}"
|
|
||||||
},
|
|
||||||
"Comparing {base}..{head} ({count} files changed)": {
|
|
||||||
"en": "Comparing {base}..{head} ({count} files changed)"
|
|
||||||
},
|
|
||||||
"Configuring branch protection for {branch}...": {
|
|
||||||
"en": "Configuring branch protection for {branch}...",
|
|
||||||
"bg": "Конфигуриране на защита на клона {branch}...",
|
|
||||||
"de": "Konfiguriere Branch-Schutz für {branch}...",
|
|
||||||
"ru": "Настройка защиты ветки {branch}...",
|
|
||||||
"zh": "正在配置 {branch} 的分支保护..."
|
|
||||||
},
|
|
||||||
"Configuring repository settings...": {
|
|
||||||
"en": "Configuring repository settings...",
|
|
||||||
"bg": "Конфигуриране на настройките на хранилището...",
|
|
||||||
"de": "Repository-Einstellungen konfigurieren...",
|
|
||||||
"ru": "Настройка параметров репозитория...",
|
|
||||||
"zh": "正在配置仓库设置..."
|
|
||||||
},
|
|
||||||
"Could not extract conventional commit message from PR commits.": {
|
|
||||||
"en": "Could not extract conventional commit message from PR commits."
|
|
||||||
},
|
|
||||||
"Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.": {
|
|
||||||
"en": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task."
|
|
||||||
},
|
|
||||||
"Could not find __version__ in {file}": {
|
|
||||||
"en": "Could not find __version__ in {file}"
|
|
||||||
},
|
|
||||||
"Could not parse test execution time from output.": {
|
|
||||||
"en": "Could not parse test execution time from output."
|
|
||||||
},
|
|
||||||
"Created issue #{issue_id}: {title}": {
|
|
||||||
"en": "Created issue #{issue_id}: {title}"
|
|
||||||
},
|
|
||||||
"Created release commit.": {
|
|
||||||
"en": "Created release commit."
|
|
||||||
},
|
|
||||||
"Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.": {
|
|
||||||
"en": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently."
|
|
||||||
},
|
|
||||||
"ERROR: REPO_TOKEN is not set.": {
|
|
||||||
"en": "ERROR: REPO_TOKEN is not set.",
|
|
||||||
"bg": "ГРЕШКА: REPO_TOKEN не е зададен.",
|
|
||||||
"de": "FEHLER: REPO_TOKEN ist nicht gesetzt.",
|
|
||||||
"ru": "ОШИБКА: REPO_TOKEN не задан.",
|
|
||||||
"zh": "错误:未设置 REPO_TOKEN。"
|
|
||||||
},
|
|
||||||
"ERROR: VIKUNJA_TOKEN is not set.": {
|
|
||||||
"en": "ERROR: VIKUNJA_TOKEN is not set.",
|
|
||||||
"bg": "ГРЕШКА: VIKUNJA_TOKEN не е зададен.",
|
|
||||||
"de": "FEHLER: VIKUNJA_TOKEN ist nicht gesetzt.",
|
|
||||||
"ru": "ОШИБКА: VIKUNJA_TOKEN не задан.",
|
|
||||||
"zh": "错误:未设置 VIKUNJA_TOKEN。"
|
|
||||||
},
|
|
||||||
"ERROR: mapping.json not found at {path}": {
|
|
||||||
"en": "ERROR: mapping.json not found at {path}"
|
|
||||||
},
|
|
||||||
"FAILED: {pair} exited with code {code}": {
|
|
||||||
"en": "FAILED: {pair} exited with code {code}"
|
|
||||||
},
|
|
||||||
"Failed to create issue via tea: {error}": {
|
|
||||||
"en": "Failed to create issue via tea: {error}"
|
|
||||||
},
|
|
||||||
"Found {count} existing wiki pages.": {
|
|
||||||
"en": "Found {count} existing wiki pages."
|
|
||||||
},
|
|
||||||
"GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.": {
|
|
||||||
"en": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."
|
|
||||||
},
|
|
||||||
"HEAD is already a release commit ('{msg}'). Another release may have just completed. Skipping.": {
|
|
||||||
"en": "HEAD is already a release commit ('{msg}'). Another release may have just completed. Skipping."
|
|
||||||
},
|
|
||||||
"HTTP error: {status} — {message}": {
|
|
||||||
"en": "HTTP error: {status} — {message}",
|
|
||||||
"bg": "HTTP грешка: {status} — {message}",
|
|
||||||
"de": "HTTP-Fehler: {status} — {message}",
|
|
||||||
"ru": "Ошибка HTTP: {status} — {message}",
|
|
||||||
"zh": "HTTP 错误: {status} — {message}"
|
|
||||||
},
|
|
||||||
"HTTP {status} Forbidden — your token lacks admin rights.\nMake sure the token belongs to a repo owner or organisation admin.\nAlternatively, configure branch protection manually in Settings → Branches.": {
|
|
||||||
"en": "HTTP {status} Forbidden — your token lacks admin rights.\nMake sure the token belongs to a repo owner or organisation admin.\nAlternatively, configure branch protection manually in Settings → Branches.",
|
|
||||||
"bg": "HTTP {status} Забранено — вашият токен няма администраторски права.\nУверете се, че токенът принадлежи на собственик на хранилище или администратор на организация.\nАлтернативно, конфигурирайте защитата на клона ръчно в Настройки → Клонове.",
|
|
||||||
"de": "HTTP {status} Verboten — Ihr Token hat keine Admin-Rechte.\nStellen Sie sicher, dass das Token einem Repository-Besitzer oder Organisations-Admin gehört.\nAlternativ können Sie den Branch-Schutz manuell unter Einstellungen → Branches konfigurieren.",
|
|
||||||
"ru": "HTTP {status} Запрещено — у вашего токена нет прав администратора.\nУбедитесь, что токен принадлежит владельцу репозитория или администратору организации.\nЛибо настройте защиту ветки вручную в разделе Настройки → Ветки.",
|
|
||||||
"zh": "HTTP {status} 禁止访问 — 您的令牌缺少管理员权限。\n请确保令牌属于仓库所有者或组织管理员。\n或者,您可以在 设置 → 分支 中手动配置分支保护。"
|
|
||||||
},
|
|
||||||
"Head branch is behind master. Pulling and rebasing...": {
|
|
||||||
"en": "Head branch is behind master. Pulling and rebasing..."
|
|
||||||
},
|
|
||||||
"Infrastructure commit (no GRM-N task ID), skipping Vikunja update: {msg}": {
|
|
||||||
"en": "Infrastructure commit (no GRM-N task ID), skipping Vikunja update: {msg}"
|
|
||||||
},
|
|
||||||
"Lint failed — refusing to release. Fix lint errors first.\n{stderr}": {
|
|
||||||
"en": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}"
|
|
||||||
},
|
|
||||||
"Lint passed.": {
|
|
||||||
"en": "Lint passed."
|
|
||||||
},
|
|
||||||
"Merge failed after rebase retry: {error}\nPlease rebase the PR manually.": {
|
|
||||||
"en": "Merge failed after rebase retry: {error}\nPlease rebase the PR manually."
|
|
||||||
},
|
|
||||||
"Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.": {
|
|
||||||
"en": "Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.",
|
|
||||||
"bg": "Сливането неуспешно с HTTP {status}: {message}\nПроверете дали PR е готов и имате права за сливане.",
|
|
||||||
"de": "Merge fehlgeschlagen mit HTTP {status}: {message}\nBitte prüfen Sie, ob der PR bereit ist und Sie Merge-Rechte haben.",
|
|
||||||
"ru": "Слияние не удалось: HTTP {status}: {message}\nПроверьте, что PR готов и у вас есть права на слияние.",
|
|
||||||
"zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。"
|
|
||||||
},
|
|
||||||
"Molecule directory not found: {path}": {
|
|
||||||
"en": "Molecule directory not found: {path}",
|
|
||||||
"bg": "Директорията на molecule не е намерена: {path}",
|
|
||||||
"de": "Molecule-Verzeichnis nicht gefunden: {path}",
|
|
||||||
"ru": "Директория molecule не найдена: {path}",
|
|
||||||
"zh": "未找到 molecule 目录: {path}"
|
|
||||||
},
|
|
||||||
"Nice! Gitea release {tag} created.": {
|
|
||||||
"en": "Nice! Gitea release {tag} created.",
|
|
||||||
"bg": "Отлично! Gitea release {tag} е създаден.",
|
|
||||||
"de": "Prima! Gitea-Release {tag} erstellt.",
|
|
||||||
"ru": "Отлично! Gitea release {tag} создан.",
|
|
||||||
"zh": "不错!Gitea release {tag} 已创建。"
|
|
||||||
},
|
|
||||||
"Nice! PR #{pr_number} squash-merged with title: {merge_title}": {
|
|
||||||
"en": "Nice! PR #{pr_number} squash-merged with title: {merge_title}",
|
|
||||||
"bg": "Отлично! PR #{pr_number} е squash-merge-нат със заглавие: {merge_title}",
|
|
||||||
"de": "Prima! PR #{pr_number} wurde mit Titel {merge_title} squash-gemergt.",
|
|
||||||
"ru": "Отлично! PR #{pr_number} squash-merge с заголовком: {merge_title}",
|
|
||||||
"zh": "不错!PR #{pr_number} 已 squash 合并,标题: {merge_title}"
|
|
||||||
},
|
|
||||||
"Nice! Release v{version} tagged and pushed. The publish workflow will be triggered.": {
|
|
||||||
"en": "Nice! Release v{version} tagged and pushed. The publish workflow will be triggered."
|
|
||||||
},
|
|
||||||
"Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.": {
|
|
||||||
"en": "Nice! Vikunja task {task_id} (ID {vikunja_id}) updated and marked done.",
|
|
||||||
"bg": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) е обновена и маркирана като готова.",
|
|
||||||
"de": "Prima! Vikunja-Aufgabe {task_id} (ID {vikunja_id}) aktualisiert und als erledigt markiert.",
|
|
||||||
"ru": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) обновлена и отмечена как выполненная.",
|
|
||||||
"zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。"
|
|
||||||
},
|
|
||||||
"No changes between {base} and {head}.": {
|
|
||||||
"en": "No changes between {base} and {head}."
|
|
||||||
},
|
|
||||||
"No staged changes — version and changelog already up to date.": {
|
|
||||||
"en": "No staged changes — version and changelog already up to date."
|
|
||||||
},
|
|
||||||
"No tags found — treating all changes as user-facing.": {
|
|
||||||
"en": "No tags found — treating all changes as user-facing."
|
|
||||||
},
|
|
||||||
"No unreleased changes found. Nothing to release.": {
|
|
||||||
"en": "No unreleased changes found. Nothing to release."
|
|
||||||
},
|
|
||||||
"No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.": {
|
|
||||||
"en": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release."
|
|
||||||
},
|
|
||||||
"Note: Self-approval not allowed. Posting COMMENT instead.": {
|
|
||||||
"en": "Note: Self-approval not allowed. Posting COMMENT instead."
|
|
||||||
},
|
|
||||||
"Oops! Commit message must follow conventional commit format.\n Expected: <type>: <description>\n Got: {subject}\n Allowed types: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE": {
|
|
||||||
"en": "Oops! Commit message must follow conventional commit format.\n Expected: <type>: <description>\n Got: {subject}\n Allowed types: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
|
|
||||||
"bg": "Опа! Съобщението за commit трябва да следва конвенционален формат.\n Очаква се: <type>: <description>\n Получено: {subject}\n Разрешени типове: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
|
|
||||||
"de": "Ups! Commit-Nachricht muss dem konventionellen Commit-Format folgen.\n Erwartet: <type>: <description>\n Erhalten: {subject}\n Erlaubte Typen: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
|
|
||||||
"ru": "Ой! Сообщение коммита должно соответствовать формату conventional commit.\n Ожидается: <type>: <description>\n Получено: {subject}\n Допустимые типы: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
|
|
||||||
"zh": "哎呀!提交消息必须遵循 conventional commit 格式。\n 预期格式: <type>: <description>\n 实际: {subject}\n 允许的类型: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE"
|
|
||||||
},
|
|
||||||
"Oops! Do not include task ID (GRM-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.": {
|
|
||||||
"en": "Oops! Do not include task ID (GRM-N) in feature branch commits.\n The task ID will be added automatically on merge via CI.",
|
|
||||||
"bg": "Опа! Не включвайте идентификатор на задача (GRM-N) в commit-и от feature клонове.\n Идентификаторът ще бъде добавен автоматично при сливане чрез CI.",
|
|
||||||
"de": "Ups! Keine Task-ID (GRM-N) in Feature-Branch-Commits einfügen.\n Die Task-ID wird beim Merge automatisch über CI hinzugefügt.",
|
|
||||||
"ru": "Ой! Не включайте ID задачи (GRM-N) в коммиты feature-веток.\n ID задачи будет добавлен автоматически при слиянии через CI.",
|
|
||||||
"zh": "哎呀!不要在 feature 分支的提交中包含任务 ID (GRM-N)。\n 任务 ID 将在通过 CI 合并时自动添加。"
|
|
||||||
},
|
|
||||||
"Oops! Master branch commit must follow conventional format after task ID.\n Expected: GRM-N: <type>: <description>\n Got: {subject}": {
|
|
||||||
"en": "Oops! Master branch commit must follow conventional format after task ID.\n Expected: GRM-N: <type>: <description>\n Got: {subject}",
|
|
||||||
"bg": "Опа! Commit-ът в клона master трябва да следва конвенционален формат след идентификатора.\n Очаква се: GRM-N: <type>: <description>\n Получено: {subject}",
|
|
||||||
"de": "Ups! Master-Branch-Commit muss nach der Task-ID dem konventionellen Format folgen.\n Erwartet: GRM-N: <type>: <description>\n Erhalten: {subject}",
|
|
||||||
"ru": "Ой! Коммит в ветку master после ID задачи должен соответствовать conventional формату.\n Ожидается: GRM-N: <type>: <description>\n Получено: {subject}",
|
|
||||||
"zh": "哎呀!master 分支提交在任务 ID 后必须遵循 conventional commit 格式。\n 预期格式: GRM-N: <type>: <description>\n 实际: {subject}"
|
|
||||||
},
|
|
||||||
"Oops! Master branch commits must start with a task ID.\n Expected: GRM-N: <conventional commit message>\n Got: {subject}": {
|
|
||||||
"en": "Oops! Master branch commits must start with a task ID.\n Expected: GRM-N: <conventional commit message>\n Got: {subject}",
|
|
||||||
"bg": "Опа! Commit-ите в клона master трябва да започват с идентификатор на задача.\n Очаква се: GRM-N: <conventional commit message>\n Получено: {subject}",
|
|
||||||
"de": "Ups! Master-Branch-Commits müssen mit einer Task-ID beginnen.\n Erwartet: GRM-N: <conventional commit message>\n Erhalten: {subject}",
|
|
||||||
"ru": "Ой! Коммиты в ветку master должны начинаться с ID задачи.\n Ожидается: GRM-N: <conventional commit message>\n Получено: {subject}",
|
|
||||||
"zh": "哎呀!master 分支的提交必须以任务 ID 开头。\n 预期格式: GRM-N: <conventional commit message>\n 实际: {subject}"
|
|
||||||
},
|
|
||||||
"Oops! No task ID found in .taskid file or branch name '{branch}'.": {
|
|
||||||
"en": "Oops! No task ID found in .taskid file or branch name '{branch}'."
|
|
||||||
},
|
|
||||||
"Oops! PR title must follow format 'GRM-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}": {
|
|
||||||
"en": "Oops! PR title must follow format 'GRM-N: <task title>'.\n Expected: {task_id}: <task title>\n Got: {pr_title}"
|
|
||||||
},
|
|
||||||
"Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}": {
|
|
||||||
"en": "Oops! PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {pr_title}"
|
|
||||||
},
|
|
||||||
"Oops! Package build failed:\n{stderr}": {
|
|
||||||
"en": "Oops! Package build failed:\n{stderr}",
|
|
||||||
"bg": "Опа! Сборката на пакета неуспешна:\n{stderr}",
|
|
||||||
"de": "Ups! Paket-Build fehlgeschlagen:\n{stderr}",
|
|
||||||
"ru": "Ой! Сборка пакета не удалась:\n{stderr}",
|
|
||||||
"zh": "哎呀!包构建失败:\n{stderr}"
|
|
||||||
},
|
|
||||||
"Oops! PyPI publish failed:\n{stderr}": {
|
|
||||||
"en": "Oops! PyPI publish failed:\n{stderr}",
|
|
||||||
"bg": "Опа! Публикуването в PyPI неуспешно:\n{stderr}",
|
|
||||||
"de": "Ups! PyPI-Veröffentlichung fehlgeschlagen:\n{stderr}",
|
|
||||||
"ru": "Ой! Публикация в PyPI не удалась:\n{stderr}",
|
|
||||||
"zh": "哎呀!PyPI 发布失败:\n{stderr}"
|
|
||||||
},
|
|
||||||
"PASSED: {pair}": {
|
|
||||||
"en": "PASSED: {pair}"
|
|
||||||
},
|
|
||||||
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}": {
|
|
||||||
"en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}"
|
|
||||||
},
|
|
||||||
"PYPI_TOKEN not set — skipping PyPI publish. No worries, we'll just create the Gitea release.": {
|
|
||||||
"en": "PYPI_TOKEN not set — skipping PyPI publish. No worries, we'll just create the Gitea release.",
|
|
||||||
"bg": "PYPI_TOKEN не е зададен — пропускаме публикуването в PyPI. Без притеснения, просто ще създадем Gitea release.",
|
|
||||||
"de": "PYPI_TOKEN nicht gesetzt — PyPI-Veröffentlichung wird übersprungen. Keine Sorge, wir erstellen einfach das Gitea-Release.",
|
|
||||||
"ru": "PYPI_TOKEN не задан — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.",
|
|
||||||
"zh": "未设置 PYPI_TOKEN — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。"
|
|
||||||
},
|
|
||||||
"Published to PyPI.": {
|
|
||||||
"en": "Published to PyPI.",
|
|
||||||
"bg": "Публикувано в PyPI.",
|
|
||||||
"de": "In PyPI veröffentlicht.",
|
|
||||||
"ru": "Опубликовано в PyPI.",
|
|
||||||
"zh": "已发布到 PyPI。"
|
|
||||||
},
|
|
||||||
"Pushed release commit to master.": {
|
|
||||||
"en": "Pushed release commit to master."
|
|
||||||
},
|
|
||||||
"Rebased and pushed. Retrying merge...": {
|
|
||||||
"en": "Rebased and pushed. Retrying merge..."
|
|
||||||
},
|
|
||||||
"Release creation failed: {error}": {
|
|
||||||
"en": "Release creation failed: {error}"
|
|
||||||
},
|
|
||||||
"Release must be run on master, currently on '{branch}'.": {
|
|
||||||
"en": "Release must be run on master, currently on '{branch}'."
|
|
||||||
},
|
|
||||||
"Repository configuration complete.": {
|
|
||||||
"en": "Repository configuration complete.",
|
|
||||||
"bg": "Конфигурирането на хранилището е завършено.",
|
|
||||||
"de": "Repository-Konfiguration abgeschlossen.",
|
|
||||||
"ru": "Конфигурация репозитория завершена.",
|
|
||||||
"zh": "仓库配置完成。"
|
|
||||||
},
|
|
||||||
"Runner index {index} out of range (0..{max})": {
|
|
||||||
"en": "Runner index {index} out of range (0..{max})",
|
|
||||||
"bg": "Индексът на runner {index} е извън диапазона (0..{max})",
|
|
||||||
"de": "Runner-Index {index} außerhalb des Bereichs (0..{max})",
|
|
||||||
"ru": "Индекс runner {index} вне диапазона (0..{max})",
|
|
||||||
"zh": "Runner 索引 {index} 超出范围 (0..{max})"
|
|
||||||
},
|
|
||||||
"Running lint checks...": {
|
|
||||||
"en": "Running lint checks..."
|
|
||||||
},
|
|
||||||
"Running tests...": {
|
|
||||||
"en": "Running tests..."
|
|
||||||
},
|
|
||||||
"Running: {scenario} on {platform}": {
|
|
||||||
"en": "Running: {scenario} on {platform}"
|
|
||||||
},
|
|
||||||
"Skipping commit push — no staged changes.": {
|
|
||||||
"en": "Skipping commit push — no staged changes."
|
|
||||||
},
|
|
||||||
"Syncing {count} documentation pages to wiki...": {
|
|
||||||
"en": "Syncing {count} documentation pages to wiki..."
|
|
||||||
},
|
|
||||||
"Tag v{version} already existed. Publish workflow should already have been triggered.": {
|
|
||||||
"en": "Tag v{version} already existed. Publish workflow should already have been triggered."
|
|
||||||
},
|
|
||||||
"Tag {tag} already exists, skipping creation.": {
|
|
||||||
"en": "Tag {tag} already exists, skipping creation."
|
|
||||||
},
|
|
||||||
"Task ID: {task_id}": {
|
|
||||||
"en": "Task ID: {task_id}"
|
|
||||||
},
|
|
||||||
"Tests failed — refusing to release. Fix test failures first.\n{stderr}": {
|
|
||||||
"en": "Tests failed — refusing to release. Fix test failures first.\n{stderr}"
|
|
||||||
},
|
|
||||||
"Tests passed.": {
|
|
||||||
"en": "Tests passed."
|
|
||||||
},
|
|
||||||
"Unit tests passed in {duration:.2f}s (under {max}s limit).": {
|
|
||||||
"en": "Unit tests passed in {duration:.2f}s (under {max}s limit)."
|
|
||||||
},
|
|
||||||
"Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures.": {
|
|
||||||
"en": "Unit tests too slow: {duration:.2f}s (max allowed: {max}s).\n Fix: run 'make pytest-cov' to profile, then optimise slow tests.\n Hint: avoid unnecessary imports, use lighter mocks, or cache fixtures."
|
|
||||||
},
|
|
||||||
"Updated version in {init}": {
|
|
||||||
"en": "Updated version in {init}"
|
|
||||||
},
|
|
||||||
"Updated {changelog_file}": {
|
|
||||||
"en": "Updated {changelog_file}"
|
|
||||||
},
|
|
||||||
"WARNING: --skip-tests passed — skipping test verification.": {
|
|
||||||
"en": "WARNING: --skip-tests passed — skipping test verification."
|
|
||||||
},
|
|
||||||
"WARNING: File {file} is empty — skipping.": {
|
|
||||||
"en": "WARNING: File {file} is empty — skipping."
|
|
||||||
},
|
|
||||||
"WARNING: File {file} not found — skipping.": {
|
|
||||||
"en": "WARNING: File {file} not found — skipping."
|
|
||||||
},
|
|
||||||
"Warning: No task ID (GRM-N) found in commit message: {msg}. Skipping Vikunja update.": {
|
|
||||||
"en": "Warning: No task ID (GRM-N) found in commit message: {msg}. Skipping Vikunja update."
|
|
||||||
},
|
|
||||||
"Warning: VIKUNJA_TOKEN not set, skipping title match validation.": {
|
|
||||||
"en": "Warning: VIKUNJA_TOKEN not set, skipping title match validation."
|
|
||||||
},
|
|
||||||
"Warning: Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded — please update the Vikunja task manually.": {
|
|
||||||
"en": "Warning: Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded — please update the Vikunja task manually."
|
|
||||||
},
|
|
||||||
"Warning: git-cliff generated empty changelog.": {
|
|
||||||
"en": "Warning: git-cliff generated empty changelog."
|
|
||||||
},
|
|
||||||
"Wiki integrity check failed — {count} issue(s)": {
|
|
||||||
"en": "Wiki integrity check failed — {count} issue(s)"
|
|
||||||
},
|
|
||||||
"Wiki verification failed — {failures} page(s) empty or mismatched": {
|
|
||||||
"en": "Wiki verification failed — {failures} page(s) empty or mismatched"
|
|
||||||
},
|
|
||||||
"[dry-run] Would commit: release: v{version}": {
|
|
||||||
"en": "[dry-run] Would commit: release: v{version}"
|
|
||||||
},
|
|
||||||
"[dry-run] Would create tag: v{version}": {
|
|
||||||
"en": "[dry-run] Would create tag: v{version}"
|
|
||||||
},
|
|
||||||
"[dry-run] Would create tag: {tag}": {
|
|
||||||
"en": "[dry-run] Would create tag: {tag}"
|
|
||||||
},
|
|
||||||
"[dry-run] Would push commit to master": {
|
|
||||||
"en": "[dry-run] Would push commit to master"
|
|
||||||
},
|
|
||||||
"[dry-run] Would sync page: {title} ({chars} chars)": {
|
|
||||||
"en": "[dry-run] Would sync page: {title} ({chars} chars)"
|
|
||||||
},
|
|
||||||
"[dry-run] Would update {changelog_file}": {
|
|
||||||
"en": "[dry-run] Would update {changelog_file}"
|
|
||||||
},
|
|
||||||
"[dry-run] Would update {init}": {
|
|
||||||
"en": "[dry-run] Would update {init}"
|
|
||||||
},
|
|
||||||
"git command failed ({cmd}): {stderr}": {
|
|
||||||
"en": "git command failed ({cmd}): {stderr}"
|
|
||||||
},
|
|
||||||
"git-cliff returned empty version.": {
|
|
||||||
"en": "git-cliff returned empty version."
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,345 +0,0 @@
|
|||||||
"""Unit tests for scripts/ci/auto_merge.py."""
|
|
||||||
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import click
|
|
||||||
import pytest
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
from gitea_runner_manager.exceptions import APIError
|
|
||||||
from scripts.ci.auto_merge import (
|
|
||||||
extract_conventional_msg,
|
|
||||||
extract_task_id,
|
|
||||||
main,
|
|
||||||
read_taskid,
|
|
||||||
run_cmd,
|
|
||||||
validate_pr_title,
|
|
||||||
validate_pr_title_matches_vikunja,
|
|
||||||
)
|
|
||||||
|
|
||||||
# -- read_taskid --
|
|
||||||
|
|
||||||
|
|
||||||
class TestReadTaskid:
|
|
||||||
def test_reads_from_file(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
(tmp_path / ".taskid").write_text("GRM-60\n")
|
|
||||||
assert read_taskid("some-branch") == "GRM-60"
|
|
||||||
|
|
||||||
def test_falls_back_to_branch_name(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
assert read_taskid("GRM-19-fix-bug") == "GRM-19"
|
|
||||||
|
|
||||||
def test_returns_empty_when_no_file_no_match(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
assert read_taskid("feature-branch") == ""
|
|
||||||
|
|
||||||
def test_empty_file_falls_back_to_branch(self, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
(tmp_path / ".taskid").write_text("\n")
|
|
||||||
assert read_taskid("GRM-42-test") == "GRM-42"
|
|
||||||
|
|
||||||
|
|
||||||
# -- extract_task_id (legacy fallback) --
|
|
||||||
|
|
||||||
|
|
||||||
class TestExtractTaskId:
|
|
||||||
def test_extracts_from_branch(self) -> None:
|
|
||||||
assert extract_task_id("GRM-19-fix-bug") == "GRM-19"
|
|
||||||
assert extract_task_id("GRM-123") == "GRM-123"
|
|
||||||
|
|
||||||
def test_returns_empty_when_no_match(self) -> None:
|
|
||||||
assert extract_task_id("feature-branch") == ""
|
|
||||||
|
|
||||||
|
|
||||||
# -- validate_pr_title --
|
|
||||||
|
|
||||||
|
|
||||||
class TestValidatePrTitle:
|
|
||||||
def test_valid_title(self) -> None:
|
|
||||||
validate_pr_title("GRM-19: Add new feature", "GRM-19")
|
|
||||||
|
|
||||||
def test_missing_colon(self) -> None:
|
|
||||||
with pytest.raises(click.ClickException, match="format"):
|
|
||||||
validate_pr_title("GRM-19 Add new feature", "GRM-19")
|
|
||||||
|
|
||||||
def test_task_id_mismatch(self) -> None:
|
|
||||||
with pytest.raises(click.ClickException, match="mismatch"):
|
|
||||||
validate_pr_title("GRM-20: Add feature", "GRM-19")
|
|
||||||
|
|
||||||
def test_no_task_id_in_title(self) -> None:
|
|
||||||
with pytest.raises(click.ClickException, match="format"):
|
|
||||||
validate_pr_title("Add new feature", "GRM-19")
|
|
||||||
|
|
||||||
|
|
||||||
# -- validate_pr_title_matches_vikunja --
|
|
||||||
|
|
||||||
|
|
||||||
class TestValidatePrTitleMatchesVikunja:
|
|
||||||
@patch.dict("os.environ", {}, clear=True)
|
|
||||||
def test_skips_when_no_token(self) -> None:
|
|
||||||
# Should not raise — just warn
|
|
||||||
validate_pr_title_matches_vikunja("GRM-19: test", "GRM-19")
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("scripts.ci.auto_merge.VikunjaClient")
|
|
||||||
def test_matches(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.list_project_tasks.return_value = [
|
|
||||||
{"id": 1, "identifier": "GRM-19", "title": "Add new feature"},
|
|
||||||
]
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
validate_pr_title_matches_vikunja("GRM-19: Add new feature", "GRM-19")
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("scripts.ci.auto_merge.VikunjaClient")
|
|
||||||
def test_mismatch_raises(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.list_project_tasks.return_value = [
|
|
||||||
{"id": 1, "identifier": "GRM-19", "title": "Different title"},
|
|
||||||
]
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
with pytest.raises(click.ClickException, match="does not match"):
|
|
||||||
validate_pr_title_matches_vikunja("GRM-19: Add new feature", "GRM-19")
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("scripts.ci.auto_merge.VikunjaClient")
|
|
||||||
def test_task_not_found_raises(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
"""When Vikunja task is not found and token is set, raises ClickException."""
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.list_project_tasks.return_value = []
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
with pytest.raises(click.ClickException, match="Could not find"):
|
|
||||||
validate_pr_title_matches_vikunja("GRM-99: test", "GRM-99")
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("scripts.ci.auto_merge.VikunjaClient")
|
|
||||||
def test_task_found_on_second_page(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
"""Pagination: task found on page 2."""
|
|
||||||
mock_client = MagicMock()
|
|
||||||
page1 = [{"id": i, "identifier": f"GRM-{i}", "title": f"Title {i}"} for i in range(50)]
|
|
||||||
page2 = [{"id": 100, "identifier": "GRM-99", "title": "Found me"}]
|
|
||||||
mock_client.list_project_tasks.side_effect = [page1, page2]
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
# Should not raise — title matches
|
|
||||||
validate_pr_title_matches_vikunja("GRM-99: Found me", "GRM-99")
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("scripts.ci.auto_merge.VikunjaClient")
|
|
||||||
def test_task_not_found_partial_page_raises(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
"""Pagination stops when page has fewer than DEFAULT_PER_PAGE results. Task not found raises."""
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.list_project_tasks.return_value = [
|
|
||||||
{"id": 1, "identifier": "GRM-1", "title": "Title 1"},
|
|
||||||
]
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
with pytest.raises(click.ClickException, match="Could not find"):
|
|
||||||
validate_pr_title_matches_vikunja("GRM-99: test", "GRM-99")
|
|
||||||
|
|
||||||
|
|
||||||
# -- extract_conventional_msg --
|
|
||||||
|
|
||||||
|
|
||||||
class TestExtractConventionalMsg:
|
|
||||||
def test_finds_conventional(self) -> None:
|
|
||||||
commits = [
|
|
||||||
{"commit": {"message": "fix: resolve timeout"}},
|
|
||||||
{"commit": {"message": "merge branch"}},
|
|
||||||
]
|
|
||||||
assert extract_conventional_msg(commits) == "fix: resolve timeout"
|
|
||||||
|
|
||||||
def test_finds_latest_conventional(self) -> None:
|
|
||||||
commits = [
|
|
||||||
{"commit": {"message": "merge branch"}},
|
|
||||||
{"commit": {"message": "feat: add feature"}},
|
|
||||||
]
|
|
||||||
assert extract_conventional_msg(commits) == "feat: add feature"
|
|
||||||
|
|
||||||
def test_falls_back_to_newest(self) -> None:
|
|
||||||
commits = [
|
|
||||||
{"commit": {"message": "random message"}},
|
|
||||||
]
|
|
||||||
assert extract_conventional_msg(commits) == "random message"
|
|
||||||
|
|
||||||
def test_empty_commits(self) -> None:
|
|
||||||
assert extract_conventional_msg([]) == ""
|
|
||||||
|
|
||||||
def test_multiline_message(self) -> None:
|
|
||||||
commits = [
|
|
||||||
{"commit": {"message": "feat: add feature\n\nBody text."}},
|
|
||||||
]
|
|
||||||
assert extract_conventional_msg(commits) == "feat: add feature"
|
|
||||||
|
|
||||||
|
|
||||||
# -- run_cmd --
|
|
||||||
|
|
||||||
|
|
||||||
class TestRunCmd:
|
|
||||||
def test_success(self) -> None:
|
|
||||||
result = run_cmd(["echo", "hello"])
|
|
||||||
assert result.returncode == 0
|
|
||||||
|
|
||||||
def test_failure_raises(self) -> None:
|
|
||||||
with pytest.raises(click.ClickException, match="Command failed"):
|
|
||||||
run_cmd(["false"])
|
|
||||||
|
|
||||||
def test_failure_no_check(self) -> None:
|
|
||||||
result = run_cmd(["false"], check=False)
|
|
||||||
assert result.returncode != 0
|
|
||||||
|
|
||||||
|
|
||||||
# -- main (integration) --
|
|
||||||
|
|
||||||
|
|
||||||
class TestMain:
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": ""}, clear=True)
|
|
||||||
@patch("scripts.ci.auto_merge.GiteaClient")
|
|
||||||
def test_full_merge_flow(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
(tmp_path / ".taskid").write_text("GRM-19\n")
|
|
||||||
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.get_pr_commits.return_value = [
|
|
||||||
{"commit": {"message": "fix: resolve timeout"}},
|
|
||||||
]
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(
|
|
||||||
main,
|
|
||||||
["GRM-19-fix-bug", "GRM-19: Fix timeout", "owner/repo", "7"],
|
|
||||||
)
|
|
||||||
assert result.exit_code == 0, result.output
|
|
||||||
mock_client.merge_pr.assert_called_once_with("7", "GRM-19: fix: resolve timeout")
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
|
|
||||||
def test_no_token_raises(self) -> None:
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: test", "owner/repo", "7"])
|
|
||||||
assert result.exit_code != 0
|
|
||||||
assert "REPO_TOKEN" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("scripts.ci.auto_merge.GiteaClient")
|
|
||||||
def test_no_task_id_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
# No .taskid file, no GRM-N in branch name
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["feature-branch", "GRM-19: test", "owner/repo", "7"])
|
|
||||||
assert result.exit_code != 0
|
|
||||||
assert "No task ID" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("scripts.ci.auto_merge.GiteaClient")
|
|
||||||
def test_invalid_pr_title_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
(tmp_path / ".taskid").write_text("GRM-19\n")
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["GRM-19-fix", "Bad title", "owner/repo", "7"])
|
|
||||||
assert result.exit_code != 0
|
|
||||||
assert "format" in result.output.lower()
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("scripts.ci.auto_merge.GiteaClient")
|
|
||||||
def test_merge_behind_master_rebases(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
(tmp_path / ".taskid").write_text("GRM-19\n")
|
|
||||||
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.get_pr_commits.return_value = [
|
|
||||||
{"commit": {"message": "fix: resolve timeout"}},
|
|
||||||
]
|
|
||||||
mock_client.merge_pr.side_effect = [
|
|
||||||
APIError(405, "HEAD branch is behind master"),
|
|
||||||
None, # Second call succeeds
|
|
||||||
]
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
|
|
||||||
with patch("scripts.ci.auto_merge.run_cmd") as mock_run:
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(
|
|
||||||
main,
|
|
||||||
["GRM-19-fix-bug", "GRM-19: Fix timeout", "owner/repo", "7"],
|
|
||||||
)
|
|
||||||
assert result.exit_code == 0, result.output
|
|
||||||
assert mock_client.merge_pr.call_count == 2
|
|
||||||
# Should have fetched, rebased, and pushed
|
|
||||||
assert mock_run.call_count == 3
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("scripts.ci.auto_merge.GiteaClient")
|
|
||||||
def test_merge_failure_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
(tmp_path / ".taskid").write_text("GRM-19\n")
|
|
||||||
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.get_pr_commits.return_value = [
|
|
||||||
{"commit": {"message": "fix: resolve timeout"}},
|
|
||||||
]
|
|
||||||
mock_client.merge_pr.side_effect = APIError(409, "Conflict")
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(
|
|
||||||
main,
|
|
||||||
["GRM-19-fix-bug", "GRM-19: Fix timeout", "owner/repo", "7"],
|
|
||||||
)
|
|
||||||
assert result.exit_code != 0
|
|
||||||
assert "Merge failed" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("scripts.ci.auto_merge.GiteaClient")
|
|
||||||
def test_no_conventional_msg_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
|
||||||
"""When no conventional commit message is found in PR commits, raises."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
(tmp_path / ".taskid").write_text("GRM-19\n")
|
|
||||||
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.get_pr_commits.return_value = []
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(
|
|
||||||
main,
|
|
||||||
["GRM-19-fix-bug", "GRM-19: Fix timeout", "owner/repo", "7"],
|
|
||||||
)
|
|
||||||
assert result.exit_code != 0
|
|
||||||
assert "conventional commit" in result.output.lower()
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("scripts.ci.auto_merge.GiteaClient")
|
|
||||||
def test_rebase_retry_failure_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
|
||||||
"""When rebase retry also fails, raises with helpful message."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
(tmp_path / ".taskid").write_text("GRM-19\n")
|
|
||||||
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.get_pr_commits.return_value = [
|
|
||||||
{"commit": {"message": "fix: resolve timeout"}},
|
|
||||||
]
|
|
||||||
mock_client.merge_pr.side_effect = APIError(405, "HEAD branch is behind master")
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
|
|
||||||
with patch("scripts.ci.auto_merge.run_cmd") as mock_run:
|
|
||||||
mock_run.side_effect = click.ClickException("git rebase failed")
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(
|
|
||||||
main,
|
|
||||||
["GRM-19-fix-bug", "GRM-19: Fix timeout", "owner/repo", "7"],
|
|
||||||
)
|
|
||||||
assert result.exit_code != 0
|
|
||||||
assert "rebase" in result.output.lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_main_module_block() -> None:
|
|
||||||
"""Test that the __main__ block can be executed."""
|
|
||||||
import scripts.ci.auto_merge as am
|
|
||||||
|
|
||||||
with open(am.__file__) as f:
|
|
||||||
source = f.read()
|
|
||||||
source = source.replace('if __name__ == "__main__":\n main()\n', "")
|
|
||||||
namespace = dict(am.__dict__)
|
|
||||||
exec(compile(source, am.__file__, "exec"), namespace)
|
|
||||||
# Verify main is callable
|
|
||||||
assert callable(namespace["main"])
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
"""Unit tests for scripts/check_test_speed.py."""
|
|
||||||
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import click
|
|
||||||
import pytest
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
from scripts.check_test_speed import (
|
|
||||||
DEFAULT_MAX_SECONDS,
|
|
||||||
TEST_COMMAND,
|
|
||||||
check_speed,
|
|
||||||
cli,
|
|
||||||
parse_duration,
|
|
||||||
run_tests,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestRunTests:
|
|
||||||
@patch("scripts.check_test_speed.subprocess.run")
|
|
||||||
def test_run_tests_returns_stdout_stderr(self, mock_run: MagicMock) -> None:
|
|
||||||
mock_run.return_value = MagicMock(stdout="out", stderr="err", returncode=0)
|
|
||||||
stdout, stderr = run_tests()
|
|
||||||
assert stdout == "out"
|
|
||||||
assert stderr == "err"
|
|
||||||
mock_run.assert_called_once_with(
|
|
||||||
TEST_COMMAND,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestParseDuration:
|
|
||||||
def test_parses_valid_line(self) -> None:
|
|
||||||
assert parse_duration("234 passed in 0.70s") == 0.70
|
|
||||||
|
|
||||||
def test_parses_with_warnings(self) -> None:
|
|
||||||
assert parse_duration("293 passed, 1 warning in 0.45s") == 0.45
|
|
||||||
|
|
||||||
def test_parses_multiline_output(self) -> None:
|
|
||||||
output = "some header\n234 passed in 1.23s\nfooter"
|
|
||||||
assert parse_duration(output) == 1.23
|
|
||||||
|
|
||||||
def test_raises_when_no_timing_line(self) -> None:
|
|
||||||
with pytest.raises(click.ClickException) as exc:
|
|
||||||
parse_duration("no timing here")
|
|
||||||
assert "Could not parse" in str(exc.value)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCheckSpeed:
|
|
||||||
def test_under_budget_passes(self) -> None:
|
|
||||||
check_speed(1.0, 2.0) # should not raise
|
|
||||||
|
|
||||||
def test_exact_budget_passes(self) -> None:
|
|
||||||
check_speed(2.0, 2.0) # should not raise
|
|
||||||
|
|
||||||
def test_over_budget_raises(self) -> None:
|
|
||||||
with pytest.raises(click.ClickException) as exc:
|
|
||||||
check_speed(2.1, 2.0)
|
|
||||||
msg = str(exc.value)
|
|
||||||
assert "too slow" in msg.lower()
|
|
||||||
assert "2.10s" in msg
|
|
||||||
assert "max allowed: 2.0s" in msg
|
|
||||||
|
|
||||||
|
|
||||||
def test_main_module_block() -> None:
|
|
||||||
import scripts.check_test_speed as cts
|
|
||||||
|
|
||||||
with patch.object(cts, "cli") as mock_cli:
|
|
||||||
with patch.object(cts, "__name__", "__main__"):
|
|
||||||
cts.cli([])
|
|
||||||
mock_cli.assert_called_once_with([])
|
|
||||||
|
|
||||||
|
|
||||||
class TestMain:
|
|
||||||
@patch("scripts.check_test_speed.run_tests")
|
|
||||||
@patch("scripts.check_test_speed.parse_duration")
|
|
||||||
@patch("scripts.check_test_speed.check_speed")
|
|
||||||
def test_successful_run(
|
|
||||||
self,
|
|
||||||
mock_check: MagicMock,
|
|
||||||
mock_parse: MagicMock,
|
|
||||||
mock_run: MagicMock,
|
|
||||||
) -> None:
|
|
||||||
mock_run.return_value = ("stdout\n", "stderr\n")
|
|
||||||
mock_parse.return_value = 1.5
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, [])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "1.50s" in result.output
|
|
||||||
assert "under 2.0s limit" in result.output
|
|
||||||
mock_run.assert_called_once()
|
|
||||||
mock_parse.assert_called_once_with("stdout\n\nstderr\n")
|
|
||||||
mock_check.assert_called_once_with(1.5, DEFAULT_MAX_SECONDS)
|
|
||||||
|
|
||||||
@patch("scripts.check_test_speed.run_tests")
|
|
||||||
@patch("scripts.check_test_speed.parse_duration")
|
|
||||||
def test_slow_tests_exit(
|
|
||||||
self,
|
|
||||||
mock_parse: MagicMock,
|
|
||||||
mock_run: MagicMock,
|
|
||||||
) -> None:
|
|
||||||
mock_run.return_value = ("out\n", "err\n")
|
|
||||||
mock_parse.return_value = 3.0
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, [])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "too slow" in result.output.lower()
|
|
||||||
|
|
||||||
@patch("scripts.check_test_speed.run_tests")
|
|
||||||
def test_parse_failure_exits(
|
|
||||||
self,
|
|
||||||
mock_run: MagicMock,
|
|
||||||
) -> None:
|
|
||||||
mock_run.return_value = ("bad output\n", "")
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, [])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "Could not parse" in result.output
|
|
||||||
|
|
||||||
@patch("scripts.check_test_speed.run_tests")
|
|
||||||
@patch("scripts.check_test_speed.parse_duration")
|
|
||||||
@patch("scripts.check_test_speed.check_speed")
|
|
||||||
def test_custom_max_seconds(
|
|
||||||
self,
|
|
||||||
mock_check: MagicMock,
|
|
||||||
mock_parse: MagicMock,
|
|
||||||
mock_run: MagicMock,
|
|
||||||
) -> None:
|
|
||||||
mock_run.return_value = ("out\n", "err\n")
|
|
||||||
mock_parse.return_value = 0.5
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, ["--max-seconds", "1.5"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
mock_check.assert_called_once_with(0.5, 1.5)
|
|
||||||
@@ -1,273 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
import scripts.ci.check_translations as check_translations
|
|
||||||
|
|
||||||
|
|
||||||
class TestExtractKeys:
|
|
||||||
def test_extracts_underscore_calls(self, tmp_path: Path) -> None:
|
|
||||||
f = tmp_path / "test.py"
|
|
||||||
f.write_text('from scripts.i18n import _\nprint(_("Hello world"))\n')
|
|
||||||
keys = check_translations.extract_keys(f)
|
|
||||||
assert "Hello world" in keys
|
|
||||||
|
|
||||||
def test_extracts_wrapper_calls(self, tmp_path: Path) -> None:
|
|
||||||
f = tmp_path / "test.py"
|
|
||||||
f.write_text('@_handle_errors("Update failed: {error}")\ndef foo(): pass\n')
|
|
||||||
keys = check_translations.extract_keys(f)
|
|
||||||
assert "Update failed: {error}" in keys
|
|
||||||
|
|
||||||
def test_ignores_non_string_args(self, tmp_path: Path) -> None:
|
|
||||||
f = tmp_path / "test.py"
|
|
||||||
f.write_text('x = "key"\n_(x)\n')
|
|
||||||
keys = check_translations.extract_keys(f)
|
|
||||||
assert keys == set()
|
|
||||||
|
|
||||||
def test_syntax_error_returns_empty(self, tmp_path: Path) -> None:
|
|
||||||
f = tmp_path / "test.py"
|
|
||||||
f.write_text("def broken(:\n")
|
|
||||||
keys = check_translations.extract_keys(f)
|
|
||||||
assert keys == set()
|
|
||||||
|
|
||||||
|
|
||||||
class TestCheckTranslationSet:
|
|
||||||
def test_all_good(self, tmp_path: Path) -> None:
|
|
||||||
src_dir = tmp_path / "src"
|
|
||||||
src_dir.mkdir()
|
|
||||||
(src_dir / "mod.py").write_text('_("Hello")\n')
|
|
||||||
trans_file = tmp_path / "translations.json"
|
|
||||||
trans_file.write_text(
|
|
||||||
json.dumps({"Hello": {"en": "Hello", "bg": "Здравей", "de": "Hallo", "ru": "Привет", "zh": "你好"}})
|
|
||||||
)
|
|
||||||
|
|
||||||
result = check_translations.check_translation_set("test", src_dir, trans_file)
|
|
||||||
assert not result.errors
|
|
||||||
assert not result.warnings
|
|
||||||
|
|
||||||
def test_missing_key(self, tmp_path: Path) -> None:
|
|
||||||
src_dir = tmp_path / "src"
|
|
||||||
src_dir.mkdir()
|
|
||||||
(src_dir / "mod.py").write_text('_("Missing")\n')
|
|
||||||
trans_file = tmp_path / "translations.json"
|
|
||||||
trans_file.write_text(json.dumps({"Other": {"en": "Other"}}))
|
|
||||||
|
|
||||||
result = check_translations.check_translation_set("test", src_dir, trans_file)
|
|
||||||
assert any("Missing key" in e for e in result.errors)
|
|
||||||
|
|
||||||
def test_dead_key(self, tmp_path: Path) -> None:
|
|
||||||
src_dir = tmp_path / "src"
|
|
||||||
src_dir.mkdir()
|
|
||||||
(src_dir / "mod.py").write_text('_("Used")\n')
|
|
||||||
trans_file = tmp_path / "translations.json"
|
|
||||||
trans_file.write_text(json.dumps({"Used": {"en": "Used"}, "Dead": {"en": "Dead"}}))
|
|
||||||
|
|
||||||
result = check_translations.check_translation_set("test", src_dir, trans_file)
|
|
||||||
assert any("Dead key" in w for w in result.warnings)
|
|
||||||
|
|
||||||
def test_missing_language(self, tmp_path: Path) -> None:
|
|
||||||
src_dir = tmp_path / "src"
|
|
||||||
src_dir.mkdir()
|
|
||||||
(src_dir / "mod.py").write_text('_("Hello")\n')
|
|
||||||
trans_file = tmp_path / "translations.json"
|
|
||||||
trans_file.write_text(json.dumps({"Hello": {"en": "Hello"}}))
|
|
||||||
|
|
||||||
result = check_translations.check_translation_set("test", src_dir, trans_file)
|
|
||||||
assert any("Missing languages" in w for w in result.warnings)
|
|
||||||
|
|
||||||
def test_missing_translations_file(self, tmp_path: Path) -> None:
|
|
||||||
src_dir = tmp_path / "src"
|
|
||||||
src_dir.mkdir()
|
|
||||||
(src_dir / "mod.py").write_text('_("Hello")\n')
|
|
||||||
trans_file = tmp_path / "nonexistent.json"
|
|
||||||
|
|
||||||
result = check_translations.check_translation_set("test", src_dir, trans_file)
|
|
||||||
assert any("not found" in e for e in result.errors)
|
|
||||||
|
|
||||||
|
|
||||||
class TestMain:
|
|
||||||
def test_passes_on_clean_repo(self) -> None:
|
|
||||||
"""The actual repo should pass (with warnings for missing langs)."""
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(check_translations.main, [])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
|
|
||||||
def test_strict_fails_on_warnings(self) -> None:
|
|
||||||
"""--strict should fail if there are missing language warnings."""
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(check_translations.main, ["--strict"])
|
|
||||||
# The repo has missing language warnings, so --strict should fail
|
|
||||||
assert result.exit_code == 1
|
|
||||||
|
|
||||||
def test_fails_on_errors(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""Should fail with exit code 1 when errors are found."""
|
|
||||||
error_result = check_translations.TranslationCheckResult(
|
|
||||||
name="mock",
|
|
||||||
src_dir=Path("/tmp"),
|
|
||||||
trans_file=Path("/tmp/t.json"),
|
|
||||||
errors=["Missing key: 'foo'"],
|
|
||||||
)
|
|
||||||
ok_result = check_translations.TranslationCheckResult(
|
|
||||||
name="mock2",
|
|
||||||
src_dir=Path("/tmp"),
|
|
||||||
trans_file=Path("/tmp/t.json"),
|
|
||||||
used_keys={"a"},
|
|
||||||
defined_keys={"a"},
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
check_translations,
|
|
||||||
"check_translation_set",
|
|
||||||
lambda name, src, trans: error_result if name == "GRM tool" else ok_result,
|
|
||||||
)
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(check_translations.main, [])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "FAIL" in result.output
|
|
||||||
|
|
||||||
def test_passes_no_warnings(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""Should pass with exit code 0 and 'PASS:' message when no warnings."""
|
|
||||||
ok_result = check_translations.TranslationCheckResult(
|
|
||||||
name="mock",
|
|
||||||
src_dir=Path("/tmp"),
|
|
||||||
trans_file=Path("/tmp/t.json"),
|
|
||||||
used_keys={"a"},
|
|
||||||
defined_keys={"a"},
|
|
||||||
)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
check_translations,
|
|
||||||
"check_translation_set",
|
|
||||||
lambda name, src, trans: ok_result,
|
|
||||||
)
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(check_translations.main, [])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "PASS: All translations" in result.output
|
|
||||||
|
|
||||||
|
|
||||||
class TestPrintResult:
|
|
||||||
def test_prints_all_good(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
||||||
result = check_translations.TranslationCheckResult(
|
|
||||||
name="test",
|
|
||||||
src_dir=Path("/tmp"),
|
|
||||||
trans_file=Path("/tmp/t.json"),
|
|
||||||
used_keys={"a"},
|
|
||||||
defined_keys={"a"},
|
|
||||||
)
|
|
||||||
check_translations.print_result(result)
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
assert "All good!" in captured.out
|
|
||||||
|
|
||||||
def test_prints_errors(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
||||||
result = check_translations.TranslationCheckResult(
|
|
||||||
name="test",
|
|
||||||
src_dir=Path("/tmp"),
|
|
||||||
trans_file=Path("/tmp/t.json"),
|
|
||||||
errors=["Missing key: 'foo'"],
|
|
||||||
)
|
|
||||||
check_translations.print_result(result)
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
assert "ERROR" in captured.err
|
|
||||||
|
|
||||||
def test_prints_warnings(self, capsys: pytest.CaptureFixture[str]) -> None:
|
|
||||||
result = check_translations.TranslationCheckResult(
|
|
||||||
name="test",
|
|
||||||
src_dir=Path("/tmp"),
|
|
||||||
trans_file=Path("/tmp/t.json"),
|
|
||||||
warnings=["Dead key: 'bar'"],
|
|
||||||
)
|
|
||||||
check_translations.print_result(result)
|
|
||||||
captured = capsys.readouterr()
|
|
||||||
assert "WARN" in captured.err
|
|
||||||
|
|
||||||
|
|
||||||
class TestScriptsI18n:
|
|
||||||
"""Test the scripts/i18n.py module."""
|
|
||||||
|
|
||||||
def test_english_default(self) -> None:
|
|
||||||
from scripts.i18n import _
|
|
||||||
|
|
||||||
assert _("Running tests...") == "Running tests..."
|
|
||||||
|
|
||||||
def test_format_kwargs(self) -> None:
|
|
||||||
from scripts.i18n import _
|
|
||||||
|
|
||||||
result = _("Comparing {base}..{head} ({count} files changed)", base="a", head="b", count=5)
|
|
||||||
assert "a..b" in result
|
|
||||||
assert "5 files" in result
|
|
||||||
|
|
||||||
def test_unknown_key_returns_key(self) -> None:
|
|
||||||
from scripts.i18n import _
|
|
||||||
|
|
||||||
assert _("Nonexistent key 12345") == "Nonexistent key 12345"
|
|
||||||
|
|
||||||
def test_grm_lang_override(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
import importlib
|
|
||||||
|
|
||||||
monkeypatch.setenv("GRM_LANG", "de")
|
|
||||||
import scripts.i18n
|
|
||||||
|
|
||||||
importlib.reload(scripts.i18n)
|
|
||||||
# "ERROR: REPO_TOKEN is not set." has a German translation
|
|
||||||
result = scripts.i18n._("ERROR: REPO_TOKEN is not set.")
|
|
||||||
assert "FEHLER" in result
|
|
||||||
|
|
||||||
# Restore
|
|
||||||
monkeypatch.delenv("GRM_LANG", raising=False)
|
|
||||||
importlib.reload(scripts.i18n)
|
|
||||||
|
|
||||||
def test_unsupported_lang_fallback(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
import importlib
|
|
||||||
|
|
||||||
monkeypatch.setenv("GRM_LANG", "xx")
|
|
||||||
import scripts.i18n
|
|
||||||
|
|
||||||
importlib.reload(scripts.i18n)
|
|
||||||
result = scripts.i18n._("Running tests...")
|
|
||||||
assert result == "Running tests..." # Falls back to English
|
|
||||||
|
|
||||||
monkeypatch.delenv("GRM_LANG", raising=False)
|
|
||||||
importlib.reload(scripts.i18n)
|
|
||||||
|
|
||||||
|
|
||||||
class TestGrmI18nSeparation:
|
|
||||||
"""Test that GRM and CI translations are properly separated."""
|
|
||||||
|
|
||||||
def test_grm_translations_exist(self) -> None:
|
|
||||||
trans = Path("src/gitea_runner_manager/translations.json")
|
|
||||||
assert trans.exists()
|
|
||||||
data = json.loads(trans.read_text())
|
|
||||||
assert len(data) > 0
|
|
||||||
|
|
||||||
def test_ci_translations_exist(self) -> None:
|
|
||||||
trans = Path("scripts/translations.json")
|
|
||||||
assert trans.exists()
|
|
||||||
data = json.loads(trans.read_text())
|
|
||||||
assert len(data) > 0
|
|
||||||
|
|
||||||
def test_no_overlap_between_sets(self) -> None:
|
|
||||||
grm_data = json.loads(Path("src/gitea_runner_manager/translations.json").read_text())
|
|
||||||
ci_data = json.loads(Path("scripts/translations.json").read_text())
|
|
||||||
grm_keys = set(grm_data)
|
|
||||||
ci_keys = set(ci_data)
|
|
||||||
overlap = grm_keys & ci_keys
|
|
||||||
assert not overlap, f"Keys found in both translation sets: {overlap}"
|
|
||||||
|
|
||||||
def test_grm_translations_only_grm_keys(self) -> None:
|
|
||||||
"""GRM translations should not contain CI-only keys."""
|
|
||||||
grm_data = json.loads(Path("src/gitea_runner_manager/translations.json").read_text())
|
|
||||||
# These are CI-only keys that should NOT be in GRM translations
|
|
||||||
ci_only_keys = {"Running tests...", "Lint passed.", "Published to PyPI."}
|
|
||||||
for key in ci_only_keys:
|
|
||||||
assert key not in grm_data, f"CI key {key!r} found in GRM translations"
|
|
||||||
|
|
||||||
def test_ci_translations_only_ci_keys(self) -> None:
|
|
||||||
"""CI translations should not contain GRM-only keys."""
|
|
||||||
ci_data = json.loads(Path("scripts/translations.json").read_text())
|
|
||||||
# These are GRM-only keys that should NOT be in CI translations
|
|
||||||
grm_only_keys = {"Installing Gitea Runner on {host}", "SSH user", "NAME"}
|
|
||||||
for key in grm_only_keys:
|
|
||||||
assert key not in ci_data, f"GRM key {key!r} found in CI translations"
|
|
||||||
@@ -1,369 +0,0 @@
|
|||||||
"""Unit tests for scripts/ci/classify_changes.py."""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import click
|
|
||||||
import pytest
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
import scripts.ci.classify_changes as classify_changes_mod
|
|
||||||
from scripts.ci.classify_changes import (
|
|
||||||
classify_changes,
|
|
||||||
get_changed_files,
|
|
||||||
get_latest_tag,
|
|
||||||
has_user_facing_changes,
|
|
||||||
is_user_facing,
|
|
||||||
is_workflow_only,
|
|
||||||
main,
|
|
||||||
run_git,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestIsUserFacing:
|
|
||||||
def test_src_is_user_facing(self) -> None:
|
|
||||||
assert is_user_facing("src/gitea_runner_manager/cli.py") is True
|
|
||||||
|
|
||||||
def test_ansible_is_user_facing(self) -> None:
|
|
||||||
assert is_user_facing("ansible/roles/gitea-runner/tasks/main.yml") is True
|
|
||||||
|
|
||||||
def test_pyproject_is_user_facing(self) -> None:
|
|
||||||
assert is_user_facing("pyproject.toml") is True
|
|
||||||
|
|
||||||
def test_workflow_is_not_user_facing(self) -> None:
|
|
||||||
assert is_user_facing(".gitea/workflows/ci.yml") is False
|
|
||||||
|
|
||||||
def test_ci_scripts_are_not_user_facing(self) -> None:
|
|
||||||
assert is_user_facing("scripts/ci/release.py") is False
|
|
||||||
|
|
||||||
def test_dev_scripts_are_not_user_facing(self) -> None:
|
|
||||||
"""All scripts under scripts/ are infrastructure (CI/CD, dev tools).
|
|
||||||
User-facing code lives in src/gitea_runner_manager/."""
|
|
||||||
assert is_user_facing("scripts/check_test_speed.py") is False
|
|
||||||
assert is_user_facing("scripts/configure_repo.py") is False
|
|
||||||
assert is_user_facing("scripts/install_checkmake.py") is False
|
|
||||||
|
|
||||||
def test_shell_scripts_are_not_user_facing(self) -> None:
|
|
||||||
assert is_user_facing("scripts/setup.sh") is False
|
|
||||||
assert is_user_facing("scripts/molecule_all.sh") is False
|
|
||||||
|
|
||||||
def test_scripts_init_is_not_user_facing(self) -> None:
|
|
||||||
assert is_user_facing("scripts/__init__.py") is False
|
|
||||||
|
|
||||||
def test_version_file_is_not_user_facing(self) -> None:
|
|
||||||
"""__init__.py only contains __version__ — a release artifact,
|
|
||||||
not user-facing code. Version bumps alone should not trigger releases."""
|
|
||||||
assert is_user_facing("src/gitea_runner_manager/__init__.py") is False
|
|
||||||
|
|
||||||
def test_api_clients_is_not_user_facing(self) -> None:
|
|
||||||
"""api_clients.py is used only by CI/CD scripts, not by the GRM CLI."""
|
|
||||||
assert is_user_facing("src/gitea_runner_manager/api_clients.py") is False
|
|
||||||
|
|
||||||
def test_docs_are_not_user_facing(self) -> None:
|
|
||||||
assert is_user_facing("docs/user/getting-started.md") is False
|
|
||||||
|
|
||||||
def test_tests_are_not_user_facing(self) -> None:
|
|
||||||
assert is_user_facing("tests/unit/test_cli.py") is False
|
|
||||||
|
|
||||||
def test_agents_md_is_not_user_facing(self) -> None:
|
|
||||||
assert is_user_facing("AGENTS.md") is False
|
|
||||||
|
|
||||||
def test_makefile_is_not_user_facing(self) -> None:
|
|
||||||
assert is_user_facing("Makefile") is False
|
|
||||||
|
|
||||||
def test_unknown_file_defaults_to_user_facing(self) -> None:
|
|
||||||
"""Safe default: unknown files are user-facing (require release)."""
|
|
||||||
assert is_user_facing("some/new/file.type") is True
|
|
||||||
assert is_user_facing("new_root_file.txt") is True
|
|
||||||
|
|
||||||
def test_is_workflow_only_inverse(self) -> None:
|
|
||||||
assert is_workflow_only(".gitea/workflows/ci.yml") is True
|
|
||||||
assert is_workflow_only("src/gitea_runner_manager/cli.py") is False
|
|
||||||
assert is_workflow_only("pyproject.toml") is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestClassifyChanges:
|
|
||||||
def test_all_user_facing(self) -> None:
|
|
||||||
files = ["src/gitea_runner_manager/cli.py", "ansible/roles/gitea-runner/tasks/main.yml"]
|
|
||||||
result = classify_changes(files)
|
|
||||||
assert result["user_facing"] == files
|
|
||||||
assert result["workflow_only"] == []
|
|
||||||
|
|
||||||
def test_all_workflow_only(self) -> None:
|
|
||||||
files = [".gitea/workflows/ci.yml", "docs/index.md", "AGENTS.md"]
|
|
||||||
result = classify_changes(files)
|
|
||||||
assert result["user_facing"] == []
|
|
||||||
assert result["workflow_only"] == files
|
|
||||||
|
|
||||||
def test_mixed(self) -> None:
|
|
||||||
files = [
|
|
||||||
"src/gitea_runner_manager/cli.py",
|
|
||||||
".gitea/workflows/ci.yml",
|
|
||||||
"pyproject.toml",
|
|
||||||
"docs/index.md",
|
|
||||||
]
|
|
||||||
result = classify_changes(files)
|
|
||||||
assert "src/gitea_runner_manager/cli.py" in result["user_facing"]
|
|
||||||
assert "pyproject.toml" in result["user_facing"]
|
|
||||||
assert ".gitea/workflows/ci.yml" in result["workflow_only"]
|
|
||||||
assert "docs/index.md" in result["workflow_only"]
|
|
||||||
|
|
||||||
def test_empty(self) -> None:
|
|
||||||
result = classify_changes([])
|
|
||||||
assert result == {"user_facing": [], "workflow_only": []}
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetChangedFiles:
|
|
||||||
@patch("scripts.ci.classify_changes.run_git")
|
|
||||||
def test_returns_file_list(self, mock_run_git: MagicMock) -> None:
|
|
||||||
mock_run_git.return_value = "file1.py\nfile2.py\nfile3.md"
|
|
||||||
result = get_changed_files("v0.1.0", "HEAD")
|
|
||||||
assert result == ["file1.py", "file2.py", "file3.md"]
|
|
||||||
|
|
||||||
@patch("scripts.ci.classify_changes.run_git")
|
|
||||||
def test_empty_when_no_changes(self, mock_run_git: MagicMock) -> None:
|
|
||||||
mock_run_git.return_value = ""
|
|
||||||
result = get_changed_files("v0.1.0", "HEAD")
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
|
|
||||||
class TestHasUserFacingChanges:
|
|
||||||
@patch("scripts.ci.classify_changes.get_changed_files")
|
|
||||||
def test_true_when_user_facing(self, mock_get: MagicMock) -> None:
|
|
||||||
mock_get.return_value = ["src/gitea_runner_manager/cli.py", "docs/index.md"]
|
|
||||||
assert has_user_facing_changes("v0.1.0", "HEAD") is True
|
|
||||||
|
|
||||||
@patch("scripts.ci.classify_changes.get_changed_files")
|
|
||||||
def test_false_when_workflow_only(self, mock_get: MagicMock) -> None:
|
|
||||||
mock_get.return_value = [".gitea/workflows/ci.yml", "docs/index.md"]
|
|
||||||
assert has_user_facing_changes("v0.1.0", "HEAD") is False
|
|
||||||
|
|
||||||
@patch("scripts.ci.classify_changes.get_changed_files")
|
|
||||||
def test_false_when_no_changes(self, mock_get: MagicMock) -> None:
|
|
||||||
mock_get.return_value = []
|
|
||||||
assert has_user_facing_changes("v0.1.0", "HEAD") is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetLatestTag:
|
|
||||||
@patch("subprocess.run")
|
|
||||||
def test_returns_tag(self, mock_run: MagicMock) -> None:
|
|
||||||
mock_run.return_value = MagicMock(returncode=0, stdout="v0.3.0\n", stderr="")
|
|
||||||
assert get_latest_tag() == "v0.3.0"
|
|
||||||
|
|
||||||
@patch("subprocess.run")
|
|
||||||
def test_returns_empty_when_no_tags(self, mock_run: MagicMock) -> None:
|
|
||||||
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
|
|
||||||
assert get_latest_tag() == ""
|
|
||||||
|
|
||||||
|
|
||||||
class TestRunGit:
|
|
||||||
@patch("scripts.ci.classify_changes.subprocess.run")
|
|
||||||
def test_success(self, mock_run: MagicMock) -> None:
|
|
||||||
mock_run.return_value = MagicMock(returncode=0, stdout="file1.py\n", stderr="")
|
|
||||||
result = run_git(["git", "diff", "--name-only", "v0.1.0", "HEAD"])
|
|
||||||
assert result == "file1.py"
|
|
||||||
|
|
||||||
@patch("scripts.ci.classify_changes.subprocess.run")
|
|
||||||
def test_failure_raises(self, mock_run: MagicMock) -> None:
|
|
||||||
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="git error")
|
|
||||||
with pytest.raises(click.ClickException):
|
|
||||||
run_git(["git", "bad-command"])
|
|
||||||
|
|
||||||
|
|
||||||
class TestMain:
|
|
||||||
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="")
|
|
||||||
def test_no_tags_outputs_true(self, mock_tag: MagicMock) -> None:
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--quiet"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "true" in result.output
|
|
||||||
|
|
||||||
@patch("scripts.ci.classify_changes.get_changed_files", return_value=[])
|
|
||||||
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
|
||||||
def test_no_changes_outputs_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--quiet"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "false" in result.output
|
|
||||||
|
|
||||||
@patch("scripts.ci.classify_changes.get_changed_files")
|
|
||||||
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
|
||||||
def test_workflow_only_exits_2(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
|
||||||
mock_changes.return_value = [".gitea/workflows/ci.yml", "docs/index.md"]
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, [])
|
|
||||||
assert result.exit_code == 2
|
|
||||||
assert "no release needed" in result.output
|
|
||||||
|
|
||||||
@patch("scripts.ci.classify_changes.get_changed_files")
|
|
||||||
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
|
||||||
def test_user_facing_exits_0(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
|
||||||
mock_changes.return_value = ["src/gitea_runner_manager/cli.py", "docs/index.md"]
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, [])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "release needed" in result.output
|
|
||||||
|
|
||||||
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="")
|
|
||||||
def test_no_tags_non_quiet(self, mock_tag: MagicMock) -> None:
|
|
||||||
"""Non-quiet mode with no tags prints user-facing message."""
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, [])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "No tags found" in result.output
|
|
||||||
|
|
||||||
@patch("scripts.ci.classify_changes.get_changed_files", return_value=[])
|
|
||||||
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
|
||||||
def test_no_changes_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
|
||||||
"""Non-quiet mode with no changes prints message."""
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, [])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "No changes" in result.output
|
|
||||||
|
|
||||||
@patch("scripts.ci.classify_changes.get_changed_files")
|
|
||||||
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
|
||||||
def test_quiet_user_facing(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
|
||||||
"""Quiet mode with user-facing changes outputs true."""
|
|
||||||
mock_changes.return_value = ["src/gitea_runner_manager/cli.py"]
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--quiet"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "true" in result.output
|
|
||||||
|
|
||||||
@patch("scripts.ci.classify_changes.get_changed_files")
|
|
||||||
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
|
||||||
def test_quiet_workflow_only(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
|
||||||
"""Quiet mode with workflow-only changes outputs false."""
|
|
||||||
mock_changes.return_value = [".gitea/workflows/ci.yml"]
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--quiet"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "false" in result.output
|
|
||||||
|
|
||||||
@patch("scripts.ci.classify_changes.get_changed_files")
|
|
||||||
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
|
||||||
def test_with_explicit_base(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
|
||||||
"""Explicit --base overrides latest tag."""
|
|
||||||
mock_changes.return_value = ["src/gitea_runner_manager/cli.py"]
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--base", "v0.2.0", "--head", "HEAD"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "release needed" in result.output
|
|
||||||
|
|
||||||
@patch("scripts.ci.classify_changes.get_changed_files")
|
|
||||||
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
|
||||||
def test_check_ansible_true(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
|
||||||
"""--check ansible with Ansible changes outputs true."""
|
|
||||||
mock_changes.return_value = ["ansible/tasks/main.yml", ".gitea/workflows/ci.yml"]
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--check", "ansible", "--quiet"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "true" in result.output
|
|
||||||
|
|
||||||
@patch("scripts.ci.classify_changes.get_changed_files")
|
|
||||||
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
|
||||||
def test_check_ansible_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
|
||||||
"""--check ansible with no Ansible changes outputs false."""
|
|
||||||
mock_changes.return_value = ["src/gitea_runner_manager/cli.py", ".gitea/workflows/ci.yml"]
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--check", "ansible", "--quiet"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "false" in result.output
|
|
||||||
|
|
||||||
@patch("scripts.ci.classify_changes.get_changed_files")
|
|
||||||
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
|
||||||
def test_check_user_facing_true(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
|
||||||
"""--check user-facing with user-facing changes outputs true."""
|
|
||||||
mock_changes.return_value = ["src/gitea_runner_manager/cli.py", ".gitea/workflows/ci.yml"]
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--check", "user-facing", "--quiet"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "true" in result.output
|
|
||||||
|
|
||||||
@patch("scripts.ci.classify_changes.get_changed_files")
|
|
||||||
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
|
||||||
def test_check_user_facing_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
|
||||||
"""--check user-facing with only workflow changes outputs false."""
|
|
||||||
mock_changes.return_value = [".gitea/workflows/ci.yml", "tests/test_foo.py"]
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--check", "user-facing", "--quiet"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "false" in result.output
|
|
||||||
|
|
||||||
@patch("scripts.ci.classify_changes.get_changed_files")
|
|
||||||
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
|
||||||
def test_check_ansible_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
|
||||||
"""--check ansible in non-quiet mode prints file list."""
|
|
||||||
mock_changes.return_value = ["ansible/tasks/main.yml"]
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--check", "ansible"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Ansible changes detected" in result.output
|
|
||||||
|
|
||||||
@patch("scripts.ci.classify_changes.get_changed_files")
|
|
||||||
@patch("scripts.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
|
||||||
def test_check_user_facing_non_quiet(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
|
||||||
"""--check user-facing in non-quiet mode prints file list."""
|
|
||||||
mock_changes.return_value = ["src/gitea_runner_manager/cli.py"]
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--check", "user-facing"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "User-facing changes detected" in result.output
|
|
||||||
|
|
||||||
|
|
||||||
class TestGithubOutput:
|
|
||||||
def test_writes_outputs(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
gh_file = tmp_path / "output.txt"
|
|
||||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
|
||||||
with patch.object(
|
|
||||||
classify_changes_mod, "get_changed_files", return_value=["src/cli.py", "ansible/tasks/main.yml"]
|
|
||||||
):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
content = gh_file.read_text()
|
|
||||||
assert "ansible-changed=true" in content
|
|
||||||
assert "user-facing-changed=true" in content
|
|
||||||
|
|
||||||
def test_no_changes(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
gh_file = tmp_path / "output.txt"
|
|
||||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
|
||||||
with patch.object(classify_changes_mod, "get_changed_files", return_value=[]):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
content = gh_file.read_text()
|
|
||||||
assert "ansible-changed=false" in content
|
|
||||||
assert "user-facing-changed=false" in content
|
|
||||||
|
|
||||||
def test_no_tags(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
gh_file = tmp_path / "output.txt"
|
|
||||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
|
||||||
with patch.object(classify_changes_mod, "get_latest_tag", return_value=""):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--github-output"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
content = gh_file.read_text()
|
|
||||||
assert "ansible-changed=true" in content
|
|
||||||
assert "user-facing-changed=true" in content
|
|
||||||
|
|
||||||
def test_no_env_var(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
|
|
||||||
with patch.object(classify_changes_mod, "get_changed_files", return_value=["src/cli.py"]):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
|
|
||||||
assert result.exit_code != 0
|
|
||||||
|
|
||||||
def test_workflow_only(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
gh_file = tmp_path / "output.txt"
|
|
||||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
|
||||||
with patch.object(
|
|
||||||
classify_changes_mod, "get_changed_files", return_value=[".gitea/workflows/ci.yml", "AGENTS.md"]
|
|
||||||
):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--base", "v1.0", "--head", "HEAD", "--github-output"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
content = gh_file.read_text()
|
|
||||||
assert "ansible-changed=false" in content
|
|
||||||
assert "user-facing-changed=false" in content
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
"""Unit tests for scripts/configure_repo.py."""
|
|
||||||
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import click
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from gitea_runner_manager.config import BRANCH_PROTECTION_CONFIG, REPO_SETTINGS_CONFIG
|
|
||||||
from gitea_runner_manager.exceptions import APIError
|
|
||||||
from scripts.configure_repo import _handle_http_error, main
|
|
||||||
|
|
||||||
|
|
||||||
class TestHandleHttpError:
|
|
||||||
def test_forbidden_raises_click_exception(self) -> None:
|
|
||||||
with pytest.raises(click.ClickException, match="Forbidden"):
|
|
||||||
_handle_http_error(APIError(403, "Forbidden"))
|
|
||||||
|
|
||||||
def test_other_error_raises_click_exception(self) -> None:
|
|
||||||
with pytest.raises(click.ClickException, match="HTTP error"):
|
|
||||||
_handle_http_error(APIError(500, "Server error"))
|
|
||||||
|
|
||||||
|
|
||||||
class TestMain:
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("scripts.configure_repo.GiteaClient")
|
|
||||||
def test_main_success(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
|
|
||||||
with click.Context(click.Command("test")):
|
|
||||||
main()
|
|
||||||
|
|
||||||
mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
|
|
||||||
mock_client.update_repo_settings.assert_called_once_with(REPO_SETTINGS_CONFIG)
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("scripts.configure_repo.GiteaClient")
|
|
||||||
def test_main_api_error(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.ensure_branch_protection.side_effect = APIError(403, "Forbidden")
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
|
|
||||||
with pytest.raises(click.ClickException, match="Forbidden"):
|
|
||||||
main()
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {}, clear=True)
|
|
||||||
def test_main_no_token(self) -> None:
|
|
||||||
with pytest.raises(click.ClickException, match="REPO_TOKEN"):
|
|
||||||
main()
|
|
||||||
|
|
||||||
|
|
||||||
def test_main_module_block() -> None:
|
|
||||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
|
||||||
with patch("scripts.configure_repo.GiteaClient") as mock_client_cls:
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
import scripts.configure_repo as cr
|
|
||||||
|
|
||||||
with open(cr.__file__) as f:
|
|
||||||
source = f.read()
|
|
||||||
source = source.replace('if __name__ == "__main__":\n main() # pragma: no cover\n', "")
|
|
||||||
namespace = dict(cr.__dict__)
|
|
||||||
exec(compile(source, cr.__file__, "exec"), namespace)
|
|
||||||
namespace["GiteaClient"] = mock_client_cls
|
|
||||||
namespace["main"]()
|
|
||||||
mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
|
|
||||||
mock_client.update_repo_settings.assert_called_once_with(REPO_SETTINGS_CONFIG)
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from click import ClickException
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
import scripts.ci.detect_release_commit as detect_release_commit
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetCommitMessage:
|
|
||||||
def test_success(self) -> None:
|
|
||||||
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="feat: add feature\n", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
assert detect_release_commit.get_commit_message() == "feat: add feature"
|
|
||||||
|
|
||||||
def test_failure(self) -> None:
|
|
||||||
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="git error")
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
with pytest.raises(ClickException, match="git log failed"):
|
|
||||||
detect_release_commit.get_commit_message()
|
|
||||||
|
|
||||||
|
|
||||||
class TestIsReleaseCommit:
|
|
||||||
def test_release_commit(self) -> None:
|
|
||||||
assert detect_release_commit.is_release_commit("release: v1.0.0 [skip ci]") is True
|
|
||||||
|
|
||||||
def test_release_commit_no_skip(self) -> None:
|
|
||||||
assert detect_release_commit.is_release_commit("release: v0.1.0") is True
|
|
||||||
|
|
||||||
def test_regular_commit(self) -> None:
|
|
||||||
assert detect_release_commit.is_release_commit("feat: add feature") is False
|
|
||||||
|
|
||||||
def test_empty(self) -> None:
|
|
||||||
assert detect_release_commit.is_release_commit("") is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestWriteGithubOutput:
|
|
||||||
def test_write(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
gh_file = tmp_path / "output.txt"
|
|
||||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
|
||||||
detect_release_commit.write_github_output("is-release", "true")
|
|
||||||
with open(gh_file) as f:
|
|
||||||
assert f.read() == "is-release=true\n"
|
|
||||||
|
|
||||||
def test_no_env(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
|
|
||||||
with pytest.raises(ClickException, match="GITHUB_OUTPUT"):
|
|
||||||
detect_release_commit.write_github_output("is-release", "true")
|
|
||||||
|
|
||||||
|
|
||||||
class TestMain:
|
|
||||||
def test_release_commit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
gh_file = tmp_path / "output.txt"
|
|
||||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
|
||||||
with patch.object(detect_release_commit, "get_commit_message", return_value="release: v1.0.0 [skip ci]"):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(detect_release_commit.main, [])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Release commit" in result.output
|
|
||||||
with open(gh_file) as f:
|
|
||||||
assert "is-release=true" in f.read()
|
|
||||||
|
|
||||||
def test_regular_commit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
gh_file = tmp_path / "output.txt"
|
|
||||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
|
||||||
with patch.object(detect_release_commit, "get_commit_message", return_value="feat: add feature"):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(detect_release_commit.main, [])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Regular merge commit" in result.output
|
|
||||||
with open(gh_file) as f:
|
|
||||||
assert "is-release=false" in f.read()
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
"""Unit tests for scripts/ci/discover_runners.py."""
|
|
||||||
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
from scripts.ci.discover_runners import (
|
|
||||||
DEFAULT_MAX_RUNNERS,
|
|
||||||
generate_indices,
|
|
||||||
get_runner_count,
|
|
||||||
main,
|
|
||||||
query_runners,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestGenerateIndices:
|
|
||||||
def test_zero(self) -> None:
|
|
||||||
assert generate_indices(0) == []
|
|
||||||
|
|
||||||
def test_one(self) -> None:
|
|
||||||
assert generate_indices(1) == ["1"]
|
|
||||||
|
|
||||||
def test_three(self) -> None:
|
|
||||||
assert generate_indices(3) == ["1", "2", "3"]
|
|
||||||
|
|
||||||
def test_five(self) -> None:
|
|
||||||
assert generate_indices(5) == ["1", "2", "3", "4", "5"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestQueryRunners:
|
|
||||||
@patch("scripts.ci.discover_runners.requests.get")
|
|
||||||
def test_returns_total_from_all_levels(self, mock_get: MagicMock) -> None:
|
|
||||||
"""Runners from repo, org, and admin levels are summed."""
|
|
||||||
responses = [
|
|
||||||
MagicMock(status_code=200, json=lambda: {"runners": [], "total_count": 2}),
|
|
||||||
MagicMock(status_code=200, json=lambda: {"runners": [], "total_count": 1}),
|
|
||||||
MagicMock(status_code=200, json=lambda: {"runners": [], "total_count": 3}),
|
|
||||||
]
|
|
||||||
mock_get.side_effect = responses
|
|
||||||
result = query_runners("https://api.example.com", "token", "owner", "repo")
|
|
||||||
assert result == 6
|
|
||||||
|
|
||||||
@patch("scripts.ci.discover_runners.requests.get")
|
|
||||||
def test_skips_non_200(self, mock_get: MagicMock) -> None:
|
|
||||||
"""Non-200 responses (e.g., 403 for admin) are skipped."""
|
|
||||||
responses = [
|
|
||||||
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
|
|
||||||
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
|
|
||||||
MagicMock(status_code=403, json=lambda: {"message": "forbidden"}),
|
|
||||||
]
|
|
||||||
mock_get.side_effect = responses
|
|
||||||
result = query_runners("https://api.example.com", "token", "owner", "repo")
|
|
||||||
assert result == 3
|
|
||||||
|
|
||||||
@patch("scripts.ci.discover_runners.requests.get")
|
|
||||||
def test_handles_request_exception(self, mock_get: MagicMock) -> None:
|
|
||||||
"""Network errors are caught and don't crash."""
|
|
||||||
mock_get.side_effect = [
|
|
||||||
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
|
|
||||||
MagicMock(side_effect=__import__("requests").RequestException("network error")),
|
|
||||||
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
|
|
||||||
]
|
|
||||||
result = query_runners("https://api.example.com", "token", "owner", "repo")
|
|
||||||
assert result == 3
|
|
||||||
|
|
||||||
@patch("scripts.ci.discover_runners.requests.get")
|
|
||||||
def test_all_failures_return_zero(self, mock_get: MagicMock) -> None:
|
|
||||||
"""When all API calls fail, returns 0."""
|
|
||||||
mock_get.side_effect = [
|
|
||||||
MagicMock(status_code=404),
|
|
||||||
MagicMock(status_code=404),
|
|
||||||
MagicMock(status_code=403),
|
|
||||||
]
|
|
||||||
result = query_runners("https://api.example.com", "token", "owner", "repo")
|
|
||||||
assert result == 0
|
|
||||||
|
|
||||||
@patch("scripts.ci.discover_runners.requests.get")
|
|
||||||
def test_value_error_on_repo_level(self, mock_get: MagicMock) -> None:
|
|
||||||
"""JSON parse error on repo level is caught."""
|
|
||||||
responses = [
|
|
||||||
MagicMock(status_code=200, json=MagicMock(side_effect=ValueError("bad json"))),
|
|
||||||
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
|
|
||||||
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
|
|
||||||
]
|
|
||||||
mock_get.side_effect = responses
|
|
||||||
result = query_runners("https://api.example.com", "token", "owner", "repo")
|
|
||||||
assert result == 3
|
|
||||||
|
|
||||||
@patch("scripts.ci.discover_runners.requests.get")
|
|
||||||
def test_value_error_on_org_level(self, mock_get: MagicMock) -> None:
|
|
||||||
"""JSON parse error on org level is caught."""
|
|
||||||
responses = [
|
|
||||||
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
|
|
||||||
MagicMock(status_code=200, json=MagicMock(side_effect=ValueError("bad json"))),
|
|
||||||
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
|
|
||||||
]
|
|
||||||
mock_get.side_effect = responses
|
|
||||||
result = query_runners("https://api.example.com", "token", "owner", "repo")
|
|
||||||
assert result == 3
|
|
||||||
|
|
||||||
@patch("scripts.ci.discover_runners.requests.get")
|
|
||||||
def test_value_error_on_admin_level(self, mock_get: MagicMock) -> None:
|
|
||||||
"""JSON parse error on admin level is caught."""
|
|
||||||
responses = [
|
|
||||||
MagicMock(status_code=200, json=lambda: {"total_count": 1}),
|
|
||||||
MagicMock(status_code=200, json=lambda: {"total_count": 2}),
|
|
||||||
MagicMock(status_code=200, json=MagicMock(side_effect=ValueError("bad json"))),
|
|
||||||
]
|
|
||||||
mock_get.side_effect = responses
|
|
||||||
result = query_runners("https://api.example.com", "token", "owner", "repo")
|
|
||||||
assert result == 3
|
|
||||||
|
|
||||||
@patch("scripts.ci.discover_runners.requests.get")
|
|
||||||
def test_request_exception_on_all_levels(self, mock_get: MagicMock) -> None:
|
|
||||||
"""Network errors on all levels return 0."""
|
|
||||||
mock_get.side_effect = __import__("requests").RequestException("network error")
|
|
||||||
result = query_runners("https://api.example.com", "token", "owner", "repo")
|
|
||||||
assert result == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetRunnerCount:
|
|
||||||
@patch("scripts.ci.discover_runners.query_runners", return_value=5)
|
|
||||||
def test_uses_api_count_when_positive(self, mock_query: MagicMock) -> None:
|
|
||||||
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
|
|
||||||
assert result == 5
|
|
||||||
|
|
||||||
@patch("scripts.ci.discover_runners.query_runners", return_value=0)
|
|
||||||
@patch.dict("os.environ", {"MOLECULE_RUNNERS": "4"})
|
|
||||||
def test_falls_back_to_env_var(self, mock_query: MagicMock) -> None:
|
|
||||||
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
|
|
||||||
assert result == 4
|
|
||||||
|
|
||||||
@patch("scripts.ci.discover_runners.query_runners", return_value=0)
|
|
||||||
@patch.dict("os.environ", {"MOLECULE_RUNNERS": "invalid"})
|
|
||||||
def test_falls_back_to_default_on_invalid_env(self, mock_query: MagicMock) -> None:
|
|
||||||
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
|
|
||||||
assert result == DEFAULT_MAX_RUNNERS
|
|
||||||
|
|
||||||
@patch("scripts.ci.discover_runners.query_runners", return_value=0)
|
|
||||||
@patch.dict("os.environ", {}, clear=True)
|
|
||||||
def test_falls_back_to_default_when_no_env(self, mock_query: MagicMock) -> None:
|
|
||||||
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
|
|
||||||
assert result == DEFAULT_MAX_RUNNERS
|
|
||||||
|
|
||||||
@patch("scripts.ci.discover_runners.query_runners", return_value=0)
|
|
||||||
@patch.dict("os.environ", {"MOLECULE_RUNNERS": "0"})
|
|
||||||
def test_env_var_zero_falls_back_to_default(self, mock_query: MagicMock) -> None:
|
|
||||||
"""MOLECULE_RUNNERS=0 is invalid, falls back to default."""
|
|
||||||
result = get_runner_count("https://api.example.com", "token", "owner", "repo")
|
|
||||||
assert result == DEFAULT_MAX_RUNNERS
|
|
||||||
|
|
||||||
@patch("scripts.ci.discover_runners.query_runners", return_value=0)
|
|
||||||
@patch.dict("os.environ", {}, clear=True)
|
|
||||||
def test_no_token_uses_env_var(self, mock_query: MagicMock) -> None:
|
|
||||||
"""When no token, skips API and uses env/default."""
|
|
||||||
with patch.dict("os.environ", {"MOLECULE_RUNNERS": "2"}):
|
|
||||||
result = get_runner_count("https://api.example.com", "", "owner", "repo")
|
|
||||||
assert result == 2
|
|
||||||
|
|
||||||
|
|
||||||
class TestMain:
|
|
||||||
@patch("scripts.ci.discover_runners.get_runner_count", return_value=3)
|
|
||||||
def test_default_output(self, mock_count: MagicMock) -> None:
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, [])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "count=3" in result.output
|
|
||||||
assert 'indices=["1", "2", "3"]' in result.output
|
|
||||||
|
|
||||||
@patch("scripts.ci.discover_runners.get_runner_count", return_value=5)
|
|
||||||
def test_count_only(self, mock_count: MagicMock) -> None:
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--count"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert result.output.strip() == "5"
|
|
||||||
|
|
||||||
@patch("scripts.ci.discover_runners.get_runner_count", return_value=4)
|
|
||||||
def test_indices_only(self, mock_count: MagicMock) -> None:
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--indices"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert json.loads(result.output.strip()) == ["1", "2", "3", "4"]
|
|
||||||
|
|
||||||
@patch("scripts.ci.discover_runners.get_runner_count", return_value=1)
|
|
||||||
def test_single_runner(self, mock_count: MagicMock) -> None:
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--indices"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert json.loads(result.output.strip()) == ["1"]
|
|
||||||
|
|
||||||
@patch("scripts.ci.discover_runners.get_runner_count", return_value=3)
|
|
||||||
def test_github_output(self, mock_count: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
gh_file = tmp_path / "output.txt"
|
|
||||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--github-output"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
content = gh_file.read_text()
|
|
||||||
assert "runner-count=3" in content
|
|
||||||
assert "runner-indices=" in content
|
|
||||||
|
|
||||||
@patch("scripts.ci.discover_runners.get_runner_count", return_value=3)
|
|
||||||
def test_github_output_no_env(self, mock_count: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.delenv("GITHUB_OUTPUT", raising=False)
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--github-output"])
|
|
||||||
assert result.exit_code != 0
|
|
||||||
@@ -1,250 +0,0 @@
|
|||||||
"""Unit tests for scripts/ci/distribute_molecule.py."""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import click
|
|
||||||
import pytest
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
from scripts.ci.distribute_molecule import (
|
|
||||||
MOLECULE_ROOT,
|
|
||||||
PLATFORMS,
|
|
||||||
TestPair,
|
|
||||||
build_pairs,
|
|
||||||
cli,
|
|
||||||
discover_scenarios,
|
|
||||||
distribute,
|
|
||||||
pairs_for_runner,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDiscoverScenarios:
|
|
||||||
def test_discovers_scenarios(self, tmp_path: Path) -> None:
|
|
||||||
root = tmp_path / "molecule"
|
|
||||||
(root / "default").mkdir(parents=True)
|
|
||||||
(root / "binary").mkdir(parents=True)
|
|
||||||
(root / "common").mkdir(parents=True)
|
|
||||||
(root / "_shared").mkdir(parents=True)
|
|
||||||
result = discover_scenarios(root)
|
|
||||||
assert result == ["binary", "default"]
|
|
||||||
|
|
||||||
def test_raises_when_dir_missing(self, tmp_path: Path) -> None:
|
|
||||||
with pytest.raises(click.ClickException) as exc:
|
|
||||||
discover_scenarios(tmp_path / "nonexistent")
|
|
||||||
assert "not found" in str(exc.value)
|
|
||||||
|
|
||||||
def test_default_root_constant(self) -> None:
|
|
||||||
assert Path("ansible/roles/gitea-runner/molecule") == MOLECULE_ROOT
|
|
||||||
|
|
||||||
|
|
||||||
class TestPairEncoding:
|
|
||||||
def test_encode_roundtrip(self) -> None:
|
|
||||||
pair = TestPair("default", {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": ""})
|
|
||||||
encoded = pair.encode()
|
|
||||||
assert encoded == "default|ubuntu-2204|ubuntu:22.04|"
|
|
||||||
decoded = TestPair.decode(encoded)
|
|
||||||
assert decoded.scenario == "default"
|
|
||||||
assert decoded.platform["name"] == "ubuntu-2204"
|
|
||||||
assert decoded.platform["image"] == "ubuntu:22.04"
|
|
||||||
assert decoded.platform["command"] == ""
|
|
||||||
|
|
||||||
def test_encode_with_command(self) -> None:
|
|
||||||
pair = TestPair(
|
|
||||||
"default",
|
|
||||||
{"name": "archlinux", "image": "archlinux:latest", "command": "/usr/lib/systemd/systemd"},
|
|
||||||
)
|
|
||||||
encoded = pair.encode()
|
|
||||||
assert encoded == "default|archlinux|archlinux:latest|/usr/lib/systemd/systemd"
|
|
||||||
decoded = TestPair.decode(encoded)
|
|
||||||
assert decoded.platform["command"] == "/usr/lib/systemd/systemd"
|
|
||||||
|
|
||||||
|
|
||||||
class TestBuildPairs:
|
|
||||||
def test_cross_product(self) -> None:
|
|
||||||
scenarios = ["a", "b"]
|
|
||||||
platforms = [
|
|
||||||
{"name": "p1", "image": "img1", "command": ""},
|
|
||||||
{"name": "p2", "image": "img2", "command": ""},
|
|
||||||
]
|
|
||||||
pairs = build_pairs(scenarios, platforms)
|
|
||||||
assert len(pairs) == 4
|
|
||||||
assert pairs[0].scenario == "a"
|
|
||||||
assert pairs[0].platform["name"] == "p1"
|
|
||||||
assert pairs[1].scenario == "a"
|
|
||||||
assert pairs[1].platform["name"] == "p2"
|
|
||||||
assert pairs[2].scenario == "b"
|
|
||||||
assert pairs[2].platform["name"] == "p1"
|
|
||||||
assert pairs[3].scenario == "b"
|
|
||||||
assert pairs[3].platform["name"] == "p2"
|
|
||||||
|
|
||||||
def test_default_platforms(self) -> None:
|
|
||||||
pairs = build_pairs(["default"])
|
|
||||||
assert len(pairs) == len(PLATFORMS)
|
|
||||||
assert all(p.scenario == "default" for p in pairs)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDistribute:
|
|
||||||
def test_even_split(self) -> None:
|
|
||||||
pairs = [TestPair(f"s{i}", {"name": "p", "image": "i", "command": ""}) for i in range(6)]
|
|
||||||
groups = distribute(pairs, 3)
|
|
||||||
assert len(groups) == 3
|
|
||||||
assert len(groups[0]) == 2
|
|
||||||
assert len(groups[1]) == 2
|
|
||||||
assert len(groups[2]) == 2
|
|
||||||
|
|
||||||
def test_uneven_split(self) -> None:
|
|
||||||
pairs = [TestPair(f"s{i}", {"name": "p", "image": "i", "command": ""}) for i in range(5)]
|
|
||||||
groups = distribute(pairs, 3)
|
|
||||||
assert len(groups[0]) == 2
|
|
||||||
assert len(groups[1]) == 2
|
|
||||||
assert len(groups[2]) == 1
|
|
||||||
|
|
||||||
def test_more_runners_than_pairs(self) -> None:
|
|
||||||
pairs = [TestPair("a", {"name": "p", "image": "i", "command": ""})]
|
|
||||||
groups = distribute(pairs, 5)
|
|
||||||
assert len(groups) == 5
|
|
||||||
assert len(groups[0]) == 1
|
|
||||||
assert all(len(g) == 0 for g in groups[1:])
|
|
||||||
|
|
||||||
def test_empty_pairs(self) -> None:
|
|
||||||
groups = distribute([], 3)
|
|
||||||
assert groups == [[], [], []]
|
|
||||||
|
|
||||||
|
|
||||||
class TestPairsForRunner:
|
|
||||||
def test_returns_correct_subset(self) -> None:
|
|
||||||
pairs = [TestPair(f"s{i}", {"name": "p", "image": "i", "command": ""}) for i in range(6)]
|
|
||||||
assert len(pairs_for_runner(pairs, 0, 3)) == 2
|
|
||||||
assert len(pairs_for_runner(pairs, 1, 3)) == 2
|
|
||||||
assert len(pairs_for_runner(pairs, 2, 3)) == 2
|
|
||||||
|
|
||||||
def test_out_of_range_raises(self) -> None:
|
|
||||||
pairs = [TestPair("a", {"name": "p", "image": "i", "command": ""})]
|
|
||||||
with pytest.raises(click.ClickException) as exc:
|
|
||||||
pairs_for_runner(pairs, 5, 3)
|
|
||||||
assert "out of range" in str(exc.value)
|
|
||||||
|
|
||||||
def test_negative_index_raises(self) -> None:
|
|
||||||
pairs = [TestPair("a", {"name": "p", "image": "i", "command": ""})]
|
|
||||||
with pytest.raises(click.ClickException) as exc:
|
|
||||||
pairs_for_runner(pairs, -1, 3)
|
|
||||||
assert "out of range" in str(exc.value)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCli:
|
|
||||||
def test_list_flag(self, tmp_path: Path) -> None:
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
from scripts.ci.distribute_molecule import cli
|
|
||||||
|
|
||||||
root = tmp_path / "molecule"
|
|
||||||
(root / "alpha").mkdir(parents=True)
|
|
||||||
(root / "beta").mkdir(parents=True)
|
|
||||||
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, ["--list"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "alpha" in result.output
|
|
||||||
assert "beta" in result.output
|
|
||||||
|
|
||||||
def test_list_platforms_flag(self) -> None:
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
from scripts.ci.distribute_molecule import cli
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, ["--list-platforms"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "ubuntu-2204" in result.output
|
|
||||||
assert "ubuntu-2404" in result.output
|
|
||||||
assert "debian-12" in result.output
|
|
||||||
assert "archlinux" in result.output
|
|
||||||
|
|
||||||
def test_no_runner_index_prints_all_groups(self, tmp_path: Path) -> None:
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
from scripts.ci.distribute_molecule import cli
|
|
||||||
|
|
||||||
root = tmp_path / "molecule"
|
|
||||||
for s in ["a", "b", "c"]:
|
|
||||||
(root / s).mkdir(parents=True)
|
|
||||||
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, ["--max-runners", "3"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Runner 0:" in result.output
|
|
||||||
assert "Runner 1:" in result.output
|
|
||||||
assert "Runner 2:" in result.output
|
|
||||||
|
|
||||||
def test_runner_index_prints_assigned(self, tmp_path: Path) -> None:
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
from scripts.ci.distribute_molecule import cli
|
|
||||||
|
|
||||||
root = tmp_path / "molecule"
|
|
||||||
(root / "alpha").mkdir(parents=True)
|
|
||||||
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
|
|
||||||
runner = CliRunner()
|
|
||||||
# 1-based index: "1" maps to internal 0
|
|
||||||
result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
# Output should contain encoded pairs with platform info
|
|
||||||
assert "alpha|" in result.output
|
|
||||||
assert "ubuntu-2204" in result.output
|
|
||||||
|
|
||||||
|
|
||||||
class TestGithubEnv:
|
|
||||||
def test_writes_env(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
gh_file = tmp_path / "env.txt"
|
|
||||||
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
|
|
||||||
root = tmp_path / "molecule"
|
|
||||||
scenario = root / "alpha"
|
|
||||||
scenario.mkdir(parents=True)
|
|
||||||
(scenario / "molecule.yml").write_text("name: alpha\n")
|
|
||||||
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3", "--github-env"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
content = gh_file.read_text()
|
|
||||||
assert "TEST_PAIRS=" in content
|
|
||||||
assert "SKIP=false" in content
|
|
||||||
|
|
||||||
def test_skip_if_excess(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
gh_file = tmp_path / "env.txt"
|
|
||||||
monkeypatch.setenv("GITHUB_ENV", str(gh_file))
|
|
||||||
root = tmp_path / "molecule"
|
|
||||||
scenario = root / "alpha"
|
|
||||||
scenario.mkdir(parents=True)
|
|
||||||
(scenario / "molecule.yml").write_text("name: alpha\n")
|
|
||||||
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(
|
|
||||||
cli, ["--runner-index", "5", "--max-runners", "3", "--github-env", "--skip-if-excess"]
|
|
||||||
)
|
|
||||||
assert result.exit_code == 0
|
|
||||||
content = gh_file.read_text()
|
|
||||||
assert "TEST_PAIRS=\n" in content
|
|
||||||
assert "SKIP=true" in content
|
|
||||||
|
|
||||||
def test_no_env_var(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.delenv("GITHUB_ENV", raising=False)
|
|
||||||
root = tmp_path / "molecule"
|
|
||||||
scenario = root / "alpha"
|
|
||||||
scenario.mkdir(parents=True)
|
|
||||||
(scenario / "molecule.yml").write_text("name: alpha\n")
|
|
||||||
with patch("scripts.ci.distribute_molecule.MOLECULE_ROOT", root):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, ["--runner-index", "1", "--max-runners", "3", "--github-env"])
|
|
||||||
assert result.exit_code != 0
|
|
||||||
|
|
||||||
|
|
||||||
def test_main_module_block() -> None:
|
|
||||||
import scripts.ci.distribute_molecule as dm
|
|
||||||
|
|
||||||
with open(dm.__file__) as f:
|
|
||||||
source = f.read()
|
|
||||||
source = source.replace('if __name__ == "__main__":\n cli()\n', "")
|
|
||||||
namespace = dict(dm.__dict__)
|
|
||||||
exec(compile(source, dm.__file__, "exec"), namespace)
|
|
||||||
assert callable(namespace["cli"])
|
|
||||||
@@ -1,112 +0,0 @@
|
|||||||
"""Unit tests for scripts/ci/doc_coverage.py."""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
from scripts.ci.doc_coverage import (
|
|
||||||
check_command_documented,
|
|
||||||
check_module_documented,
|
|
||||||
extract_cli_commands,
|
|
||||||
main,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestExtractCliCommands:
|
|
||||||
def test_extracts_commands(self) -> None:
|
|
||||||
commands = extract_cli_commands()
|
|
||||||
# Should find all 9 CLI commands
|
|
||||||
assert "install" in commands
|
|
||||||
assert "update" in commands
|
|
||||||
assert "start" in commands
|
|
||||||
assert "stop" in commands
|
|
||||||
assert "enable" in commands
|
|
||||||
assert "disable" in commands
|
|
||||||
assert "status" in commands
|
|
||||||
assert "remove" in commands
|
|
||||||
assert "list" in commands
|
|
||||||
|
|
||||||
def test_returns_list(self) -> None:
|
|
||||||
commands = extract_cli_commands()
|
|
||||||
assert isinstance(commands, list)
|
|
||||||
assert len(commands) == 9
|
|
||||||
|
|
||||||
|
|
||||||
class TestCheckCommandDocumented:
|
|
||||||
def test_finds_command_in_heading(self) -> None:
|
|
||||||
content = "## install\n\nInstall a runner."
|
|
||||||
assert check_command_documented("install", content) is True
|
|
||||||
|
|
||||||
def test_finds_command_in_code_block(self) -> None:
|
|
||||||
content = "```bash\ngrm install 192.168.1.10\n```"
|
|
||||||
assert check_command_documented("install", content) is True
|
|
||||||
|
|
||||||
def test_finds_command_with_grm_prefix(self) -> None:
|
|
||||||
content = "Use `grm start prod-runner` to start."
|
|
||||||
assert check_command_documented("start", content) is True
|
|
||||||
|
|
||||||
def test_missing_command(self) -> None:
|
|
||||||
content = "## Other stuff\n\nNo commands here."
|
|
||||||
assert check_command_documented("install", content) is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestCheckModuleDocumented:
|
|
||||||
def test_finds_module(self) -> None:
|
|
||||||
content = "The cli.py module handles..."
|
|
||||||
assert check_module_documented("cli.py", content) is True
|
|
||||||
|
|
||||||
def test_missing_module(self) -> None:
|
|
||||||
content = "No modules mentioned."
|
|
||||||
assert check_module_documented("cli.py", content) is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestMain:
|
|
||||||
def test_all_present(self, tmp_path: Path) -> None:
|
|
||||||
"""When all docs exist and cover all commands/modules, exit 0."""
|
|
||||||
docs = tmp_path / "docs"
|
|
||||||
(docs / "user").mkdir(parents=True)
|
|
||||||
(docs / "tech").mkdir(parents=True)
|
|
||||||
# Write cli-commands.md with all commands
|
|
||||||
(docs / "user" / "cli-commands.md").write_text(
|
|
||||||
"## install\n## update\n## start\n## stop\n## enable\n## disable\n## status\n## remove\n## list\n"
|
|
||||||
)
|
|
||||||
# Write architecture.md with all modules
|
|
||||||
(docs / "tech" / "architecture.md").write_text(
|
|
||||||
"cli.py runner_manager.py executor.py registry.py i18n.py exceptions.py api_clients.py config.py"
|
|
||||||
)
|
|
||||||
# Write ci-cd-workflow.md with all scripts
|
|
||||||
(docs / "tech" / "ci-cd-workflow.md").write_text(
|
|
||||||
"auto_merge.py release.py publish.py review_pr.py "
|
|
||||||
"notify_failure.py post_merge.py classify_changes.py discover_runners.py "
|
|
||||||
"detect_release_commit.py push_badges.py "
|
|
||||||
"distribute_molecule.py molecule_ci_guard.py validate_commit_msg.py"
|
|
||||||
)
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "100%" in result.output
|
|
||||||
|
|
||||||
def test_missing_docs_fail(self, tmp_path: Path) -> None:
|
|
||||||
"""When docs are missing and --fail-on-missing is set, exit 1."""
|
|
||||||
docs = tmp_path / "docs"
|
|
||||||
(docs / "user").mkdir(parents=True)
|
|
||||||
(docs / "tech").mkdir(parents=True)
|
|
||||||
(docs / "user" / "cli-commands.md").write_text("No commands here.")
|
|
||||||
(docs / "tech" / "architecture.md").write_text("No modules here.")
|
|
||||||
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--docs-dir", str(docs), "--fail-on-missing"])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
|
|
||||||
def test_missing_docs_warn_only(self, tmp_path: Path) -> None:
|
|
||||||
"""Without --fail-on-missing, missing docs only warn (exit 0)."""
|
|
||||||
docs = tmp_path / "docs"
|
|
||||||
(docs / "user").mkdir(parents=True)
|
|
||||||
(docs / "tech").mkdir(parents=True)
|
|
||||||
(docs / "user" / "cli-commands.md").write_text("No commands here.")
|
|
||||||
(docs / "tech" / "architecture.md").write_text("No modules here.")
|
|
||||||
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "MISSING" in result.output
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
"""Unit tests for scripts/generate_badges.py."""
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
from scripts.generate_badges import (
|
|
||||||
COLOR_HEX,
|
|
||||||
cli,
|
|
||||||
coverage_color,
|
|
||||||
doc_coverage_color,
|
|
||||||
extract_coverage,
|
|
||||||
extract_doc_coverage,
|
|
||||||
extract_test_count,
|
|
||||||
generate_badges,
|
|
||||||
make_badge,
|
|
||||||
read_version,
|
|
||||||
render_svg,
|
|
||||||
run_command,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestRunCommand:
|
|
||||||
@patch("scripts.generate_badges.subprocess.run")
|
|
||||||
def test_returns_returncode_stdout_stderr(self, mock_run: MagicMock) -> None:
|
|
||||||
mock_run.return_value = MagicMock(returncode=0, stdout="out", stderr="err")
|
|
||||||
rc, out, err = run_command(["echo", "hello"])
|
|
||||||
assert rc == 0
|
|
||||||
assert out == "out"
|
|
||||||
assert err == "err"
|
|
||||||
mock_run.assert_called_once()
|
|
||||||
|
|
||||||
|
|
||||||
class TestMakeBadge:
|
|
||||||
def test_creates_valid_badge_dict(self) -> None:
|
|
||||||
badge = make_badge("coverage", "100%", "brightgreen")
|
|
||||||
assert badge == {
|
|
||||||
"schemaVersion": 1,
|
|
||||||
"label": "coverage",
|
|
||||||
"message": "100%",
|
|
||||||
"color": "brightgreen",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class TestRenderSvg:
|
|
||||||
def test_generates_valid_svg(self) -> None:
|
|
||||||
svg = render_svg("coverage", "100%", "brightgreen")
|
|
||||||
assert svg.startswith("<svg")
|
|
||||||
assert svg.endswith("</svg>\n")
|
|
||||||
assert "coverage" in svg
|
|
||||||
assert "100%" in svg
|
|
||||||
assert COLOR_HEX["brightgreen"] in svg
|
|
||||||
|
|
||||||
def test_uses_color_hex_for_known_colors(self) -> None:
|
|
||||||
svg = render_svg("tests", "573 passing", "brightgreen")
|
|
||||||
assert "#4c1" in svg
|
|
||||||
|
|
||||||
def test_uses_hex_directly_for_unknown_hex_color(self) -> None:
|
|
||||||
svg = render_svg("label", "msg", "#abc123")
|
|
||||||
assert "#abc123" in svg
|
|
||||||
|
|
||||||
def test_uses_lightgrey_for_unknown_named_color(self) -> None:
|
|
||||||
svg = render_svg("label", "msg", "nonexistent")
|
|
||||||
assert "#9f9f9f" in svg
|
|
||||||
|
|
||||||
def test_escapes_xml_special_chars(self) -> None:
|
|
||||||
svg = render_svg("label", "<script>", "red")
|
|
||||||
assert "<script>" not in svg
|
|
||||||
assert "<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([])
|
|
||||||
@@ -1,355 +0,0 @@
|
|||||||
"""Unit tests for scripts/gitea_cli.py."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from scripts.gitea_cli import TeaCLI, TeaCLIError, _extract_issue_number, _extract_pr_number
|
|
||||||
|
|
||||||
|
|
||||||
class TestExtractIssueNumber:
|
|
||||||
def test_extract_from_created_issue(self) -> None:
|
|
||||||
assert _extract_issue_number("Created issue #42: Bug title") == 42
|
|
||||||
|
|
||||||
def test_extract_no_hash(self) -> None:
|
|
||||||
assert _extract_issue_number("No issue number here") == 0
|
|
||||||
|
|
||||||
def test_extract_multiple_hashes(self) -> None:
|
|
||||||
assert _extract_issue_number("Issue #5 and PR #10") == 5
|
|
||||||
|
|
||||||
def test_extract_with_colon(self) -> None:
|
|
||||||
assert _extract_issue_number("Created issue #7: title") == 7
|
|
||||||
|
|
||||||
def test_extract_invalid_number(self) -> None:
|
|
||||||
assert _extract_issue_number("Issue #abc: title") == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestExtractPrNumber:
|
|
||||||
def test_extract_from_created_pr(self) -> None:
|
|
||||||
assert _extract_pr_number("Created PR #128: Feature") == 128
|
|
||||||
|
|
||||||
def test_extract_no_number(self) -> None:
|
|
||||||
assert _extract_pr_number("No PR number") == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestTeaCLIInit:
|
|
||||||
def test_auto_detect_tea(self) -> None:
|
|
||||||
with patch("shutil.which", return_value="/usr/bin/tea"):
|
|
||||||
cli = TeaCLI()
|
|
||||||
assert cli._tea == "/usr/bin/tea"
|
|
||||||
|
|
||||||
def test_explicit_tea_bin(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/custom/tea")
|
|
||||||
assert cli._tea == "/custom/tea"
|
|
||||||
|
|
||||||
def test_fallback_to_tea(self) -> None:
|
|
||||||
with patch("shutil.which", return_value=None):
|
|
||||||
cli = TeaCLI()
|
|
||||||
assert cli._tea == "tea"
|
|
||||||
|
|
||||||
def test_with_repo(self) -> None:
|
|
||||||
cli = TeaCLI(repo="owner/repo")
|
|
||||||
assert cli._repo == "owner/repo"
|
|
||||||
|
|
||||||
|
|
||||||
class TestTeaCLIRun:
|
|
||||||
def test_run_success_json(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
mock_result = MagicMock(returncode=0, stdout='[{"id": 1}]', stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
output = cli._run(["labels", "list"])
|
|
||||||
assert output == '[{"id": 1}]'
|
|
||||||
|
|
||||||
def test_run_success_raw(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
mock_result = MagicMock(returncode=0, stdout="Created issue #42", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
output = cli._run_raw(["issues", "create"])
|
|
||||||
assert output == "Created issue #42"
|
|
||||||
|
|
||||||
def test_run_failure_raises(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
mock_result = MagicMock(returncode=1, stdout="", stderr="auth error")
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
with pytest.raises(TeaCLIError, match="auth error"):
|
|
||||||
cli._run(["labels", "list"])
|
|
||||||
|
|
||||||
def test_run_includes_json_flag(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
mock_result = MagicMock(returncode=0, stdout="[]", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
|
||||||
cli._run(["labels", "list"])
|
|
||||||
cmd = mock_run.call_args[0][0]
|
|
||||||
assert "--output" in cmd
|
|
||||||
assert "json" in cmd
|
|
||||||
|
|
||||||
def test_run_raw_no_json_flag(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
mock_result = MagicMock(returncode=0, stdout="ok", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
|
||||||
cli._run_raw(["whoami"])
|
|
||||||
cmd = mock_run.call_args[0][0]
|
|
||||||
assert "--output" not in cmd
|
|
||||||
|
|
||||||
|
|
||||||
class TestRepoArg:
|
|
||||||
def test_with_repo_arg(self) -> None:
|
|
||||||
cli = TeaCLI(repo="owner/repo")
|
|
||||||
assert cli._repo_arg() == ["--repo", "owner/repo"]
|
|
||||||
|
|
||||||
def test_with_explicit_repo(self) -> None:
|
|
||||||
cli = TeaCLI()
|
|
||||||
assert cli._repo_arg("other/repo") == ["--repo", "other/repo"]
|
|
||||||
|
|
||||||
def test_without_repo(self) -> None:
|
|
||||||
cli = TeaCLI()
|
|
||||||
assert cli._repo_arg() == []
|
|
||||||
|
|
||||||
def test_explicit_overrides_default(self) -> None:
|
|
||||||
cli = TeaCLI(repo="default/repo")
|
|
||||||
assert cli._repo_arg("override/repo") == ["--repo", "override/repo"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreateIssue:
|
|
||||||
def test_create_issue_basic(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea", repo="owner/repo")
|
|
||||||
mock_result = MagicMock(returncode=0, stdout="Created issue #42: Bug", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
issue = cli.create_issue("owner/repo", title="Bug", body="Description")
|
|
||||||
assert issue["index"] == 42
|
|
||||||
assert issue["title"] == "Bug"
|
|
||||||
|
|
||||||
def test_create_issue_with_labels(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
mock_result = MagicMock(returncode=0, stdout="Created issue #5: Title", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
issue = cli.create_issue("owner/repo", title="Title", body="Body", labels=["bug"])
|
|
||||||
assert issue["index"] == 5
|
|
||||||
|
|
||||||
|
|
||||||
class TestListLabels:
|
|
||||||
def test_list_labels_with_data(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
labels_json = json.dumps([{"id": 1, "name": "bug"}, {"id": 2, "name": "enhancement"}])
|
|
||||||
mock_result = MagicMock(returncode=0, stdout=labels_json, stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
labels = cli.list_labels("owner/repo")
|
|
||||||
assert len(labels) == 2
|
|
||||||
assert labels[0]["name"] == "bug"
|
|
||||||
|
|
||||||
def test_list_labels_empty(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
mock_result = MagicMock(returncode=0, stdout="", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
labels = cli.list_labels("owner/repo")
|
|
||||||
assert labels == []
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreateLabel:
|
|
||||||
def test_create_label_full(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
mock_result = MagicMock(returncode=0, stdout="Label created", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
label = cli.create_label("owner/repo", name="bug", color="ff0000", description="A bug")
|
|
||||||
assert label["name"] == "bug"
|
|
||||||
assert label["color"] == "ff0000"
|
|
||||||
|
|
||||||
def test_create_label_name_only(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
mock_result = MagicMock(returncode=0, stdout="Label created", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
label = cli.create_label("owner/repo", name="wip")
|
|
||||||
assert label["name"] == "wip"
|
|
||||||
assert label["color"] == ""
|
|
||||||
|
|
||||||
|
|
||||||
class TestAddLabel:
|
|
||||||
def test_add_label_single(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
mock_result = MagicMock(returncode=0, stdout="ok", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
|
||||||
cli.add_label("owner/repo", 42, ["ready-to-merge"])
|
|
||||||
cmd = mock_run.call_args[0][0]
|
|
||||||
assert "--add-labels" in cmd
|
|
||||||
assert "ready-to-merge" in cmd
|
|
||||||
assert "42" in cmd
|
|
||||||
|
|
||||||
def test_add_label_multiple(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
mock_result = MagicMock(returncode=0, stdout="ok", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
|
||||||
cli.add_label("owner/repo", 42, ["bug", "urgent"])
|
|
||||||
cmd = mock_run.call_args[0][0]
|
|
||||||
assert "--add-labels" in cmd
|
|
||||||
|
|
||||||
def test_add_label_empty_list(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
with patch("subprocess.run") as mock_run:
|
|
||||||
cli.add_label("owner/repo", 42, [])
|
|
||||||
mock_run.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreatePR:
|
|
||||||
def test_create_pr_basic(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
mock_result = MagicMock(returncode=0, stdout="Created PR #128: Feature", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
pr = cli.create_pr("owner/repo", title="Feature", head="feature-branch", base="master")
|
|
||||||
assert pr["index"] == 128
|
|
||||||
|
|
||||||
def test_create_pr_with_body(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
mock_result = MagicMock(returncode=0, stdout="Created PR #10: Title", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
|
||||||
cli.create_pr("owner/repo", title="Title", head="feat", base="master", body="Description")
|
|
||||||
cmd = mock_run.call_args[0][0]
|
|
||||||
assert "--body" in cmd
|
|
||||||
assert "Description" in cmd
|
|
||||||
|
|
||||||
|
|
||||||
class TestMergePR:
|
|
||||||
def test_merge_pr_squash(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
mock_result = MagicMock(returncode=0, stdout="Merged", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
|
||||||
cli.merge_pr("owner/repo", 42, style="squash")
|
|
||||||
cmd = mock_run.call_args[0][0]
|
|
||||||
assert "--style" in cmd
|
|
||||||
assert "squash" in cmd
|
|
||||||
assert "42" in cmd
|
|
||||||
|
|
||||||
def test_merge_pr_default_style(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
mock_result = MagicMock(returncode=0, stdout="Merged", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
|
||||||
cli.merge_pr("owner/repo", 42)
|
|
||||||
cmd = mock_run.call_args[0][0]
|
|
||||||
assert "squash" in cmd
|
|
||||||
|
|
||||||
|
|
||||||
class TestReviewPR:
|
|
||||||
def test_review_approve(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
|
||||||
cli.review_pr("owner/repo", 42, event="APPROVE", body="LGTM")
|
|
||||||
cmd = mock_run.call_args[0][0]
|
|
||||||
assert "--approve" in cmd
|
|
||||||
assert "--comment" in cmd
|
|
||||||
|
|
||||||
def test_review_reject(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
|
||||||
cli.review_pr("owner/repo", 42, event="REQUEST_CHANGES", body="Needs work")
|
|
||||||
cmd = mock_run.call_args[0][0]
|
|
||||||
assert "--reject" in cmd
|
|
||||||
|
|
||||||
def test_review_comment(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
|
||||||
cli.review_pr("owner/repo", 42, event="COMMENT", body="Note")
|
|
||||||
cmd = mock_run.call_args[0][0]
|
|
||||||
assert "--approve" not in cmd
|
|
||||||
assert "--reject" not in cmd
|
|
||||||
assert "--comment" in cmd
|
|
||||||
|
|
||||||
def test_review_no_body(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
mock_result = MagicMock(returncode=0, stdout="Reviewed", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
|
||||||
cli.review_pr("owner/repo", 42, event="COMMENT")
|
|
||||||
cmd = mock_run.call_args[0][0]
|
|
||||||
assert "--comment" not in cmd
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreateRelease:
|
|
||||||
def test_create_release_full(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
mock_result = MagicMock(returncode=0, stdout="Release created", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
|
||||||
release = cli.create_release(
|
|
||||||
"owner/repo",
|
|
||||||
tag="v1.0.0",
|
|
||||||
title="Release 1.0.0",
|
|
||||||
body="Notes",
|
|
||||||
target="master",
|
|
||||||
)
|
|
||||||
cmd = mock_run.call_args[0][0]
|
|
||||||
assert "v1.0.0" in cmd
|
|
||||||
assert "--title" in cmd
|
|
||||||
assert "--note" in cmd
|
|
||||||
assert "--target" in cmd
|
|
||||||
assert release["tag"] == "v1.0.0"
|
|
||||||
|
|
||||||
def test_create_release_draft(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
mock_result = MagicMock(returncode=0, stdout="Release created", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
|
||||||
cli.create_release("owner/repo", tag="v0.1.0", draft=True)
|
|
||||||
cmd = mock_run.call_args[0][0]
|
|
||||||
assert "--draft" in cmd
|
|
||||||
|
|
||||||
def test_create_release_prerelease(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
mock_result = MagicMock(returncode=0, stdout="Release created", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
|
||||||
cli.create_release("owner/repo", tag="v0.1.0-rc1", prerelease=True)
|
|
||||||
cmd = mock_run.call_args[0][0]
|
|
||||||
assert "--prerelease" in cmd
|
|
||||||
|
|
||||||
def test_create_release_minimal(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
mock_result = MagicMock(returncode=0, stdout="Release created", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
|
||||||
release = cli.create_release("owner/repo", tag="v1.0.0")
|
|
||||||
cmd = mock_run.call_args[0][0]
|
|
||||||
assert "--title" not in cmd
|
|
||||||
assert "--note" not in cmd
|
|
||||||
assert release["tag"] == "v1.0.0"
|
|
||||||
|
|
||||||
|
|
||||||
class TestListReleases:
|
|
||||||
def test_list_releases_with_data(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
releases_json = json.dumps([{"tag": "v1.0.0"}, {"tag": "v0.9.0"}])
|
|
||||||
mock_result = MagicMock(returncode=0, stdout=releases_json, stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
releases = cli.list_releases("owner/repo")
|
|
||||||
assert len(releases) == 2
|
|
||||||
|
|
||||||
def test_list_releases_empty(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
mock_result = MagicMock(returncode=0, stdout="", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
releases = cli.list_releases("owner/repo")
|
|
||||||
assert releases == []
|
|
||||||
|
|
||||||
|
|
||||||
class TestListBranches:
|
|
||||||
def test_list_branches_with_data(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
branches_json = json.dumps([{"name": "master"}, {"name": "develop"}])
|
|
||||||
mock_result = MagicMock(returncode=0, stdout=branches_json, stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
branches = cli.list_branches("owner/repo")
|
|
||||||
assert len(branches) == 2
|
|
||||||
|
|
||||||
def test_list_branches_empty(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
mock_result = MagicMock(returncode=0, stdout="", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
branches = cli.list_branches("owner/repo")
|
|
||||||
assert branches == []
|
|
||||||
|
|
||||||
|
|
||||||
class TestWhoami:
|
|
||||||
def test_whoami(self) -> None:
|
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
|
||||||
mock_result = MagicMock(returncode=0, stdout="emil", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
assert cli.whoami() == "emil"
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import platform
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from click import ClickException
|
|
||||||
|
|
||||||
import scripts.install_checkmake as install_checkmake
|
|
||||||
|
|
||||||
|
|
||||||
class TestArch:
|
|
||||||
def test_amd64(self) -> None:
|
|
||||||
with patch.object(platform, "machine", return_value="x86_64"):
|
|
||||||
assert install_checkmake._arch() == "amd64"
|
|
||||||
|
|
||||||
def test_arm64(self) -> None:
|
|
||||||
with patch.object(platform, "machine", return_value="aarch64"):
|
|
||||||
assert install_checkmake._arch() == "arm64"
|
|
||||||
|
|
||||||
def test_unsupported(self) -> None:
|
|
||||||
with patch.object(platform, "machine", return_value="riscv64"):
|
|
||||||
with pytest.raises(ClickException):
|
|
||||||
install_checkmake._arch()
|
|
||||||
|
|
||||||
|
|
||||||
class TestInstallWithGo:
|
|
||||||
def test_no_go(self) -> None:
|
|
||||||
with patch("shutil.which", return_value=None):
|
|
||||||
assert install_checkmake._install_with_go() is False
|
|
||||||
|
|
||||||
def test_with_go(self) -> None:
|
|
||||||
with patch("shutil.which", return_value="/usr/bin/go"):
|
|
||||||
with patch("subprocess.run") as mock_run:
|
|
||||||
assert install_checkmake._install_with_go() is True
|
|
||||||
mock_run.assert_called_once_with(
|
|
||||||
[
|
|
||||||
"/usr/bin/go",
|
|
||||||
"install",
|
|
||||||
"github.com/checkmake/checkmake/cmd/checkmake@latest",
|
|
||||||
],
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestDownloadBinary:
|
|
||||||
def test_download(self, tmp_path: Path) -> None:
|
|
||||||
target = tmp_path / "checkmake"
|
|
||||||
|
|
||||||
def _write_file(url: str, path: str) -> tuple[str, None]:
|
|
||||||
Path(path).write_bytes(b"binary")
|
|
||||||
return path, None
|
|
||||||
|
|
||||||
with patch.object(install_checkmake, "TARGET_PATH", target):
|
|
||||||
with patch.object(platform, "machine", return_value="x86_64"):
|
|
||||||
with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve:
|
|
||||||
install_checkmake._download_binary()
|
|
||||||
mock_retrieve.assert_called_once()
|
|
||||||
assert target.exists()
|
|
||||||
assert target.stat().st_mode & 0o111
|
|
||||||
|
|
||||||
|
|
||||||
class TestMain:
|
|
||||||
def test_already_installed(self) -> None:
|
|
||||||
with patch("shutil.which", return_value="/usr/bin/checkmake"):
|
|
||||||
install_checkmake.main()
|
|
||||||
|
|
||||||
def test_install_with_go(self) -> None:
|
|
||||||
with patch("shutil.which", side_effect=[None, "/usr/bin/go"]):
|
|
||||||
with patch("subprocess.run") as mock_run:
|
|
||||||
install_checkmake.main()
|
|
||||||
mock_run.assert_called_once_with(
|
|
||||||
[
|
|
||||||
"/usr/bin/go",
|
|
||||||
"install",
|
|
||||||
"github.com/checkmake/checkmake/cmd/checkmake@latest",
|
|
||||||
],
|
|
||||||
check=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_download_when_no_go(self, tmp_path: Path) -> None:
|
|
||||||
target = tmp_path / "checkmake"
|
|
||||||
|
|
||||||
def _write_file(url: str, path: str) -> tuple[str, None]:
|
|
||||||
Path(path).write_bytes(b"binary")
|
|
||||||
return path, None
|
|
||||||
|
|
||||||
with patch.object(install_checkmake, "TARGET_PATH", target):
|
|
||||||
with patch("shutil.which", side_effect=[None, None]):
|
|
||||||
with patch.object(platform, "machine", return_value="x86_64"):
|
|
||||||
with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve:
|
|
||||||
install_checkmake.main()
|
|
||||||
mock_retrieve.assert_called_once()
|
|
||||||
@@ -1,312 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import platform
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from click import ClickException
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
import scripts.install_tools as install_tools
|
|
||||||
|
|
||||||
|
|
||||||
class TestArch:
|
|
||||||
def test_amd64(self) -> None:
|
|
||||||
with patch.object(platform, "machine", return_value="x86_64"):
|
|
||||||
assert install_tools._arch() == "amd64"
|
|
||||||
|
|
||||||
def test_arm64(self) -> None:
|
|
||||||
with patch.object(platform, "machine", return_value="aarch64"):
|
|
||||||
assert install_tools._arch() == "arm64"
|
|
||||||
|
|
||||||
def test_unsupported(self) -> None:
|
|
||||||
with patch.object(platform, "machine", return_value="riscv64"):
|
|
||||||
with pytest.raises(ClickException):
|
|
||||||
install_tools._arch()
|
|
||||||
|
|
||||||
|
|
||||||
class TestIsInstalled:
|
|
||||||
def test_on_path(self) -> None:
|
|
||||||
with patch("shutil.which", return_value="/usr/bin/actionlint"):
|
|
||||||
assert install_tools._is_installed("actionlint") is True
|
|
||||||
|
|
||||||
def test_in_target_dir(self, tmp_path: Path) -> None:
|
|
||||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
|
||||||
(tmp_path / "actionlint").touch()
|
|
||||||
with patch("shutil.which", return_value=None):
|
|
||||||
assert install_tools._is_installed("actionlint") is True
|
|
||||||
|
|
||||||
def test_not_installed(self, tmp_path: Path) -> None:
|
|
||||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
|
||||||
with patch("shutil.which", return_value=None):
|
|
||||||
assert install_tools._is_installed("actionlint") is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestDownload:
|
|
||||||
def test_download(self, tmp_path: Path) -> None:
|
|
||||||
dest = tmp_path / "file.bin"
|
|
||||||
|
|
||||||
def _write_file(url: str, path: Path) -> tuple[str, None]:
|
|
||||||
Path(path).write_bytes(b"data")
|
|
||||||
return str(path), None
|
|
||||||
|
|
||||||
with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve:
|
|
||||||
install_tools._download("https://example.com/file", dest)
|
|
||||||
mock_retrieve.assert_called_once()
|
|
||||||
assert dest.read_bytes() == b"data"
|
|
||||||
|
|
||||||
|
|
||||||
class TestDownloadBinary:
|
|
||||||
def test_download(self, tmp_path: Path) -> None:
|
|
||||||
dest = tmp_path / "act_runner"
|
|
||||||
|
|
||||||
def _write_file(url: str, path: Path) -> tuple[str, None]:
|
|
||||||
Path(path).write_bytes(b"binary")
|
|
||||||
return str(path), None
|
|
||||||
|
|
||||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
|
||||||
with patch.object(install_tools, "_download", side_effect=_write_file):
|
|
||||||
result = install_tools._download_binary("https://example.com/act_runner", "act_runner")
|
|
||||||
assert result == dest
|
|
||||||
assert dest.exists()
|
|
||||||
assert dest.stat().st_mode & 0o111
|
|
||||||
|
|
||||||
|
|
||||||
class TestDownloadAndExtractTarball:
|
|
||||||
def test_extract(self, tmp_path: Path) -> None:
|
|
||||||
import tarfile
|
|
||||||
|
|
||||||
# Create a fake tarball with a binary
|
|
||||||
tarball_path = tmp_path / "archive.tar.gz"
|
|
||||||
binary_content = b"fake binary"
|
|
||||||
with tarfile.open(tarball_path, "w:gz") as tar:
|
|
||||||
import io
|
|
||||||
|
|
||||||
info = tarfile.TarInfo(name="actionlint")
|
|
||||||
info.size = len(binary_content)
|
|
||||||
tar.addfile(info, io.BytesIO(binary_content))
|
|
||||||
|
|
||||||
target_dir = tmp_path / "bin"
|
|
||||||
target_dir.mkdir()
|
|
||||||
|
|
||||||
with patch.object(install_tools, "TARGET_DIR", target_dir):
|
|
||||||
with patch.object(
|
|
||||||
install_tools,
|
|
||||||
"_download",
|
|
||||||
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
|
|
||||||
):
|
|
||||||
result = install_tools._download_and_extract_tarball(
|
|
||||||
"https://example.com/actionlint.tar.gz", "actionlint"
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result == target_dir / "actionlint"
|
|
||||||
assert result.exists()
|
|
||||||
assert result.read_bytes() == binary_content
|
|
||||||
|
|
||||||
def test_binary_not_found(self, tmp_path: Path) -> None:
|
|
||||||
import tarfile
|
|
||||||
|
|
||||||
tarball_path = tmp_path / "archive.tar.gz"
|
|
||||||
with tarfile.open(tarball_path, "w:gz") as tar:
|
|
||||||
import io
|
|
||||||
|
|
||||||
info = tarfile.TarInfo(name="other_file")
|
|
||||||
info.size = 0
|
|
||||||
tar.addfile(info, io.BytesIO(b""))
|
|
||||||
|
|
||||||
target_dir = tmp_path / "bin"
|
|
||||||
target_dir.mkdir()
|
|
||||||
|
|
||||||
with patch.object(install_tools, "TARGET_DIR", target_dir):
|
|
||||||
with patch.object(
|
|
||||||
install_tools,
|
|
||||||
"_download",
|
|
||||||
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
|
|
||||||
):
|
|
||||||
with pytest.raises(ClickException, match="not found in archive"):
|
|
||||||
install_tools._download_and_extract_tarball("https://example.com/actionlint.tar.gz", "actionlint")
|
|
||||||
|
|
||||||
|
|
||||||
class TestInstallActionlint:
|
|
||||||
def test_already_installed(self) -> None:
|
|
||||||
with patch.object(install_tools, "_is_installed", return_value=True):
|
|
||||||
assert install_tools.install_actionlint() is True
|
|
||||||
|
|
||||||
def test_install(self, tmp_path: Path) -> None:
|
|
||||||
import tarfile
|
|
||||||
|
|
||||||
tarball_path = tmp_path / "archive.tar.gz"
|
|
||||||
binary_content = b"fake actionlint"
|
|
||||||
with tarfile.open(tarball_path, "w:gz") as tar:
|
|
||||||
import io
|
|
||||||
|
|
||||||
info = tarfile.TarInfo(name="actionlint")
|
|
||||||
info.size = len(binary_content)
|
|
||||||
tar.addfile(info, io.BytesIO(binary_content))
|
|
||||||
|
|
||||||
with patch.object(install_tools, "_is_installed", return_value=False):
|
|
||||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
|
||||||
with patch.object(platform, "machine", return_value="x86_64"):
|
|
||||||
with patch.object(
|
|
||||||
install_tools,
|
|
||||||
"_download",
|
|
||||||
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
|
|
||||||
):
|
|
||||||
assert install_tools.install_actionlint() is True
|
|
||||||
assert (tmp_path / "actionlint").exists()
|
|
||||||
|
|
||||||
|
|
||||||
class TestInstallGitCliff:
|
|
||||||
def test_already_installed(self) -> None:
|
|
||||||
with patch.object(install_tools, "_is_installed", return_value=True):
|
|
||||||
assert install_tools.install_git_cliff() is True
|
|
||||||
|
|
||||||
def test_install(self, tmp_path: Path) -> None:
|
|
||||||
import tarfile
|
|
||||||
|
|
||||||
tarball_path = tmp_path / "archive.tar.gz"
|
|
||||||
binary_content = b"fake git-cliff"
|
|
||||||
with tarfile.open(tarball_path, "w:gz") as tar:
|
|
||||||
import io
|
|
||||||
|
|
||||||
info = tarfile.TarInfo(name="git-cliff")
|
|
||||||
info.size = len(binary_content)
|
|
||||||
tar.addfile(info, io.BytesIO(binary_content))
|
|
||||||
|
|
||||||
with patch.object(install_tools, "_is_installed", return_value=False):
|
|
||||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
|
||||||
with patch.object(platform, "machine", return_value="x86_64"):
|
|
||||||
with patch.object(
|
|
||||||
install_tools,
|
|
||||||
"_download",
|
|
||||||
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
|
|
||||||
):
|
|
||||||
assert install_tools.install_git_cliff() is True
|
|
||||||
assert (tmp_path / "git-cliff").exists()
|
|
||||||
|
|
||||||
|
|
||||||
class TestInstallActRunner:
|
|
||||||
def test_already_installed(self) -> None:
|
|
||||||
with patch.object(install_tools, "_is_installed", return_value=True):
|
|
||||||
assert install_tools.install_act_runner() is True
|
|
||||||
|
|
||||||
def test_install(self, tmp_path: Path) -> None:
|
|
||||||
def _write_file(url: str, path: Path) -> tuple[str, None]:
|
|
||||||
Path(path).write_bytes(b"binary")
|
|
||||||
return str(path), None
|
|
||||||
|
|
||||||
with patch.object(install_tools, "_is_installed", return_value=False):
|
|
||||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
|
||||||
with patch.object(platform, "machine", return_value="x86_64"):
|
|
||||||
with patch.object(install_tools, "_download", side_effect=_write_file):
|
|
||||||
assert install_tools.install_act_runner() is True
|
|
||||||
assert (tmp_path / "act_runner").exists()
|
|
||||||
|
|
||||||
|
|
||||||
class TestInstallTea:
|
|
||||||
def test_already_installed(self) -> None:
|
|
||||||
with patch.object(install_tools, "_is_installed", return_value=True):
|
|
||||||
assert install_tools.install_tea() is True
|
|
||||||
|
|
||||||
def test_install(self, tmp_path: Path) -> None:
|
|
||||||
def _write_file(url: str, path: Path) -> tuple[str, None]:
|
|
||||||
Path(path).write_bytes(b"binary")
|
|
||||||
return str(path), None
|
|
||||||
|
|
||||||
with patch.object(install_tools, "_is_installed", return_value=False):
|
|
||||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
|
||||||
with patch.object(platform, "machine", return_value="x86_64"):
|
|
||||||
with patch.object(install_tools, "_download", side_effect=_write_file):
|
|
||||||
assert install_tools.install_tea() is True
|
|
||||||
assert (tmp_path / "tea").exists()
|
|
||||||
|
|
||||||
|
|
||||||
class TestListTools:
|
|
||||||
def test_list(self, tmp_path: Path) -> None:
|
|
||||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
|
||||||
with patch("shutil.which", return_value=None):
|
|
||||||
with patch.object(install_tools, "TOOL_NAMES", ["actionlint", "git-cliff", "act_runner"]):
|
|
||||||
install_tools.list_tools()
|
|
||||||
|
|
||||||
|
|
||||||
class TestInstallTool:
|
|
||||||
def test_actionlint(self) -> None:
|
|
||||||
with patch.object(install_tools, "install_actionlint", return_value=True) as mock:
|
|
||||||
assert install_tools._install_tool("actionlint") is True
|
|
||||||
mock.assert_called_once()
|
|
||||||
|
|
||||||
def test_git_cliff(self) -> None:
|
|
||||||
with patch.object(install_tools, "install_git_cliff", return_value=True) as mock:
|
|
||||||
assert install_tools._install_tool("git-cliff") is True
|
|
||||||
mock.assert_called_once()
|
|
||||||
|
|
||||||
def test_act_runner(self) -> None:
|
|
||||||
with patch.object(install_tools, "install_act_runner", return_value=True) as mock:
|
|
||||||
assert install_tools._install_tool("act_runner") is True
|
|
||||||
mock.assert_called_once()
|
|
||||||
|
|
||||||
def test_tea(self) -> None:
|
|
||||||
with patch.object(install_tools, "install_tea", return_value=True) as mock:
|
|
||||||
assert install_tools._install_tool("tea") is True
|
|
||||||
mock.assert_called_once()
|
|
||||||
|
|
||||||
def test_unknown_tool(self) -> None:
|
|
||||||
with pytest.raises(ClickException, match="Unknown tool"):
|
|
||||||
install_tools._install_tool("unknown")
|
|
||||||
|
|
||||||
|
|
||||||
class TestMain:
|
|
||||||
def test_list_status(self) -> None:
|
|
||||||
runner = CliRunner()
|
|
||||||
with patch.object(install_tools, "_is_installed", return_value=True):
|
|
||||||
result = runner.invoke(install_tools.main, ["--list"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "actionlint" in result.output
|
|
||||||
|
|
||||||
def test_install_all(self) -> None:
|
|
||||||
runner = CliRunner()
|
|
||||||
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
|
|
||||||
result = runner.invoke(install_tools.main, [])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert mock_install.call_count == 4
|
|
||||||
|
|
||||||
def test_install_specific_tool(self) -> None:
|
|
||||||
runner = CliRunner()
|
|
||||||
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
|
|
||||||
result = runner.invoke(install_tools.main, ["--tool", "actionlint"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
mock_install.assert_called_once_with("actionlint")
|
|
||||||
|
|
||||||
def test_install_multiple_specific_tools(self) -> None:
|
|
||||||
runner = CliRunner()
|
|
||||||
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
|
|
||||||
result = runner.invoke(install_tools.main, ["--tool", "git-cliff", "--tool", "tea"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert mock_install.call_count == 2
|
|
||||||
|
|
||||||
def test_install_failure(self) -> None:
|
|
||||||
runner = CliRunner()
|
|
||||||
with patch.object(install_tools, "_install_tool", side_effect=Exception("network error")):
|
|
||||||
result = runner.invoke(install_tools.main, ["--tool", "actionlint"])
|
|
||||||
assert result.exit_code != 0
|
|
||||||
|
|
||||||
def test_path_reminder(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""When TARGET_DIR is not in PATH, a reminder is printed."""
|
|
||||||
monkeypatch.setenv("PATH", "/usr/bin:/bin")
|
|
||||||
runner = CliRunner()
|
|
||||||
with patch.object(install_tools, "_install_tool", return_value=True):
|
|
||||||
result = runner.invoke(install_tools.main, [])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Add" in result.output
|
|
||||||
assert "PATH" in result.output
|
|
||||||
|
|
||||||
def test_no_path_reminder_when_in_path(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""When TARGET_DIR is in PATH, no reminder is printed."""
|
|
||||||
target_dir = str(install_tools.TARGET_DIR)
|
|
||||||
monkeypatch.setenv("PATH", f"/usr/bin:{target_dir}:/bin")
|
|
||||||
runner = CliRunner()
|
|
||||||
with patch.object(install_tools, "_install_tool", return_value=True):
|
|
||||||
result = runner.invoke(install_tools.main, [])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Add" not in result.output
|
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
import scripts.molecule_all as molecule_all
|
|
||||||
|
|
||||||
|
|
||||||
class TestRunMolecule:
|
|
||||||
def test_success(self) -> None:
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
|
||||||
rc = molecule_all._run_molecule("/bin/molecule", "default", Path("/tmp/role"), {})
|
|
||||||
assert rc == 0
|
|
||||||
mock_run.assert_called_once()
|
|
||||||
|
|
||||||
def test_failure(self) -> None:
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
rc = molecule_all._run_molecule("/bin/molecule", "default", Path("/tmp/role"), {})
|
|
||||||
assert rc == 1
|
|
||||||
|
|
||||||
def test_non_default_scenario_adds_flag(self) -> None:
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result) as mock_run:
|
|
||||||
molecule_all._run_molecule("/bin/molecule", "lifecycle", Path("/tmp/role"), {})
|
|
||||||
cmd = mock_run.call_args[0][0]
|
|
||||||
assert "-s" in cmd
|
|
||||||
assert "lifecycle" in cmd
|
|
||||||
|
|
||||||
|
|
||||||
class TestRunPlatform:
|
|
||||||
def test_all_scenarios_pass(self) -> None:
|
|
||||||
platform = {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": "/lib/systemd/systemd"}
|
|
||||||
with patch("scripts.molecule_all._run_molecule", return_value=0) as mock_run:
|
|
||||||
rc = molecule_all._run_platform("/bin/molecule", platform, Path("/tmp/role"), ["default", "lifecycle"], {})
|
|
||||||
assert rc == 0
|
|
||||||
assert mock_run.call_count == 2
|
|
||||||
|
|
||||||
def test_stops_on_failure(self) -> None:
|
|
||||||
platform = {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": "/lib/systemd/systemd"}
|
|
||||||
with patch("scripts.molecule_all._run_molecule", side_effect=[1, 0]) as mock_run:
|
|
||||||
rc = molecule_all._run_platform("/bin/molecule", platform, Path("/tmp/role"), ["default", "lifecycle"], {})
|
|
||||||
assert rc == 1
|
|
||||||
assert mock_run.call_count == 1
|
|
||||||
|
|
||||||
def test_sets_env_vars(self) -> None:
|
|
||||||
platform = {"name": "ubuntu-2204", "image": "ubuntu:22.04", "command": "/lib/systemd/systemd"}
|
|
||||||
captured_env: dict[str, str] = {}
|
|
||||||
|
|
||||||
def _capture_env(cmd, cwd, env):
|
|
||||||
captured_env.update(env)
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
|
|
||||||
|
|
||||||
with patch("subprocess.run", side_effect=_capture_env):
|
|
||||||
molecule_all._run_platform("/bin/molecule", platform, Path("/tmp/role"), ["default"], {"PATH": "/usr/bin"})
|
|
||||||
assert captured_env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2204"
|
|
||||||
assert captured_env["MOLECULE_PLATFORM_IMAGE"] == "ubuntu:22.04"
|
|
||||||
assert captured_env["MOLECULE_PLATFORM_COMMAND"] == "/lib/systemd/systemd"
|
|
||||||
|
|
||||||
def test_empty_command_removes_env(self) -> None:
|
|
||||||
platform = {"name": "custom", "image": "custom:latest", "command": ""}
|
|
||||||
captured_env: dict[str, str] = {}
|
|
||||||
|
|
||||||
def _capture_env(cmd, cwd, env):
|
|
||||||
captured_env.update(env)
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
return subprocess.CompletedProcess(args=cmd, returncode=0, stdout="", stderr="")
|
|
||||||
|
|
||||||
with patch("subprocess.run", side_effect=_capture_env):
|
|
||||||
molecule_all._run_platform(
|
|
||||||
"/bin/molecule", platform, Path("/tmp/role"), ["default"], {"MOLECULE_PLATFORM_COMMAND": "old"}
|
|
||||||
)
|
|
||||||
assert "MOLECULE_PLATFORM_COMMAND" not in captured_env
|
|
||||||
|
|
||||||
|
|
||||||
class TestMain:
|
|
||||||
def test_molecule_not_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(molecule_all.main, ["--bin", "nonexistent/bin"])
|
|
||||||
assert result.exit_code != 0
|
|
||||||
assert "molecule not found" in result.output
|
|
||||||
|
|
||||||
def test_role_dir_not_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
bin_dir = tmp_path / ".venv" / "bin"
|
|
||||||
bin_dir.mkdir(parents=True)
|
|
||||||
(bin_dir / "molecule").touch()
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(molecule_all.main, ["--bin", str(bin_dir)])
|
|
||||||
assert result.exit_code != 0
|
|
||||||
assert "Role directory not found" in result.output
|
|
||||||
|
|
||||||
def test_all_pass(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
bin_dir = tmp_path / ".venv" / "bin"
|
|
||||||
bin_dir.mkdir(parents=True)
|
|
||||||
(bin_dir / "molecule").touch()
|
|
||||||
(tmp_path / "ansible" / "roles" / "gitea-runner").mkdir(parents=True)
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
with patch("scripts.molecule_all._run_platform", return_value=0):
|
|
||||||
result = runner.invoke(molecule_all.main, ["--bin", str(bin_dir)])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "All molecule scenarios passed" in result.output
|
|
||||||
|
|
||||||
def test_platform_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
bin_dir = tmp_path / ".venv" / "bin"
|
|
||||||
bin_dir.mkdir(parents=True)
|
|
||||||
(bin_dir / "molecule").touch()
|
|
||||||
(tmp_path / "ansible" / "roles" / "gitea-runner").mkdir(parents=True)
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
with patch("scripts.molecule_all._run_platform", return_value=1):
|
|
||||||
result = runner.invoke(molecule_all.main, ["--bin", str(bin_dir)])
|
|
||||||
assert result.exit_code != 0
|
|
||||||
@@ -1,428 +0,0 @@
|
|||||||
"""Unit tests for scripts/ci/molecule_ci_guard.py."""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess # nosec B404
|
|
||||||
import time
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import requests
|
|
||||||
|
|
||||||
from scripts.ci.molecule_ci_guard import (
|
|
||||||
any_other_runner_failed,
|
|
||||||
build_env_for_pair,
|
|
||||||
build_molecule_cmd,
|
|
||||||
cli,
|
|
||||||
get_running_jobs,
|
|
||||||
poll_for_other_failures,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetRunningJobs:
|
|
||||||
def test_returns_jobs(self) -> None:
|
|
||||||
with patch("scripts.ci.molecule_ci_guard.requests.get") as mock_get:
|
|
||||||
mock_response = MagicMock()
|
|
||||||
mock_response.json.return_value = {
|
|
||||||
"jobs": [
|
|
||||||
{"name": "molecule-tests (0)", "conclusion": "success"},
|
|
||||||
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
mock_response.raise_for_status.return_value = None
|
|
||||||
mock_get.return_value = mock_response
|
|
||||||
|
|
||||||
jobs = get_running_jobs("https://gitea.example", "owner", "repo", "token", 123)
|
|
||||||
assert len(jobs) == 2
|
|
||||||
mock_get.assert_called_once()
|
|
||||||
|
|
||||||
def test_raises_on_request_error(self) -> None:
|
|
||||||
with patch("scripts.ci.molecule_ci_guard.requests.get") as mock_get:
|
|
||||||
mock_get.side_effect = requests.RequestException("boom")
|
|
||||||
with pytest.raises(requests.RequestException):
|
|
||||||
get_running_jobs("https://gitea.example", "owner", "repo", "token", 123)
|
|
||||||
|
|
||||||
|
|
||||||
class TestAnyOtherRunnerFailed:
|
|
||||||
def test_detects_other_failure(self) -> None:
|
|
||||||
jobs = [
|
|
||||||
{"name": "molecule-tests (0)", "conclusion": "success"},
|
|
||||||
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
|
||||||
{"name": "molecule-tests (2)", "conclusion": "running"},
|
|
||||||
]
|
|
||||||
assert any_other_runner_failed(jobs, "molecule-tests", 0) is True
|
|
||||||
|
|
||||||
def test_ignores_current_runner(self) -> None:
|
|
||||||
jobs = [
|
|
||||||
{"name": "molecule-tests (0)", "conclusion": "failure"},
|
|
||||||
{"name": "molecule-tests (1)", "conclusion": "success"},
|
|
||||||
]
|
|
||||||
assert any_other_runner_failed(jobs, "molecule-tests", 0) is False
|
|
||||||
|
|
||||||
def test_ignores_non_molecule_jobs(self) -> None:
|
|
||||||
jobs = [
|
|
||||||
{"name": "quality", "conclusion": "failure"},
|
|
||||||
{"name": "molecule-tests (1)", "conclusion": "success"},
|
|
||||||
]
|
|
||||||
assert any_other_runner_failed(jobs, "molecule-tests", 0) is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestBuildMoleculeCmd:
|
|
||||||
def test_default_scenario(self) -> None:
|
|
||||||
assert build_molecule_cmd("default") == ["molecule", "test"]
|
|
||||||
|
|
||||||
def test_named_scenario(self) -> None:
|
|
||||||
assert build_molecule_cmd("lifecycle") == ["molecule", "test", "-s", "lifecycle"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestBuildEnvForPair:
|
|
||||||
def test_with_command(self) -> None:
|
|
||||||
env = build_env_for_pair("default|ubuntu-2204|img:latest|/lib/systemd/systemd", {})
|
|
||||||
assert env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2204"
|
|
||||||
assert env["MOLECULE_PLATFORM_IMAGE"] == "img:latest"
|
|
||||||
assert env["MOLECULE_PLATFORM_COMMAND"] == "/lib/systemd/systemd"
|
|
||||||
assert env["ANSIBLE_ALLOW_BROKEN_CONDITIONALS"] == "true"
|
|
||||||
|
|
||||||
def test_without_command(self) -> None:
|
|
||||||
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {})
|
|
||||||
assert env["MOLECULE_PLATFORM_NAME"] == "ubuntu-2204"
|
|
||||||
assert "MOLECULE_PLATFORM_COMMAND" not in env
|
|
||||||
|
|
||||||
def test_without_command_removes_existing(self) -> None:
|
|
||||||
env = build_env_for_pair("default|ubuntu-2204|img:latest|", {"MOLECULE_PLATFORM_COMMAND": "old"})
|
|
||||||
assert "MOLECULE_PLATFORM_COMMAND" not in env
|
|
||||||
|
|
||||||
|
|
||||||
class TestPollForOtherFailures:
|
|
||||||
def test_sets_failed_event_when_other_runner_fails(self) -> None:
|
|
||||||
stop_event = MagicMock()
|
|
||||||
failed_event = MagicMock()
|
|
||||||
|
|
||||||
def side_effect(*args, **kwargs):
|
|
||||||
if stop_event.wait.call_count < 1:
|
|
||||||
return [
|
|
||||||
{"name": "molecule-tests (0)", "conclusion": "success"},
|
|
||||||
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
|
||||||
]
|
|
||||||
return []
|
|
||||||
|
|
||||||
with patch("scripts.ci.molecule_ci_guard.get_running_jobs") as mock_get_jobs:
|
|
||||||
mock_get_jobs.side_effect = side_effect
|
|
||||||
stop_event.is_set.side_effect = [False, False]
|
|
||||||
stop_event.wait.return_value = True
|
|
||||||
|
|
||||||
poll_for_other_failures(
|
|
||||||
"https://gitea.example",
|
|
||||||
"owner",
|
|
||||||
"repo",
|
|
||||||
"token",
|
|
||||||
123,
|
|
||||||
"molecule-tests",
|
|
||||||
0,
|
|
||||||
stop_event,
|
|
||||||
failed_event,
|
|
||||||
)
|
|
||||||
|
|
||||||
failed_event.set.assert_called_once()
|
|
||||||
|
|
||||||
def test_poll_warns_on_api_error(self) -> None:
|
|
||||||
stop_event = MagicMock()
|
|
||||||
failed_event = MagicMock()
|
|
||||||
|
|
||||||
with patch("scripts.ci.molecule_ci_guard.get_running_jobs") as mock_get_jobs:
|
|
||||||
mock_get_jobs.side_effect = requests.RequestException("boom")
|
|
||||||
stop_event.is_set.side_effect = [False, True]
|
|
||||||
stop_event.wait.return_value = True
|
|
||||||
|
|
||||||
poll_for_other_failures(
|
|
||||||
"https://gitea.example",
|
|
||||||
"owner",
|
|
||||||
"repo",
|
|
||||||
"token",
|
|
||||||
123,
|
|
||||||
"molecule-tests",
|
|
||||||
0,
|
|
||||||
stop_event,
|
|
||||||
failed_event,
|
|
||||||
)
|
|
||||||
|
|
||||||
failed_event.set.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
class TestCli:
|
|
||||||
def test_all_pass(self) -> None:
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
|
||||||
patch("time.sleep"),
|
|
||||||
):
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.poll.return_value = 0
|
|
||||||
proc.returncode = 0
|
|
||||||
mock_popen.return_value = proc
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "All molecule tests passed" in result.output
|
|
||||||
|
|
||||||
def test_failure_exits_nonzero(self) -> None:
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
|
||||||
patch("time.sleep"),
|
|
||||||
):
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.poll.return_value = 1
|
|
||||||
proc.returncode = 1
|
|
||||||
mock_popen.return_value = proc
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "FAILED" in result.output
|
|
||||||
|
|
||||||
def test_exits_before_starting_when_already_failed(self) -> None:
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.dict(
|
|
||||||
os.environ,
|
|
||||||
{
|
|
||||||
"GITEA_URL": "https://gitea.example",
|
|
||||||
"REPO_TOKEN": "token",
|
|
||||||
"RUN_ID": "123",
|
|
||||||
"JOB_NAME": "molecule-tests",
|
|
||||||
"MATRIX_INDEX": "0",
|
|
||||||
"GITEA_REPOSITORY": "oblachno-oss/grm",
|
|
||||||
"PATH": os.environ.get("PATH", ""),
|
|
||||||
},
|
|
||||||
clear=True,
|
|
||||||
),
|
|
||||||
patch("scripts.ci.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
|
||||||
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
|
||||||
patch("scripts.ci.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
|
|
||||||
patch("time.sleep"),
|
|
||||||
):
|
|
||||||
mock_get_jobs.return_value = [
|
|
||||||
{"name": "molecule-tests (0)", "conclusion": "running"},
|
|
||||||
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
|
||||||
]
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.poll.return_value = 0
|
|
||||||
proc.returncode = 0
|
|
||||||
mock_popen.return_value = proc
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "Another molecule runner failed" in result.output
|
|
||||||
|
|
||||||
def test_keyboard_interrupt_kills_process(self) -> None:
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
|
||||||
patch("time.sleep", side_effect=KeyboardInterrupt),
|
|
||||||
patch("os.killpg") as mock_killpg,
|
|
||||||
patch("os.getpgid") as mock_getpgid,
|
|
||||||
):
|
|
||||||
mock_getpgid.return_value = 123
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.poll.return_value = None
|
|
||||||
proc.wait.return_value = 0
|
|
||||||
mock_popen.return_value = proc
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
mock_killpg.assert_called()
|
|
||||||
|
|
||||||
def test_exits_when_other_runner_fails(self) -> None:
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
real_sleep = time.sleep
|
|
||||||
call_count = [0]
|
|
||||||
|
|
||||||
def get_jobs_side_effect(*args, **kwargs):
|
|
||||||
call_count[0] += 1
|
|
||||||
if call_count[0] < 2:
|
|
||||||
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
|
|
||||||
return [
|
|
||||||
{"name": "molecule-tests (0)", "conclusion": "running"},
|
|
||||||
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
|
||||||
]
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.dict(
|
|
||||||
os.environ,
|
|
||||||
{
|
|
||||||
"GITEA_URL": "https://gitea.example",
|
|
||||||
"REPO_TOKEN": "token",
|
|
||||||
"RUN_ID": "123",
|
|
||||||
"JOB_NAME": "molecule-tests",
|
|
||||||
"MATRIX_INDEX": "0",
|
|
||||||
"GITEA_REPOSITORY": "oblachno-oss/grm",
|
|
||||||
"PATH": os.environ.get("PATH", ""),
|
|
||||||
},
|
|
||||||
clear=True,
|
|
||||||
),
|
|
||||||
patch("scripts.ci.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
|
||||||
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
|
||||||
patch("scripts.ci.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
|
||||||
patch("os.killpg") as mock_killpg,
|
|
||||||
patch("os.getpgid") as mock_getpgid,
|
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
|
||||||
):
|
|
||||||
mock_getpgid.return_value = 123
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.poll.return_value = None
|
|
||||||
proc.wait.return_value = 0
|
|
||||||
mock_popen.return_value = proc
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
mock_killpg.assert_called()
|
|
||||||
|
|
||||||
def test_default_owner_repo_fallback(self) -> None:
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
real_sleep = time.sleep
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.dict(
|
|
||||||
os.environ,
|
|
||||||
{
|
|
||||||
"GITEA_URL": "https://gitea.example",
|
|
||||||
"REPO_TOKEN": "token",
|
|
||||||
"RUN_ID": "123",
|
|
||||||
"JOB_NAME": "molecule-tests",
|
|
||||||
"MATRIX_INDEX": "0",
|
|
||||||
"GITEA_REPOSITORY": "invalid",
|
|
||||||
"PATH": os.environ.get("PATH", ""),
|
|
||||||
},
|
|
||||||
clear=True,
|
|
||||||
),
|
|
||||||
patch("scripts.ci.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
|
||||||
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
|
||||||
patch("scripts.ci.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
|
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.05)),
|
|
||||||
):
|
|
||||||
mock_get_jobs.return_value = [{"name": "molecule-tests (1)", "conclusion": "success"}]
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.poll.return_value = 0
|
|
||||||
proc.returncode = 0
|
|
||||||
mock_popen.return_value = proc
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
|
|
||||||
def test_exits_when_other_runner_fails_with_process_lookup_error(self) -> None:
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
real_sleep = time.sleep
|
|
||||||
call_count = [0]
|
|
||||||
|
|
||||||
def get_jobs_side_effect(*args, **kwargs):
|
|
||||||
call_count[0] += 1
|
|
||||||
if call_count[0] < 2:
|
|
||||||
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
|
|
||||||
return [
|
|
||||||
{"name": "molecule-tests (0)", "conclusion": "running"},
|
|
||||||
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
|
||||||
]
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.dict(
|
|
||||||
os.environ,
|
|
||||||
{
|
|
||||||
"GITEA_URL": "https://gitea.example",
|
|
||||||
"REPO_TOKEN": "token",
|
|
||||||
"RUN_ID": "123",
|
|
||||||
"JOB_NAME": "molecule-tests",
|
|
||||||
"MATRIX_INDEX": "0",
|
|
||||||
"GITEA_REPOSITORY": "oblachno-oss/grm",
|
|
||||||
"PATH": os.environ.get("PATH", ""),
|
|
||||||
},
|
|
||||||
clear=True,
|
|
||||||
),
|
|
||||||
patch("scripts.ci.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
|
||||||
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
|
||||||
patch("scripts.ci.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
|
||||||
patch("os.killpg") as mock_killpg,
|
|
||||||
patch("os.getpgid") as mock_getpgid,
|
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
|
||||||
):
|
|
||||||
mock_getpgid.return_value = 123
|
|
||||||
mock_killpg.side_effect = ProcessLookupError("no such process")
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.poll.return_value = None
|
|
||||||
proc.wait.return_value = 0
|
|
||||||
mock_popen.return_value = proc
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
|
|
||||||
def test_exits_when_other_runner_fails_with_timeout(self) -> None:
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
real_sleep = time.sleep
|
|
||||||
call_count = [0]
|
|
||||||
|
|
||||||
def get_jobs_side_effect(*args, **kwargs):
|
|
||||||
call_count[0] += 1
|
|
||||||
if call_count[0] < 2:
|
|
||||||
return [{"name": "molecule-tests (1)", "conclusion": "running"}]
|
|
||||||
return [
|
|
||||||
{"name": "molecule-tests (0)", "conclusion": "running"},
|
|
||||||
{"name": "molecule-tests (1)", "conclusion": "failure"},
|
|
||||||
]
|
|
||||||
|
|
||||||
with (
|
|
||||||
patch.dict(
|
|
||||||
os.environ,
|
|
||||||
{
|
|
||||||
"GITEA_URL": "https://gitea.example",
|
|
||||||
"REPO_TOKEN": "token",
|
|
||||||
"RUN_ID": "123",
|
|
||||||
"JOB_NAME": "molecule-tests",
|
|
||||||
"MATRIX_INDEX": "0",
|
|
||||||
"GITEA_REPOSITORY": "oblachno-oss/grm",
|
|
||||||
"PATH": os.environ.get("PATH", ""),
|
|
||||||
},
|
|
||||||
clear=True,
|
|
||||||
),
|
|
||||||
patch("scripts.ci.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
|
||||||
patch("scripts.ci.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
|
||||||
patch("scripts.ci.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
|
||||||
patch("os.killpg") as mock_killpg,
|
|
||||||
patch("os.getpgid") as mock_getpgid,
|
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
|
||||||
):
|
|
||||||
mock_getpgid.return_value = 123
|
|
||||||
mock_killpg.side_effect = [None, ProcessLookupError("no such process")]
|
|
||||||
proc = MagicMock()
|
|
||||||
proc.poll.return_value = None
|
|
||||||
proc.wait.side_effect = [subprocess.TimeoutExpired("cmd", 10)]
|
|
||||||
mock_popen.return_value = proc
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_main_module_block() -> None:
|
|
||||||
import scripts.ci.molecule_ci_guard as mg
|
|
||||||
|
|
||||||
with open(mg.__file__) as f:
|
|
||||||
source = f.read()
|
|
||||||
source = source.replace('if __name__ == "__main__":\n cli()\n', "")
|
|
||||||
namespace = dict(mg.__dict__)
|
|
||||||
exec(compile(source, mg.__file__, "exec"), namespace)
|
|
||||||
assert callable(namespace["cli"])
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
"""Unit tests for scripts/ci/notify_failure.py."""
|
|
||||||
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
from scripts.ci.notify_failure import main
|
|
||||||
from scripts.gitea_cli import TeaCLIError
|
|
||||||
|
|
||||||
|
|
||||||
class TestNotifyFailure:
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
|
||||||
@patch("scripts.ci.notify_failure.TeaCLI")
|
|
||||||
def test_creates_issue_with_tea(self, mock_tea_cls: MagicMock) -> None:
|
|
||||||
mock_tea = MagicMock()
|
|
||||||
mock_tea.list_labels.return_value = [{"id": 5, "name": "bug"}]
|
|
||||||
mock_tea.create_issue.return_value = {"index": 42, "title": "test"}
|
|
||||||
mock_tea_cls.return_value = mock_tea
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(
|
|
||||||
main,
|
|
||||||
[
|
|
||||||
"--repo",
|
|
||||||
"owner/repo",
|
|
||||||
"--run-id",
|
|
||||||
"123",
|
|
||||||
"--workflow",
|
|
||||||
"release",
|
|
||||||
"--commit",
|
|
||||||
"abc123def456",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "issue #42" in result.output
|
|
||||||
mock_tea.create_issue.assert_called_once()
|
|
||||||
mock_tea.add_label.assert_called_once_with("owner/repo", 42, ["bug"])
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
|
||||||
@patch("scripts.ci.notify_failure.TeaCLI")
|
|
||||||
def test_tea_creates_issue_without_bug_label(self, mock_tea_cls: MagicMock) -> None:
|
|
||||||
mock_tea = MagicMock()
|
|
||||||
mock_tea.list_labels.return_value = [{"id": 1, "name": "enhancement"}]
|
|
||||||
mock_tea.create_issue.return_value = {"index": 43, "title": "test"}
|
|
||||||
mock_tea_cls.return_value = mock_tea
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(
|
|
||||||
main,
|
|
||||||
["--repo", "owner/repo", "--run-id", "124", "--workflow", "publish", "--commit", "def789"],
|
|
||||||
)
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "issue #43" in result.output
|
|
||||||
mock_tea.add_label.assert_not_called()
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
|
||||||
@patch("scripts.ci.notify_failure.TeaCLI")
|
|
||||||
def test_tea_error_raises(self, mock_tea_cls: MagicMock) -> None:
|
|
||||||
"""When tea fails, the workflow fails — no fallback."""
|
|
||||||
mock_tea = MagicMock()
|
|
||||||
mock_tea.list_labels.side_effect = TeaCLIError("network error")
|
|
||||||
mock_tea.create_issue.side_effect = TeaCLIError("network error")
|
|
||||||
mock_tea_cls.return_value = mock_tea
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(
|
|
||||||
main,
|
|
||||||
["--repo", "owner/repo", "--run-id", "125", "--workflow", "release", "--commit", "abc"],
|
|
||||||
)
|
|
||||||
assert result.exit_code != 0
|
|
||||||
assert "tea" in result.output.lower()
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
|
||||||
@patch("scripts.ci.notify_failure.TeaCLI")
|
|
||||||
def test_tea_list_labels_error_continues_without_labels(self, mock_tea_cls: MagicMock) -> None:
|
|
||||||
"""If listing labels fails via tea, issue is still created without labels."""
|
|
||||||
mock_tea = MagicMock()
|
|
||||||
mock_tea.list_labels.side_effect = TeaCLIError("network error")
|
|
||||||
mock_tea.create_issue.return_value = {"index": 50, "title": "test"}
|
|
||||||
mock_tea_cls.return_value = mock_tea
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(
|
|
||||||
main,
|
|
||||||
["--repo", "owner/repo", "--run-id", "128", "--workflow", "release", "--commit", "abc"],
|
|
||||||
)
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "issue #50" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
|
||||||
@patch("scripts.ci.notify_failure.TeaCLI")
|
|
||||||
def test_tea_add_label_error_is_ignored(self, mock_tea_cls: MagicMock) -> None:
|
|
||||||
"""If adding label fails via tea, issue is still reported as created."""
|
|
||||||
mock_tea = MagicMock()
|
|
||||||
mock_tea.list_labels.return_value = [{"id": 5, "name": "bug"}]
|
|
||||||
mock_tea.create_issue.return_value = {"index": 51, "title": "test"}
|
|
||||||
mock_tea.add_label.side_effect = TeaCLIError("permission denied")
|
|
||||||
mock_tea_cls.return_value = mock_tea
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(
|
|
||||||
main,
|
|
||||||
["--repo", "owner/repo", "--run-id", "129", "--workflow", "release", "--commit", "abc"],
|
|
||||||
)
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "issue #51" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
|
|
||||||
def test_missing_token_exits(self) -> None:
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(
|
|
||||||
main,
|
|
||||||
["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc"],
|
|
||||||
)
|
|
||||||
assert result.exit_code != 0
|
|
||||||
assert "REPO_TOKEN" in result.output
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
"""Unit tests for scripts/ci/platforms.py."""
|
|
||||||
|
|
||||||
from scripts.ci.platforms import PLATFORMS
|
|
||||||
|
|
||||||
|
|
||||||
class TestPlatforms:
|
|
||||||
def test_platforms_not_empty(self) -> None:
|
|
||||||
assert len(PLATFORMS) >= 4
|
|
||||||
|
|
||||||
def test_each_platform_has_required_keys(self) -> None:
|
|
||||||
for p in PLATFORMS:
|
|
||||||
assert "name" in p
|
|
||||||
assert "image" in p
|
|
||||||
assert "command" in p
|
|
||||||
|
|
||||||
def test_platform_names_unique(self) -> None:
|
|
||||||
names = [p["name"] for p in PLATFORMS]
|
|
||||||
assert len(names) == len(set(names))
|
|
||||||
|
|
||||||
def test_known_platforms_present(self) -> None:
|
|
||||||
names = {p["name"] for p in PLATFORMS}
|
|
||||||
assert "ubuntu-2204" in names
|
|
||||||
assert "ubuntu-2404" in names
|
|
||||||
assert "debian-12" in names
|
|
||||||
assert "archlinux" in names
|
|
||||||
@@ -1,328 +0,0 @@
|
|||||||
"""Unit tests for scripts/ci/post_merge.py."""
|
|
||||||
|
|
||||||
import http
|
|
||||||
import subprocess
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import click
|
|
||||||
import pytest
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
from gitea_runner_manager.exceptions import APIError
|
|
||||||
from scripts.ci.post_merge import (
|
|
||||||
_get_git_commit_message,
|
|
||||||
_get_git_commit_sha,
|
|
||||||
build_comment,
|
|
||||||
extract_conventional_msg,
|
|
||||||
extract_task_id,
|
|
||||||
main,
|
|
||||||
resolve_task_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestExtractTaskId:
|
|
||||||
def test_extracts_from_first_line(self) -> None:
|
|
||||||
assert extract_task_id("GRM-19: fix: resolve bug\n\nBody") == "GRM-19"
|
|
||||||
|
|
||||||
def test_returns_empty_when_missing(self) -> None:
|
|
||||||
assert extract_task_id("fix: resolve bug") == ""
|
|
||||||
|
|
||||||
|
|
||||||
class TestExtractConventionalMsg:
|
|
||||||
def test_strips_colon_prefix(self) -> None:
|
|
||||||
"""Legacy format: GRM-N: <message>"""
|
|
||||||
assert extract_conventional_msg("GRM-19: fix: resolve bug") == "fix: resolve bug"
|
|
||||||
|
|
||||||
def test_strips_space_prefix(self) -> None:
|
|
||||||
"""Current format: GRM-N <message>"""
|
|
||||||
assert extract_conventional_msg("GRM-19 fix: resolve bug") == "fix: resolve bug"
|
|
||||||
|
|
||||||
def test_returns_unchanged_without_prefix(self) -> None:
|
|
||||||
assert extract_conventional_msg("fix: resolve bug") == "fix: resolve bug"
|
|
||||||
|
|
||||||
|
|
||||||
class TestBuildComment:
|
|
||||||
def test_html_format(self) -> None:
|
|
||||||
html = build_comment("GRM-19", "fix: bug", "abc123")
|
|
||||||
assert "<strong>GRM-19</strong>" in html
|
|
||||||
assert "fix: bug" in html
|
|
||||||
assert "<code>abc123</code>" in html
|
|
||||||
|
|
||||||
|
|
||||||
class TestResolveTaskId:
|
|
||||||
def test_found(self) -> None:
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.list_project_tasks.return_value = [
|
|
||||||
{"id": 42, "identifier": "GRM-19"},
|
|
||||||
]
|
|
||||||
assert resolve_task_id(mock_client, "GRM-19") == 42
|
|
||||||
mock_client.list_project_tasks.assert_called_once()
|
|
||||||
|
|
||||||
def test_not_found_raises(self) -> None:
|
|
||||||
"""Missing Vikunja task is a fatal error — every PR must have a task."""
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.list_project_tasks.return_value = []
|
|
||||||
with pytest.raises(click.ClickException, match="Could not find"):
|
|
||||||
resolve_task_id(mock_client, "GRM-99")
|
|
||||||
|
|
||||||
def test_found_on_second_page(self) -> None:
|
|
||||||
"""Task is on page 2 when project has more than 50 tasks."""
|
|
||||||
mock_client = MagicMock()
|
|
||||||
page1 = [{"id": i, "identifier": f"GRM-{i}"} for i in range(50)]
|
|
||||||
page2 = [{"id": 100, "identifier": "GRM-99"}]
|
|
||||||
mock_client.list_project_tasks.side_effect = [page1, page2]
|
|
||||||
assert resolve_task_id(mock_client, "GRM-99") == 100
|
|
||||||
assert mock_client.list_project_tasks.call_count == 2
|
|
||||||
|
|
||||||
def test_stops_when_page_is_partial(self) -> None:
|
|
||||||
"""Stops paginating when a page has fewer than DEFAULT_PER_PAGE results."""
|
|
||||||
mock_client = MagicMock()
|
|
||||||
page1 = [{"id": i, "identifier": f"GRM-{i}"} for i in range(10)]
|
|
||||||
mock_client.list_project_tasks.return_value = page1
|
|
||||||
with pytest.raises(click.ClickException, match="Could not find"):
|
|
||||||
resolve_task_id(mock_client, "GRM-99")
|
|
||||||
assert mock_client.list_project_tasks.call_count == 1
|
|
||||||
|
|
||||||
def test_http_error_propagates(self) -> None:
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.list_project_tasks.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
|
|
||||||
with pytest.raises(APIError):
|
|
||||||
resolve_task_id(mock_client, "GRM-19")
|
|
||||||
|
|
||||||
|
|
||||||
class TestMain:
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
||||||
@patch("scripts.ci.post_merge.VikunjaClient")
|
|
||||||
def test_full_flow(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.list_project_tasks.return_value = [
|
|
||||||
{"id": 267, "identifier": "GRM-20"},
|
|
||||||
]
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(
|
|
||||||
main,
|
|
||||||
["GRM-20: fix: resolve bug\n\nBody", "--commit-sha", "abc123"],
|
|
||||||
)
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "updated and marked done" in result.output
|
|
||||||
mock_client.post_comment.assert_called_once()
|
|
||||||
mock_client.update_task.assert_called_once_with(267, done=True)
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
||||||
@patch("scripts.ci.post_merge.VikunjaClient")
|
|
||||||
def test_no_commit_sha(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.list_project_tasks.return_value = [
|
|
||||||
{"id": 267, "identifier": "GRM-20"},
|
|
||||||
]
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["GRM-20: fix: resolve bug"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
mock_client.post_comment.assert_called_once()
|
|
||||||
args, _ = mock_client.post_comment.call_args
|
|
||||||
assert "unknown" in args[1]
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": ""}, clear=True)
|
|
||||||
def test_missing_token_exits(self) -> None:
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["GRM-20: fix: bug"])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "VIKUNJA_TOKEN" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
||||||
def test_no_task_id_non_release_warns(self) -> None:
|
|
||||||
"""Non-release commits without GRM-N prefix should warn, not fail."""
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["fix: resolve bug"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "No task ID" in result.output
|
|
||||||
assert "Skipping" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
||||||
def test_release_commit_without_task_id_skips(self) -> None:
|
|
||||||
"""Release commits without GRM-N prefix should skip gracefully."""
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["release: v0.3.2"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "skipping" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
||||||
def test_revert_commit_skips(self) -> None:
|
|
||||||
"""Revert commits without GRM-N prefix should skip gracefully."""
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["revert: remove v0.6.0 release"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Infrastructure commit" in result.output
|
|
||||||
assert "skipping" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
||||||
def test_merge_commit_skips(self) -> None:
|
|
||||||
"""Merge commits without GRM-N prefix should skip gracefully."""
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["Merge pull request #42"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Infrastructure commit" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
||||||
@patch("scripts.ci.post_merge.VikunjaClient")
|
|
||||||
def test_resolve_failure_fails(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
"""Missing Vikunja task is a fatal error — every PR must have a task."""
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.list_project_tasks.return_value = []
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["GRM-20: fix: bug"])
|
|
||||||
assert result.exit_code != 0
|
|
||||||
assert "Could not find" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
||||||
@patch("scripts.ci.post_merge.VikunjaClient")
|
|
||||||
def test_post_comment_failure_warns(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
"""Vikunja API errors should warn, not fail — the merge already succeeded."""
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.list_project_tasks.return_value = [
|
|
||||||
{"id": 267, "identifier": "GRM-20"},
|
|
||||||
]
|
|
||||||
mock_client.post_comment.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["GRM-20: fix: bug"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Warning" in result.output
|
|
||||||
assert "not updated" in result.output.lower()
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
||||||
@patch("scripts.ci.post_merge.VikunjaClient")
|
|
||||||
def test_mark_done_failure_warns(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
"""Vikunja API errors should warn, not fail — the merge already succeeded."""
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.list_project_tasks.return_value = [
|
|
||||||
{"id": 267, "identifier": "GRM-20"},
|
|
||||||
]
|
|
||||||
mock_client.post_comment.return_value = None
|
|
||||||
mock_client.update_task.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["GRM-20: fix: bug"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Warning" in result.output
|
|
||||||
assert "not updated" in result.output.lower()
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetGitCommitMessage:
|
|
||||||
def test_success(self) -> None:
|
|
||||||
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="GRM-20: fix: bug\n", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
assert _get_git_commit_message() == "GRM-20: fix: bug"
|
|
||||||
|
|
||||||
def test_failure(self) -> None:
|
|
||||||
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="git error")
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
with pytest.raises(click.ClickException, match="git log failed"):
|
|
||||||
_get_git_commit_message()
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetGitCommitSha:
|
|
||||||
def test_success(self) -> None:
|
|
||||||
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="abc123\n", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
assert _get_git_commit_sha() == "abc123"
|
|
||||||
|
|
||||||
def test_failure(self) -> None:
|
|
||||||
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="git error")
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
with pytest.raises(click.ClickException, match="git rev-parse failed"):
|
|
||||||
_get_git_commit_sha()
|
|
||||||
|
|
||||||
|
|
||||||
class TestFromGit:
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
||||||
@patch("scripts.ci.post_merge.VikunjaClient")
|
|
||||||
@patch("scripts.ci.post_merge._get_git_commit_sha", return_value="abc123")
|
|
||||||
@patch("scripts.ci.post_merge._get_git_commit_message", return_value="GRM-20: fix: bug")
|
|
||||||
def test_from_git(self, mock_msg: MagicMock, mock_sha: MagicMock, mock_client_cls: MagicMock) -> None:
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.list_project_tasks.return_value = [
|
|
||||||
{"id": 267, "identifier": "GRM-20"},
|
|
||||||
]
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--from-git"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "updated and marked done" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
||||||
@patch("scripts.ci.post_merge.VikunjaClient")
|
|
||||||
@patch("scripts.ci.post_merge._get_git_commit_sha", return_value="abc123")
|
|
||||||
@patch("scripts.ci.post_merge._get_git_commit_message", return_value="GRM-20: fix: bug")
|
|
||||||
def test_from_git_with_explicit_sha(
|
|
||||||
self, mock_msg: MagicMock, mock_sha: MagicMock, mock_client_cls: MagicMock
|
|
||||||
) -> None:
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.list_project_tasks.return_value = [
|
|
||||||
{"id": 267, "identifier": "GRM-20"},
|
|
||||||
]
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--from-git", "--commit-sha", "explicit_sha"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
||||||
def test_no_msg_and_no_from_git(self) -> None:
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, [])
|
|
||||||
assert result.exit_code != 0
|
|
||||||
assert "commit_msg" in result.output
|
|
||||||
|
|
||||||
|
|
||||||
class TestGitSha:
|
|
||||||
"""Tests for the --git-sha option (race condition fix)."""
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
||||||
@patch("scripts.ci.post_merge.VikunjaClient")
|
|
||||||
def test_git_sha_reads_commit_from_specific_sha(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
"""--git-sha reads commit message from a specific SHA, not HEAD."""
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.list_project_tasks.return_value = [
|
|
||||||
{"id": 267, "identifier": "GRM-20"},
|
|
||||||
]
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="GRM-20: fix: bug\n", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--git-sha", "abc123"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "updated and marked done" in result.output
|
|
||||||
mock_client.post_comment.assert_called_once()
|
|
||||||
# Verify the SHA was passed to the comment
|
|
||||||
args, _ = mock_client.post_comment.call_args
|
|
||||||
assert "abc123" in args[1]
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
||||||
def test_git_sha_failure_raises(self) -> None:
|
|
||||||
"""--git-sha with invalid SHA should raise."""
|
|
||||||
mock_result = subprocess.CompletedProcess(args=[], returncode=1, stdout="", stderr="bad sha")
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--git-sha", "badsha"])
|
|
||||||
assert result.exit_code != 0
|
|
||||||
assert "git log failed" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
|
||||||
@patch("scripts.ci.post_merge.VikunjaClient")
|
|
||||||
def test_git_sha_with_explicit_commit_sha(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
"""--git-sha with --commit-sha uses the explicit SHA for the comment."""
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client.list_project_tasks.return_value = [
|
|
||||||
{"id": 267, "identifier": "GRM-20"},
|
|
||||||
]
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
mock_result = subprocess.CompletedProcess(args=[], returncode=0, stdout="GRM-20: fix: bug\n", stderr="")
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--git-sha", "abc123", "--commit-sha", "explicit_sha"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
args, _ = mock_client.post_comment.call_args
|
|
||||||
assert "explicit_sha" in args[1]
|
|
||||||
@@ -1,691 +0,0 @@
|
|||||||
"""Unit tests for scripts/ci/pr_review.py."""
|
|
||||||
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
from gitea_runner_manager.exceptions import APIError
|
|
||||||
from scripts.ci.pr_review import (
|
|
||||||
ReviewResult,
|
|
||||||
build_review_body,
|
|
||||||
check_architecture_compliance,
|
|
||||||
check_best_practices,
|
|
||||||
check_commit_conventions,
|
|
||||||
check_documentation,
|
|
||||||
check_function_length,
|
|
||||||
check_i18n,
|
|
||||||
check_resource_management,
|
|
||||||
check_security,
|
|
||||||
check_test_coverage,
|
|
||||||
is_python_file,
|
|
||||||
is_workflow_only,
|
|
||||||
main,
|
|
||||||
post_review,
|
|
||||||
run_review,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestIsPythonFile:
|
|
||||||
def test_python_file_in_src(self) -> None:
|
|
||||||
assert is_python_file("src/gitea_runner_manager/cli.py") is True
|
|
||||||
|
|
||||||
def test_python_file_in_scripts(self) -> None:
|
|
||||||
assert is_python_file("scripts/ci/release.py") is True
|
|
||||||
|
|
||||||
def test_test_file_excluded(self) -> None:
|
|
||||||
assert is_python_file("tests/unit/test_cli.py") is False
|
|
||||||
|
|
||||||
def test_non_python_file(self) -> None:
|
|
||||||
assert is_python_file("README.md") is False
|
|
||||||
|
|
||||||
def test_yaml_file(self) -> None:
|
|
||||||
assert is_python_file(".gitea/workflows/ci.yml") is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestIsWorkflowOnly:
|
|
||||||
def test_yaml_is_workflow(self) -> None:
|
|
||||||
assert is_workflow_only(".gitea/workflows/ci.yml") is True
|
|
||||||
|
|
||||||
def test_md_is_workflow(self) -> None:
|
|
||||||
assert is_workflow_only("README.md") is True
|
|
||||||
|
|
||||||
def test_python_is_not_workflow(self) -> None:
|
|
||||||
assert is_workflow_only("src/gitea_runner_manager/cli.py") is False
|
|
||||||
|
|
||||||
def test_ansible_is_workflow(self) -> None:
|
|
||||||
assert is_workflow_only("ansible/tasks/main.yml") is True
|
|
||||||
|
|
||||||
|
|
||||||
class TestReviewResult:
|
|
||||||
def test_empty_result_has_no_issues(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
assert result.has_issues is False
|
|
||||||
|
|
||||||
def test_add_issue_makes_has_issues_true(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
result.add_issue("src/foo.py", 10, "bad code")
|
|
||||||
assert result.has_issues is True
|
|
||||||
assert len(result.issues) == 1
|
|
||||||
assert result.issues[0]["path"] == "src/foo.py"
|
|
||||||
assert result.issues[0]["new_position"] == 10
|
|
||||||
|
|
||||||
def test_add_summary(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
result.add_summary("all good")
|
|
||||||
assert "all good" in result.summary
|
|
||||||
|
|
||||||
|
|
||||||
class TestCheckArchitectureCompliance:
|
|
||||||
def test_subprocess_in_cli_triggers_issue(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
"filename": "src/gitea_runner_manager/cli.py",
|
|
||||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ subprocess.run(['ls'])\n",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
check_architecture_compliance(files, result)
|
|
||||||
assert result.has_issues
|
|
||||||
assert "subprocess" in result.issues[0]["body"].lower()
|
|
||||||
|
|
||||||
def test_subprocess_in_other_file_ok(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
"filename": "src/gitea_runner_manager/executor.py",
|
|
||||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ subprocess.run(['ls'])\n",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
check_architecture_compliance(files, result)
|
|
||||||
assert not result.has_issues
|
|
||||||
|
|
||||||
def test_no_changes_adds_ok_summary(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": ""}]
|
|
||||||
check_architecture_compliance(files, result)
|
|
||||||
assert any("Architecture compliance: OK" in s for s in result.summary)
|
|
||||||
|
|
||||||
def test_non_python_file_skipped(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [{"filename": "README.md", "patch": "@@ -1,1 +1,2 @@\n+subprocess.run(['ls'])\n"}]
|
|
||||||
check_architecture_compliance(files, result)
|
|
||||||
assert not result.has_issues
|
|
||||||
|
|
||||||
def test_empty_patch_skipped(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": ""}]
|
|
||||||
check_architecture_compliance(files, result)
|
|
||||||
assert not result.has_issues
|
|
||||||
|
|
||||||
def test_os_system_in_cli_triggers_issue(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
"filename": "src/gitea_runner_manager/cli.py",
|
|
||||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ os.system('ls')\n",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
check_architecture_compliance(files, result)
|
|
||||||
assert result.has_issues
|
|
||||||
assert "os.system" in result.issues[0]["body"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestCheckBestPractices:
|
|
||||||
def test_print_triggers_warning(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
"filename": "src/gitea_runner_manager/cli.py",
|
|
||||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ print('hello')\n",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
check_best_practices(files, result)
|
|
||||||
assert result.has_issues
|
|
||||||
assert "print()" in result.issues[0]["body"]
|
|
||||||
|
|
||||||
def test_bare_except_triggers_warning(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
"filename": "src/gitea_runner_manager/runner_manager.py",
|
|
||||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ except:\n pass\n",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
check_best_practices(files, result)
|
|
||||||
assert result.has_issues
|
|
||||||
assert "bare except" in result.issues[0]["body"]
|
|
||||||
|
|
||||||
def test_todo_triggers_warning(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
"filename": "src/gitea_runner_manager/cli.py",
|
|
||||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ # TODO: fix this\n",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
check_best_practices(files, result)
|
|
||||||
assert result.has_issues
|
|
||||||
assert "TODO" in result.issues[0]["body"]
|
|
||||||
|
|
||||||
def test_clean_code_no_issues(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
"filename": "src/gitea_runner_manager/cli.py",
|
|
||||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ click.echo('hello')\n",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
check_best_practices(files, result)
|
|
||||||
assert not result.has_issues
|
|
||||||
|
|
||||||
def test_empty_patch_skipped(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": ""}]
|
|
||||||
check_best_practices(files, result)
|
|
||||||
assert not result.has_issues
|
|
||||||
|
|
||||||
def test_non_python_file_skipped(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [{"filename": "README.md", "patch": "@@ -1,1 +1,2 @@\n+print('hello')\n"}]
|
|
||||||
check_best_practices(files, result)
|
|
||||||
assert not result.has_issues
|
|
||||||
|
|
||||||
|
|
||||||
class TestCheckSecurity:
|
|
||||||
def test_hardcoded_secret_triggers_error(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
"filename": "src/gitea_runner_manager/config.py",
|
|
||||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ token = 'abc123secrettoken456'\n",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
check_security(files, result)
|
|
||||||
assert result.has_issues
|
|
||||||
assert "secret" in result.issues[0]["body"].lower()
|
|
||||||
|
|
||||||
def test_example_token_not_flagged(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
"filename": ".env.example",
|
|
||||||
"patch": "@@ -1,1 +1,2 @@\n+token = your-example-token\n",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
check_security(files, result)
|
|
||||||
assert not result.has_issues
|
|
||||||
|
|
||||||
def test_shell_true_triggers_warning(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
"filename": "src/gitea_runner_manager/executor.py",
|
|
||||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ subprocess.run('ls', shell=True)\n",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
check_best_practices(files, result)
|
|
||||||
assert result.has_issues
|
|
||||||
assert "shell=True" in result.issues[0]["body"]
|
|
||||||
|
|
||||||
def test_empty_patch_skipped(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [{"filename": "src/gitea_runner_manager/config.py", "patch": ""}]
|
|
||||||
check_security(files, result)
|
|
||||||
assert not result.has_issues
|
|
||||||
|
|
||||||
def test_non_python_file_skipped(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [{"filename": "docs/config.md", "patch": "@@ -1,1 +1,2 @@\n+token = 'abc123secrettoken456'\n"}]
|
|
||||||
check_security(files, result)
|
|
||||||
assert not result.has_issues
|
|
||||||
|
|
||||||
|
|
||||||
class TestCheckI18n:
|
|
||||||
def test_raw_string_in_echo_triggers_warning(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [
|
|
||||||
{"filename": "src/gitea_runner_manager/cli.py", "patch": '@@ -1,1 +1,1 @@\n+click.echo("Hello world")\n'}
|
|
||||||
]
|
|
||||||
check_i18n(files, result)
|
|
||||||
assert result.has_issues
|
|
||||||
assert any("i18n" in i["body"] for i in result.issues)
|
|
||||||
|
|
||||||
def test_translated_string_no_warning(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [
|
|
||||||
{"filename": "src/gitea_runner_manager/cli.py", "patch": '@@ -1,1 +1,1 @@\n+click.echo(_("Hello world"))\n'}
|
|
||||||
]
|
|
||||||
check_i18n(files, result)
|
|
||||||
assert not result.has_issues
|
|
||||||
|
|
||||||
def test_fstring_in_echo_triggers_warning(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [
|
|
||||||
{"filename": "src/gitea_runner_manager/cli.py", "patch": '@@ -1,1 +1,1 @@\n+click.echo(f"Hello {name}")\n'}
|
|
||||||
]
|
|
||||||
check_i18n(files, result)
|
|
||||||
assert result.has_issues
|
|
||||||
|
|
||||||
def test_raw_exception_triggers_warning(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
"filename": "src/gitea_runner_manager/cli.py",
|
|
||||||
"patch": '@@ -1,1 +1,1 @@\n+raise click.ClickException("Something went wrong")\n',
|
|
||||||
}
|
|
||||||
]
|
|
||||||
check_i18n(files, result)
|
|
||||||
assert result.has_issues
|
|
||||||
|
|
||||||
def test_non_src_file_skipped(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [{"filename": "scripts/ci/test.py", "patch": '@@ -1,1 +1,1 @@\n+click.echo("Hello world")\n'}]
|
|
||||||
check_i18n(files, result)
|
|
||||||
assert not result.has_issues
|
|
||||||
|
|
||||||
def test_comment_skipped(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [
|
|
||||||
{"filename": "src/gitea_runner_manager/cli.py", "patch": '@@ -1,1 +1,1 @@\n+# click.echo("Hello world")\n'}
|
|
||||||
]
|
|
||||||
check_i18n(files, result)
|
|
||||||
assert not result.has_issues
|
|
||||||
|
|
||||||
def test_empty_patch_skipped(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": ""}]
|
|
||||||
check_i18n(files, result)
|
|
||||||
assert not result.has_issues
|
|
||||||
|
|
||||||
def test_clean_code_adds_ok_summary(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [
|
|
||||||
{"filename": "src/gitea_runner_manager/cli.py", "patch": '@@ -1,1 +1,1 @@\n+click.echo(_("Hello world"))\n'}
|
|
||||||
]
|
|
||||||
check_i18n(files, result)
|
|
||||||
assert any("i18n: OK" in s for s in result.summary)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCheckResourceManagement:
|
|
||||||
def test_open_without_with_triggers_warning(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": '@@ -1,1 +1,1 @@\n+f = open("file.txt")\n'}]
|
|
||||||
check_resource_management(files, result)
|
|
||||||
assert result.has_issues
|
|
||||||
assert any("resource" in i["body"].lower() for i in result.issues)
|
|
||||||
|
|
||||||
def test_open_with_with_no_warning(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
"filename": "src/gitea_runner_manager/cli.py",
|
|
||||||
"patch": '@@ -1,1 +1,1 @@\n+with open("file.txt") as f:\n+ pass\n',
|
|
||||||
}
|
|
||||||
]
|
|
||||||
check_resource_management(files, result)
|
|
||||||
assert not result.has_issues
|
|
||||||
|
|
||||||
def test_popen_without_cleanup_triggers_warning(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
"filename": "src/gitea_runner_manager/executor.py",
|
|
||||||
"patch": '@@ -1,1 +1,1 @@\n+proc = subprocess.Popen(["cmd"])\n',
|
|
||||||
}
|
|
||||||
]
|
|
||||||
check_resource_management(files, result)
|
|
||||||
assert result.has_issues
|
|
||||||
|
|
||||||
def test_popen_with_communicate_no_warning(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
"filename": "src/gitea_runner_manager/executor.py",
|
|
||||||
"patch": '@@ -1,1 +1,1 @@\n+out, err = subprocess.Popen(["cmd"], stdout=PIPE).communicate()\n',
|
|
||||||
}
|
|
||||||
]
|
|
||||||
check_resource_management(files, result)
|
|
||||||
assert not result.has_issues
|
|
||||||
|
|
||||||
def test_comment_skipped(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": '@@ -1,1 +1,1 @@\n+# f = open("file.txt")\n'}]
|
|
||||||
check_resource_management(files, result)
|
|
||||||
assert not result.has_issues
|
|
||||||
|
|
||||||
def test_empty_patch_skipped(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": ""}]
|
|
||||||
check_resource_management(files, result)
|
|
||||||
assert not result.has_issues
|
|
||||||
|
|
||||||
def test_non_python_file_skipped(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [{"filename": "docs/config.md", "patch": '@@ -1,1 +1,1 @@\n+f = open("file.txt")\n'}]
|
|
||||||
check_resource_management(files, result)
|
|
||||||
assert not result.has_issues
|
|
||||||
|
|
||||||
def test_clean_code_adds_ok_summary(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [
|
|
||||||
{
|
|
||||||
"filename": "src/gitea_runner_manager/cli.py",
|
|
||||||
"patch": '@@ -1,1 +1,1 @@\n+with open("file.txt") as f:\n+ data = f.read()\n',
|
|
||||||
}
|
|
||||||
]
|
|
||||||
check_resource_management(files, result)
|
|
||||||
assert any("Resource management: OK" in s for s in result.summary)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCheckFunctionLength:
|
|
||||||
def test_long_function_triggers_warning(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
# Create a patch with a function that adds > 50 lines
|
|
||||||
added_lines = "\n".join(f"+ x = {i}" for i in range(55))
|
|
||||||
patch = f"@@ -10,3 +10,59 @@\n+def foo():\n+ pass\n{added_lines}\n"
|
|
||||||
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": patch}]
|
|
||||||
check_function_length(files, result)
|
|
||||||
assert result.has_issues
|
|
||||||
assert "foo" in result.issues[0]["body"]
|
|
||||||
|
|
||||||
def test_short_function_no_warning(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
patch = "@@ -10,3 +10,8 @@\n def foo():\n pass\n+ x = 1\n+ y = 2\n+ z = 3\n"
|
|
||||||
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": patch}]
|
|
||||||
check_function_length(files, result)
|
|
||||||
assert not result.has_issues
|
|
||||||
|
|
||||||
def test_empty_patch_skipped(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": ""}]
|
|
||||||
check_function_length(files, result)
|
|
||||||
assert not result.has_issues
|
|
||||||
|
|
||||||
def test_non_python_file_skipped(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
added_lines = "\n".join(f"+ x = {i}" for i in range(55))
|
|
||||||
patch = f"@@ -10,3 +10,59 @@\n+def foo():\n+ pass\n{added_lines}\n"
|
|
||||||
files = [{"filename": "README.md", "patch": patch}]
|
|
||||||
check_function_length(files, result)
|
|
||||||
assert not result.has_issues
|
|
||||||
|
|
||||||
def test_multiple_functions_resets_count(self) -> None:
|
|
||||||
"""Two short functions back-to-back should not trigger the length warning."""
|
|
||||||
result = ReviewResult()
|
|
||||||
patch = "@@ -10,3 +10,15 @@\n+def foo():\n+ x = 1\n+def bar():\n+ y = 2\n"
|
|
||||||
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": patch}]
|
|
||||||
check_function_length(files, result)
|
|
||||||
assert not result.has_issues
|
|
||||||
|
|
||||||
def test_long_function_followed_by_new_hunk(self) -> None:
|
|
||||||
"""Long function followed by @@ header triggers the warning at hunk boundary."""
|
|
||||||
result = ReviewResult()
|
|
||||||
added_lines = "\n".join(f"+ x = {i}" for i in range(55))
|
|
||||||
patch = (
|
|
||||||
f"@@ -10,3 +10,59 @@\n+def foo():\n+ pass\n{added_lines}\n@@ -100,3 +100,5 @@\n+def bar():\n+ pass\n"
|
|
||||||
)
|
|
||||||
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": patch}]
|
|
||||||
check_function_length(files, result)
|
|
||||||
assert result.has_issues
|
|
||||||
assert "foo" in result.issues[0]["body"]
|
|
||||||
|
|
||||||
def test_long_function_followed_by_new_def(self) -> None:
|
|
||||||
"""Long function followed by another def triggers the warning at def boundary."""
|
|
||||||
result = ReviewResult()
|
|
||||||
added_lines = "\n".join(f"+ x = {i}" for i in range(55))
|
|
||||||
patch = f"@@ -10,3 +10,60 @@\n+def foo():\n+ pass\n{added_lines}\n+def bar():\n+ pass\n"
|
|
||||||
files = [{"filename": "src/gitea_runner_manager/cli.py", "patch": patch}]
|
|
||||||
check_function_length(files, result)
|
|
||||||
assert result.has_issues
|
|
||||||
assert "foo" in result.issues[0]["body"]
|
|
||||||
|
|
||||||
|
|
||||||
class TestCheckDocumentation:
|
|
||||||
def test_src_changes_without_docs_warns(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [{"filename": "src/gitea_runner_manager/cli.py"}]
|
|
||||||
check_documentation(files, result)
|
|
||||||
assert any("WARNING" in s for s in result.summary)
|
|
||||||
|
|
||||||
def test_src_changes_with_docs_ok(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [{"filename": "src/gitea_runner_manager/cli.py"}, {"filename": "docs/user/cli-commands.md"}]
|
|
||||||
check_documentation(files, result)
|
|
||||||
assert any("Documentation: OK" in s for s in result.summary)
|
|
||||||
|
|
||||||
def test_ansible_changes_without_docs_warns(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [{"filename": "ansible/roles/gitea-runner/tasks/main.yml"}]
|
|
||||||
check_documentation(files, result)
|
|
||||||
assert any("WARNING" in s for s in result.summary)
|
|
||||||
|
|
||||||
def test_only_doc_changes_ok(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [{"filename": "README.md"}]
|
|
||||||
check_documentation(files, result)
|
|
||||||
assert any("Documentation: OK" in s for s in result.summary)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCheckTestCoverage:
|
|
||||||
def test_src_changes_without_tests_warns(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [{"filename": "src/gitea_runner_manager/cli.py"}]
|
|
||||||
check_test_coverage(files, result)
|
|
||||||
assert any("WARNING" in s for s in result.summary)
|
|
||||||
|
|
||||||
def test_src_changes_with_tests_ok(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [{"filename": "src/gitea_runner_manager/cli.py"}, {"filename": "tests/unit/test_cli.py"}]
|
|
||||||
check_test_coverage(files, result)
|
|
||||||
assert any("Tests: OK" in s for s in result.summary)
|
|
||||||
|
|
||||||
def test_only_test_changes_ok(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
files = [{"filename": "tests/unit/test_cli.py"}]
|
|
||||||
check_test_coverage(files, result)
|
|
||||||
assert any("Tests: OK" in s for s in result.summary)
|
|
||||||
|
|
||||||
|
|
||||||
class TestBuildReviewBody:
|
|
||||||
def test_body_contains_summary(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
result.add_summary("- Architecture compliance: OK")
|
|
||||||
body = build_review_body(result)
|
|
||||||
assert "Architecture compliance: OK" in body
|
|
||||||
assert "Automated PR Review" in body
|
|
||||||
|
|
||||||
def test_body_contains_issues(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
result.add_issue("src/foo.py", 10, "bad code")
|
|
||||||
body = build_review_body(result)
|
|
||||||
assert "1 issue(s) found" in body
|
|
||||||
assert "src/foo.py:10" in body
|
|
||||||
assert "bad code" in body
|
|
||||||
|
|
||||||
def test_body_contains_no_issues_message(self) -> None:
|
|
||||||
result = ReviewResult()
|
|
||||||
body = build_review_body(result)
|
|
||||||
assert "No issues found" in body
|
|
||||||
|
|
||||||
def test_body_contains_auto_merge_note(self) -> None:
|
|
||||||
"""Review body must mention auto-merge."""
|
|
||||||
result = ReviewResult()
|
|
||||||
body = build_review_body(result)
|
|
||||||
assert "Auto-merge" in body
|
|
||||||
|
|
||||||
|
|
||||||
class TestRunReview:
|
|
||||||
@patch("scripts.ci.pr_review.GiteaClient")
|
|
||||||
def test_run_review_with_no_files(self, mock_client_class: MagicMock) -> None:
|
|
||||||
mock_client = mock_client_class.return_value
|
|
||||||
mock_client.get_pr_files.return_value = []
|
|
||||||
result = run_review(mock_client, "42")
|
|
||||||
assert "No files changed" in result.summary[0]
|
|
||||||
|
|
||||||
@patch("scripts.ci.pr_review.GiteaClient")
|
|
||||||
def test_run_review_finds_issues(self, mock_client_class: MagicMock) -> None:
|
|
||||||
mock_client = mock_client_class.return_value
|
|
||||||
mock_client.get_pr_files.return_value = [
|
|
||||||
{
|
|
||||||
"filename": "src/gitea_runner_manager/cli.py",
|
|
||||||
"patch": "@@ -10,3 +10,4 @@\n def foo():\n pass\n+ print('hello')\n",
|
|
||||||
}
|
|
||||||
]
|
|
||||||
mock_client.get_pr_commits.return_value = [{"commit": {"message": "fix: resolve print issue"}}]
|
|
||||||
result = run_review(mock_client, "42")
|
|
||||||
assert result.has_issues
|
|
||||||
|
|
||||||
def test_run_review_handles_api_error(self) -> None:
|
|
||||||
client = MagicMock()
|
|
||||||
client.get_pr_files.side_effect = APIError(404, "Not found")
|
|
||||||
result = run_review(client, "42")
|
|
||||||
assert any("ERROR" in s for s in result.summary)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCheckCommitConventions:
|
|
||||||
def test_conventional_commit_found(self) -> None:
|
|
||||||
"""Should report OK when at least one commit is conventional."""
|
|
||||||
client = MagicMock()
|
|
||||||
client.get_pr_commits.return_value = [
|
|
||||||
{"commit": {"message": "fix: resolve bug\n\nDetails"}},
|
|
||||||
{"commit": {"message": "wip: testing"}},
|
|
||||||
]
|
|
||||||
result = ReviewResult()
|
|
||||||
check_commit_conventions(client, "42", result)
|
|
||||||
assert any("OK" in s for s in result.summary)
|
|
||||||
|
|
||||||
def test_no_conventional_commit(self) -> None:
|
|
||||||
"""Should warn when no commits are conventional."""
|
|
||||||
client = MagicMock()
|
|
||||||
client.get_pr_commits.return_value = [
|
|
||||||
{"commit": {"message": "updated stuff"}},
|
|
||||||
{"commit": {"message": "wip: testing"}},
|
|
||||||
]
|
|
||||||
result = ReviewResult()
|
|
||||||
check_commit_conventions(client, "42", result)
|
|
||||||
assert any("WARNING" in s for s in result.summary)
|
|
||||||
|
|
||||||
def test_merge_commits_excluded(self) -> None:
|
|
||||||
"""Merge commits should be excluded from the check."""
|
|
||||||
client = MagicMock()
|
|
||||||
client.get_pr_commits.return_value = [
|
|
||||||
{"commit": {"message": "Merge branch 'feature' into master"}},
|
|
||||||
{"commit": {"message": "fix: resolve bug"}},
|
|
||||||
]
|
|
||||||
result = ReviewResult()
|
|
||||||
check_commit_conventions(client, "42", result)
|
|
||||||
assert any("OK" in s for s in result.summary)
|
|
||||||
|
|
||||||
def test_all_merges_and_reverts(self) -> None:
|
|
||||||
"""Should report OK when all commits are merges/reverts."""
|
|
||||||
client = MagicMock()
|
|
||||||
client.get_pr_commits.return_value = [
|
|
||||||
{"commit": {"message": "Merge branch 'feature' into master"}},
|
|
||||||
{"commit": {"message": "Revert: bad commit"}},
|
|
||||||
]
|
|
||||||
result = ReviewResult()
|
|
||||||
check_commit_conventions(client, "42", result)
|
|
||||||
assert any("merges/reverts" in s for s in result.summary)
|
|
||||||
|
|
||||||
def test_no_commits(self) -> None:
|
|
||||||
"""Should report OK when there are no commits."""
|
|
||||||
client = MagicMock()
|
|
||||||
client.get_pr_commits.return_value = []
|
|
||||||
result = ReviewResult()
|
|
||||||
check_commit_conventions(client, "42", result)
|
|
||||||
assert any("no commits" in s for s in result.summary)
|
|
||||||
|
|
||||||
def test_api_error(self) -> None:
|
|
||||||
"""Should report ERROR when API call fails."""
|
|
||||||
client = MagicMock()
|
|
||||||
client.get_pr_commits.side_effect = APIError(500, "server error")
|
|
||||||
result = ReviewResult()
|
|
||||||
check_commit_conventions(client, "42", result)
|
|
||||||
assert any("ERROR" in s for s in result.summary)
|
|
||||||
|
|
||||||
|
|
||||||
class TestPostReview:
|
|
||||||
def test_post_review_with_issues(self) -> None:
|
|
||||||
client = MagicMock()
|
|
||||||
result = ReviewResult()
|
|
||||||
result.add_issue("src/foo.py", 10, "bad code")
|
|
||||||
post_review(client, "42", result)
|
|
||||||
client.create_review.assert_called_once()
|
|
||||||
call_args = client.create_review.call_args
|
|
||||||
assert call_args[1]["event"] == "REQUEST_CHANGES"
|
|
||||||
assert call_args[1]["comments"] == result.issues
|
|
||||||
|
|
||||||
def test_post_review_without_issues_uses_comment_not_approve(self) -> None:
|
|
||||||
"""Automated review posts COMMENT, not APPROVE (self-approval not allowed)."""
|
|
||||||
client = MagicMock()
|
|
||||||
result = ReviewResult()
|
|
||||||
post_review(client, "42", result)
|
|
||||||
client.create_review.assert_called_once()
|
|
||||||
call_args = client.create_review.call_args
|
|
||||||
assert call_args[1]["event"] == "COMMENT"
|
|
||||||
assert call_args[1]["comments"] == []
|
|
||||||
|
|
||||||
|
|
||||||
class TestMain:
|
|
||||||
@patch("scripts.ci.pr_review.run_review")
|
|
||||||
@patch("scripts.ci.pr_review.GiteaClient")
|
|
||||||
def test_dry_run_does_not_post(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
|
|
||||||
mock_run.return_value = ReviewResult()
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["42", "oblachno-oss/grm", "--dry-run"], env={"REPO_TOKEN": "fake"})
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "[dry-run]" in result.output
|
|
||||||
mock_client_class.return_value.create_review.assert_not_called()
|
|
||||||
|
|
||||||
@patch("scripts.ci.pr_review.run_review")
|
|
||||||
@patch("scripts.ci.pr_review.GiteaClient")
|
|
||||||
def test_post_review_on_success(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
|
|
||||||
mock_run.return_value = ReviewResult()
|
|
||||||
mock_client_class.return_value.create_review.return_value = {"id": 123}
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": "fake"})
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Review #123" in result.output
|
|
||||||
mock_client_class.return_value.create_review.assert_called_once()
|
|
||||||
|
|
||||||
@patch("scripts.ci.pr_review.run_review")
|
|
||||||
@patch("scripts.ci.pr_review.GiteaClient")
|
|
||||||
def test_self_approval_falls_back_to_comment(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
|
|
||||||
"""If REQUEST_CHANGES fails with 422 (self-approval), fall back to COMMENT."""
|
|
||||||
mock_run.return_value = ReviewResult()
|
|
||||||
client = mock_client_class.return_value
|
|
||||||
client.create_review.side_effect = [
|
|
||||||
APIError(422, "approve your own pull is not allowed"),
|
|
||||||
{"id": 124},
|
|
||||||
]
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": "fake"})
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Review #124" in result.output
|
|
||||||
assert client.create_review.call_count == 2
|
|
||||||
|
|
||||||
@patch("scripts.ci.pr_review.run_review")
|
|
||||||
@patch("scripts.ci.pr_review.GiteaClient")
|
|
||||||
def test_other_api_error_re_raises(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
|
|
||||||
"""Non-approval API errors should re-raise, not fall back."""
|
|
||||||
mock_run.return_value = ReviewResult()
|
|
||||||
client = mock_client_class.return_value
|
|
||||||
client.create_review.side_effect = APIError(500, "Internal server error")
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": "fake"})
|
|
||||||
assert result.exit_code != 0
|
|
||||||
|
|
||||||
def test_no_token_raises(self) -> None:
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": ""})
|
|
||||||
assert result.exit_code != 0
|
|
||||||
assert "REPO_TOKEN" in result.output
|
|
||||||
|
|
||||||
|
|
||||||
def test_main_module_block() -> None:
|
|
||||||
import scripts.ci.pr_review as pr
|
|
||||||
|
|
||||||
with patch.object(pr, "main") as mock_main:
|
|
||||||
with patch.object(pr, "__name__", "__main__"):
|
|
||||||
pr.main([])
|
|
||||||
mock_main.assert_called_once_with([])
|
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
"""Unit tests for scripts/ci/publish.py."""
|
|
||||||
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import click
|
|
||||||
import pytest
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
from scripts.ci.publish import (
|
|
||||||
build_package,
|
|
||||||
generate_release_notes,
|
|
||||||
main,
|
|
||||||
publish_to_pypi,
|
|
||||||
)
|
|
||||||
from scripts.gitea_cli import TeaCLIError
|
|
||||||
|
|
||||||
|
|
||||||
class TestGenerateReleaseNotes:
|
|
||||||
@patch("scripts.ci.publish.subprocess.run")
|
|
||||||
@patch("scripts.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
|
||||||
def test_generates_from_git_cliff(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
|
|
||||||
mock_run.return_value = MagicMock(returncode=0, stdout="## v1.0.0\n- feat: x")
|
|
||||||
result = generate_release_notes("v1.0.0")
|
|
||||||
assert "## v1.0.0" in result
|
|
||||||
assert "feat: x" in result
|
|
||||||
|
|
||||||
@patch("scripts.ci.publish.subprocess.run")
|
|
||||||
@patch("scripts.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
|
||||||
def test_strips_whitespace(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
|
|
||||||
mock_run.return_value = MagicMock(returncode=0, stdout=" changelog \n")
|
|
||||||
result = generate_release_notes("v1.0.0")
|
|
||||||
assert result == "changelog"
|
|
||||||
|
|
||||||
@patch("scripts.ci.publish.subprocess.run")
|
|
||||||
@patch("scripts.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
|
||||||
def test_falls_back_on_failure(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
|
|
||||||
mock_run.return_value = MagicMock(returncode=1, stdout="")
|
|
||||||
result = generate_release_notes("v1.0.0")
|
|
||||||
assert "Release v1.0.0" in result
|
|
||||||
assert "CHANGELOG.md" in result
|
|
||||||
|
|
||||||
@patch("scripts.ci.publish.subprocess.run")
|
|
||||||
@patch("scripts.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
|
||||||
def test_falls_back_on_empty_output(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
|
|
||||||
mock_run.return_value = MagicMock(returncode=0, stdout=" ")
|
|
||||||
result = generate_release_notes("v1.0.0")
|
|
||||||
assert "Release v1.0.0" in result
|
|
||||||
|
|
||||||
@patch("scripts.ci.publish.subprocess.run", side_effect=FileNotFoundError)
|
|
||||||
@patch("scripts.ci.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
|
||||||
def test_falls_back_on_file_not_found(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
|
|
||||||
result = generate_release_notes("v1.0.0")
|
|
||||||
assert "Release v1.0.0" in result
|
|
||||||
assert "CHANGELOG.md" in result
|
|
||||||
|
|
||||||
@patch("scripts.ci.publish.shutil.which", return_value=None)
|
|
||||||
def test_falls_back_when_not_installed(self, mock_which: MagicMock) -> None:
|
|
||||||
result = generate_release_notes("v1.0.0")
|
|
||||||
assert "Release v1.0.0" in result
|
|
||||||
assert "CHANGELOG.md" in result
|
|
||||||
|
|
||||||
|
|
||||||
class TestBuildPackage:
|
|
||||||
@patch("scripts.ci.publish.subprocess.run")
|
|
||||||
def test_success(self, mock_run: MagicMock) -> None:
|
|
||||||
mock_run.return_value = MagicMock(returncode=0, stderr="")
|
|
||||||
build_package()
|
|
||||||
args, _ = mock_run.call_args
|
|
||||||
assert args[0][1] == "-m"
|
|
||||||
assert args[0][2] == "build"
|
|
||||||
|
|
||||||
@patch("scripts.ci.publish.subprocess.run")
|
|
||||||
def test_failure_raises(self, mock_run: MagicMock) -> None:
|
|
||||||
mock_run.return_value = MagicMock(returncode=1, stderr="build error")
|
|
||||||
with pytest.raises(click.ClickException) as exc:
|
|
||||||
build_package()
|
|
||||||
assert "build" in str(exc.value)
|
|
||||||
|
|
||||||
|
|
||||||
class TestPublishToPypi:
|
|
||||||
@patch("scripts.ci.publish.subprocess.run")
|
|
||||||
def test_success(self, mock_run: MagicMock) -> None:
|
|
||||||
mock_run.return_value = MagicMock(returncode=0, stderr="")
|
|
||||||
publish_to_pypi("pypi-tok")
|
|
||||||
args, _ = mock_run.call_args
|
|
||||||
assert "twine" in args[0]
|
|
||||||
assert "pypi-tok" in args[0]
|
|
||||||
|
|
||||||
@patch("scripts.ci.publish.subprocess.run")
|
|
||||||
def test_failure_raises(self, mock_run: MagicMock) -> None:
|
|
||||||
mock_run.return_value = MagicMock(returncode=1, stderr="upload failed")
|
|
||||||
with pytest.raises(click.ClickException) as exc:
|
|
||||||
publish_to_pypi("pypi-tok")
|
|
||||||
assert "PyPI" in str(exc.value)
|
|
||||||
|
|
||||||
|
|
||||||
class TestMain:
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
|
||||||
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
|
|
||||||
@patch("scripts.ci.publish.TeaCLI")
|
|
||||||
@patch("scripts.ci.publish.publish_to_pypi")
|
|
||||||
@patch("scripts.ci.publish.build_package")
|
|
||||||
def test_full_flow_with_pypi(
|
|
||||||
self,
|
|
||||||
mock_build: MagicMock,
|
|
||||||
mock_publish: MagicMock,
|
|
||||||
mock_tea_cls: MagicMock,
|
|
||||||
mock_notes: MagicMock,
|
|
||||||
) -> None:
|
|
||||||
mock_tea = MagicMock()
|
|
||||||
mock_tea_cls.return_value = mock_tea
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Gitea release v1.0.0 created" in result.output
|
|
||||||
mock_build.assert_called_once()
|
|
||||||
mock_publish.assert_called_once_with("pypi-tok")
|
|
||||||
mock_tea.create_release.assert_called_once_with(
|
|
||||||
"owner/repo", tag="v1.0.0", title="v1.0.0", body="Release notes"
|
|
||||||
)
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True)
|
|
||||||
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
|
|
||||||
@patch("scripts.ci.publish.TeaCLI")
|
|
||||||
@patch("scripts.ci.publish.build_package")
|
|
||||||
def test_without_pypi(
|
|
||||||
self,
|
|
||||||
mock_build: MagicMock,
|
|
||||||
mock_tea_cls: MagicMock,
|
|
||||||
mock_notes: MagicMock,
|
|
||||||
) -> None:
|
|
||||||
mock_tea = MagicMock()
|
|
||||||
mock_tea_cls.return_value = mock_tea
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
mock_build.assert_called_once()
|
|
||||||
mock_tea.create_release.assert_called_once()
|
|
||||||
assert "PYPI_TOKEN not set" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
|
|
||||||
def test_missing_repo_token_exits(self) -> None:
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "REPO_TOKEN" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
|
||||||
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
|
|
||||||
@patch("scripts.ci.publish.TeaCLI")
|
|
||||||
@patch("scripts.ci.publish.publish_to_pypi")
|
|
||||||
@patch("scripts.ci.publish.build_package")
|
|
||||||
def test_build_failure_raises_click(
|
|
||||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
|
||||||
) -> None:
|
|
||||||
mock_build.side_effect = click.ClickException("build failed")
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "build" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
|
||||||
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
|
|
||||||
@patch("scripts.ci.publish.TeaCLI")
|
|
||||||
@patch("scripts.ci.publish.publish_to_pypi")
|
|
||||||
@patch("scripts.ci.publish.build_package")
|
|
||||||
def test_publish_failure_raises_click(
|
|
||||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
|
||||||
) -> None:
|
|
||||||
mock_publish.side_effect = click.ClickException("publish failed")
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "publish" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
|
||||||
@patch("scripts.ci.publish.generate_release_notes", return_value="Release notes")
|
|
||||||
@patch("scripts.ci.publish.TeaCLI")
|
|
||||||
@patch("scripts.ci.publish.publish_to_pypi")
|
|
||||||
@patch("scripts.ci.publish.build_package")
|
|
||||||
def test_release_failure_raises_click(
|
|
||||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
|
||||||
) -> None:
|
|
||||||
mock_tea = MagicMock()
|
|
||||||
mock_tea.create_release.side_effect = TeaCLIError("server error")
|
|
||||||
mock_tea_cls.return_value = mock_tea
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "Release creation failed" in result.output
|
|
||||||
@@ -1,225 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from click import ClickException
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
import scripts.ci.push_badges as push_badges
|
|
||||||
|
|
||||||
|
|
||||||
class TestFetchLatestMaster:
|
|
||||||
def test_fetch_and_reset(self) -> None:
|
|
||||||
with patch("subprocess.run") as mock_run:
|
|
||||||
push_badges.fetch_latest_master("master")
|
|
||||||
# Should call git fetch and git reset --hard
|
|
||||||
calls = [str(c.args[0]) for c in mock_run.call_args_list]
|
|
||||||
assert any("fetch" in c for c in calls)
|
|
||||||
assert any("reset" in c for c in calls)
|
|
||||||
|
|
||||||
def test_custom_branch(self) -> None:
|
|
||||||
with patch("subprocess.run") as mock_run:
|
|
||||||
push_badges.fetch_latest_master("develop")
|
|
||||||
calls = [list(c.args[0]) for c in mock_run.call_args_list]
|
|
||||||
# fetch call should include the branch name
|
|
||||||
fetch_call = [c for c in calls if "fetch" in c][0]
|
|
||||||
assert "develop" in fetch_call
|
|
||||||
# reset call should include origin/develop
|
|
||||||
reset_call = [c for c in calls if "reset" in c][0]
|
|
||||||
assert "origin/develop" in reset_call
|
|
||||||
|
|
||||||
def test_fetch_failure_raises(self) -> None:
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")):
|
|
||||||
with pytest.raises(subprocess.CalledProcessError):
|
|
||||||
push_badges.fetch_latest_master()
|
|
||||||
|
|
||||||
|
|
||||||
class TestGenerateBadges:
|
|
||||||
def test_success(self, tmp_path: Path) -> None:
|
|
||||||
output_dir = tmp_path / ".badges"
|
|
||||||
output_dir.mkdir()
|
|
||||||
(output_dir / "badge1.svg").touch()
|
|
||||||
|
|
||||||
with patch("subprocess.run") as mock_run:
|
|
||||||
push_badges.generate_badges(str(output_dir))
|
|
||||||
mock_run.assert_called_once()
|
|
||||||
|
|
||||||
def test_no_badges_generated(self, tmp_path: Path) -> None:
|
|
||||||
output_dir = tmp_path / ".badges"
|
|
||||||
output_dir.mkdir()
|
|
||||||
|
|
||||||
with patch("subprocess.run"):
|
|
||||||
with pytest.raises(ClickException, match="No badge SVG files generated"):
|
|
||||||
push_badges.generate_badges(str(output_dir))
|
|
||||||
|
|
||||||
|
|
||||||
class TestPushToBadgesBranch:
|
|
||||||
def test_success(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
badges_dir = tmp_path / ".badges"
|
|
||||||
badges_dir.mkdir()
|
|
||||||
svg = badges_dir / "badge1.svg"
|
|
||||||
svg.write_text("<svg></svg>")
|
|
||||||
|
|
||||||
sha_result = MagicMock()
|
|
||||||
sha_result.stdout = "abc123\n"
|
|
||||||
default_result = MagicMock()
|
|
||||||
with patch(
|
|
||||||
"subprocess.run",
|
|
||||||
side_effect=[default_result] * 7 + [sha_result],
|
|
||||||
) as mock_run:
|
|
||||||
sha = push_badges.push_to_badges_branch(str(badges_dir))
|
|
||||||
|
|
||||||
# Should have called git config x2, checkout, rm, add, commit, push, rev-parse
|
|
||||||
assert mock_run.call_count >= 8
|
|
||||||
# Verify the SVG was copied to cwd
|
|
||||||
assert (tmp_path / "badge1.svg").exists()
|
|
||||||
assert sha == "abc123"
|
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateBadgeUrls:
|
|
||||||
def test_replaces_branch_url(self) -> None:
|
|
||||||
content = "[]"
|
|
||||||
result = push_badges.update_badge_urls(content, "abc123def456")
|
|
||||||
assert "raw/commit/abc123def456/tests.svg" in result
|
|
||||||
assert "raw/branch/badges" not in result
|
|
||||||
|
|
||||||
def test_replaces_commit_url(self) -> None:
|
|
||||||
"""Old commit SHA URLs should be replaced with the new one."""
|
|
||||||
old_sha = "aabb123456789012345678901234567890123456" # 40 hex chars
|
|
||||||
new_sha = "ccdd123456789012345678901234567890123456" # 40 hex chars
|
|
||||||
content = f"[]"
|
|
||||||
result = push_badges.update_badge_urls(content, new_sha)
|
|
||||||
assert f"raw/commit/{new_sha}/tests.svg" in result
|
|
||||||
assert old_sha not in result
|
|
||||||
|
|
||||||
def test_no_badge_urls(self) -> None:
|
|
||||||
content = "# No badges here\nJust text."
|
|
||||||
result = push_badges.update_badge_urls(content, "abc123")
|
|
||||||
assert result == content
|
|
||||||
|
|
||||||
def test_multiple_badges(self) -> None:
|
|
||||||
content = (
|
|
||||||
"[]\n"
|
|
||||||
"[]\n"
|
|
||||||
"[]"
|
|
||||||
)
|
|
||||||
result = push_badges.update_badge_urls(content, "abc123def456")
|
|
||||||
assert result.count("raw/commit/abc123def456/") == 3
|
|
||||||
assert "raw/branch/badges" not in result
|
|
||||||
|
|
||||||
def test_preserves_non_badge_urls(self) -> None:
|
|
||||||
content = "[]"
|
|
||||||
result = push_badges.update_badge_urls(content, "abc123")
|
|
||||||
assert result == content
|
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateReadmeWithBadgeSha:
|
|
||||||
def test_updates_readme(self, tmp_path: Path) -> None:
|
|
||||||
readme = tmp_path / "README.md"
|
|
||||||
readme.write_text("[]")
|
|
||||||
with patch("subprocess.run"):
|
|
||||||
push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path)
|
|
||||||
content = readme.read_text()
|
|
||||||
assert "raw/commit/abc123def456/tests.svg" in content
|
|
||||||
|
|
||||||
def test_no_badge_urls_skips_commit(self, tmp_path: Path) -> None:
|
|
||||||
readme = tmp_path / "README.md"
|
|
||||||
readme.write_text("# No badges here")
|
|
||||||
with patch("subprocess.run") as mock_run:
|
|
||||||
push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path)
|
|
||||||
# Should checkout master but not commit/push
|
|
||||||
calls = [list(c.args[0]) for c in mock_run.call_args_list]
|
|
||||||
assert not any("commit" in c for c in calls)
|
|
||||||
assert not any("push" in c for c in calls)
|
|
||||||
|
|
||||||
def test_missing_readme_skips(self, tmp_path: Path) -> None:
|
|
||||||
with patch("subprocess.run"):
|
|
||||||
push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path)
|
|
||||||
# Should not raise
|
|
||||||
|
|
||||||
|
|
||||||
class TestMain:
|
|
||||||
def test_success(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
badges_dir = tmp_path / ".badges"
|
|
||||||
badges_dir.mkdir()
|
|
||||||
(badges_dir / "badge1.svg").touch()
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
with patch("subprocess.run"):
|
|
||||||
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
|
|
||||||
def test_no_badges(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
badges_dir = tmp_path / ".badges"
|
|
||||||
badges_dir.mkdir()
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
with patch("subprocess.run"):
|
|
||||||
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update"])
|
|
||||||
assert result.exit_code != 0
|
|
||||||
|
|
||||||
def test_custom_branch(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
badges_dir = tmp_path / ".badges"
|
|
||||||
badges_dir.mkdir()
|
|
||||||
(badges_dir / "badge1.svg").touch()
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
with patch("subprocess.run") as mock_run:
|
|
||||||
result = runner.invoke(
|
|
||||||
push_badges.main,
|
|
||||||
["--output-dir", str(badges_dir), "--branch", "develop", "--no-readme-update"],
|
|
||||||
)
|
|
||||||
assert result.exit_code == 0
|
|
||||||
# Verify fetch was called with the custom branch
|
|
||||||
calls = [list(c.args[0]) for c in mock_run.call_args_list]
|
|
||||||
fetch_call = [c for c in calls if "fetch" in c][0]
|
|
||||||
assert "develop" in fetch_call
|
|
||||||
|
|
||||||
def test_fetch_failure(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git fetch")):
|
|
||||||
result = runner.invoke(push_badges.main, ["--no-readme-update"])
|
|
||||||
assert result.exit_code != 0
|
|
||||||
|
|
||||||
def test_readme_update_called_by_default(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""Without --no-readme-update, update_readme_with_badge_sha is called."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
badges_dir = tmp_path / ".badges"
|
|
||||||
badges_dir.mkdir()
|
|
||||||
(badges_dir / "badge1.svg").touch()
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
with (
|
|
||||||
patch("subprocess.run"),
|
|
||||||
patch("scripts.ci.push_badges.update_readme_with_badge_sha") as mock_update,
|
|
||||||
):
|
|
||||||
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir)])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
mock_update.assert_called_once()
|
|
||||||
|
|
||||||
def test_readme_update_skipped_with_flag(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""With --no-readme-update, update_readme_with_badge_sha is not called."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
badges_dir = tmp_path / ".badges"
|
|
||||||
badges_dir.mkdir()
|
|
||||||
(badges_dir / "badge1.svg").touch()
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
with (
|
|
||||||
patch("subprocess.run"),
|
|
||||||
patch("scripts.ci.push_badges.update_readme_with_badge_sha") as mock_update,
|
|
||||||
):
|
|
||||||
result = runner.invoke(push_badges.main, ["--output-dir", str(badges_dir), "--no-readme-update"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
mock_update.assert_not_called()
|
|
||||||
@@ -1,606 +0,0 @@
|
|||||||
"""Unit tests for scripts/ci/release.py."""
|
|
||||||
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import click
|
|
||||||
import pytest
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
from scripts.ci.release import (
|
|
||||||
commit_release_changes,
|
|
||||||
create_and_push_tag,
|
|
||||||
get_bumped_version,
|
|
||||||
get_changelog,
|
|
||||||
get_latest_tag,
|
|
||||||
has_unreleased_changes,
|
|
||||||
main,
|
|
||||||
run_cmd,
|
|
||||||
run_tests,
|
|
||||||
tag_exists,
|
|
||||||
update_changelog,
|
|
||||||
update_init_version,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestRunCmd:
|
|
||||||
@patch("scripts.ci.release.subprocess.run")
|
|
||||||
def test_success(self, mock_run: MagicMock) -> None:
|
|
||||||
mock_run.return_value = MagicMock(returncode=0, stderr="", stdout="")
|
|
||||||
result = run_cmd(["echo", "hi"])
|
|
||||||
assert result.returncode == 0
|
|
||||||
mock_run.assert_called_once()
|
|
||||||
|
|
||||||
@patch("scripts.ci.release.subprocess.run")
|
|
||||||
def test_failure_raises(self, mock_run: MagicMock) -> None:
|
|
||||||
mock_run.return_value = MagicMock(returncode=1, stderr="err", stdout="")
|
|
||||||
with pytest.raises(click.ClickException):
|
|
||||||
run_cmd(["false"])
|
|
||||||
|
|
||||||
@patch("scripts.ci.release.subprocess.run")
|
|
||||||
def test_check_false_no_raise(self, mock_run: MagicMock) -> None:
|
|
||||||
mock_run.return_value = MagicMock(returncode=1, stderr="err", stdout="")
|
|
||||||
result = run_cmd(["false"], check=False)
|
|
||||||
assert result.returncode == 1
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetLatestTag:
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_returns_tag(self, mock_run_cmd: MagicMock) -> None:
|
|
||||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.1.0\n")
|
|
||||||
assert get_latest_tag() == "v0.1.0"
|
|
||||||
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_no_tags_returns_empty(self, mock_run_cmd: MagicMock) -> None:
|
|
||||||
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="")
|
|
||||||
assert get_latest_tag() == ""
|
|
||||||
|
|
||||||
|
|
||||||
class TestTagExists:
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_exists(self, mock_run_cmd: MagicMock) -> None:
|
|
||||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.1.0\n")
|
|
||||||
assert tag_exists("v0.1.0") is True
|
|
||||||
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_not_exists(self, mock_run_cmd: MagicMock) -> None:
|
|
||||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="")
|
|
||||||
assert tag_exists("v0.2.0") is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetBumpedVersion:
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_returns_version(self, mock_run_cmd: MagicMock) -> None:
|
|
||||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="0.2.0\n")
|
|
||||||
assert get_bumped_version() == "0.2.0"
|
|
||||||
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_strips_v_prefix(self, mock_run_cmd: MagicMock) -> None:
|
|
||||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.2.0\n")
|
|
||||||
assert get_bumped_version() == "0.2.0"
|
|
||||||
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_empty_raises(self, mock_run_cmd: MagicMock) -> None:
|
|
||||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="")
|
|
||||||
with pytest.raises(click.ClickException):
|
|
||||||
get_bumped_version()
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetChangelog:
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_returns_changelog(self, mock_run_cmd: MagicMock) -> None:
|
|
||||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="## 0.2.0\n- fix\n")
|
|
||||||
assert get_changelog("0.2.0") == "## 0.2.0\n- fix"
|
|
||||||
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_strips_whitespace(self, mock_run_cmd: MagicMock) -> None:
|
|
||||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout=" text \n")
|
|
||||||
assert get_changelog("0.2.0") == "text"
|
|
||||||
|
|
||||||
|
|
||||||
class TestHasUnreleasedChanges:
|
|
||||||
@patch("scripts.ci.release.get_latest_tag")
|
|
||||||
def test_no_tags_has_changes(self, mock_latest: MagicMock) -> None:
|
|
||||||
mock_latest.return_value = ""
|
|
||||||
assert has_unreleased_changes() is True
|
|
||||||
|
|
||||||
@patch("scripts.ci.release.get_latest_tag")
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_no_commits_since_tag(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None:
|
|
||||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="")
|
|
||||||
mock_latest.return_value = "v0.2.0"
|
|
||||||
assert has_unreleased_changes() is False
|
|
||||||
|
|
||||||
@patch("scripts.ci.release.get_latest_tag")
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_commits_since_tag(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None:
|
|
||||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="abc123 fix: bug\n")
|
|
||||||
mock_latest.return_value = "v0.2.0"
|
|
||||||
assert has_unreleased_changes() is True
|
|
||||||
|
|
||||||
@patch("scripts.ci.release.get_latest_tag")
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_git_log_fails_returns_false(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None:
|
|
||||||
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="")
|
|
||||||
mock_latest.return_value = "v0.2.0"
|
|
||||||
assert has_unreleased_changes() is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateInitVersion:
|
|
||||||
def test_updates_version(self, tmp_path, monkeypatch) -> None:
|
|
||||||
init_file = tmp_path / "__init__.py"
|
|
||||||
init_file.write_text('__version__ = "0.1.0"\n')
|
|
||||||
monkeypatch.setattr("scripts.ci.release.INIT_FILE", str(init_file))
|
|
||||||
update_init_version("0.2.0")
|
|
||||||
assert '__version__ = "0.2.0"' in init_file.read_text()
|
|
||||||
|
|
||||||
def test_same_version_ok(self, tmp_path, monkeypatch) -> None:
|
|
||||||
"""Updating to the same version should not raise."""
|
|
||||||
init_file = tmp_path / "__init__.py"
|
|
||||||
init_file.write_text('__version__ = "0.1.0"\n')
|
|
||||||
monkeypatch.setattr("scripts.ci.release.INIT_FILE", str(init_file))
|
|
||||||
update_init_version("0.1.0")
|
|
||||||
assert '__version__ = "0.1.0"' in init_file.read_text()
|
|
||||||
|
|
||||||
def test_no_version_raises(self, tmp_path, monkeypatch) -> None:
|
|
||||||
init_file = tmp_path / "__init__.py"
|
|
||||||
init_file.write_text('"""module"""\n')
|
|
||||||
monkeypatch.setattr("scripts.ci.release.INIT_FILE", str(init_file))
|
|
||||||
with pytest.raises(click.ClickException):
|
|
||||||
update_init_version("0.2.0")
|
|
||||||
|
|
||||||
|
|
||||||
class TestUpdateChangelog:
|
|
||||||
def test_creates_new_file(self, tmp_path, monkeypatch) -> None:
|
|
||||||
changelog_file = tmp_path / "CHANGELOG.md"
|
|
||||||
monkeypatch.setattr("scripts.ci.release.CHANGELOG_FILE", str(changelog_file))
|
|
||||||
update_changelog("## [0.2.0] - 2026-06-21\n\n### Features\n- new thing")
|
|
||||||
content = changelog_file.read_text()
|
|
||||||
assert "## [0.2.0]" in content
|
|
||||||
assert "new thing" in content
|
|
||||||
|
|
||||||
def test_prepends_to_existing(self, tmp_path, monkeypatch) -> None:
|
|
||||||
changelog_file = tmp_path / "CHANGELOG.md"
|
|
||||||
changelog_file.write_text("# Changelog\n\n## [0.1.0] - 2026-06-20\n\n### Features\n- old thing\n")
|
|
||||||
monkeypatch.setattr("scripts.ci.release.CHANGELOG_FILE", str(changelog_file))
|
|
||||||
update_changelog("## [0.2.0] - 2026-06-21\n\n### Features\n- new thing")
|
|
||||||
content = changelog_file.read_text()
|
|
||||||
assert "# Changelog" in content
|
|
||||||
# New version should be before old version
|
|
||||||
assert content.index("0.2.0") < content.index("0.1.0")
|
|
||||||
assert "new thing" in content
|
|
||||||
assert "old thing" in content
|
|
||||||
|
|
||||||
def test_appends_when_no_version_sections(self, tmp_path, monkeypatch) -> None:
|
|
||||||
changelog_file = tmp_path / "CHANGELOG.md"
|
|
||||||
changelog_file.write_text("# Changelog\n\nSome intro text.\n")
|
|
||||||
monkeypatch.setattr("scripts.ci.release.CHANGELOG_FILE", str(changelog_file))
|
|
||||||
update_changelog("## [0.2.0] - 2026-06-21\n\n### Features\n- new thing")
|
|
||||||
content = changelog_file.read_text()
|
|
||||||
assert "Some intro text" in content
|
|
||||||
assert "## [0.2.0]" in content
|
|
||||||
|
|
||||||
def test_strips_git_cliff_header(self, tmp_path, monkeypatch) -> None:
|
|
||||||
"""git-cliff output includes a header — should be stripped before inserting."""
|
|
||||||
changelog_file = tmp_path / "CHANGELOG.md"
|
|
||||||
changelog_file.write_text("# Changelog\n\n## [0.1.0] - 2026-06-20\n\n### Features\n- old thing\n")
|
|
||||||
monkeypatch.setattr("scripts.ci.release.CHANGELOG_FILE", str(changelog_file))
|
|
||||||
# Simulate git-cliff output with header
|
|
||||||
cliff_output = "# Changelog\n\nAll notable changes...\n\n## [0.2.0] - 2026-06-21\n\n### Features\n- new thing"
|
|
||||||
update_changelog(cliff_output)
|
|
||||||
content = changelog_file.read_text()
|
|
||||||
# Header should appear only once (from the existing file)
|
|
||||||
assert content.count("# Changelog") == 1
|
|
||||||
assert "## [0.2.0]" in content
|
|
||||||
assert "new thing" in content
|
|
||||||
|
|
||||||
def test_strips_header_when_creating_new_file(self, tmp_path, monkeypatch) -> None:
|
|
||||||
"""When creating a new file, strip the git-cliff header."""
|
|
||||||
changelog_file = tmp_path / "CHANGELOG.md"
|
|
||||||
monkeypatch.setattr("scripts.ci.release.CHANGELOG_FILE", str(changelog_file))
|
|
||||||
cliff_output = "# Changelog\n\nAll notable changes...\n\n## [0.2.0] - 2026-06-21\n\n### Features\n- new thing"
|
|
||||||
update_changelog(cliff_output)
|
|
||||||
content = changelog_file.read_text()
|
|
||||||
assert "# Changelog" not in content
|
|
||||||
assert "## [0.2.0]" in content
|
|
||||||
|
|
||||||
|
|
||||||
class TestCommitReleaseChanges:
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_commits_when_changes(self, mock_run_cmd: MagicMock) -> None:
|
|
||||||
# git diff --cached --quiet returns 1 (changes exist)
|
|
||||||
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="")
|
|
||||||
result = commit_release_changes("0.2.0")
|
|
||||||
assert result is True
|
|
||||||
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
|
|
||||||
assert ["git", "add", "src/gitea_runner_manager/__init__.py", "CHANGELOG.md"] in calls
|
|
||||||
assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0 [skip ci]"] in calls
|
|
||||||
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_skips_when_no_changes(self, mock_run_cmd: MagicMock) -> None:
|
|
||||||
# git diff --cached --quiet returns 0 (no changes)
|
|
||||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
|
||||||
result = commit_release_changes("0.1.0")
|
|
||||||
assert result is False
|
|
||||||
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
|
|
||||||
assert ["git", "commit", "--no-verify", "-m", "release: v0.1.0"] not in calls
|
|
||||||
|
|
||||||
|
|
||||||
class TestCreateAndPushTag:
|
|
||||||
@patch("scripts.ci.release.tag_exists", return_value=False)
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_creates_tag(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
|
|
||||||
create_and_push_tag("0.2.0", "changelog", dry_run=False)
|
|
||||||
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
|
|
||||||
assert ["git", "tag", "-a", "v0.2.0", "-m", "Release v0.2.0\n\nchangelog"] in calls
|
|
||||||
assert ["git", "push", "origin", "v0.2.0"] in calls
|
|
||||||
|
|
||||||
@patch("scripts.ci.release.tag_exists", return_value=False)
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_dry_run_no_push(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
|
|
||||||
result = create_and_push_tag("0.2.0", "changelog", dry_run=True)
|
|
||||||
assert result is True
|
|
||||||
for call in mock_run_cmd.call_args_list:
|
|
||||||
assert call.args[0][0:2] != ["git", "push"]
|
|
||||||
assert call.args[0][0:2] != ["git", "tag"]
|
|
||||||
|
|
||||||
@patch("scripts.ci.release.tag_exists", return_value=True)
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_tag_exists_skips_creation(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
|
|
||||||
result = create_and_push_tag("0.1.0", "changelog", dry_run=False)
|
|
||||||
assert result is False
|
|
||||||
# Should not create tag, but should ensure it's pushed
|
|
||||||
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
|
|
||||||
assert ["git", "tag", "-a"] not in [c[:3] for c in calls]
|
|
||||||
assert ["git", "push", "origin", "v0.1.0"] in calls
|
|
||||||
|
|
||||||
@patch("scripts.ci.release.tag_exists", return_value=True)
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_tag_exists_dry_run_no_push(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
|
|
||||||
result = create_and_push_tag("0.1.0", "changelog", dry_run=True)
|
|
||||||
assert result is False
|
|
||||||
# No git commands at all in dry-run when tag exists
|
|
||||||
mock_run_cmd.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
class TestRunTests:
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_lint_and_tests_pass(self, mock_run_cmd: MagicMock) -> None:
|
|
||||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
|
||||||
run_tests() # should not raise
|
|
||||||
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_lint_fails_raises(self, mock_run_cmd: MagicMock) -> None:
|
|
||||||
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="", stderr="lint error")
|
|
||||||
with pytest.raises(click.ClickException, match="Lint failed"):
|
|
||||||
run_tests()
|
|
||||||
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_tests_fail_raises(self, mock_run_cmd: MagicMock) -> None:
|
|
||||||
mock_run_cmd.side_effect = [
|
|
||||||
MagicMock(returncode=0, stdout="", stderr=""), # lint passes
|
|
||||||
MagicMock(returncode=1, stdout="", stderr="test failure"), # tests fail
|
|
||||||
]
|
|
||||||
with pytest.raises(click.ClickException, match="Tests failed"):
|
|
||||||
run_tests()
|
|
||||||
|
|
||||||
|
|
||||||
class TestMain:
|
|
||||||
@patch.dict("os.environ", {})
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_not_on_master_exits(self, mock_run_cmd: MagicMock) -> None:
|
|
||||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="feature-branch\n", stderr="")
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, [])
|
|
||||||
assert result.exit_code != 0
|
|
||||||
assert "master" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {})
|
|
||||||
@patch("scripts.ci.release.has_user_facing_changes", return_value=False)
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_dry_run_on_non_master_warns(self, mock_run_cmd: MagicMock, mock_uf: MagicMock) -> None:
|
|
||||||
"""Dry-run mode should not fail on non-master branches."""
|
|
||||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="feature-branch\n", stderr="")
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--dry-run"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Dry-run mode" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {})
|
|
||||||
@patch("scripts.ci.release.has_user_facing_changes", return_value=True)
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_release_lock_skips_when_head_is_release_commit(self, mock_run_cmd: MagicMock, mock_uf: MagicMock) -> None:
|
|
||||||
"""If HEAD is already a release commit, should skip to prevent duplicate releases."""
|
|
||||||
# First call: git rev-parse (master), second: git log -1 (release commit)
|
|
||||||
mock_run_cmd.side_effect = [
|
|
||||||
MagicMock(returncode=0, stdout="master\n", stderr=""),
|
|
||||||
MagicMock(returncode=0, stdout="release: v0.5.0\n", stderr=""),
|
|
||||||
]
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, [])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "already a release commit" in result.output
|
|
||||||
assert "Skipping" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {})
|
|
||||||
@patch("scripts.ci.release.has_user_facing_changes", return_value=True)
|
|
||||||
@patch("scripts.ci.release.has_unreleased_changes", return_value=False)
|
|
||||||
@patch("scripts.ci.release.get_bumped_version", return_value="0.2.0")
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_no_unreleased_changes(
|
|
||||||
self,
|
|
||||||
mock_run_cmd: MagicMock,
|
|
||||||
mock_bumped: MagicMock,
|
|
||||||
mock_has: MagicMock,
|
|
||||||
mock_user: MagicMock,
|
|
||||||
) -> None:
|
|
||||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, [])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "No unreleased changes" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {})
|
|
||||||
@patch("scripts.ci.release.has_user_facing_changes", return_value=True)
|
|
||||||
@patch("scripts.ci.release.create_and_push_tag")
|
|
||||||
@patch("scripts.ci.release.commit_release_changes")
|
|
||||||
@patch("scripts.ci.release.update_changelog")
|
|
||||||
@patch("scripts.ci.release.update_init_version")
|
|
||||||
@patch("scripts.ci.release.get_changelog", return_value="")
|
|
||||||
@patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0")
|
|
||||||
@patch("scripts.ci.release.get_bumped_version", return_value="0.2.0")
|
|
||||||
@patch("scripts.ci.release.has_unreleased_changes", return_value=True)
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_dry_run_empty_changelog(
|
|
||||||
self,
|
|
||||||
mock_run_cmd: MagicMock,
|
|
||||||
mock_has: MagicMock,
|
|
||||||
mock_bumped: MagicMock,
|
|
||||||
mock_latest: MagicMock,
|
|
||||||
mock_changelog: MagicMock,
|
|
||||||
mock_update_init: MagicMock,
|
|
||||||
mock_update_changelog: MagicMock,
|
|
||||||
mock_commit: MagicMock,
|
|
||||||
mock_tag: MagicMock,
|
|
||||||
mock_user: MagicMock,
|
|
||||||
) -> None:
|
|
||||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--dry-run"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "empty changelog" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {})
|
|
||||||
@patch("scripts.ci.release.has_user_facing_changes", return_value=True)
|
|
||||||
@patch("scripts.ci.release.create_and_push_tag")
|
|
||||||
@patch("scripts.ci.release.commit_release_changes")
|
|
||||||
@patch("scripts.ci.release.update_changelog")
|
|
||||||
@patch("scripts.ci.release.update_init_version")
|
|
||||||
@patch("scripts.ci.release.get_changelog", return_value="changelog")
|
|
||||||
@patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0")
|
|
||||||
@patch("scripts.ci.release.get_bumped_version", return_value="0.2.0")
|
|
||||||
@patch("scripts.ci.release.has_unreleased_changes", return_value=True)
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_dry_run(
|
|
||||||
self,
|
|
||||||
mock_run_cmd: MagicMock,
|
|
||||||
mock_has: MagicMock,
|
|
||||||
mock_bumped: MagicMock,
|
|
||||||
mock_latest: MagicMock,
|
|
||||||
mock_changelog: MagicMock,
|
|
||||||
mock_update_init: MagicMock,
|
|
||||||
mock_update_changelog: MagicMock,
|
|
||||||
mock_commit: MagicMock,
|
|
||||||
mock_tag: MagicMock,
|
|
||||||
mock_user: MagicMock,
|
|
||||||
) -> None:
|
|
||||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--dry-run"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "[dry-run]" in result.output
|
|
||||||
mock_update_init.assert_not_called()
|
|
||||||
mock_update_changelog.assert_not_called()
|
|
||||||
mock_commit.assert_not_called()
|
|
||||||
mock_tag.assert_not_called()
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {})
|
|
||||||
@patch("scripts.ci.release.get_latest_tag", return_value="v0.3.0")
|
|
||||||
@patch("scripts.ci.release.has_user_facing_changes", return_value=False)
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_skips_release_when_workflow_only(
|
|
||||||
self,
|
|
||||||
mock_run_cmd: MagicMock,
|
|
||||||
mock_user_facing: MagicMock,
|
|
||||||
mock_latest: MagicMock,
|
|
||||||
) -> None:
|
|
||||||
"""Release is skipped when only workflow/infra files changed."""
|
|
||||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, [])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "No user-facing changes" in result.output
|
|
||||||
assert "Skipping release" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {})
|
|
||||||
@patch("scripts.ci.release.has_user_facing_changes", return_value=True)
|
|
||||||
@patch("scripts.ci.release.run_tests")
|
|
||||||
@patch("scripts.ci.release.create_and_push_tag", return_value=True)
|
|
||||||
@patch("scripts.ci.release.commit_release_changes", return_value=True)
|
|
||||||
@patch("scripts.ci.release.update_changelog")
|
|
||||||
@patch("scripts.ci.release.update_init_version")
|
|
||||||
@patch("scripts.ci.release.get_changelog", return_value="changelog")
|
|
||||||
@patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0")
|
|
||||||
@patch("scripts.ci.release.get_bumped_version", return_value="0.2.0")
|
|
||||||
@patch("scripts.ci.release.has_unreleased_changes", return_value=True)
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_full_flow(
|
|
||||||
self,
|
|
||||||
mock_run_cmd: MagicMock,
|
|
||||||
mock_has: MagicMock,
|
|
||||||
mock_bumped: MagicMock,
|
|
||||||
mock_latest: MagicMock,
|
|
||||||
mock_changelog: MagicMock,
|
|
||||||
mock_update_init: MagicMock,
|
|
||||||
mock_update_changelog: MagicMock,
|
|
||||||
mock_commit: MagicMock,
|
|
||||||
mock_tag: MagicMock,
|
|
||||||
mock_run_tests: MagicMock,
|
|
||||||
mock_user: MagicMock,
|
|
||||||
) -> None:
|
|
||||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, [])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Bumping version" in result.output
|
|
||||||
mock_update_init.assert_called_once_with("0.2.0")
|
|
||||||
mock_update_changelog.assert_called_once_with("changelog")
|
|
||||||
mock_run_tests.assert_called_once()
|
|
||||||
mock_commit.assert_called_once_with("0.2.0")
|
|
||||||
mock_tag.assert_called_once_with("0.2.0", "changelog", False)
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {})
|
|
||||||
@patch("scripts.ci.release.has_user_facing_changes", return_value=True)
|
|
||||||
@patch("scripts.ci.release.run_tests")
|
|
||||||
@patch("scripts.ci.release.create_and_push_tag", return_value=False)
|
|
||||||
@patch("scripts.ci.release.commit_release_changes", return_value=False)
|
|
||||||
@patch("scripts.ci.release.update_changelog")
|
|
||||||
@patch("scripts.ci.release.update_init_version")
|
|
||||||
@patch("scripts.ci.release.get_changelog", return_value="changelog")
|
|
||||||
@patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0")
|
|
||||||
@patch("scripts.ci.release.get_bumped_version", return_value="0.1.0")
|
|
||||||
@patch("scripts.ci.release.has_unreleased_changes", return_value=True)
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_full_flow_tag_exists(
|
|
||||||
self,
|
|
||||||
mock_run_cmd: MagicMock,
|
|
||||||
mock_has: MagicMock,
|
|
||||||
mock_bumped: MagicMock,
|
|
||||||
mock_latest: MagicMock,
|
|
||||||
mock_changelog: MagicMock,
|
|
||||||
mock_update_init: MagicMock,
|
|
||||||
mock_update_changelog: MagicMock,
|
|
||||||
mock_commit: MagicMock,
|
|
||||||
mock_tag: MagicMock,
|
|
||||||
mock_run_tests: MagicMock,
|
|
||||||
mock_user: MagicMock,
|
|
||||||
) -> None:
|
|
||||||
"""When tag already exists, still update files but report existing tag."""
|
|
||||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, [])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "already existed" in result.output
|
|
||||||
mock_tag.assert_called_once_with("0.1.0", "changelog", False)
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {})
|
|
||||||
@patch("scripts.ci.release.has_user_facing_changes", return_value=True)
|
|
||||||
@patch("scripts.ci.release.create_and_push_tag", return_value=True)
|
|
||||||
@patch("scripts.ci.release.commit_release_changes", return_value=True)
|
|
||||||
@patch("scripts.ci.release.update_changelog")
|
|
||||||
@patch("scripts.ci.release.update_init_version")
|
|
||||||
@patch("scripts.ci.release.get_changelog", return_value="changelog")
|
|
||||||
@patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0")
|
|
||||||
@patch("scripts.ci.release.get_bumped_version", return_value="0.2.0")
|
|
||||||
@patch("scripts.ci.release.has_unreleased_changes", return_value=True)
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_full_flow_skip_tests(
|
|
||||||
self,
|
|
||||||
mock_run_cmd: MagicMock,
|
|
||||||
mock_has: MagicMock,
|
|
||||||
mock_bumped: MagicMock,
|
|
||||||
mock_latest: MagicMock,
|
|
||||||
mock_changelog: MagicMock,
|
|
||||||
mock_update_init: MagicMock,
|
|
||||||
mock_update_changelog: MagicMock,
|
|
||||||
mock_commit: MagicMock,
|
|
||||||
mock_tag: MagicMock,
|
|
||||||
mock_user: MagicMock,
|
|
||||||
) -> None:
|
|
||||||
"""--skip-tests bypasses test verification."""
|
|
||||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--skip-tests"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "WARNING: --skip-tests" in result.output
|
|
||||||
# run_tests should NOT be called — verify no "make lint-ruff" or "make pytest-cov" calls
|
|
||||||
make_calls = [c.args[0] for c in mock_run_cmd.call_args_list if c.args[0][:1] == ["make"]]
|
|
||||||
assert make_calls == []
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {})
|
|
||||||
@patch("scripts.ci.release.has_user_facing_changes", return_value=True)
|
|
||||||
@patch("scripts.ci.release.create_and_push_tag")
|
|
||||||
@patch("scripts.ci.release.commit_release_changes")
|
|
||||||
@patch("scripts.ci.release.update_changelog")
|
|
||||||
@patch("scripts.ci.release.update_init_version")
|
|
||||||
@patch("scripts.ci.release.get_changelog", return_value="changelog")
|
|
||||||
@patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0")
|
|
||||||
@patch("scripts.ci.release.get_bumped_version", return_value="0.2.0")
|
|
||||||
@patch("scripts.ci.release.has_unreleased_changes", return_value=True)
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_tests_fail_aborts_before_tag(
|
|
||||||
self,
|
|
||||||
mock_run_cmd: MagicMock,
|
|
||||||
mock_has: MagicMock,
|
|
||||||
mock_bumped: MagicMock,
|
|
||||||
mock_latest: MagicMock,
|
|
||||||
mock_changelog: MagicMock,
|
|
||||||
mock_update_init: MagicMock,
|
|
||||||
mock_update_changelog: MagicMock,
|
|
||||||
mock_commit: MagicMock,
|
|
||||||
mock_tag: MagicMock,
|
|
||||||
mock_user: MagicMock,
|
|
||||||
) -> None:
|
|
||||||
"""If tests fail, release aborts — no commit, no tag."""
|
|
||||||
# Calls: git rev-parse (master), git log -1 (release lock check),
|
|
||||||
# make lint-ruff (success), make pytest-cov (failure)
|
|
||||||
mock_run_cmd.side_effect = [
|
|
||||||
MagicMock(returncode=0, stdout="master\n", stderr=""),
|
|
||||||
MagicMock(returncode=0, stdout="GRM-50 fix: something\n", stderr=""),
|
|
||||||
MagicMock(returncode=0, stdout="", stderr=""),
|
|
||||||
MagicMock(returncode=1, stdout="", stderr="test failure"),
|
|
||||||
]
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, [])
|
|
||||||
assert result.exit_code != 0
|
|
||||||
assert "Tests failed" in result.output
|
|
||||||
mock_commit.assert_not_called()
|
|
||||||
mock_tag.assert_not_called()
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {})
|
|
||||||
@patch("scripts.ci.release.has_user_facing_changes", return_value=True)
|
|
||||||
@patch("scripts.ci.release.create_and_push_tag")
|
|
||||||
@patch("scripts.ci.release.commit_release_changes")
|
|
||||||
@patch("scripts.ci.release.update_changelog")
|
|
||||||
@patch("scripts.ci.release.update_init_version")
|
|
||||||
@patch("scripts.ci.release.get_changelog", return_value="changelog")
|
|
||||||
@patch("scripts.ci.release.get_latest_tag", return_value="v0.1.0")
|
|
||||||
@patch("scripts.ci.release.get_bumped_version", return_value="0.2.0")
|
|
||||||
@patch("scripts.ci.release.has_unreleased_changes", return_value=True)
|
|
||||||
@patch("scripts.ci.release.run_cmd")
|
|
||||||
def test_lint_fail_aborts_before_tag(
|
|
||||||
self,
|
|
||||||
mock_run_cmd: MagicMock,
|
|
||||||
mock_has: MagicMock,
|
|
||||||
mock_bumped: MagicMock,
|
|
||||||
mock_latest: MagicMock,
|
|
||||||
mock_changelog: MagicMock,
|
|
||||||
mock_update_init: MagicMock,
|
|
||||||
mock_update_changelog: MagicMock,
|
|
||||||
mock_commit: MagicMock,
|
|
||||||
mock_tag: MagicMock,
|
|
||||||
mock_user: MagicMock,
|
|
||||||
) -> None:
|
|
||||||
"""If lint fails, release aborts — no commit, no tag."""
|
|
||||||
# Calls: git rev-parse (master), git log -1 (release lock check),
|
|
||||||
# make lint-ruff (failure)
|
|
||||||
mock_run_cmd.side_effect = [
|
|
||||||
MagicMock(returncode=0, stdout="master\n", stderr=""),
|
|
||||||
MagicMock(returncode=0, stdout="GRM-50 fix: something\n", stderr=""),
|
|
||||||
MagicMock(returncode=1, stdout="", stderr="lint error"),
|
|
||||||
]
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, [])
|
|
||||||
assert result.exit_code != 0
|
|
||||||
assert "Lint failed" in result.output
|
|
||||||
mock_commit.assert_not_called()
|
|
||||||
mock_tag.assert_not_called()
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
import click
|
|
||||||
import pytest
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
import scripts.setup as setup
|
|
||||||
|
|
||||||
|
|
||||||
class TestRun:
|
|
||||||
def test_run_success(self) -> None:
|
|
||||||
with patch("subprocess.run") as mock_run:
|
|
||||||
setup._run(["echo", "hello"], ".venv/bin")
|
|
||||||
mock_run.assert_called_once_with(["echo", "hello"], check=True)
|
|
||||||
|
|
||||||
def test_run_failure(self) -> None:
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, ["echo"])):
|
|
||||||
with pytest.raises(subprocess.CalledProcessError):
|
|
||||||
setup._run(["echo", "hello"], ".venv/bin")
|
|
||||||
|
|
||||||
|
|
||||||
class TestInstallPythonDeps:
|
|
||||||
def test_install_default_dev(self) -> None:
|
|
||||||
with patch("scripts.setup._run") as mock_run:
|
|
||||||
setup._install_python_deps(".venv/bin")
|
|
||||||
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[dev]"], ".venv/bin")
|
|
||||||
|
|
||||||
def test_install_ci_extras(self) -> None:
|
|
||||||
with patch("scripts.setup._run") as mock_run:
|
|
||||||
setup._install_python_deps(".venv/bin", extras="ci")
|
|
||||||
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci]"], ".venv/bin")
|
|
||||||
|
|
||||||
def test_install_lint_extras(self) -> None:
|
|
||||||
with patch("scripts.setup._run") as mock_run:
|
|
||||||
setup._install_python_deps(".venv/bin", extras="ci,lint")
|
|
||||||
mock_run.assert_called_once_with([".venv/bin/pip", "install", "-e", ".[ci,lint]"], ".venv/bin")
|
|
||||||
|
|
||||||
|
|
||||||
class TestInstallAnsibleCollections:
|
|
||||||
def test_install(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
(tmp_path / "ansible").mkdir()
|
|
||||||
(tmp_path / "ansible/requirements.yml").write_text("collections: []")
|
|
||||||
with patch("scripts.setup._run") as mock_run:
|
|
||||||
setup._install_ansible_collections(".venv/bin")
|
|
||||||
mock_run.assert_called_once()
|
|
||||||
|
|
||||||
def test_no_requirements(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
with patch("scripts.setup._run") as mock_run:
|
|
||||||
setup._install_ansible_collections(".venv/bin")
|
|
||||||
mock_run.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
class TestInstallPreCommitHooks:
|
|
||||||
def test_install(self) -> None:
|
|
||||||
with patch("scripts.setup._run") as mock_run:
|
|
||||||
setup._install_pre_commit_hooks(".venv/bin")
|
|
||||||
assert mock_run.call_count == 3
|
|
||||||
calls = [c.args[0] for c in mock_run.call_args_list]
|
|
||||||
# Each call should have the pre-commit binary and --hook-type flag
|
|
||||||
for call in calls:
|
|
||||||
assert ".venv/bin/pre-commit" in call[0]
|
|
||||||
assert "--hook-type" in call
|
|
||||||
|
|
||||||
|
|
||||||
class TestVerify:
|
|
||||||
def test_verify_success(self) -> None:
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
mock_result = subprocess.CompletedProcess(
|
|
||||||
args=["grm", "--version"], returncode=0, stdout="grm 1.0.0", stderr=""
|
|
||||||
)
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
setup._verify(".venv/bin")
|
|
||||||
|
|
||||||
def test_verify_not_found(self) -> None:
|
|
||||||
|
|
||||||
with patch("subprocess.run", side_effect=FileNotFoundError()):
|
|
||||||
setup._verify(".venv/bin")
|
|
||||||
|
|
||||||
def test_verify_timeout(self) -> None:
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd=["grm", "--version"], timeout=10)):
|
|
||||||
setup._verify(".venv/bin")
|
|
||||||
|
|
||||||
|
|
||||||
class TestConfigureTeaLogin:
|
|
||||||
def test_tea_not_installed_raises(self) -> None:
|
|
||||||
"""tea must be installed — setup fails if it's missing."""
|
|
||||||
with patch("shutil.which", return_value=None):
|
|
||||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
|
||||||
with pytest.raises(click.ClickException, match="not installed"):
|
|
||||||
setup._configure_tea_login()
|
|
||||||
|
|
||||||
def test_no_repo_token(self) -> None:
|
|
||||||
with patch("shutil.which", return_value="/usr/bin/tea"):
|
|
||||||
with patch.dict("os.environ", {}, clear=True):
|
|
||||||
setup._configure_tea_login()
|
|
||||||
|
|
||||||
def test_login_already_exists(self) -> None:
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
mock_result = subprocess.CompletedProcess(
|
|
||||||
args=["tea", "login", "list"], returncode=0, stdout="grm https://git.example.com", stderr=""
|
|
||||||
)
|
|
||||||
with patch("shutil.which", return_value="/usr/bin/tea"):
|
|
||||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
|
||||||
with patch("subprocess.run", return_value=mock_result):
|
|
||||||
setup._configure_tea_login()
|
|
||||||
|
|
||||||
def test_login_added_successfully(self) -> None:
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
list_result = subprocess.CompletedProcess(args=["tea", "login", "list"], returncode=0, stdout="", stderr="")
|
|
||||||
add_result = subprocess.CompletedProcess(
|
|
||||||
args=["tea", "login", "add"], returncode=0, stdout="Login added", stderr=""
|
|
||||||
)
|
|
||||||
default_result = subprocess.CompletedProcess(
|
|
||||||
args=["tea", "login", "default"], returncode=0, stdout="", stderr=""
|
|
||||||
)
|
|
||||||
with patch("shutil.which", return_value="/usr/bin/tea"):
|
|
||||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
|
||||||
with patch("subprocess.run", side_effect=[list_result, add_result, default_result]):
|
|
||||||
setup._configure_tea_login()
|
|
||||||
|
|
||||||
def test_login_add_failure(self) -> None:
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
list_result = subprocess.CompletedProcess(args=["tea", "login", "list"], returncode=0, stdout="", stderr="")
|
|
||||||
add_result = subprocess.CompletedProcess(
|
|
||||||
args=["tea", "login", "add"], returncode=1, stdout="", stderr="auth failed"
|
|
||||||
)
|
|
||||||
with patch("shutil.which", return_value="/usr/bin/tea"):
|
|
||||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
|
||||||
with patch("subprocess.run", side_effect=[list_result, add_result]):
|
|
||||||
setup._configure_tea_login()
|
|
||||||
|
|
||||||
|
|
||||||
class TestMain:
|
|
||||||
def test_bin_not_found(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(setup.main, ["--bin", "nonexistent/bin"])
|
|
||||||
assert result.exit_code != 0
|
|
||||||
assert "Bin directory not found" in result.output
|
|
||||||
|
|
||||||
def test_success(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
bin_dir = tmp_path / ".venv" / "bin"
|
|
||||||
bin_dir.mkdir(parents=True)
|
|
||||||
(bin_dir / "grm").touch()
|
|
||||||
(bin_dir / "pre-commit").touch()
|
|
||||||
(tmp_path / "ansible").mkdir()
|
|
||||||
(tmp_path / "ansible/requirements.yml").write_text("collections: []")
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
with patch("scripts.setup._install_python_deps"):
|
|
||||||
with patch("scripts.setup._install_ansible_collections"):
|
|
||||||
with patch("scripts.setup._install_pre_commit_hooks"):
|
|
||||||
with patch("scripts.setup._configure_tea_login"):
|
|
||||||
with patch("scripts.setup._verify"):
|
|
||||||
result = runner.invoke(setup.main, ["--bin", str(bin_dir)])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Setup complete" in result.output
|
|
||||||
|
|
||||||
def test_extras_ci(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""--extras ci should pass 'ci' to _install_python_deps."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
bin_dir = tmp_path / ".venv" / "bin"
|
|
||||||
bin_dir.mkdir(parents=True)
|
|
||||||
runner = CliRunner()
|
|
||||||
with patch("scripts.setup._install_python_deps") as mock_deps:
|
|
||||||
with patch("scripts.setup._install_ansible_collections"):
|
|
||||||
with patch("scripts.setup._install_pre_commit_hooks"):
|
|
||||||
with patch("scripts.setup._configure_tea_login"):
|
|
||||||
with patch("scripts.setup._verify"):
|
|
||||||
result = runner.invoke(setup.main, ["--bin", str(bin_dir), "--extras", "ci"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
mock_deps.assert_called_once_with(str(bin_dir), "ci")
|
|
||||||
|
|
||||||
def test_no_ansible_collections(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""--no-ansible-collections should skip ansible collection install."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
bin_dir = tmp_path / ".venv" / "bin"
|
|
||||||
bin_dir.mkdir(parents=True)
|
|
||||||
runner = CliRunner()
|
|
||||||
with patch("scripts.setup._install_python_deps"):
|
|
||||||
with patch("scripts.setup._install_ansible_collections") as mock_ansible:
|
|
||||||
with patch("scripts.setup._install_pre_commit_hooks"):
|
|
||||||
with patch("scripts.setup._configure_tea_login"):
|
|
||||||
with patch("scripts.setup._verify"):
|
|
||||||
result = runner.invoke(
|
|
||||||
setup.main,
|
|
||||||
["--bin", str(bin_dir), "--no-ansible-collections"],
|
|
||||||
)
|
|
||||||
assert result.exit_code == 0
|
|
||||||
mock_ansible.assert_not_called()
|
|
||||||
|
|
||||||
def test_no_pre_commit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""--no-pre-commit should skip pre-commit hook install."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
bin_dir = tmp_path / ".venv" / "bin"
|
|
||||||
bin_dir.mkdir(parents=True)
|
|
||||||
runner = CliRunner()
|
|
||||||
with patch("scripts.setup._install_python_deps"):
|
|
||||||
with patch("scripts.setup._install_ansible_collections"):
|
|
||||||
with patch("scripts.setup._install_pre_commit_hooks") as mock_hooks:
|
|
||||||
with patch("scripts.setup._configure_tea_login"):
|
|
||||||
with patch("scripts.setup._verify"):
|
|
||||||
result = runner.invoke(
|
|
||||||
setup.main,
|
|
||||||
["--bin", str(bin_dir), "--no-pre-commit"],
|
|
||||||
)
|
|
||||||
assert result.exit_code == 0
|
|
||||||
mock_hooks.assert_not_called()
|
|
||||||
|
|
||||||
def test_no_tea_login(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""--no-tea-login should skip tea login configuration."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
bin_dir = tmp_path / ".venv" / "bin"
|
|
||||||
bin_dir.mkdir(parents=True)
|
|
||||||
runner = CliRunner()
|
|
||||||
with patch("scripts.setup._install_python_deps"):
|
|
||||||
with patch("scripts.setup._install_ansible_collections"):
|
|
||||||
with patch("scripts.setup._install_pre_commit_hooks"):
|
|
||||||
with patch("scripts.setup._configure_tea_login") as mock_tea:
|
|
||||||
with patch("scripts.setup._verify"):
|
|
||||||
result = runner.invoke(
|
|
||||||
setup.main,
|
|
||||||
["--bin", str(bin_dir), "--no-tea-login"],
|
|
||||||
)
|
|
||||||
assert result.exit_code == 0
|
|
||||||
mock_tea.assert_not_called()
|
|
||||||
|
|
||||||
def test_lean_ci_setup(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
||||||
"""Simulate the setup-ci make target: all skip flags together."""
|
|
||||||
monkeypatch.chdir(tmp_path)
|
|
||||||
bin_dir = tmp_path / ".venv" / "bin"
|
|
||||||
bin_dir.mkdir(parents=True)
|
|
||||||
runner = CliRunner()
|
|
||||||
with patch("scripts.setup._install_python_deps") as mock_deps:
|
|
||||||
with patch("scripts.setup._install_ansible_collections") as mock_ansible:
|
|
||||||
with patch("scripts.setup._install_pre_commit_hooks") as mock_hooks:
|
|
||||||
with patch("scripts.setup._configure_tea_login") as mock_tea:
|
|
||||||
with patch("scripts.setup._verify"):
|
|
||||||
result = runner.invoke(
|
|
||||||
setup.main,
|
|
||||||
[
|
|
||||||
"--bin",
|
|
||||||
str(bin_dir),
|
|
||||||
"--extras",
|
|
||||||
"ci",
|
|
||||||
"--no-ansible-collections",
|
|
||||||
"--no-pre-commit",
|
|
||||||
"--no-tea-login",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
assert result.exit_code == 0
|
|
||||||
mock_deps.assert_called_once_with(str(bin_dir), "ci")
|
|
||||||
mock_ansible.assert_not_called()
|
|
||||||
mock_hooks.assert_not_called()
|
|
||||||
mock_tea.assert_not_called()
|
|
||||||
@@ -1,483 +0,0 @@
|
|||||||
"""Unit tests for scripts/ci/sync_wiki.py."""
|
|
||||||
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
from pathlib import Path
|
|
||||||
from unittest.mock import MagicMock, patch
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
from scripts.ci.sync_wiki import (
|
|
||||||
decode_content,
|
|
||||||
encode_content,
|
|
||||||
fetch_page_content,
|
|
||||||
list_wiki_pages,
|
|
||||||
load_mapping,
|
|
||||||
main,
|
|
||||||
read_doc_content,
|
|
||||||
sync_page,
|
|
||||||
verify_wiki_integrity,
|
|
||||||
verify_wiki_page,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestEncodeContent:
|
|
||||||
def test_encodes_utf8_to_base64(self) -> None:
|
|
||||||
result = encode_content("# Hello World")
|
|
||||||
assert result == base64.b64encode(b"# Hello World").decode("ascii")
|
|
||||||
|
|
||||||
def test_encodes_empty_string(self) -> None:
|
|
||||||
assert encode_content("") == ""
|
|
||||||
|
|
||||||
def test_encodes_unicode(self) -> None:
|
|
||||||
result = encode_content("# Café — résumé")
|
|
||||||
decoded = base64.b64decode(result).decode("utf-8")
|
|
||||||
assert decoded == "# Café — résumé"
|
|
||||||
|
|
||||||
|
|
||||||
class TestDecodeContent:
|
|
||||||
def test_decodes_base64_to_utf8(self) -> None:
|
|
||||||
encoded = base64.b64encode(b"# Hello").decode("ascii")
|
|
||||||
assert decode_content(encoded) == "# Hello"
|
|
||||||
|
|
||||||
def test_empty_string_returns_empty(self) -> None:
|
|
||||||
assert decode_content("") == ""
|
|
||||||
|
|
||||||
def test_roundtrip(self) -> None:
|
|
||||||
original = "# Wiki Page\n\nContent with **markdown**."
|
|
||||||
encoded = encode_content(original)
|
|
||||||
assert decode_content(encoded) == original
|
|
||||||
|
|
||||||
|
|
||||||
class TestLoadMapping:
|
|
||||||
def test_loads_mapping(self, tmp_path: Path) -> None:
|
|
||||||
mapping_file = tmp_path / "mapping.json"
|
|
||||||
mapping_file.write_text(json.dumps({"user/getting-started.md": "Getting-Started"}))
|
|
||||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE", mapping_file):
|
|
||||||
result = load_mapping()
|
|
||||||
assert result == {"user/getting-started.md": "Getting-Started"}
|
|
||||||
|
|
||||||
def test_missing_mapping_raises(self, tmp_path: Path) -> None:
|
|
||||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE", tmp_path / "nonexistent.json"):
|
|
||||||
with pytest.raises(FileNotFoundError):
|
|
||||||
load_mapping()
|
|
||||||
|
|
||||||
|
|
||||||
class TestReadDocContent:
|
|
||||||
def test_reads_file(self, tmp_path: Path) -> None:
|
|
||||||
docs_dir = tmp_path / "docs"
|
|
||||||
docs_dir.mkdir()
|
|
||||||
(docs_dir / "test.md").write_text("# Test\n\nContent")
|
|
||||||
with patch("scripts.ci.sync_wiki.DOCS_DIR", docs_dir):
|
|
||||||
content = read_doc_content("test.md")
|
|
||||||
assert content == "# Test\n\nContent"
|
|
||||||
|
|
||||||
def test_missing_file_raises(self, tmp_path: Path) -> None:
|
|
||||||
with patch("scripts.ci.sync_wiki.DOCS_DIR", tmp_path):
|
|
||||||
with pytest.raises(FileNotFoundError):
|
|
||||||
read_doc_content("nonexistent.md")
|
|
||||||
|
|
||||||
|
|
||||||
class TestListWikiPages:
|
|
||||||
def test_returns_empty_on_api_error(self) -> None:
|
|
||||||
from gitea_runner_manager.exceptions import APIError
|
|
||||||
|
|
||||||
client = MagicMock()
|
|
||||||
client._request.side_effect = APIError(404, "not found")
|
|
||||||
result = list_wiki_pages(client)
|
|
||||||
assert result == {}
|
|
||||||
|
|
||||||
def test_returns_page_dict(self) -> None:
|
|
||||||
client = MagicMock()
|
|
||||||
client._request.return_value.json.return_value = [
|
|
||||||
{"title": "Home", "sub_url": "Home"},
|
|
||||||
{"title": "Getting-Started", "sub_url": "Getting-Started.-"},
|
|
||||||
]
|
|
||||||
result = list_wiki_pages(client)
|
|
||||||
assert result == {"Home": "Home", "Getting-Started": "Getting-Started.-"}
|
|
||||||
|
|
||||||
|
|
||||||
class TestFetchPageContent:
|
|
||||||
def test_fetches_and_decodes_content(self) -> None:
|
|
||||||
client = MagicMock()
|
|
||||||
encoded = base64.b64encode(b"# Hello Wiki").decode("ascii")
|
|
||||||
client._request.return_value.json.return_value = {"content_base64": encoded}
|
|
||||||
result = fetch_page_content(client, "Home")
|
|
||||||
assert result == "# Hello Wiki"
|
|
||||||
|
|
||||||
def test_returns_empty_on_api_error(self) -> None:
|
|
||||||
from gitea_runner_manager.exceptions import APIError
|
|
||||||
|
|
||||||
client = MagicMock()
|
|
||||||
client._request.side_effect = APIError(404, "not found")
|
|
||||||
assert fetch_page_content(client, "Missing") == ""
|
|
||||||
|
|
||||||
def test_returns_empty_for_empty_content(self) -> None:
|
|
||||||
client = MagicMock()
|
|
||||||
client._request.return_value.json.return_value = {"content_base64": ""}
|
|
||||||
assert fetch_page_content(client, "Home") == ""
|
|
||||||
|
|
||||||
|
|
||||||
class TestSyncPage:
|
|
||||||
def test_dry_run_skips(self) -> None:
|
|
||||||
client = MagicMock()
|
|
||||||
result = sync_page(client, "Test-Page", "# Content", {}, dry_run=True)
|
|
||||||
assert result == "skipped"
|
|
||||||
client._request.assert_not_called()
|
|
||||||
|
|
||||||
def test_creates_new_page_with_base64(self) -> None:
|
|
||||||
client = MagicMock()
|
|
||||||
result = sync_page(client, "New-Page", "# Content", {}, dry_run=False)
|
|
||||||
assert result == "created"
|
|
||||||
client._request.assert_called_once()
|
|
||||||
call_args = client._request.call_args
|
|
||||||
assert call_args.args[0] == "POST"
|
|
||||||
assert call_args.args[1] == "/wiki/new"
|
|
||||||
# Verify content_base64 is used, not content
|
|
||||||
payload = call_args.kwargs["json"]
|
|
||||||
assert "content_base64" in payload
|
|
||||||
assert "content" not in payload
|
|
||||||
assert base64.b64decode(payload["content_base64"]).decode("utf-8") == "# Content"
|
|
||||||
|
|
||||||
def test_updates_existing_page_with_base64(self) -> None:
|
|
||||||
client = MagicMock()
|
|
||||||
existing = {"Existing-Page": "Existing-Page.-"}
|
|
||||||
result = sync_page(client, "Existing-Page", "# Updated", existing, dry_run=False)
|
|
||||||
assert result == "updated"
|
|
||||||
client._request.assert_called_once()
|
|
||||||
call_args = client._request.call_args
|
|
||||||
assert call_args.args[0] == "PATCH"
|
|
||||||
assert "/wiki/page/Existing-Page.-" in call_args.args[1]
|
|
||||||
# Verify content_base64 is used
|
|
||||||
payload = call_args.kwargs["json"]
|
|
||||||
assert "content_base64" in payload
|
|
||||||
assert "content" not in payload
|
|
||||||
assert base64.b64decode(payload["content_base64"]).decode("utf-8") == "# Updated"
|
|
||||||
|
|
||||||
|
|
||||||
class TestVerifyWikiPage:
|
|
||||||
def test_verifies_matching_content(self) -> None:
|
|
||||||
client = MagicMock()
|
|
||||||
encoded = base64.b64encode(b"# Hello Wiki").decode("ascii")
|
|
||||||
client._request.return_value.json.return_value = {"content_base64": encoded}
|
|
||||||
existing = {"Home": "Home"}
|
|
||||||
assert verify_wiki_page(client, "Home", "# Hello Wiki", existing) is True
|
|
||||||
|
|
||||||
def test_fails_on_mismatch(self) -> None:
|
|
||||||
client = MagicMock()
|
|
||||||
encoded = base64.b64encode(b"# Old Content").decode("ascii")
|
|
||||||
client._request.return_value.json.return_value = {"content_base64": encoded}
|
|
||||||
existing = {"Home": "Home"}
|
|
||||||
assert verify_wiki_page(client, "Home", "# New Content", existing) is False
|
|
||||||
|
|
||||||
def test_fails_on_empty_wiki_content(self) -> None:
|
|
||||||
client = MagicMock()
|
|
||||||
client._request.return_value.json.return_value = {"content_base64": ""}
|
|
||||||
existing = {"Home": "Home"}
|
|
||||||
assert verify_wiki_page(client, "Home", "# Expected", existing) is False
|
|
||||||
|
|
||||||
def test_fails_when_page_not_in_existing(self) -> None:
|
|
||||||
client = MagicMock()
|
|
||||||
assert verify_wiki_page(client, "Missing", "# Content", {}) is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestVerifyWikiIntegrity:
|
|
||||||
def _make_client(self, pages: dict[str, str], contents: dict[str, str]) -> MagicMock:
|
|
||||||
"""Create a mock client that returns the given pages and contents."""
|
|
||||||
client = MagicMock()
|
|
||||||
# list_wiki_pages calls GET /wiki/pages
|
|
||||||
page_list = [{"title": t, "sub_url": s} for t, s in pages.items()]
|
|
||||||
|
|
||||||
# fetch_page_content calls GET /wiki/page/{sub_url}
|
|
||||||
def mock_request(method, path, **kwargs):
|
|
||||||
resp = MagicMock()
|
|
||||||
if path == "/wiki/pages":
|
|
||||||
resp.json.return_value = page_list
|
|
||||||
elif path.startswith("/wiki/page/"):
|
|
||||||
sub_url = path.replace("/wiki/page/", "")
|
|
||||||
content = contents.get(sub_url, "")
|
|
||||||
encoded = base64.b64encode(content.encode()).decode("ascii") if content else ""
|
|
||||||
resp.json.return_value = {"content_base64": encoded}
|
|
||||||
return resp
|
|
||||||
|
|
||||||
client._request.side_effect = mock_request
|
|
||||||
return client
|
|
||||||
|
|
||||||
def test_all_good_no_failures(self) -> None:
|
|
||||||
pages = {"Home": "Home", "FAQ": "FAQ"}
|
|
||||||
contents = {"Home": "# Home", "FAQ": "# FAQ"}
|
|
||||||
client = self._make_client(pages, contents)
|
|
||||||
mapping = {"index.md": "Home", "faq.md": "FAQ"}
|
|
||||||
synced = {"Home": "# Home", "FAQ": "# FAQ"}
|
|
||||||
failures = verify_wiki_integrity(client, mapping, synced)
|
|
||||||
assert failures == []
|
|
||||||
|
|
||||||
def test_missing_page_detected(self) -> None:
|
|
||||||
pages = {"Home": "Home"} # FAQ missing from wiki
|
|
||||||
contents = {"Home": "# Home"}
|
|
||||||
client = self._make_client(pages, contents)
|
|
||||||
mapping = {"index.md": "Home", "faq.md": "FAQ"}
|
|
||||||
synced = {"Home": "# Home"}
|
|
||||||
failures = verify_wiki_integrity(client, mapping, synced)
|
|
||||||
assert any("Missing page: FAQ" in f for f in failures)
|
|
||||||
|
|
||||||
def test_stale_page_detected(self) -> None:
|
|
||||||
pages = {"Home": "Home", "Old-Page": "Old-Page"} # Old-Page not in mapping
|
|
||||||
contents = {"Home": "# Home", "Old-Page": "# Old"}
|
|
||||||
client = self._make_client(pages, contents)
|
|
||||||
mapping = {"index.md": "Home"}
|
|
||||||
synced = {"Home": "# Home"}
|
|
||||||
failures = verify_wiki_integrity(client, mapping, synced)
|
|
||||||
assert any("Stale page" in f and "Old-Page" in f for f in failures)
|
|
||||||
|
|
||||||
def test_page_count_mismatch_detected(self) -> None:
|
|
||||||
pages = {"Home": "Home", "Extra": "Extra"}
|
|
||||||
contents = {"Home": "# Home", "Extra": "# Extra"}
|
|
||||||
client = self._make_client(pages, contents)
|
|
||||||
mapping = {"index.md": "Home"}
|
|
||||||
synced = {"Home": "# Home"}
|
|
||||||
failures = verify_wiki_integrity(client, mapping, synced)
|
|
||||||
assert any("Page count mismatch" in f for f in failures)
|
|
||||||
|
|
||||||
def test_empty_content_detected(self) -> None:
|
|
||||||
pages = {"Home": "Home"}
|
|
||||||
contents = {"Home": ""} # Empty content
|
|
||||||
client = self._make_client(pages, contents)
|
|
||||||
mapping = {"index.md": "Home"}
|
|
||||||
synced = {"Home": "# Expected Content"}
|
|
||||||
failures = verify_wiki_integrity(client, mapping, synced)
|
|
||||||
assert any("Empty content: Home" in f for f in failures)
|
|
||||||
|
|
||||||
def test_content_mismatch_detected(self) -> None:
|
|
||||||
pages = {"Home": "Home"}
|
|
||||||
contents = {"Home": "# Wrong Content"}
|
|
||||||
client = self._make_client(pages, contents)
|
|
||||||
mapping = {"index.md": "Home"}
|
|
||||||
synced = {"Home": "# Correct Content"}
|
|
||||||
failures = verify_wiki_integrity(client, mapping, synced)
|
|
||||||
assert any("Content mismatch: Home" in f for f in failures)
|
|
||||||
|
|
||||||
def test_multiple_failures_all_reported(self) -> None:
|
|
||||||
pages = {"Home": "Home", "Stale": "Stale"}
|
|
||||||
contents = {"Home": "", "Stale": "# Stale"}
|
|
||||||
client = self._make_client(pages, contents)
|
|
||||||
mapping = {"index.md": "Home", "faq.md": "FAQ"} # FAQ missing
|
|
||||||
synced = {"Home": "# Home Content"}
|
|
||||||
failures = verify_wiki_integrity(client, mapping, synced)
|
|
||||||
assert len(failures) >= 3 # count mismatch, missing FAQ, stale Stale, empty Home
|
|
||||||
|
|
||||||
|
|
||||||
class TestMain:
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
|
||||||
@patch("scripts.ci.sync_wiki.MAPPING_FILE")
|
|
||||||
@patch("scripts.ci.sync_wiki.DOCS_DIR")
|
|
||||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
|
||||||
def test_dry_run(self, mock_client_cls: MagicMock, mock_docs_dir: Path, mock_mapping_file: Path) -> None:
|
|
||||||
mock_mapping_file.exists.return_value = True
|
|
||||||
mock_mapping_file.__str__ = lambda _: "/docs/mapping.json"
|
|
||||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
|
||||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
|
||||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={}):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "dry-run" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
|
|
||||||
def test_missing_token_exits(self) -> None:
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--repo", "owner/repo"])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "REPO_TOKEN" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "GRM_REPO_OWNER": "me", "GRM_REPO_NAME": "myrepo"}, clear=True)
|
|
||||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
|
||||||
def test_auto_detect_repo(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
"""Test that repo is auto-detected from env vars when --repo is not passed."""
|
|
||||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
|
||||||
mock_mapping.exists.return_value = True
|
|
||||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
|
||||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
|
||||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={}):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--dry-run"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
mock_client_cls.assert_called_once()
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
|
||||||
def test_missing_mapping_file(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
"""Test that missing mapping.json exits with error."""
|
|
||||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
|
||||||
mock_mapping.exists.return_value = False
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--repo", "owner/repo"])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "mapping.json" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
|
||||||
def test_existing_pages_message(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
"""Test that existing wiki pages are reported."""
|
|
||||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
|
||||||
mock_mapping.exists.return_value = True
|
|
||||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
|
||||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
|
||||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "existing wiki pages" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
|
||||||
def test_file_not_found_warning(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
"""Test that missing doc files are skipped with a warning."""
|
|
||||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
|
||||||
mock_mapping.exists.return_value = True
|
|
||||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"missing.md": "Missing"}):
|
|
||||||
with patch("scripts.ci.sync_wiki.read_doc_content", side_effect=FileNotFoundError):
|
|
||||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={}):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "not found" in result.output
|
|
||||||
assert "Skipped: 1" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
|
||||||
def test_empty_doc_file_skipped(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
"""Test that empty doc files are skipped with a warning."""
|
|
||||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
|
||||||
mock_mapping.exists.return_value = True
|
|
||||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"empty.md": "Empty-Page"}):
|
|
||||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value=" \n "):
|
|
||||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={}):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--dry-run", "--repo", "owner/repo"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "empty" in result.output.lower()
|
|
||||||
assert "Skipped: 1" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
|
||||||
def test_create_and_update(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
"""Test that pages are created and updated correctly (non-dry-run)."""
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
|
||||||
mock_mapping.exists.return_value = True
|
|
||||||
mapping = {"new.md": "New-Page", "existing.md": "Existing-Page"}
|
|
||||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value=mapping):
|
|
||||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Content"):
|
|
||||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Existing-Page": "Existing-Page"}):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--repo", "owner/repo"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Created: 1" in result.output
|
|
||||||
assert "Updated: 1" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
|
||||||
def test_verify_passes(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
"""Test that --verify passes when content matches."""
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
encoded = base64.b64encode(b"# Home Content").decode("ascii")
|
|
||||||
# list_wiki_pages returns {"Home": "Home"}, fetch returns encoded content
|
|
||||||
mock_client._request.return_value.json.return_value = {"content_base64": encoded}
|
|
||||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
|
||||||
mock_mapping.exists.return_value = True
|
|
||||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
|
||||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home Content"):
|
|
||||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
|
|
||||||
with patch("scripts.ci.sync_wiki.verify_wiki_page", return_value=True):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--repo", "owner/repo", "--verify"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Verification passed" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
|
||||||
def test_verify_fails_on_empty_content(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
"""Test that --verify fails when wiki pages have empty content."""
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
|
||||||
mock_mapping.exists.return_value = True
|
|
||||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
|
||||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home Content"):
|
|
||||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
|
|
||||||
with patch("scripts.ci.sync_wiki.verify_wiki_page", return_value=False):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--repo", "owner/repo", "--verify"])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "FAIL" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
|
||||||
def test_verify_skipped_in_dry_run(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
"""Test that --verify is skipped during dry-run."""
|
|
||||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
|
||||||
mock_mapping.exists.return_value = True
|
|
||||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
|
||||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
|
||||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={}):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--dry-run", "--verify", "--repo", "owner/repo"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Verification" not in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
|
||||||
def test_strict_passes(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
"""Test that --strict passes when integrity check succeeds."""
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
|
||||||
mock_mapping.exists.return_value = True
|
|
||||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
|
||||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
|
||||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
|
|
||||||
with patch("scripts.ci.sync_wiki.verify_wiki_integrity", return_value=[]):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--repo", "owner/repo", "--strict"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Integrity check passed" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
|
||||||
def test_strict_fails_on_integrity_issues(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
"""Test that --strict fails when integrity check finds issues."""
|
|
||||||
mock_client = MagicMock()
|
|
||||||
mock_client_cls.return_value = mock_client
|
|
||||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
|
||||||
mock_mapping.exists.return_value = True
|
|
||||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
|
||||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
|
||||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
|
|
||||||
with patch(
|
|
||||||
"scripts.ci.sync_wiki.verify_wiki_integrity",
|
|
||||||
return_value=["Missing page: FAQ", "Stale page: Old-Page"],
|
|
||||||
):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--repo", "owner/repo", "--strict"])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "Integrity check FAILED" in result.output
|
|
||||||
assert "Missing page: FAQ" in result.output
|
|
||||||
assert "Stale page: Old-Page" in result.output
|
|
||||||
|
|
||||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
|
||||||
@patch("scripts.ci.sync_wiki.GiteaClient")
|
|
||||||
def test_strict_skipped_in_dry_run(self, mock_client_cls: MagicMock) -> None:
|
|
||||||
"""Test that --strict verification is skipped during dry-run."""
|
|
||||||
with patch("scripts.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
|
||||||
mock_mapping.exists.return_value = True
|
|
||||||
with patch("scripts.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
|
||||||
with patch("scripts.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
|
||||||
with patch("scripts.ci.sync_wiki.list_wiki_pages", return_value={}):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, ["--dry-run", "--strict", "--repo", "owner/repo"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
assert "Integrity check" not in result.output
|
|
||||||
@@ -1,176 +0,0 @@
|
|||||||
"""Unit tests for scripts/ci/validate_commit_msg.py."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import subprocess
|
|
||||||
import tempfile
|
|
||||||
from unittest.mock import patch
|
|
||||||
|
|
||||||
from click.testing import CliRunner
|
|
||||||
|
|
||||||
from gitea_runner_manager.config import CONVENTIONAL_RE, TASK_ID_RE
|
|
||||||
from scripts.ci.validate_commit_msg import first_line, get_branch, main
|
|
||||||
|
|
||||||
|
|
||||||
class TestHelpers:
|
|
||||||
def test_first_line_single(self) -> None:
|
|
||||||
assert first_line("feat: add something") == "feat: add something"
|
|
||||||
|
|
||||||
def test_first_line_multiline(self) -> None:
|
|
||||||
msg = "feat: add something\n\nBody text here.\nMore body."
|
|
||||||
assert first_line(msg) == "feat: add something"
|
|
||||||
|
|
||||||
def test_conventional_re_matches_valid(self) -> None:
|
|
||||||
assert CONVENTIONAL_RE.match("feat: add feature")
|
|
||||||
assert CONVENTIONAL_RE.match("fix: bug fix")
|
|
||||||
assert CONVENTIONAL_RE.match("chore: update deps")
|
|
||||||
assert CONVENTIONAL_RE.match("docs: update readme")
|
|
||||||
assert CONVENTIONAL_RE.match("style: format code")
|
|
||||||
assert CONVENTIONAL_RE.match("refactor: simplify")
|
|
||||||
assert CONVENTIONAL_RE.match("perf: speed up")
|
|
||||||
assert CONVENTIONAL_RE.match("test: add tests")
|
|
||||||
assert CONVENTIONAL_RE.match("ci: update workflow")
|
|
||||||
assert CONVENTIONAL_RE.match("build: update deps")
|
|
||||||
assert CONVENTIONAL_RE.match("revert: undo change")
|
|
||||||
|
|
||||||
def test_conventional_re_allows_scope(self) -> None:
|
|
||||||
assert CONVENTIONAL_RE.match("feat(cli): add --url option")
|
|
||||||
assert CONVENTIONAL_RE.match("fix(api): handle timeout")
|
|
||||||
|
|
||||||
def test_conventional_re_rejects_invalid(self) -> None:
|
|
||||||
assert not CONVENTIONAL_RE.match("GRM-19: feat: something")
|
|
||||||
assert not CONVENTIONAL_RE.match("random message")
|
|
||||||
assert not CONVENTIONAL_RE.match("feat:")
|
|
||||||
assert not CONVENTIONAL_RE.match(": description")
|
|
||||||
|
|
||||||
def test_task_id_re_matches(self) -> None:
|
|
||||||
assert TASK_ID_RE.match("GRM-19: feat: something")
|
|
||||||
assert TASK_ID_RE.match("GRM-123: fix: bug")
|
|
||||||
|
|
||||||
def test_task_id_re_rejects(self) -> None:
|
|
||||||
assert not TASK_ID_RE.match("feat: something")
|
|
||||||
assert not TASK_ID_RE.match("GRM: something")
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetBranch:
|
|
||||||
def test_returns_branch_name(self) -> None:
|
|
||||||
with patch("subprocess.run") as mock_run:
|
|
||||||
mock_run.return_value.stdout = "feature-branch\n"
|
|
||||||
mock_run.return_value.returncode = 0
|
|
||||||
assert get_branch() == "feature-branch"
|
|
||||||
|
|
||||||
def test_returns_empty_on_error(self) -> None:
|
|
||||||
with patch("subprocess.run", side_effect=subprocess.CalledProcessError(1, "git")):
|
|
||||||
assert get_branch() == ""
|
|
||||||
|
|
||||||
|
|
||||||
class TestMain:
|
|
||||||
def _write_msg(self, content: str) -> str:
|
|
||||||
fd, path = tempfile.mkstemp()
|
|
||||||
with os.fdopen(fd, "w") as f:
|
|
||||||
f.write(content)
|
|
||||||
return path
|
|
||||||
|
|
||||||
def test_rejects_task_id_on_feature_branch(self) -> None:
|
|
||||||
msg_path = self._write_msg("GRM-19: feat: add feature")
|
|
||||||
with patch("scripts.ci.validate_commit_msg.get_branch", return_value="GRM-19"):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, [msg_path])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "task ID" in result.output
|
|
||||||
|
|
||||||
def test_accepts_conventional_on_feature_branch(self) -> None:
|
|
||||||
msg_path = self._write_msg("feat: add feature")
|
|
||||||
with patch("scripts.ci.validate_commit_msg.get_branch", return_value="GRM-19"):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, [msg_path])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
|
|
||||||
def test_accepts_valid_master_commit(self) -> None:
|
|
||||||
msg_path = self._write_msg("GRM-19: feat: add feature")
|
|
||||||
with patch("scripts.ci.validate_commit_msg.get_branch", return_value="master"):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, [msg_path])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
|
|
||||||
def test_rejects_master_without_task_id(self) -> None:
|
|
||||||
msg_path = self._write_msg("feat: add feature")
|
|
||||||
with patch("scripts.ci.validate_commit_msg.get_branch", return_value="master"):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, [msg_path])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "task ID" in result.output
|
|
||||||
|
|
||||||
def test_rejects_master_with_non_conventional_after_task_id(self) -> None:
|
|
||||||
msg_path = self._write_msg("GRM-19: random message")
|
|
||||||
with patch("scripts.ci.validate_commit_msg.get_branch", return_value="master"):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, [msg_path])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "conventional" in result.output
|
|
||||||
|
|
||||||
def test_rejects_non_conventional_on_feature_branch(self) -> None:
|
|
||||||
msg_path = self._write_msg("random message")
|
|
||||||
with patch("scripts.ci.validate_commit_msg.get_branch", return_value="feature"):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, [msg_path])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "conventional" in result.output
|
|
||||||
|
|
||||||
def test_accepts_multiline_conventional(self) -> None:
|
|
||||||
msg_path = self._write_msg("feat: add feature\n\nBody text.\nMore text.")
|
|
||||||
with patch("scripts.ci.validate_commit_msg.get_branch", return_value="feature"):
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, [msg_path])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
|
|
||||||
def test_usage_message_without_args(self) -> None:
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, [])
|
|
||||||
assert result.exit_code == 2
|
|
||||||
|
|
||||||
def test_branch_override_accepts_master_commit(self) -> None:
|
|
||||||
"""--branch master overrides branch detection (for CI use)."""
|
|
||||||
msg_path = self._write_msg("GRM-19: feat: add feature")
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, [msg_path, "--branch", "master"])
|
|
||||||
assert result.exit_code == 0
|
|
||||||
|
|
||||||
def test_branch_override_rejects_missing_task_id(self) -> None:
|
|
||||||
"""--branch master still enforces GRM-N: prefix."""
|
|
||||||
msg_path = self._write_msg("feat: add feature")
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, [msg_path, "--branch", "master"])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "task ID" in result.output
|
|
||||||
|
|
||||||
def test_branch_override_feature_accepts_conventional(self) -> None:
|
|
||||||
"""--branch feature still rejects GRM-N prefix."""
|
|
||||||
msg_path = self._write_msg("GRM-19: feat: add feature")
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(main, [msg_path, "--branch", "feature"])
|
|
||||||
assert result.exit_code == 1
|
|
||||||
assert "task ID" in result.output
|
|
||||||
|
|
||||||
|
|
||||||
def test_main_module_block() -> None:
|
|
||||||
import tempfile
|
|
||||||
|
|
||||||
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
|
|
||||||
f.write("GRM-1: feat: test")
|
|
||||||
msg_path = f.name
|
|
||||||
|
|
||||||
with patch("scripts.ci.validate_commit_msg.get_branch", return_value="master"):
|
|
||||||
import scripts.ci.validate_commit_msg as vcm
|
|
||||||
|
|
||||||
with open(vcm.__file__) as f:
|
|
||||||
source = f.read()
|
|
||||||
# Remove __main__ block so exec doesn't call main() before we control sys.argv
|
|
||||||
source = source.replace('if __name__ == "__main__":\n main()\n', "")
|
|
||||||
namespace = dict(vcm.__dict__)
|
|
||||||
exec(compile(source, vcm.__file__, "exec"), namespace)
|
|
||||||
# exec() redefines get_branch() from source, overwriting the mock.
|
|
||||||
# Restore the patched mock so main() uses it.
|
|
||||||
namespace["get_branch"] = vcm.get_branch
|
|
||||||
namespace["main"]([msg_path], standalone_mode=False)
|
|
||||||
|
|
||||||
os.unlink(msg_path)
|
|
||||||
Reference in New Issue
Block a user