Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f14ef14dc6 | ||
|
|
6758b69a5f | ||
|
|
ba5b05bde2 | ||
|
|
041e5ac4aa | ||
|
|
a0f997cb3e | ||
|
|
00828527f9 | ||
|
|
564b917234 | ||
|
|
f445085d54 | ||
|
|
60a7f72156 | ||
|
|
5174e103f7 | ||
|
|
1189807d4e | ||
|
|
9e420e3dab | ||
|
|
c9c46e88ac | ||
|
|
1e04d38d59 | ||
|
|
dcb2ed0fd9 | ||
|
|
5e270f21d1 | ||
|
|
8e532839fd | ||
|
|
f5177c823c | ||
|
|
f649120172 | ||
|
|
7d3cf999d6 | ||
|
|
3ae263fb00 | ||
|
|
505673bc62 | ||
|
|
a791800809 | ||
|
|
d1531ac81f | ||
|
|
0bf78d4f83 | ||
|
|
ad3c43bf8e | ||
|
|
28e61aa166 | ||
|
|
b8ab4f854b | ||
|
|
8bde4cd12b | ||
|
|
b6e87a519b | ||
|
|
bec7b59671 | ||
|
|
7a6d93cddc | ||
|
|
81e8c2a159 | ||
|
|
fe715f99be | ||
|
|
eedef42c13 | ||
|
|
1f75017395 | ||
|
|
8c16703106 | ||
|
|
69089f6d2c | ||
|
|
ff0e733e07 | ||
|
|
8dc014a907 | ||
|
|
599fc17dd3 | ||
|
|
5cd7c15d11 | ||
|
|
1b3c3f6ca8 | ||
|
|
7591c99c02 | ||
|
|
789890b9ea | ||
|
|
f8327a8e89 | ||
|
|
d8d025789b | ||
|
|
5b4aaffa3b | ||
|
|
b2b7383266 | ||
|
|
c8e12dc722 | ||
|
|
31edf866f3 | ||
|
|
9959b9c4ed | ||
|
|
749b4d025f | ||
|
|
28b4acf323 | ||
|
|
f5431c54cf | ||
|
|
cff8a35244 | ||
|
|
54a584d609 | ||
|
|
402e2dce7e | ||
|
|
7dfc9f6014 | ||
|
|
a0e6cd0a73 | ||
|
|
a0b03f01ef | ||
|
|
3f5808d6be | ||
|
|
60c94b2b93 | ||
|
|
2ca56ed317 | ||
|
|
fb76ac91ef | ||
|
|
0c54efbf6b | ||
|
|
945344f960 | ||
|
|
713e752860 | ||
|
|
e0ae01e36e | ||
|
|
5511cba1b0 | ||
|
|
56eb241b77 | ||
|
|
6b3f2a5866 | ||
|
|
6f181e85e1 | ||
|
|
539bfea516 | ||
|
|
5b05db4e6d | ||
|
|
7fe85423b4 | ||
|
|
76d9983514 | ||
|
|
3dcdde80ad | ||
|
|
2e5ca5a88f | ||
|
|
e7f8e4ac66 |
+14
-3
@@ -1,11 +1,14 @@
|
||||
# Gitea instance URL (used for runner registration and API validation)
|
||||
GITEA_URL=https://git.example.com
|
||||
|
||||
# Runner registration token from Gitea admin panel:
|
||||
# Admin → Actions → Runners → Create Registration Token
|
||||
# Runner registration token from Gitea.
|
||||
# Three levels are available:
|
||||
# Instance-level: Site Administration → Actions → Runners → Create Registration Token
|
||||
# Org-level: Organization → Settings → Actions → Runners → Create Registration Token
|
||||
# Repo-level: Repository → Settings → Actions → Runners → Create Registration Token
|
||||
GITEA_REGISTRATION_TOKEN=your-registration-token
|
||||
|
||||
# Gitea API token for optional post-install API checks (informational only).
|
||||
# Gitea admin API token for optional post-install API checks (informational only).
|
||||
# The integration test primarily verifies the runner by checking:
|
||||
# 1. The .runner registration file exists and is valid
|
||||
# 2. The container/service is running
|
||||
@@ -33,3 +36,11 @@ GITEA_REGISTRATION_TOKEN=your-registration-token
|
||||
# UI language for GRM console messages (optional, default: en)
|
||||
# Supported: en, bg, de, ru, zh
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# actionlint configuration for Gitea Actions workflows
|
||||
# https://github.com/rhysd/actionlint/blob/main/docs/config.md
|
||||
#
|
||||
# Run: actionlint -config-file .gitea/actionlint.yaml .gitea/workflows/*.yml
|
||||
|
||||
# Custom self-hosted runner labels used in runs-on
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
- docker
|
||||
@@ -1,25 +0,0 @@
|
||||
name: Auto-merge
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
jobs:
|
||||
merge:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
run: python3 -m pip install --break-system-packages requests python-dotenv click
|
||||
- name: Squash merge with task ID
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
python3 scripts/auto_merge.py \
|
||||
"${{ github.head_ref }}" \
|
||||
"${{ github.event.pull_request.title }}" \
|
||||
"${{ github.repository }}" \
|
||||
"${{ github.event.number }}" \
|
||||
"${{ github.event.label.name }}"
|
||||
+167
-36
@@ -3,84 +3,159 @@ name: CI
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
push:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
run: make setup
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
run: make setup-quality
|
||||
- name: Lint all
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
make lint-all
|
||||
- name: Unit tests with 100% coverage
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
make pytest-cov
|
||||
- name: Check unit test speed
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 scripts/check_test_speed.py --max-seconds 10
|
||||
- name: Release dry-run validation
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 10
|
||||
- name: Dependency security scan
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
PYTHONPATH=src python3 scripts/release.py --dry-run || true
|
||||
# Install pip in venv if missing (needed by pip-audit)
|
||||
.venv/bin/python -m ensurepip 2>/dev/null || true
|
||||
PIPAPI_PYTHON_LOCATION=$PWD/.venv/bin/python \
|
||||
pip-audit --desc --skip-editable 2>&1 || true
|
||||
- name: Workflow dry-run validation
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
# Best-effort: only runs if act_runner is installed
|
||||
if command -v act_runner >/dev/null 2>&1; then
|
||||
make workflow-dryrun
|
||||
else
|
||||
echo "act_runner not found — skipping workflow dry-run (static lint still passed)"
|
||||
fi
|
||||
|
||||
detect-changes:
|
||||
release-dry-run:
|
||||
needs: [quality, detect-changes]
|
||||
if: needs.detect-changes.outputs.user-facing-changed == 'true'
|
||||
runs-on: docker
|
||||
outputs:
|
||||
ansible-changed: ${{ steps.detect.outputs.ansible-changed }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
run: make setup-release
|
||||
- name: Release dry-run validation
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
DEVX_VERSION_FILE: src/gitea_runner_manager/__init__.py
|
||||
DEVX_TASK_PREFIX: GRM
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.release --dry-run || true
|
||||
|
||||
detect-changes:
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
ansible-changed: ${{ steps.detect.outputs.ansible-changed }}
|
||||
user-facing-changed: ${{ steps.detect.outputs.user-facing-changed }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
run: make setup-ci
|
||||
- name: Detect changed paths
|
||||
id: detect
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
DEVX_TASK_PREFIX: GRM
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||
BASE="origin/master"
|
||||
HEAD="${{ github.event.pull_request.head.sha }}"
|
||||
else
|
||||
BASE="HEAD~1"
|
||||
HEAD="HEAD"
|
||||
fi
|
||||
# Check if any Ansible-related files changed
|
||||
ANSIBLE_CHANGED=$(git diff --name-only "$BASE" "$HEAD" -- ansible/ .ansible-lint 2>/dev/null | head -1)
|
||||
if [ -n "$ANSIBLE_CHANGED" ]; then
|
||||
echo "ansible-changed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Ansible files changed — molecule tests will run."
|
||||
else
|
||||
echo "ansible-changed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No Ansible files changed — skipping molecule tests."
|
||||
fi
|
||||
. .venv/bin/activate
|
||||
python3 -m devx.ci.classify_changes \
|
||||
--base "origin/master" \
|
||||
--head "${{ github.event.pull_request.head.sha || github.sha }}" \
|
||||
--github-output
|
||||
|
||||
molecule-tests:
|
||||
needs: [quality, detect-changes]
|
||||
discover-runners:
|
||||
needs: [detect-changes]
|
||||
if: needs.detect-changes.outputs.ansible-changed == 'true'
|
||||
runs-on: docker
|
||||
strategy:
|
||||
matrix:
|
||||
runner-index: [0, 1, 2]
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
runner-count: ${{ steps.discover.outputs.runner-count }}
|
||||
runner-indices: ${{ steps.discover.outputs.runner-indices }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
run: make setup
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
run: make setup-ci
|
||||
- name: Discover available runners
|
||||
id: discover
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
MOLECULE_RUNNERS: ${{ vars.MOLECULE_RUNNERS }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 -m devx.molecule.discover_runners \
|
||||
--owner "${{ github.repository_owner }}" \
|
||||
--repo "${{ github.event.repository.name }}" \
|
||||
--github-output
|
||||
|
||||
molecule-tests:
|
||||
needs: [quality, detect-changes, discover-runners]
|
||||
if: needs.detect-changes.outputs.ansible-changed == 'true'
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
strategy:
|
||||
matrix:
|
||||
runner-index: [1, 2, 3]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
run: make setup-molecule
|
||||
- name: Discover assigned test pairs
|
||||
env:
|
||||
RUNNER_INDEX: ${{ matrix.runner-index }}
|
||||
MAX_RUNNERS: ${{ needs.discover-runners.outputs.runner-count }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
PAIRS=$(python3 scripts/distribute_molecule.py --runner-index ${{ matrix.runner-index }} --max-runners 3)
|
||||
echo "Assigned pairs: $PAIRS"
|
||||
echo "TEST_PAIRS=$PAIRS" >> $GITHUB_ENV
|
||||
python3 -m devx.molecule.distribute_molecule \
|
||||
--runner-index "$RUNNER_INDEX" \
|
||||
--max-runners "$MAX_RUNNERS" \
|
||||
--github-env --skip-if-excess
|
||||
- name: Run molecule tests
|
||||
if: env.SKIP != 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. .venv/bin/activate
|
||||
python3 scripts/molecule_ci_guard.py $TEST_PAIRS
|
||||
if [ -z "$TEST_PAIRS" ]; then exit 0; fi
|
||||
# shellcheck disable=SC2086 # intentional word splitting for argument expansion
|
||||
python3 -m devx.molecule.molecule_ci_guard $TEST_PAIRS
|
||||
env:
|
||||
GITEA_URL: ${{ github.server_url }}
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
@@ -88,3 +163,59 @@ jobs:
|
||||
JOB_NAME: ${{ github.job }}
|
||||
MATRIX_INDEX: ${{ matrix.runner-index }}
|
||||
GITEA_REPOSITORY: ${{ github.repository }}
|
||||
PYTHONPATH: src
|
||||
|
||||
pr-review:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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@v0.4.1"
|
||||
- name: Run automated PR review
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
set -euo pipefail
|
||||
python3 -m devx.ci.pr_review \
|
||||
"${{ github.event.number }}" \
|
||||
"${{ github.repository }}"
|
||||
|
||||
auto-merge:
|
||||
# Auto-merge runs after all CI checks pass. It reads the task ID
|
||||
# from .taskid file, validates the PR title, and squash-merges.
|
||||
# No manual label or review needed — CI is the quality gate.
|
||||
needs: [quality, detect-changes, pr-review]
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.REPO_TOKEN }}
|
||||
- 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@v0.4.1"
|
||||
- name: Squash merge with task ID
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
DEVX_TASK_PREFIX: GRM
|
||||
DEVX_VIKUNJA_PROJECT_ID: 6
|
||||
HEAD_REF: ${{ github.head_ref }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
run: |
|
||||
python3 -m devx.ci.auto_merge \
|
||||
"$HEAD_REF" \
|
||||
"$PR_TITLE" \
|
||||
"$REPOSITORY" \
|
||||
"$PR_NUMBER"
|
||||
|
||||
@@ -1,23 +1,262 @@
|
||||
name: Post-merge Vikunja update
|
||||
name: Post-merge
|
||||
|
||||
# Runs on every push to master. A single workflow with conditional jobs
|
||||
# replaces the previous 4 separate workflows (release.yml, post-merge.yml,
|
||||
# sync-wiki.yml, and the badges job from ci.yml).
|
||||
#
|
||||
# Job dependency graph:
|
||||
#
|
||||
# detect-type ──┬── release (skip if release commit)
|
||||
# ├── badges (ALWAYS runs — even on release commits)
|
||||
# ├── configure-repo (independent — skip if release commit)
|
||||
# ├── sync-wiki (needs release — skip if release commit/fails)
|
||||
# └── vikunja (needs release — skip if release commit/fails)
|
||||
#
|
||||
# sync-wiki and vikunja depend on release succeeding so that the wiki
|
||||
# and task tracker are only updated when the code is actually released.
|
||||
# If release fails, they are skipped to avoid leaving the wiki or
|
||||
# Vikunja in an inconsistent state with the codebase on master.
|
||||
#
|
||||
# The badges job depends on release so it picks up the latest version
|
||||
# number. It uses `if: always()` with no is-release condition so it
|
||||
# runs on every push to master, including release commits. This
|
||||
# ensures badges (tests, coverage, version, etc.) are always current.
|
||||
#
|
||||
# When release.py creates a "release: vX.Y.Z" commit, the release
|
||||
# commit's post-merge run still updates badges (version badge picks
|
||||
# up the new version). Other jobs skip. The tag push triggers publish.yml.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
vikunja:
|
||||
detect-type:
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
is-release: ${{ steps.check.outputs.is-release }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: 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@v0.4.1"
|
||||
- name: Check if this is a release commit
|
||||
id: check
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: python3 -m devx.ci.detect_release_commit
|
||||
|
||||
validate-commit-msg:
|
||||
needs: [detect-type]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
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@v0.4.1"
|
||||
- name: Validate latest commit message
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
git log -1 --format=%B > commit-msg.txt
|
||||
python3 -m devx.ci.validate_commit_msg commit-msg.txt --branch master
|
||||
rm -f commit-msg.txt
|
||||
|
||||
release:
|
||||
needs: [detect-type]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.REPO_TOKEN }}
|
||||
- name: Set up environment
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
run: make setup-release
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.name "grm-ci-bot"
|
||||
git config user.email "grm-ci-bot@oblachno.fyi"
|
||||
- name: Run release
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
DEVX_VERSION_FILE: src/gitea_runner_manager/__init__.py
|
||||
DEVX_TASK_PREFIX: GRM
|
||||
DEVX_VIKUNJA_PROJECT_ID: 6
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.release
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
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 }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/release" \
|
||||
--commit "${{ github.sha }}"
|
||||
|
||||
sync-wiki:
|
||||
needs: [detect-type, release]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
run: make setup-ci
|
||||
- name: Sync documentation to wiki
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --strict
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
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 }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/sync-wiki" \
|
||||
--commit "${{ github.sha }}"
|
||||
|
||||
badges:
|
||||
needs: [detect-type, release]
|
||||
if: always()
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: master
|
||||
token: ${{ secrets.REPO_TOKEN }}
|
||||
- name: Fetch latest master
|
||||
run: |
|
||||
git fetch origin master
|
||||
git reset --hard origin/master
|
||||
- name: Set up environment
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
run: make setup-ci
|
||||
- name: Generate and push badges
|
||||
env:
|
||||
PRE_COMMIT_ALLOW_NO_CONFIG: "1"
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 -m devx.ci.push_badges
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
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 }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/badges" \
|
||||
--commit "${{ github.sha }}"
|
||||
|
||||
vikunja:
|
||||
needs: [detect-type, release]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- 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@v0.4.1"
|
||||
- name: Update Vikunja task
|
||||
env:
|
||||
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
DEVX_TASK_PREFIX: GRM
|
||||
DEVX_VIKUNJA_PROJECT_ID: 6
|
||||
run: python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
python3 scripts/post_merge.py \
|
||||
"$(git log -1 --pretty=%B)" \
|
||||
--commit-sha "$(git rev-parse HEAD)"
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
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 }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/vikunja" \
|
||||
--commit "${{ github.sha }}"
|
||||
|
||||
configure-repo:
|
||||
needs: [detect-type]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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@v0.4.1"
|
||||
- name: Ensure branch protection and labels
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
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
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
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 }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/configure-repo" \
|
||||
--commit "${{ github.sha }}"
|
||||
|
||||
@@ -8,36 +8,33 @@ on:
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Install git-cliff
|
||||
- name: Install CI tools
|
||||
run: |
|
||||
GIT_CLIFF_VERSION="2.13.0"
|
||||
URL="https://github.com/orhun/git-cliff/releases/download/v${GIT_CLIFF_VERSION}/git-cliff-${GIT_CLIFF_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
|
||||
TMPDIR="$(mktemp -d)"
|
||||
curl -sL "$URL" | tar xz -C "$TMPDIR"
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
mv "$TMPDIR/git-cliff-${GIT_CLIFF_VERSION}/git-cliff" "$HOME/.local/bin/git-cliff"
|
||||
chmod +x "$HOME/.local/bin/git-cliff"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
"$HOME/.local/bin/git-cliff" --version
|
||||
. .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@v0.4.1"
|
||||
python3 -m devx.tools.install_tools --tool git-cliff --tool tea
|
||||
- name: Install build tools
|
||||
run: python3 -m pip install --break-system-packages build twine
|
||||
- name: Configure tea login
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
run: |
|
||||
python3 -m pip install --break-system-packages build twine requests python-dotenv click
|
||||
- name: Validate PYPI_TOKEN
|
||||
run: |
|
||||
if [ -z "${{ secrets.PYPI_TOKEN }}" ]; then
|
||||
echo "::warning::PYPI_TOKEN is not set — package will be built but not published to PyPI."
|
||||
fi
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
tea login add --name grm --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
|
||||
tea login default grm || true
|
||||
- name: Build and publish release
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYPI_TOKEN: ${{ secrets.PYPI_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
python3 scripts/publish.py \
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.publish \
|
||||
"${{ github.ref_name }}" \
|
||||
"${{ github.repository }}"
|
||||
- name: Notify on failure
|
||||
@@ -46,7 +43,8 @@ jobs:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
python3 scripts/notify_failure.py \
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.notify_failure \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "publish" \
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: docker
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.REPO_TOKEN }}
|
||||
- name: Install git-cliff
|
||||
run: |
|
||||
GIT_CLIFF_VERSION="2.13.0"
|
||||
URL="https://github.com/orhun/git-cliff/releases/download/v${GIT_CLIFF_VERSION}/git-cliff-${GIT_CLIFF_VERSION}-x86_64-unknown-linux-gnu.tar.gz"
|
||||
TMPDIR="$(mktemp -d)"
|
||||
curl -sL "$URL" | tar xz -C "$TMPDIR"
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
mv "$TMPDIR/git-cliff-${GIT_CLIFF_VERSION}/git-cliff" "$HOME/.local/bin/git-cliff"
|
||||
chmod +x "$HOME/.local/bin/git-cliff"
|
||||
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
|
||||
"$HOME/.local/bin/git-cliff" --version
|
||||
- name: Install Python dependencies
|
||||
run: python3 -m pip install --break-system-packages requests python-dotenv click
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.name "grm-ci-bot"
|
||||
git config user.email "grm-ci-bot@oblachno.fyi"
|
||||
- name: Run release
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
python3 scripts/release.py
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
python3 scripts/notify_failure.py \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "release" \
|
||||
--commit "${{ github.sha }}"
|
||||
@@ -35,3 +35,6 @@ bandit-report.*
|
||||
activate.sh
|
||||
activate.fish
|
||||
activate.zsh
|
||||
|
||||
# Generated badges (CI pushes to badges branch)
|
||||
.badges/
|
||||
|
||||
+11
-2
@@ -3,7 +3,7 @@ repos:
|
||||
hooks:
|
||||
- id: validate-commit-msg
|
||||
name: validate commit message
|
||||
entry: .venv/bin/python scripts/validate_commit_msg.py
|
||||
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.validate_commit_msg
|
||||
language: system
|
||||
stages: [commit-msg]
|
||||
pass_filenames: true
|
||||
@@ -48,6 +48,15 @@ repos:
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: workflow-lint
|
||||
name: actionlint (workflow YAML)
|
||||
entry: make workflow-lint
|
||||
language: system
|
||||
files: ^\.gitea/workflows/
|
||||
types: [yaml]
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: pytest-cov
|
||||
name: pytest with 100% coverage
|
||||
entry: make pytest-cov
|
||||
@@ -58,7 +67,7 @@ repos:
|
||||
|
||||
- id: commit-msg
|
||||
name: validate commit message
|
||||
entry: .venv/bin/python scripts/validate_commit_msg.py
|
||||
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.validate_commit_msg
|
||||
language: system
|
||||
stages: [commit-msg]
|
||||
pass_filenames: true
|
||||
|
||||
@@ -3,20 +3,49 @@
|
||||
## Build & Test Commands
|
||||
|
||||
```bash
|
||||
make setup # Create venv, install deps, set up hooks
|
||||
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake
|
||||
make setup # Create venv, install deps, set up hooks, install CI tools
|
||||
make install-tools # Install actionlint, git-cliff, act_runner to ~/.local/bin
|
||||
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake + actionlint
|
||||
make pytest-cov # Unit tests with 100% coverage enforcement
|
||||
make test-unit # Unit tests without coverage
|
||||
make molecule # All 6 scenarios on Ubuntu 22.04
|
||||
make molecule-all # All 6 scenarios on all 4 supported OSes
|
||||
make test-all # pytest-cov + molecule
|
||||
make workflow-lint # Static lint of .gitea/workflows/*.yml (actionlint)
|
||||
make workflow-dryrun # Dry-run all workflows in Docker (act_runner exec --dryrun)
|
||||
make workflow-check # workflow-lint + workflow-dryrun
|
||||
```
|
||||
|
||||
`make setup` automatically installs all development tools:
|
||||
- **Python deps** via `devx.tools.setup` (pip install -e .[dev], ansible-galaxy, pre-commit hooks)
|
||||
- **devx package** via `make install-devx` (installs the devx package from git, providing all CI/CD tools)
|
||||
- **checkmake** via `devx.tools.install_checkmake` (Makefile linter)
|
||||
- **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 YAML files (`.gitea/workflows/*.yml`) are verified with two tools:
|
||||
|
||||
1. **actionlint** — Static linter that catches syntax errors, invalid
|
||||
expressions, unknown keys, type mismatches, and shellcheck issues.
|
||||
Config: `.gitea/actionlint.yaml` (registers custom `docker` runner label).
|
||||
Installed automatically by `make setup` via `devx.tools.install_tools`.
|
||||
|
||||
2. **act_runner exec --dryrun** — Gitea's own runner in dry-run mode.
|
||||
Validates job dependencies, step ordering, and Docker image selection
|
||||
without starting containers. Installed automatically by `make setup`.
|
||||
|
||||
Both run via `make workflow-check` and are part of `make lint-all`.
|
||||
The pre-commit hook runs actionlint automatically when workflow files change.
|
||||
The CI `quality` job runs `make setup` (which installs all tools) then `make lint-all`.
|
||||
CI also runs a best-effort `make workflow-dryrun` step (skipped if act_runner is not installed in the CI Docker image).
|
||||
|
||||
## Architecture
|
||||
|
||||
- **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
|
||||
- **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
|
||||
|
||||
## PR Workflow (Mandatory)
|
||||
@@ -25,7 +54,12 @@ Every change to master goes through this workflow. No exceptions.
|
||||
|
||||
### Branch Protection (Required Gitea Settings)
|
||||
|
||||
Configure the following branch protection rules for `master` in Gitea repo settings:
|
||||
Branch protection and labels are automatically configured by
|
||||
`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 following rules are enforced for `master`:
|
||||
- **Require pull request**: No direct pushes to master
|
||||
- **Require approval review**: At least 1 `APPROVE` review before merge
|
||||
- **Require status checks**: CI quality + molecule tests must pass
|
||||
@@ -62,30 +96,40 @@ docs: update README
|
||||
- Add `ready-to-merge` label **only after review is complete**
|
||||
|
||||
### 6. Review the PR (Mandatory — Before Adding ready-to-merge Label)
|
||||
Review the full diff (`git diff master...HEAD`) focusing on:
|
||||
|
||||
- **Functional completeness**: Does the code do what it claims? Are all requirements met?
|
||||
- **Edge cases**: Are boundary conditions, empty inputs, error paths handled?
|
||||
- **Technical excellence**:
|
||||
- Architecture compliance and evolution
|
||||
- Single Responsibility Principle (SRP)
|
||||
- Deduplication (no copy-paste, single source of truth)
|
||||
- Code smells detection and removal
|
||||
- Best industry practices
|
||||
- Industry-grade code quality
|
||||
- Reusability
|
||||
- Clean code
|
||||
- Readability
|
||||
- Maintainability
|
||||
- Extensibility
|
||||
- **Performance**: No unnecessary allocations, O(n) vs O(n²), efficient data structures
|
||||
- **Security**: No secrets in logs/process list, input validation, no injection vectors
|
||||
- **User experience**: Clear error messages, intuitive CLI flags, helpful output
|
||||
- **Documentation**: Completeness and relevance of docs, CHANGELOG entries, AGENTS.md updates
|
||||
**Review checklist:** Every PR is reviewed against
|
||||
[REVIEW_CHECKLIST.md](REVIEW_CHECKLIST.md) — 13 categories covering
|
||||
architecture, code quality, security, i18n, testing, performance,
|
||||
UX, documentation, workflow compliance, maintainability, resource
|
||||
management, backwards compatibility, and logging.
|
||||
|
||||
Post review comments using `scripts/review_pr.py`:
|
||||
**Automated review (CI `pr-review` job):** Every PR triggers an automated
|
||||
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
|
||||
the **[auto]** items in the checklist:
|
||||
|
||||
- Architecture compliance (no subprocess in CLI, no hardcoded URLs)
|
||||
- Best practices (no `print()`, no bare `except`, no `TODO`/`FIXME`,
|
||||
no functions > 50 lines)
|
||||
- Security (no hardcoded secrets, no `shell=True`, no `eval`/`exec`)
|
||||
- i18n (no raw strings in `click.echo()` without `_()` wrapper)
|
||||
- Resource management (no `open()` without `with`, no `Popen()` without cleanup)
|
||||
- Documentation (source changes must include doc updates)
|
||||
- Test coverage (source changes must include test updates)
|
||||
- Commit conventions (conventional commit format on PR commits)
|
||||
|
||||
The automated review posts inline comments on specific lines and
|
||||
includes a link to the full checklist. The agent **must** address all
|
||||
`REQUEST_CHANGES` issues before proceeding.
|
||||
|
||||
**Manual review (agent):** After the automated review passes, the agent
|
||||
must go through **every category** in `REVIEW_CHECKLIST.md` and verify
|
||||
the **[manual]** items by reviewing the full diff
|
||||
(`git diff master...HEAD`).
|
||||
|
||||
Post review comments using `devx.ci.review_pr` (run as `python -m devx.ci.review_pr`):
|
||||
```bash
|
||||
REPO_TOKEN=<token> python3 scripts/review_pr.py <pr_number> <owner/repo> \
|
||||
REPO_TOKEN=<token> python -m devx.ci.review_pr <pr_number> <owner/repo> \
|
||||
--event REQUEST_CHANGES \
|
||||
--body "Review summary" \
|
||||
--comments-json comments.json
|
||||
@@ -95,21 +139,37 @@ REPO_TOKEN=<token> python3 scripts/review_pr.py <pr_number> <owner/repo> \
|
||||
Fix each comment one by one, commit, and push. Re-review until satisfied.
|
||||
|
||||
### 8. Approve and Merge
|
||||
Once all comments are addressed:
|
||||
Once all checklist items are verified and comments are addressed, post
|
||||
an approval review with `--checklist-confirmed` and `--checklist-categories`:
|
||||
```bash
|
||||
REPO_TOKEN=<token> python3 scripts/review_pr.py <pr_number> <owner/repo> \
|
||||
--event APPROVE \
|
||||
--body "All comments addressed. LGTM."
|
||||
REPO_TOKEN=<token> python -m devx.ci.review_pr <pr_number> <owner/repo> \
|
||||
--event APPROVE --checklist-confirmed \
|
||||
--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>."
|
||||
```
|
||||
|
||||
The `--checklist-confirmed` flag is **required** for APPROVE events —
|
||||
it attests that the reviewer has gone through every checklist category.
|
||||
The `--checklist-categories` flag is also **required** — it must list at
|
||||
least 8 of the 13 category numbers, ensuring the reviewer actually
|
||||
checked each category rather than rubber-stamping. The review body must
|
||||
be substantive (> 50 characters) — trivial approvals like "LGTM" are
|
||||
rejected.
|
||||
|
||||
Then add the `ready-to-merge` label. The auto-merge workflow will:
|
||||
1. **Validate** PR title format and match against Vikunja task title
|
||||
2. **Check** that at least one APPROVE review exists
|
||||
3. Wait for all CI checks to pass
|
||||
4. Squash-merge with title: `GRM-N <conventional commit message>` (space-separated)
|
||||
1. **Validate** PR title format (`GRM-N: <vikunja task title>`) and match against Vikunja task title
|
||||
2. **Check** that at least one substantive APPROVE review exists (body > 20 chars or has inline comments)
|
||||
3. Wait for all CI checks to pass (including the `pr-review` job)
|
||||
4. Squash-merge with title: `GRM-N <conventional commit message>` (space-separated, no colon after GRM-N)
|
||||
5. The post-merge workflow marks the Vikunja task as done
|
||||
6. The release workflow automatically versions, tags, and publishes (see below)
|
||||
|
||||
> **IMPORTANT**: Never manually merge PRs via the API. Always use the auto-merge
|
||||
> workflow by adding the `ready-to-merge` label. Manual merges bypass the
|
||||
> `GRM-N <conventional>` format enforcement, producing incorrectly named commits.
|
||||
> The auto-merge script validates the PR title matches the Vikunja task ID
|
||||
> and conventional commit format before merging.
|
||||
|
||||
### CI Path Filtering
|
||||
|
||||
The CI workflow includes a `detect-changes` job that checks whether any files
|
||||
@@ -118,21 +178,174 @@ changed, molecule tests are skipped — this prevents non-Ansible changes
|
||||
(e.g., Python scripts, workflow YAML, docs) from being blocked by molecule
|
||||
test infrastructure flakiness.
|
||||
|
||||
### Dynamic Runner Discovery
|
||||
|
||||
Molecule tests are distributed across available Gitea Actions runners
|
||||
dynamically via `devx.molecule.discover_runners`. The `discover-runners`
|
||||
job queries the Gitea API for runners at all levels (repo, org, instance)
|
||||
and generates a dynamic matrix. If the API can't see instance-level runners
|
||||
(no admin scope), it falls back to the `MOLECULE_RUNNERS` repo variable,
|
||||
then to a default of 3.
|
||||
|
||||
**When adding/removing Gitea runners:**
|
||||
1. Repo/org-level runners are auto-detected via the API
|
||||
2. For instance-level runners, update the `MOLECULE_RUNNERS` repo variable
|
||||
3. The workflow automatically scales the matrix to match available runners
|
||||
|
||||
### Automated Release Pipeline
|
||||
|
||||
After a PR is merged to master, the release pipeline runs automatically:
|
||||
After a PR is merged to master, the **post-merge workflow**
|
||||
(`.gitea/workflows/post-merge.yml`) runs automatically. This single
|
||||
workflow consolidates release, wiki sync, badge generation, and
|
||||
Vikunja task updates:
|
||||
|
||||
1. **Release workflow** (`.gitea/workflows/release.yml`):
|
||||
- Triggers on push to master
|
||||
- Runs `scripts/release.py` which uses **git-cliff** to:
|
||||
- Calculate the next semver version from conventional commits since the last tag
|
||||
- Update `__version__` in `src/gitea_runner_manager/__init__.py` (single source of truth)
|
||||
- Update `CHANGELOG.md` with the new version section
|
||||
- Commit with `release: vX.Y.Z` prefix (cleaner than `chore(release):`)
|
||||
- Create an annotated tag `vX.Y.Z` on the release commit
|
||||
- Push both the commit and tag to master
|
||||
1. **detect-type** — Checks if the commit is a regular merge or a
|
||||
release commit (`release: vX.Y.Z`). All subsequent jobs skip for
|
||||
release commits (the `[skip ci]` tag also prevents re-triggering).
|
||||
|
||||
2. **release** — Runs `devx.ci.release` which:
|
||||
- **Checks for user-facing changes** via `devx.ci.classify_changes` — if only
|
||||
workflow/infrastructure files changed (`.gitea/`, `docs/`, `tests/`,
|
||||
`AGENTS.md`, `Makefile`, etc.), the release is **skipped entirely** — no version
|
||||
bump, no tag, no publish. This prevents unnecessary releases for CI/docs-only changes.
|
||||
- Uses **git-cliff** to calculate the next semver version from conventional commits
|
||||
- Updates `__version__` in `src/gitea_runner_manager/__init__.py` (single source of truth)
|
||||
- Updates `CHANGELOG.md` with the new version section
|
||||
- **Runs `make lint-ruff` and `make pytest-cov`** to verify the release is healthy
|
||||
- If lint or tests fail, **aborts immediately** — no commit, no tag
|
||||
- Commits with `release: vX.Y.Z [skip ci]` prefix (the `[skip ci]` prevents
|
||||
re-triggering post-merge on the release commit)
|
||||
- Creates an annotated tag `vX.Y.Z` on the release commit
|
||||
- Pushes both the commit and tag to master
|
||||
- `--skip-tests` flag bypasses test verification (emergency use only, not recommended)
|
||||
- Loops are prevented by `has_unreleased_changes` — after a release commit is tagged, the next run finds no unreleased changes and exits
|
||||
- On failure, creates a Gitea issue via `scripts/notify_failure.py`
|
||||
|
||||
3. **sync-wiki** — Syncs documentation to the Gitea wiki.
|
||||
|
||||
4. **badges** — Generates and pushes quality badge SVGs to the `badges` branch.
|
||||
Runs **after** the release job (even if release fails or is skipped) so the
|
||||
version badge always reflects the latest state. The script fetches the
|
||||
latest master before generating badges to pick up any release commits.
|
||||
|
||||
5. **vikunja** — Marks the corresponding Vikunja task as done.
|
||||
|
||||
The tag push triggers the **publish workflow** (`.gitea/workflows/publish.yml`)
|
||||
which builds and publishes the package to PyPI.
|
||||
|
||||
### Smart CI: User-Facing vs Workflow-Only Changes
|
||||
|
||||
Not all changes require the full CI pipeline or a new release. The project
|
||||
classifies changes into two categories using `devx.ci.classify_changes`:
|
||||
|
||||
**Classification strategy (safe-by-default):** Any file NOT in the explicit
|
||||
workflow-only allowlist is treated as user-facing. This prevents new file
|
||||
types from accidentally skipping releases. Classification is config-driven
|
||||
via `[tool.devx.classify]` in `pyproject.toml`.
|
||||
|
||||
**Workflow-only paths** (infrastructure → no release needed):
|
||||
- `.gitea/**` — Gitea Actions workflows
|
||||
- `docs/**` — Documentation
|
||||
- `tests/**` — Test files
|
||||
- `AGENTS.md`, `README.md`, `CHANGELOG.md`, `TROUBLESHOOTING.md` — Project docs
|
||||
- `Makefile`, `cliff.toml`, `.pre-commit-config.yaml`, `.ansible-lint` — Config
|
||||
- `.env.example`, `.gitignore`, `.ruff.toml` — Config
|
||||
- `hooks/**` — Git hooks
|
||||
|
||||
**User-facing paths** (tool changes → release needed) — everything else:
|
||||
- `src/gitea_runner_manager/**` — Python CLI source (except `__init__.py` and `api_clients.py`)
|
||||
- `ansible/**` — Ansible role
|
||||
- `pyproject.toml` — Package metadata
|
||||
- Any new file type not in the allowlist
|
||||
|
||||
**devx module structure** (installed from git, not in this repo):
|
||||
- `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
|
||||
- `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:**
|
||||
- **Molecule tests**: Only run when `ansible/` or `.ansible-lint` files change
|
||||
- **Release dry-run**: Only runs when user-facing files change
|
||||
- **Quality job** (lint, unit tests, coverage, doc-coverage): Always runs
|
||||
- **Release workflow**: Skips entirely when no user-facing files changed since last tag
|
||||
|
||||
**AI agents must follow these rules:**
|
||||
- When working on workflow/CI/docs-only changes, use `ci:` or `docs:` commit prefixes
|
||||
- Do NOT bump the version or create tags for workflow-only changes
|
||||
- The `classify_changes` module enforces this automatically — no manual intervention needed
|
||||
|
||||
## Source Code Separation and devx Integration
|
||||
|
||||
The codebase enforces strict separation between the GRM tool and the devx package:
|
||||
|
||||
### Directory Layout
|
||||
|
||||
| Directory | Purpose | Release impact |
|
||||
|-----------|---------|----------------|
|
||||
| `src/gitea_runner_manager/` | User-facing GRM CLI tool | Changes trigger release |
|
||||
| `devx` package (installed from git) | Reusable CI/CD and dev tools | Not in this repo (no release impact) |
|
||||
| `ansible/` | Ansible role for runner setup | Changes trigger release |
|
||||
|
||||
### Import Rules
|
||||
|
||||
1. **`src/gitea_runner_manager/` NEVER imports from devx** — the GRM tool is self-contained
|
||||
2. **devx MAY import from `gitea_runner_manager`** — one-way dependency (devx uses the tool's API clients, config, i18n)
|
||||
3. **Cross-module imports within devx** are allowed (devx modules importing from other devx modules) and must be documented
|
||||
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
|
||||
|
||||
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`).
|
||||
|
||||
**`devx.gitea_cli`** — Python wrapper around `tea` CLI with JSON output parsing:
|
||||
- `TeaCLI.create_issue()` — Create issues with labels
|
||||
- `TeaCLI.list_labels()` / `TeaCLI.create_label()` / `TeaCLI.add_label()` — Label management
|
||||
- `TeaCLI.create_pr()` / `TeaCLI.merge_pr()` / `TeaCLI.review_pr()` — Pull request operations
|
||||
- `TeaCLI.create_release()` / `TeaCLI.list_releases()` — Release management
|
||||
- `TeaCLI.list_branches()` — Branch listing
|
||||
|
||||
**Modules using tea (via `devx.gitea_cli`):**
|
||||
- `devx.ci.publish` — Creates Gitea releases via `tea releases create`
|
||||
- `devx.ci.notify_failure` — Creates issues via `tea issues create` (falls back to `GiteaClient` if tea not installed)
|
||||
- `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):**
|
||||
- PR reviews (`devx.ci.review_pr`) — tea v0.14.1 only supports interactive reviews
|
||||
- Wiki page management (`devx.ci.sync_wiki`)
|
||||
- Commit status checks (`devx.ci.auto_merge`)
|
||||
- Runner discovery (`devx.molecule.discover_runners`)
|
||||
- Branch protection with detailed config (`devx.tools.configure_repo`)
|
||||
- PR file/commit listing (`devx.ci.pr_review`)
|
||||
|
||||
### PYTHONPATH Configuration
|
||||
|
||||
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 modules |
|
||||
|------------|-------------|-----------------|
|
||||
| `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` |
|
||||
| (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` |
|
||||
|
||||
**In workflows**, always use `env:` blocks (not inline `PYTHONPATH=value`):
|
||||
```yaml
|
||||
- name: Run module
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: python -m devx.ci.example
|
||||
```
|
||||
|
||||
**Locally**, devx is installed as a package, so only `PYTHONPATH=src` is needed if importing from `gitea_runner_manager`.
|
||||
|
||||
### Shared Constants
|
||||
|
||||
`devx.molecule.platforms` is the single source of truth for the molecule
|
||||
platform matrix. Both `devx.molecule.distribute_molecule` (CI) and
|
||||
`devx.molecule.molecule_all` (dev tool) import `PLATFORMS` from it — this
|
||||
avoids dev tools importing directly from CI modules.
|
||||
|
||||
2. **Publish workflow** (`.gitea/workflows/publish.yml`):
|
||||
- Triggers on tag push (`v*`)
|
||||
@@ -140,7 +353,7 @@ After a PR is merged to master, the release pipeline runs automatically:
|
||||
- Builds the Python package
|
||||
- Optionally publishes to PyPI (if `PYPI_TOKEN` is set)
|
||||
- Creates a Gitea release with git-cliff-generated release notes
|
||||
- On failure, creates a Gitea issue via `scripts/notify_failure.py`
|
||||
- On failure, creates a Gitea issue via `devx.ci.notify_failure`
|
||||
|
||||
### git-cliff Commit Preprocessing
|
||||
|
||||
@@ -169,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` |
|
||||
| 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
|
||||
|
||||
- Python 3.12+ required (ruff/pyright target `py312`)
|
||||
@@ -194,9 +416,54 @@ main.yml → systemd_check → user_setup → rootless_docker → install_runner
|
||||
|
||||
6 scenarios: `default`, `multi-instance`, `lifecycle`, `template-content`, `deregister`, `update`
|
||||
4 platforms: `ubuntu-2204`, `ubuntu-2404`, `debian-12`, `archlinux`
|
||||
Platform list is defined in `scripts/distribute_molecule.py` (single source of truth)
|
||||
Platform list is defined in `devx.molecule.platforms` (single source of truth)
|
||||
|
||||
## Known Issues
|
||||
|
||||
- `ansible-lint` may warn about `command-instead-of-module` for `systemctl --user` calls — this is expected (systemd module doesn't support user services) and skipped in `.ansible-lint`
|
||||
- Molecule Docker driver may print "Event loop is closed" warnings on interrupt — harmless
|
||||
|
||||
## Documentation-as-Code
|
||||
|
||||
All documentation lives in `/docs/` and is synced to the Gitea wiki automatically.
|
||||
|
||||
### Structure
|
||||
|
||||
```
|
||||
docs/
|
||||
├── index.md # Wiki homepage
|
||||
├── mapping.json # File-to-wiki-page title mapping
|
||||
├── user/ # User documentation
|
||||
│ ├── getting-started.md
|
||||
│ ├── installation.md
|
||||
│ ├── cli-commands.md
|
||||
│ ├── troubleshooting.md
|
||||
│ └── faq.md
|
||||
└── tech/ # Technical documentation
|
||||
├── architecture.md
|
||||
├── development-setup.md
|
||||
├── ci-cd-workflow.md
|
||||
├── testing-strategy.md
|
||||
├── decision-log.md
|
||||
└── contributing.md
|
||||
```
|
||||
|
||||
### Wiki Sync
|
||||
|
||||
- **On merge to master**: `sync-wiki.yml` workflow runs `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
|
||||
- `mapping.json` maps each file path to a wiki page title (e.g., `user/getting-started.md` → `Getting-Started`)
|
||||
- README.md is a lean entry point with links to the wiki — no detailed content
|
||||
|
||||
### Documentation Coverage
|
||||
|
||||
- `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)
|
||||
- Enforced: 100% coverage for public CLI commands and major architectural components
|
||||
|
||||
### Updating Documentation
|
||||
|
||||
1. Edit files in `/docs/`
|
||||
2. If adding a new page, add it to `docs/mapping.json`
|
||||
3. Commit and create a PR (standard PR workflow)
|
||||
4. On merge, wiki is automatically synced
|
||||
|
||||
+142
-76
@@ -2,95 +2,161 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.2.0] - 2026-06-21
|
||||
|
||||
### Features
|
||||
|
||||
- Fix 12 critical workflow gaps in release pipeline
|
||||
## [0.6.2] - 2026-06-22
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Release push permission and notify_failure label IDs
|
||||
- Include lint extras in setup-ci and setup-release
|
||||
- Use commit SHA URLs for badges to bypass Gitea cache
|
||||
- Make sync-wiki and vikunja depend on release
|
||||
- Pin devx to v0.4.0, fix cliff.toml preprocessor, bump to v0.7.0
|
||||
|
||||
## [Unreleased]
|
||||
### Refactor
|
||||
|
||||
### Added
|
||||
- Fully automate PR merge — no manual label/review needed
|
||||
- Require tea CLI everywhere, fail on missing Vikunja task
|
||||
- Separate GRM and CI translations with validation
|
||||
- Migrate from scripts/ to devx package
|
||||
|
||||
- **Automated semver versioning and releases**: `scripts/release.py` — CI script that uses git-cliff to calculate the next version from conventional commits, update version files, create a release commit, tag, and push.
|
||||
- `cliff.toml` — git-cliff configuration for conventional commit parsing, semver bumping, and changelog generation.
|
||||
- Release workflow (`.gitea/workflows/release.yml`) — triggers on push to master, runs `scripts/release.py` to automatically version and tag releases.
|
||||
- `publish.py` now uses git-cliff to generate release notes for Gitea releases (falls back to generic message if git-cliff is not available).
|
||||
- `pyproject.toml` now uses `dynamic = ["version"]` with setuptools `attr` to source version from `__init__.py` (single source of truth — release script only updates `__init__.py`).
|
||||
- **Mandatory PR review step**: `scripts/review_pr.py` — CLI to post Gitea PR reviews (COMMENT, APPROVE, REQUEST_CHANGES) with inline comments via `--comments-json` or `--comments-stdin`.
|
||||
- `GiteaClient.get_pr_files`, `GiteaClient.get_pr_commits`, `GiteaClient.create_review` — API methods for PR review workflow.
|
||||
- `VikunjaClient.get_task` — fetch a single task by numeric ID.
|
||||
- PR title format: `GRM-N: <vikunja task title>` (colon-separated, human-friendly).
|
||||
- Merge commit format: `GRM-N <conventional commit message>` (space-separated, conventional).
|
||||
- `auto_merge.py` now extracts the conventional commit message from PR commits and constructs the merge title as `GRM-N <conventional commit>`.
|
||||
- `post_merge.py` `extract_conventional_msg` now handles both legacy (`GRM-N: <msg>`) and current (`GRM-N <msg>`) merge commit formats.
|
||||
- Full PR workflow documented in `AGENTS.md` and `README.md` (Vikunja task → branch → implement → commit → PR → review → address comments → approve → merge).
|
||||
## [0.7.0] - 2026-06-22
|
||||
|
||||
### Changed
|
||||
### Bug Fixes
|
||||
|
||||
- Parameterized all hardcoded configuration values as Ansible variables in `defaults/main.yml`:
|
||||
- `gitea_runner_data_dir` — Runtime data directory
|
||||
- `gitea_runner_config_dir` — Config directory
|
||||
- `gitea_runner_binary_path` — Binary install path
|
||||
- `gitea_runner_prune_until` — Prune age filter
|
||||
- `gitea_runner_prune_schedule` — Prune timer schedule
|
||||
- `gitea_runner_prune_label` — Docker label for pruning
|
||||
- `gitea_runner_service_restart_sec` — systemd restart interval
|
||||
- `gitea_runner_service_user` — Service user
|
||||
- `gitea_runner_log_level` — Runner log level
|
||||
- `gitea_runner_container_label` — Container label
|
||||
- `gitea_runner_file` — Runner metadata file
|
||||
- `docker_gpg_key_path` — Docker GPG key path
|
||||
- Added `console_scripts` entry point in `pyproject.toml` (`grm = "gitea_runner_manager.cli:cli"`).
|
||||
- Added shared `molecule/common/prepare.yml` to eliminate duplicated prepare playbooks.
|
||||
- Extracted repeated systemd availability check into `tasks/systemd_check.yml`.
|
||||
- Added idempotence checks to all Molecule scenarios.
|
||||
- Comprehensive README overhaul with Architecture, Configuration, Development, Testing, and Troubleshooting sections.
|
||||
- API URLs and repo configuration in `config.py` are now overridable via environment variables (`GRM_GITEA_API_URL`, `GRM_VIKUNJA_API_URL`, `GRM_REPO_OWNER`, `GRM_REPO_NAME`, `GRM_VIKUNJA_PROJECT_ID`).
|
||||
- `remove-runner.yml` now disables lingering and removes subuid/subgid entries for complete cleanup.
|
||||
- Include lint extras in setup-ci and setup-release
|
||||
- Use commit SHA URLs for badges to bypass Gitea cache
|
||||
- Make sync-wiki and vikunja depend on release
|
||||
|
||||
### Security
|
||||
### Refactor
|
||||
|
||||
- **Critical fix**: Registration tokens and admin tokens are no longer passed via `--extra-vars` on the command line (CWE-214). Extra-vars are now written to a temporary JSON file with `0600` permissions and passed via `--extra-vars @tempfile`, which is deleted after execution. This prevents secrets from being visible in the process list (`ps aux`).
|
||||
- Fully automate PR merge — no manual label/review needed
|
||||
- Require tea CLI everywhere, fail on missing Vikunja task
|
||||
- Separate GRM and CI translations with validation
|
||||
- Migrate from scripts/ to devx package
|
||||
## [0.6.1] - 2026-06-22
|
||||
|
||||
### Changed
|
||||
### Bug Fixes
|
||||
|
||||
- Replaced legacy runner terminology with `gitea_runner` / `gitea-runner` / `Gitea Runner`.
|
||||
- Updated default Docker image from `gitea/gitea_runner` to `gitea/runner`.
|
||||
- `Makefile` now uses the installed `grm` console script instead of `python grm`.
|
||||
- `pyproject.toml` ruff and pyright target versions updated from `py311` to `py312` to match `requires-python = ">=3.12"`.
|
||||
- `BRANCH_PROTECTION_CONFIG` updated with correct Gitea Actions status check contexts (including `(pull_request)` suffix) and `required_approvals: 0` for auto-merge.
|
||||
- `CONVENTIONAL_RE` no longer matches `BREAKING CHANGE` as a commit type (it is a footer, not a type).
|
||||
- `rootless_docker.yml` apt cache update now only runs when the Docker repo file changes (idempotent, but always refreshes on first add).
|
||||
- `service.yml` and `prune.yml` template creation tasks are not guarded by `docker_rootless_setup` (templates just create files, they don't need Docker; molecule tests set `docker_rootless_setup: false` but still verify the service file exists).
|
||||
- `molecule_all.sh` now sources the platform list from `distribute_molecule.py` to avoid duplication.
|
||||
- Badges always update on release commits + fix configure-repo PYTHONPATH
|
||||
- Enforce commit message convention on master with CI validation
|
||||
- Post-merge workflow failures (4 jobs)
|
||||
## [0.6.0] - 2026-06-22
|
||||
|
||||
### Removed
|
||||
### Bug Fixes
|
||||
|
||||
- Deleted `setup.py` (redundant with `pyproject.toml`).
|
||||
- Deleted `grm` shell entrypoint script (replaced by `console_scripts`).
|
||||
- Deleted `initial-plan.md` and `tests/integration/test_provision.py` (dead code).
|
||||
- Removed empty `__init__.py` files from `tests/` directories.
|
||||
- Removed unused `runner_validated` fact from `validate.yml`.
|
||||
- Removed duplicate `prune.yml` and `integration_test.yml` includes from `install_runner.yml` (already included from `main.yml`).
|
||||
- Removed dead `tasks/config.yml` (never included by any playbook).
|
||||
- Removed dead `templates/gitea-runner.service.j2` (legacy system-level service, replaced by rootless `gitea-runner-user.service.j2`).
|
||||
- Removed dead "Reload systemd" handler (system-level reload, never notified, wrong scope for user services).
|
||||
- Removed dead `scripts/run_molecule_parallel.py` and its test (replaced by `molecule_ci_guard.py`).
|
||||
- Molecule-tests matrix runner-index renders as empty for 0
|
||||
- Use 1-based runner indices for Gitea Actions compatibility
|
||||
- Molecule-tests static matrix and role_dir path fix
|
||||
- Auto-merge label condition uses pull_request.labels
|
||||
- Revert review_pr.py to GiteaClient (tea v0.14.1 is interactive-only) (#70)
|
||||
|
||||
### Fixed
|
||||
### Revert
|
||||
|
||||
- Molecule idempotence failures caused by non-idempotent service restart.
|
||||
- Missing `/etc/docker` directory handling in Molecule tests.
|
||||
- `ansible-lint` formatting warnings (yaml empty lines).
|
||||
- Verify playbooks now explicitly load role defaults so parameterized variables are available during verification.
|
||||
- Duplicate execution of prune and integration test tasks during installation (were included from both `main.yml` and `install_runner.yml`).
|
||||
- apt cache update reporting `changed` on every run due to `cache_valid_time: 0`.
|
||||
- Remove v0.6.0 release (no user-facing changes)
|
||||
## [0.4.0] - 2026-06-21
|
||||
|
||||
### Features
|
||||
|
||||
- Replace inline workflow scripts with tested Python modules
|
||||
- User-friendly click errors with i18n in configure_repo
|
||||
- Bandit integration (#1)
|
||||
- Auto-delete branch after merge in configure_repo script
|
||||
- Add runner labels support and refactor i18n to JSON
|
||||
- Parallel molecule runner with kill-on-first-failure
|
||||
- Cross-runner molecule cancellation via Gitea API polling
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Set runner_mode to binary in multi-instance converge
|
||||
- Skip systemd operations in lifecycle molecule when unavailable
|
||||
- Improve make setup with version guard, pre-push hooks and commit-msg validator
|
||||
- Enforce GRM-N: conventional on master commits and PR titles
|
||||
- Remove molecule tests from pre-push hooks
|
||||
- Resolve bandit security warnings in source code and tests
|
||||
- CI pipeline for rootless Docker runners
|
||||
- CI workflows for rootless runner compatibility
|
||||
- Vikunja task resolution pagination in post_merge.py
|
||||
- Use PUT instead of POST for Vikunja task comments
|
||||
- Parse pytest output with warnings in check_test_speed
|
||||
- Use systemd as container command for rootless molecule tests
|
||||
- Add Docker APT repository before installing docker-ce
|
||||
- Use deb822_repository for Docker APT repo (proper GPG handling)
|
||||
- Dearmor Docker GPG key with gpg --dearmor for apt_repository
|
||||
- Use bash for gpg dearmor (pipefail not available in sh)
|
||||
- Install curl, gpg, ca-certificates in molecule prepare
|
||||
- Separate apt update after adding Docker repo, use variable for repo string
|
||||
- Add apt source debug tasks, fix arch mapping for Docker repo
|
||||
- Fail-fast CI, write Docker apt source directly, fix arch mapping
|
||||
- Skip rootless Docker daemon startup in molecule tests
|
||||
- Gate all Docker-dependent tasks behind docker_rootless_setup
|
||||
- Catch TimeoutExpired in parallel runner wait loop
|
||||
- Stream molecule subprocess output to CI logs
|
||||
- Run molecule pairs sequentially within each CI runner
|
||||
- Guard all systemctl --user tasks with docker_rootless_setup
|
||||
- Guard handler systemctl --user calls with docker_rootless_setup
|
||||
- Make user_setup and download tasks idempotent
|
||||
- Use gnupg instead of gpg package name on Arch Linux
|
||||
- Add default(0) to gitea_runner_uid in environment blocks
|
||||
- Set runner_name in deregister verify.yml
|
||||
- Security, dead code, idempotence, and documentation cleanup
|
||||
|
||||
### Refactor
|
||||
|
||||
- Standardise pre-commit hooks on make targets
|
||||
- Use http.HTTPStatus constants instead of magic numbers
|
||||
- Rework all scripts to use click and i18n
|
||||
- *(scripts)* Centralize constants, API clients, and HTTP status codes
|
||||
- Rootless Docker, fix auto-merge, molecule platform matrix
|
||||
## [0.3.0] - 2026-06-19
|
||||
|
||||
### Features
|
||||
|
||||
- Add translated operation report for success and failure cases
|
||||
- Replace print() with stdlib logging module
|
||||
- Use click.echo() for user-facing messages with dual logging
|
||||
- Add colorized output for better visual feedback
|
||||
- Add --force flag to grm remove for unreachable runners
|
||||
- Make --ask-become-pass the default behavior
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Eliminate duplicate console output, restore GRM_LOG_LEVEL filtering
|
||||
- Make grm list retrieve runner status correctly
|
||||
|
||||
### Refactor
|
||||
|
||||
- Deduplicate CLI, remove dead code, move validation to business layer
|
||||
- Resolve_runner returns gitea_url, add --url CLI option, force remove improvements, code quality fixes
|
||||
## [0.2.0] - 2026-06-18
|
||||
|
||||
### Features
|
||||
|
||||
- Parameterize all hardcoded configuration values as Ansible variables
|
||||
- Add GITEA_ADMIN_TOKEN support for integration test
|
||||
- Add AnsibleExecutor and i18n modules
|
||||
- Integrate AnsibleExecutor and i18n into CLI and RunnerManager
|
||||
- Add systemd template units and multi-instance Ansible support
|
||||
- Add lifecycle CLI commands and RunnerManager extensions
|
||||
- Add runner registry for simplified CLI UX
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Resolve idempotence issues and testing infrastructure
|
||||
- Remove recursive variable definitions in install and update playbooks
|
||||
- Add timeout to runner registration to prevent indefinite hangs
|
||||
- Override Docker container entrypoint to bypass run.sh wrapper
|
||||
- Set Docker working dir to /data for .runner persistence
|
||||
- Make integration test conditional on admin API accessibility
|
||||
- Remove recursive var definitions from install-runner.yml
|
||||
- Convert runner config from TOML to YAML format
|
||||
- Rewrite integration test to verify .runner file and container health instead of unreliable API checks
|
||||
|
||||
### Refactor
|
||||
|
||||
- Remove dead code and legacy artifacts
|
||||
- Migrate source terminology from act_runner to gitea_runner
|
||||
- Consolidate systemd checks and deduplicate role structure
|
||||
## [0.1.0] - 2026-06-17
|
||||
|
||||
### Features
|
||||
|
||||
- Initial implementation of Gitea Runner Manager
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: all setup install update lint ansible-lint makefile-lint lint-all test test-unit pytest-cov molecule molecule-all test-all clean
|
||||
.PHONY: all setup 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
|
||||
VENV := .venv
|
||||
@@ -7,8 +7,44 @@ CHECKMAKE := $(shell command -v checkmake 2>/dev/null || echo $(HOME)/go/bin/che
|
||||
|
||||
all: setup
|
||||
|
||||
setup: $(VENV)/bin/activate .env activate-scripts checkmake
|
||||
@bash scripts/setup.sh "$(BIN)"
|
||||
# Pinned devx version — update this when upgrading devx.
|
||||
# All workflow files (.gitea/workflows/*.yml) must be updated to match.
|
||||
DEVX_VERSION := v0.4.1
|
||||
|
||||
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@$(DEVX_VERSION)"
|
||||
|
||||
# Full setup for local development (all deps, tools, collections, hooks)
|
||||
# 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"; \
|
||||
$(BIN)/python -m devx.tools.setup --bin "$(BIN)"
|
||||
|
||||
# Lean setup for CI jobs that need pytest + lint tools + runtime deps
|
||||
# (detect-changes, discover-runners, pr-review, sync-wiki, badges)
|
||||
# badges job runs generate_badges.py which needs ruff, pyright, bandit
|
||||
setup-ci: $(VENV)/bin/activate .env install-devx
|
||||
@$(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)
|
||||
# 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"; \
|
||||
$(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)
|
||||
setup-molecule: $(VENV)/bin/activate .env install-devx install-tools
|
||||
@export PATH="$(HOME)/.local/bin:$$PATH"; \
|
||||
$(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-release: $(VENV)/bin/activate .env install-devx
|
||||
@$(BIN)/python -m devx.tools.install_tools --tool git-cliff --tool tea
|
||||
@export PATH="$(HOME)/.local/bin:$$PATH"; \
|
||||
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,lint" --no-ansible-collections --no-pre-commit
|
||||
|
||||
.env:
|
||||
@if [ ! -f .env ]; then \
|
||||
@@ -31,8 +67,11 @@ install-hooks:
|
||||
@cp hooks/pre-push .git/hooks/pre-push && chmod +x .git/hooks/pre-push
|
||||
@echo "Git hooks installed."
|
||||
|
||||
checkmake:
|
||||
@python3 scripts/install_checkmake.py
|
||||
checkmake: install-devx
|
||||
@$(BIN)/python -m devx.tools.install_checkmake
|
||||
|
||||
install-tools: install-devx
|
||||
@$(BIN)/python -m devx.tools.install_tools
|
||||
|
||||
install:
|
||||
@if [ -z "$(HOST)" ]; then echo "HOST is required. Example: make install HOST=192.168.1.10"; exit 1; fi
|
||||
@@ -78,7 +117,13 @@ typecheck:
|
||||
lint: lint-ruff lint-format typecheck lint-bandit
|
||||
|
||||
lint-bandit:
|
||||
$(BIN)/bandit -r src/ scripts/
|
||||
$(BIN)/bandit -r src/
|
||||
|
||||
lint-deps:
|
||||
@echo "Checking dependencies for known vulnerabilities..."
|
||||
@.venv/bin/python -m ensurepip 2>/dev/null || true
|
||||
@PIPAPI_PYTHON_LOCATION=$$(pwd)/.venv/bin/python \
|
||||
.venv/bin/pip-audit --desc --skip-editable 2>&1 || true
|
||||
|
||||
ansible-lint:
|
||||
$(BIN)/ansible-lint ansible/
|
||||
@@ -86,7 +131,22 @@ ansible-lint:
|
||||
makefile-lint:
|
||||
@$(CHECKMAKE) Makefile
|
||||
|
||||
lint-all: lint ansible-lint makefile-lint
|
||||
lint-all: lint ansible-lint makefile-lint workflow-lint
|
||||
|
||||
workflow-lint:
|
||||
@command -v actionlint >/dev/null 2>&1 || { \
|
||||
echo "actionlint not found. Install: bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash)"; \
|
||||
exit 1; \
|
||||
}
|
||||
actionlint -config-file .gitea/actionlint.yaml .gitea/workflows/*.yml
|
||||
|
||||
workflow-dryrun:
|
||||
@command -v act_runner >/dev/null 2>&1 || { echo "act_runner not found. Install: https://gitea.com/gitea/act_runner/releases"; exit 1; }
|
||||
@echo "Dry-running all workflows (no Docker containers started)..."
|
||||
act_runner exec --dryrun -W .gitea/workflows/ 2>&1 | grep -E 'DRYRUN|ERROR|FAIL|Job'
|
||||
|
||||
workflow-check: workflow-lint workflow-dryrun
|
||||
@echo "Workflow checks passed (static lint + dry-run)."
|
||||
|
||||
test-unit:
|
||||
$(BIN)/pytest tests/unit/ -v --no-cov
|
||||
@@ -95,7 +155,7 @@ test-integration:
|
||||
$(BIN)/pytest tests/integration/ -v --no-cov
|
||||
|
||||
pytest-cov:
|
||||
$(BIN)/pytest tests/unit/ -v --cov=src/gitea_runner_manager --cov=scripts --cov-report=term-missing --cov-fail-under=100
|
||||
$(BIN)/pytest tests/ -v --cov=src/gitea_runner_manager --cov-report=term-missing --cov-fail-under=100
|
||||
|
||||
MOLECULE := $(realpath $(BIN))/molecule
|
||||
MOLECULE_BASE := cd $(CURDIR)/ansible/roles/gitea-runner && ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true ANSIBLE_INJECT_INVOCATION=1 $(MOLECULE)
|
||||
@@ -106,7 +166,7 @@ molecule:
|
||||
|
||||
# All scenarios on all supported platforms (sequential; use CI matrix for parallel execution)
|
||||
molecule-all:
|
||||
@bash scripts/molecule_all.sh
|
||||
@$(BIN)/python -m devx.molecule.molecule_all --bin "$(BIN)"
|
||||
|
||||
test: test-all
|
||||
|
||||
|
||||
@@ -2,437 +2,55 @@
|
||||
|
||||
A lean command-line tool to automate the installation, configuration, and lifecycle management of Gitea Actions runners on Arch Linux, Ubuntu, and Debian hosts.
|
||||
|
||||
Each runner runs in an isolated **rootless Docker** environment under a dedicated system user, enabling multiple runners to operate in parallel on the same host without conflicts. The runner binary (`gitea_runner`) is installed directly and managed as a systemd user service.
|
||||
Each runner runs in an isolated **rootless Docker** environment under a dedicated system user, enabling multiple runners to operate in parallel on the same host without conflicts.
|
||||
|
||||
> **Pronunciation note:** GRM is short for *Gitea Runner Manager*, but say it like **ГРЪМ** (roughly "GRUM" in Latin letters) — the Bulgarian word for **thunder**. Wherever there are clouds, there may be thunders. This is an open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian).
|
||||
> **Pronunciation:** GRM is short for *Gitea Runner Manager*, but say it like **ГРЪМ** (roughly "GRUM") — the Bulgarian word for **thunder**. An open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian).
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
|
||||
## Commit Convention & Branch Naming
|
||||
|
||||
This project uses **conventional commits** and **GRM-N branch prefixes**. See [AGENTS.md](AGENTS.md) for the full workflow.
|
||||
|
||||
| What | Format | Example |
|
||||
|------|--------|---------|
|
||||
| Branch name | `GRM-N-short-description` | `GRM-33-add-pr-review-step` |
|
||||
| Branch commits | `<conventional commit>` | `feat: add review script` |
|
||||
| PR title | `GRM-N: <vikunja task title>` | `GRM-33: Add mandatory PR review step` |
|
||||
| Merge commit | `GRM-N <conventional commit>` | `GRM-33 feat: add review script` |
|
||||
|
||||
### PR Workflow
|
||||
|
||||
Every change to master goes through a mandatory review workflow:
|
||||
|
||||
1. **Create Vikunja task** — get a `GRM-N` identifier
|
||||
2. **Create branch** — `GRM-N-short-description`
|
||||
3. **Implement** — write code, tests (100% coverage), update docs
|
||||
4. **Commit** — conventional commits (no `GRM-N:` prefix on branch)
|
||||
5. **Push & create PR** — title: `GRM-N: <vikunja task title>`
|
||||
6. **Review** — review the full diff focusing on: functional completeness, edge cases, technical excellence (architecture, SRP, deduplication, code smells, best practices, code quality, reusability, clean code, readability, maintainability, extensibility), performance, security, UX, documentation completeness/relevance. Post review comments via `scripts/review_pr.py`.
|
||||
7. **Address comments** — fix each comment, commit, push, re-review
|
||||
8. **Approve** — post an `APPROVE` review via `scripts/review_pr.py`
|
||||
9. **Add `ready-to-merge` label** — auto-merge workflow squash-merges with title `GRM-N <conventional commit message>`, post-merge workflow marks the Vikunja task as done, release workflow automatically versions and tags
|
||||
|
||||
### Automated Versioning & Releases
|
||||
|
||||
Versioning is fully automated using [git-cliff](https://git-cliff.org):
|
||||
|
||||
1. **After merge to master** — the release workflow runs `scripts/release.py`
|
||||
2. **git-cliff calculates the next version** from conventional commits since the last tag
|
||||
3. **Version file is updated** (`__init__.py`) and a `chore(release): prepare for vX.Y.Z` commit is created
|
||||
4. **An annotated tag `vX.Y.Z`** is pushed with the changelog as the tag message
|
||||
5. **The publish workflow triggers** on the tag — builds the package, optionally publishes to PyPI, and creates a Gitea release with generated release notes
|
||||
|
||||
| Commit type | Version bump |
|
||||
|-------------|-------------|
|
||||
| `feat:` | minor |
|
||||
| `fix:` | patch |
|
||||
| `feat!:` / `BREAKING CHANGE` | minor (pre-1.0) |
|
||||
| `chore:`, `ci:`, `docs:` | no bump |
|
||||
|
||||
`grm --version` reports the current version from `__init__.py`.
|
||||
|
||||
## Features
|
||||
|
||||
- **Simple and focused** — no unnecessary features.
|
||||
- **Secure** — no hardcoded secrets, uses scoped tokens.
|
||||
- **Idempotent** — can be run multiple times safely.
|
||||
- **Flexible** — accepts a plain IP address or hostname, and allows specifying the SSH user and private key.
|
||||
- **Runner registry** — stores runner connection metadata locally after installation. Subsequent commands need only the runner name.
|
||||
- **Lifecycle management** — start, stop, enable, disable, status, and remove runners via CLI.
|
||||
- **Multi-instance** — run multiple isolated runners on the same host, each with its own system user, rootless Docker daemon, data directory, and systemd user service.
|
||||
- **Rootless Docker** — each runner gets its own rootless Docker daemon, avoiding conflicts with the host's Docker installation and enabling true parallel execution.
|
||||
- **Systemd-managed** — runners run as systemd user services (`gitea-runner.service`) under dedicated per-runner system users.
|
||||
|
||||
## Supported Operating Systems
|
||||
|
||||
- Arch Linux
|
||||
- Ubuntu 22.04 / 24.04
|
||||
- Debian 12
|
||||
|
||||
All supported OSes are tested in CI via molecule scenarios on every PR.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **SSH key authentication** — The remote host must be reachable via SSH using the user specified with `--user` and the private key specified with `--key`. GRM uses Ansible under the hood, which connects to the target host over SSH to execute all installation and configuration tasks. Without valid SSH credentials, Ansible cannot establish a connection and the deployment will fail.
|
||||
- **Sudo access** — GRM requires root privileges on the remote host to create system users, install packages, and configure rootless Docker. By default, you will be prompted interactively for the sudo password. For automation or uninterrupted workflows, configure passwordless sudo on the remote host and pass `--no-ask-become-pass`.
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Developer Setup
|
||||
|
||||
```bash
|
||||
git clone https://git.oblachno.oblachno.com/oblachno/gitea-runner-manager.git
|
||||
cd gitea-runner-manager
|
||||
pyenv install 3.12
|
||||
pyenv local 3.12
|
||||
git clone https://git.oblachno.oblachno.fyi/oblachno-oss/grm.git
|
||||
cd grm
|
||||
git checkout $(git describe --tags --abbrev=0) # Checkout latest stable release
|
||||
make setup
|
||||
```
|
||||
|
||||
### Configure Gitea Credentials
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env:
|
||||
# GITEA_URL=https://git.example.com
|
||||
# GITEA_REGISTRATION_TOKEN=your-registration-token
|
||||
```
|
||||
|
||||
`GITEA_REGISTRATION_TOKEN` is the runner registration token obtained from your Gitea instance (Admin → Actions → Runners → Create Registration Token).
|
||||
|
||||
#### Admin API Token (optional)
|
||||
|
||||
Set `GITEA_ADMIN_TOKEN` to enable informational API checks during integration test. This is **optional** — the test primarily verifies the runner by checking:
|
||||
|
||||
1. **`.runner` registration file** exists and contains valid JSON (proves successful registration)
|
||||
2. **Systemd user service** is active (proves daemon is polling for jobs)
|
||||
|
||||
API checks, if enabled, are purely informational and do not affect pass/fail.
|
||||
|
||||
### Install a Runner
|
||||
|
||||
Using the CLI (you will be prompted for the sudo password by default):
|
||||
|
||||
```bash
|
||||
cp .env.example .env # Edit with your Gitea URL and tokens
|
||||
grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner
|
||||
```
|
||||
|
||||
> **Automation tip:** Configure passwordless sudo on the remote host and pass `--no-ask-become-pass` to skip the password prompt. This is recommended for CI/CD pipelines.
|
||||
> **Important:** Always checkout the latest release tag before running `make setup`. The `master` branch may contain unreleased changes that are not yet stable. The command above automatically selects the most recent tagged release.
|
||||
|
||||
Using Make:
|
||||
> **Tokens:** You need two tokens from your Gitea instance — a **registration token** to register runners, and an **admin API token** for optional post-install verification. See [Getting Started](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Getting-Started.-) for detailed setup instructions.
|
||||
|
||||
```bash
|
||||
make install HOST=192.168.1.10 USER=ubuntu KEY=~/.ssh/id_ed25519 NAME=prod-runner
|
||||
```
|
||||
## Documentation
|
||||
|
||||
### Runner Registry
|
||||
Full documentation lives on the [**GRM Wiki**](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki).
|
||||
|
||||
After installation, GRM stores each runner's connection details (host, user, SSH key, Gitea URL) in a local JSON registry at `~/.local/share/grm/runners.json`. This means you rarely need to repeat connection arguments:
|
||||
### User Documentation
|
||||
|
||||
```bash
|
||||
# List all registered runners with live systemd status
|
||||
grm list
|
||||
```
|
||||
- [Getting Started](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Getting-Started.-) — Installation, quick start, token setup, first run
|
||||
- [Installation](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Installation) — Prerequisites, setup, multiple instances
|
||||
- [CLI Commands](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/CLI-Commands.-) — All commands with arguments and options
|
||||
- [Troubleshooting](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Troubleshooting) — Common issues and solutions
|
||||
- [FAQ](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/FAQ) — Frequently asked questions
|
||||
|
||||
### Manage Runner Lifecycle
|
||||
### Technical Documentation
|
||||
|
||||
Once a runner is installed, lifecycle commands work by runner name only:
|
||||
|
||||
```bash
|
||||
# Start a runner
|
||||
grm start prod-runner
|
||||
|
||||
# Stop a runner
|
||||
grm stop prod-runner
|
||||
|
||||
# Enable a runner to start on boot
|
||||
grm enable prod-runner
|
||||
|
||||
# Disable a runner (stops, deregisters, and disables systemd)
|
||||
grm disable prod-runner --token <token>
|
||||
|
||||
# Check runner status
|
||||
grm status prod-runner
|
||||
|
||||
# Remove a runner completely
|
||||
grm remove prod-runner --token <token>
|
||||
```
|
||||
|
||||
You can override any stored value by passing the corresponding flag:
|
||||
|
||||
```bash
|
||||
grm start prod-runner --host 192.168.1.11 --user root
|
||||
```
|
||||
|
||||
> **Automation tip:** If the remote host has passwordless sudo configured, pass `--no-ask-become-pass`.
|
||||
|
||||
### Multiple Instances on the Same Host
|
||||
|
||||
Each runner instance is fully isolated with its own system user, rootless Docker daemon, data directory, and systemd user service:
|
||||
|
||||
```bash
|
||||
# Install two runners on the same host
|
||||
grm install 192.168.1.10 --user ubuntu --name workflow-runner
|
||||
grm install 192.168.1.10 --user ubuntu --name build-runner
|
||||
|
||||
# Manage them independently by name
|
||||
grm stop workflow-runner
|
||||
grm status build-runner
|
||||
```
|
||||
|
||||
### Verify Runner
|
||||
|
||||
The installer performs an automated integration test that verifies:
|
||||
|
||||
1. **`.runner` file exists** with valid JSON containing `id`, `uuid`, `token`, `address` — this proves successful registration with Gitea
|
||||
2. **Systemd user service is active** — this proves the daemon is polling for jobs
|
||||
|
||||
You can also check the Gitea UI under **Actions → Runners** to confirm the runner appears as **Online**.
|
||||
|
||||
Optional: If `GITEA_ADMIN_TOKEN` is set, the installer will also query the Gitea API and report whether the runner appears in the admin or repo runners list. This is purely informational.
|
||||
|
||||
### View Logs
|
||||
|
||||
**GRM application logs** (Python CLI output):
|
||||
|
||||
```bash
|
||||
# Application log file (all messages including DEBUG)
|
||||
cat ~/.local/state/grm/logs/grm.log
|
||||
|
||||
# Enable debug logging in the current session
|
||||
GRM_LOG_LEVEL=DEBUG grm install 192.168.1.10 --user ubuntu --name prod-runner
|
||||
```
|
||||
|
||||
**Runner logs** (on the remote host):
|
||||
|
||||
```bash
|
||||
# Runner logs (via systemd user service)
|
||||
sudo -u grm-<name> journalctl --user -u gitea-runner -f
|
||||
```
|
||||
|
||||
The GRM application writes to two destinations:
|
||||
|
||||
| Destination | Level | Content |
|
||||
|-------------|-------|---------|
|
||||
| Console (stdout) | `GRM_LOG_LEVEL` (default: INFO) | Colorised user-facing messages and operation reports |
|
||||
| `~/.local/state/grm/logs/grm.log` | DEBUG | All messages with timestamps and severity |
|
||||
|
||||
Set `GRM_LOG_LEVEL` to one of `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL` to control console verbosity. The log file always captures everything at DEBUG level regardless of the console setting.
|
||||
|
||||
Console output is automatically colorised via ``click.echo``: operation headers in bright cyan, completed steps in green, failures in red, and status updates in yellow.
|
||||
|
||||
## Architecture
|
||||
|
||||
GRM consists of two layers:
|
||||
|
||||
1. **Python CLI** (`src/gitea_runner_manager/`) — built with Click, handles argument parsing, environment loading, i18n translations, and delegates to Ansible via the `ansible-playbook` subprocess.
|
||||
2. **Ansible Role** (`ansible/roles/gitea-runner/`) — idempotent role that creates a dedicated system user, sets up rootless Docker, installs the runner binary, creates a systemd user service, and registers the runner with Gitea.
|
||||
|
||||
```
|
||||
grm install <host>
|
||||
└── RunnerManager.install()
|
||||
└── ansible-playbook ansible/install-runner.yml
|
||||
└── role: gitea-runner
|
||||
├── user_setup.yml (create per-runner system user + lingering)
|
||||
├── rootless_docker.yml (rootless Docker setup under runner user)
|
||||
├── install_runner.yml (download binary, config, register, service)
|
||||
├── prune.yml (Docker prune timer)
|
||||
└── integration_test.yml (validate service is active)
|
||||
```
|
||||
|
||||
Each runner runs as a systemd user service under a dedicated system user (`grm-<name>`). Each instance has fully isolated resources:
|
||||
|
||||
- **User**: `grm-<name>` (dedicated system user with lingering enabled)
|
||||
- **Home**: `/home/grm-<name>/`
|
||||
- **Data**: `/var/lib/gitea-runner/<name>/`
|
||||
- **Config**: `/etc/gitea-runner/<name>/`
|
||||
- **Service**: `gitea-runner.service` (systemd user service)
|
||||
- **Docker socket**: `/run/user/<UID>/docker.sock` (rootless, per-runner)
|
||||
|
||||
## Configuration
|
||||
|
||||
All tunable values are exposed as Ansible variables in `ansible/roles/gitea-runner/defaults/main.yml`:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `gitea_runner_version` | `1.0.8` | Runner binary version |
|
||||
| `runner_labels` | `docker,ubuntu-latest:docker://runner-images:ubuntu-22.04` | Runner labels |
|
||||
| `skip_runner_registration` | `false` | Skip API registration (useful for tests) |
|
||||
| `gitea_runner_user_prefix` | `grm-` | Prefix for per-runner system users |
|
||||
| `gitea_runner_base_home` | `/home` | Base directory for runner user homes |
|
||||
| `gitea_runner_service_user` | `{{ prefix }}{{ runner_name }}` | Per-runner system user |
|
||||
| `gitea_runner_home` | `{{ base_home }}/{{ service_user }}` | Runner user home directory |
|
||||
| `gitea_runner_base_data_dir` | `/var/lib/gitea-runner` | Base data directory (instance-scoped) |
|
||||
| `gitea_runner_base_config_dir` | `/etc/gitea-runner` | Base config directory (instance-scoped) |
|
||||
| `gitea_runner_data_dir` | `{{ base }}/{{ runner_name }}` | Runtime data directory per instance |
|
||||
| `gitea_runner_config_dir` | `{{ base }}/{{ runner_name }}` | Config directory per instance |
|
||||
| `gitea_runner_binary_path` | `/usr/local/bin/gitea_runner` | Binary install path |
|
||||
| `gitea_runner_prune_until` | `24h` | Prune resources older than this |
|
||||
| `gitea_runner_prune_schedule` | `daily` | systemd timer schedule |
|
||||
| `gitea_runner_prune_label` | `gitea-runner=true` | Docker label for pruning |
|
||||
| `gitea_runner_service_restart_sec` | `5` | systemd RestartSec value |
|
||||
| `gitea_runner_log_level` | `info` | Runner log level |
|
||||
| `gitea_runner_container_label` | `gitea-runner=true` | Container label |
|
||||
| `docker_gpg_key_path` | `/etc/apt/keyrings/docker.asc` | Docker GPG key path |
|
||||
| `GRM_LANG` | `en` | CLI language: `en`, `bg`, `de`, `ru`, `zh` |
|
||||
| `GRM_LOG_LEVEL` | `INFO` | Console verbosity: `DEBUG`, `INFO`, `WARNING`, `ERROR`, `CRITICAL` |
|
||||
| `GRM_GITEA_API_URL` | `https://git.oblachno.oblachno.fyi/api/v1` | Gitea API URL for CI scripts |
|
||||
| `GRM_VIKUNJA_API_URL` | `https://work.oblachno.oblachno.fyi/api/v1` | Vikunja API URL for post-merge scripts |
|
||||
| `GRM_REPO_OWNER` | `oblachno-oss` | Repository owner for CI scripts |
|
||||
| `GRM_REPO_NAME` | `grm` | Repository name for CI scripts |
|
||||
| `GRM_VIKUNJA_PROJECT_ID` | `6` | Vikunja project ID for task tracking |
|
||||
|
||||
Override any variable by passing it to the CLI with `--extra-vars` or by setting it in your Ansible inventory.
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── src/gitea_runner_manager/ # Python CLI source
|
||||
│ ├── cli.py # Click commands
|
||||
│ ├── runner_manager.py # Ansible orchestration + registry integration
|
||||
│ ├── executor.py # Ansible subprocess execution
|
||||
│ ├── registry.py # Local JSON runner registry
|
||||
│ ├── i18n.py # Translations (en, bg, de, ru, zh)
|
||||
│ └── exceptions.py # Custom exceptions
|
||||
├── ansible/
|
||||
│ ├── roles/gitea-runner/ # Main Ansible role
|
||||
│ │ ├── defaults/main.yml # Default variables
|
||||
│ │ ├── tasks/ # Task files
|
||||
│ │ ├── templates/ # Jinja2 templates
|
||||
│ │ └── molecule/ # Test scenarios
|
||||
│ ├── install-runner.yml # Install playbook
|
||||
│ ├── update-runner.yml # Update playbook
|
||||
│ ├── start-runner.yml # Start playbook
|
||||
│ ├── stop-runner.yml # Stop playbook
|
||||
│ ├── enable-runner.yml # Enable playbook
|
||||
│ ├── disable-runner.yml # Disable playbook
|
||||
│ ├── status-runner.yml # Status playbook
|
||||
│ └── remove-runner.yml # Remove playbook
|
||||
├── tests/
|
||||
│ ├── unit/ # Unit tests
|
||||
│ └── integration/ # Integration tests
|
||||
├── Makefile # Build & test automation
|
||||
└── pyproject.toml # Python project metadata
|
||||
```
|
||||
|
||||
### Setup Development Environment
|
||||
|
||||
```bash
|
||||
make setup # Creates venv, installs deps, sets up hooks
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
### Running Linters
|
||||
|
||||
```bash
|
||||
make lint # Python (ruff + pyright + bandit)
|
||||
make lint-bandit # Security scan only
|
||||
make ansible-lint # Ansible
|
||||
make makefile-lint # Makefile
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit Tests
|
||||
|
||||
```bash
|
||||
make test-unit
|
||||
```
|
||||
|
||||
Runs pytest with 100% coverage requirement.
|
||||
|
||||
### Molecule Tests
|
||||
|
||||
```bash
|
||||
make molecule # Quick: all 6 scenarios on Ubuntu 22.04
|
||||
make molecule-all # Full: all 6 scenarios on all 4 supported OSes
|
||||
```
|
||||
|
||||
Runs six scenarios:
|
||||
|
||||
- **default** — Rootless Docker runner installation
|
||||
- **multi-instance** — Two isolated runner instances on the same host
|
||||
- **lifecycle** — Stop, disable, re-enable, and start sequence
|
||||
- **template-content** — Verify rendered systemd user service and prune templates
|
||||
- **deregister** — Runner deregistration
|
||||
- **update** — Runner binary update
|
||||
|
||||
All scenarios test idempotence (second run produces zero changes).
|
||||
|
||||
CI runs all 6 scenarios × 4 platforms (24 test pairs) distributed across 3 parallel runners.
|
||||
|
||||
### Integration Tests
|
||||
|
||||
```bash
|
||||
make test-integration
|
||||
```
|
||||
|
||||
Tests the full CLI lifecycle commands end-to-end ( mocked executor boundary).
|
||||
|
||||
### Full Test Suite
|
||||
|
||||
```bash
|
||||
make test-all # Runs unit tests + linters + molecule
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Event loop is closed" warning
|
||||
|
||||
This is a harmless cleanup traceback from Molecule's Docker driver when the test process is interrupted. It does not indicate a test failure.
|
||||
|
||||
### Runner appears offline after installation
|
||||
|
||||
- Check that the `GITEA_URL` and `GITEA_REGISTRATION_TOKEN` environment variables are correct.
|
||||
- Verify the runner service is running: `sudo -u grm-<name> systemctl --user status gitea-runner`.
|
||||
- Check logs for registration errors.
|
||||
|
||||
### Integration test fails
|
||||
|
||||
The test checks two things:
|
||||
|
||||
1. **`.runner` file missing or invalid** — Registration failed. Check:
|
||||
- `GITEA_URL` and `GITEA_REGISTRATION_TOKEN` are correct
|
||||
- Runner logs for registration errors
|
||||
- The `.runner` file should exist at `/var/lib/gitea-runner/<name>/.runner`
|
||||
|
||||
2. **Service not running** — Daemon failed to start. Check:
|
||||
- `sudo -u grm-<name> systemctl --user status gitea-runner`
|
||||
- Logs for connection errors
|
||||
|
||||
### Rootless Docker: service fails to start
|
||||
|
||||
- Check the service status: `sudo -u grm-<name> systemctl --user status gitea-runner`.
|
||||
- Verify the rootless Docker daemon is running: `sudo -u grm-<name> systemctl --user status docker`.
|
||||
- Verify the Docker socket exists: `ls /run/user/$(id -u grm-<name>)/docker.sock`.
|
||||
- Check logs: `sudo -u grm-<name> journalctl --user -u gitea-runner -f`.
|
||||
- Ensure lingering is enabled for the runner user: `loginctl show-user grm-<name> | grep Linger`.
|
||||
|
||||
## Makefile Targets
|
||||
|
||||
| Target | Description |
|
||||
|--------|-------------|
|
||||
| `setup` | Full environment setup |
|
||||
| `install` | Installs a runner on a host |
|
||||
| `update` | Updates a runner on a host |
|
||||
| `start` | Starts a runner instance |
|
||||
| `stop` | Stops a runner instance |
|
||||
| `enable` | Enables a runner to start on boot |
|
||||
| `disable` | Disables and deregisters a runner |
|
||||
| `status` | Checks runner status |
|
||||
| `remove` | Removes a runner completely |
|
||||
| `list` | Lists registered runners with live status |
|
||||
| `lint` | Runs Python linters (ruff, pyright, bandit) |
|
||||
| `lint-bandit` | Runs `bandit` security scanner |
|
||||
| `ansible-lint` | Runs `ansible-lint` |
|
||||
| `test-unit` | Runs unit tests with coverage |
|
||||
| `test-integration` | Runs integration tests |
|
||||
| `molecule` | Runs Ansible Molecule tests |
|
||||
| `test-all` | Runs all tests |
|
||||
- [Architecture](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Architecture) — High-level design, component interactions
|
||||
- [Development Setup](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Development-Setup.-) — Environment setup, dependencies, local testing
|
||||
- [CI/CD Workflow](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/CI-CD-Workflow.-) — How CI works, release process, branch protection
|
||||
- [Testing Strategy](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Testing-Strategy.-) — Unit, integration, and Molecule tests
|
||||
- [Decision Log](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Decision-Log.-) — Key technical decisions and rationale
|
||||
- [Contributing Guide](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Contributing-Guide.-) — Coding standards, PR workflow, commit rules
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0
|
||||
GPL-3.0
|
||||
|
||||
+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 |
|
||||
| Post-merge can't find Vikunja task | Task not in project 6 or identifier mismatch | Verify task exists in Vikunja project 6 with correct identifier |
|
||||
| `make pytest-cov` fails | Coverage below 100% | Add tests for new code paths |
|
||||
| `scripts/configure_repo.py` fails | REPO_TOKEN missing or invalid | Set token with repo admin scope and re-run |
|
||||
| `configure_repo.py` sets wrong status checks | Stale `BRANCH_PROTECTION_CONFIG` | Updated to include `(pull_request)` suffix; re-run `configure_repo.py` |
|
||||
| `devx.tools.configure_repo` fails | REPO_TOKEN missing or invalid | Set token with repo admin scope and re-run |
|
||||
| `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 |
|
||||
| `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` |
|
||||
|
||||
+15
-11
@@ -39,29 +39,33 @@ sort_commits = "oldest"
|
||||
recurse_submodules = false
|
||||
|
||||
commit_preprocessors = [
|
||||
# Strip GRM-N task ID prefix from merge commits so git-cliff sees conventional commits
|
||||
{ pattern = "^GRM-\\d+\\s+", replace = "" },
|
||||
# Strip GRM-N: task ID prefix from squash-merge commits so git-cliff sees conventional commits
|
||||
{ pattern = "^GRM-\\d+:\\s+", replace = "" },
|
||||
]
|
||||
|
||||
commit_parsers = [
|
||||
{ message = "^feat", group = "<!-- 0 -->Features" },
|
||||
{ message = "^fix", group = "<!-- 1 -->Bug Fixes" },
|
||||
{ message = "^doc", group = "<!-- 3 -->Documentation" },
|
||||
{ message = "^perf", group = "<!-- 4 -->Performance" },
|
||||
{ message = "^refactor", group = "<!-- 2 -->Refactor" },
|
||||
{ message = "^style", group = "<!-- 5 -->Styling" },
|
||||
{ message = "^test", group = "<!-- 6 -->Testing" },
|
||||
{ message = "^chore\\(release\\): prepare for", skip = true },
|
||||
{ message = "^chore\\(deps.*\\)", skip = true },
|
||||
{ message = "^chore\\(pr\\)", skip = true },
|
||||
{ message = "^chore\\(pull\\)", skip = true },
|
||||
{ message = "^chore|^ci", group = "<!-- 7 -->Miscellaneous Tasks" },
|
||||
# Skip infrastructure-only commits — they don't affect users
|
||||
{ message = "^doc", skip = true },
|
||||
{ message = "^test", skip = true },
|
||||
{ message = "^style", skip = true },
|
||||
{ message = "^chore", skip = true },
|
||||
{ message = "^ci", skip = true },
|
||||
# Skip release commits — they are release artifacts, not features
|
||||
{ message = "^release:", skip = true },
|
||||
{ body = ".*security", group = "<!-- 8 -->Security" },
|
||||
{ message = "^revert", group = "<!-- 9 -->Revert" },
|
||||
{ message = ".*", group = "<!-- 10 -->Other" },
|
||||
# Skip anything that doesn't match above — safe default
|
||||
{ message = ".*", skip = true },
|
||||
]
|
||||
|
||||
[bump]
|
||||
features_always_bump_minor = true
|
||||
breaking_always_bump_major = false
|
||||
initial_tag = "0.1.0"
|
||||
# Refactor commits bump patch — structural changes to src/ or pyproject.toml
|
||||
# affect users even though no new feature was added.
|
||||
refactor_always_bump_patch = true
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# GRM — Gitea Runner Manager
|
||||
|
||||
A lean command-line tool to automate the installation, configuration, and lifecycle management of Gitea Actions runners on Arch Linux, Ubuntu, and Debian hosts.
|
||||
|
||||
> **Pronunciation:** GRM is short for *Gitea Runner Manager*, but say it like **ГРЪМ** (roughly "GRUM") — the Bulgarian word for **thunder**. An open-source project from **Oblachno** (облачно means *cloudy* in Bulgarian).
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases)
|
||||
|
||||
## User Documentation
|
||||
|
||||
- [Getting Started](Getting-Started.-) — Installation, quick start, first run
|
||||
- [Installation](Installation) — Prerequisites, setup, multiple instances
|
||||
- [CLI Commands](CLI-Commands.-) — All commands with arguments and options
|
||||
- [Troubleshooting](Troubleshooting) — Common issues and solutions
|
||||
- [FAQ](FAQ) — Frequently asked questions
|
||||
|
||||
## Technical Documentation
|
||||
|
||||
- [Architecture](Architecture) — High-level design, component interactions, data flow
|
||||
- [Development Setup](Development-Setup.-) — Environment setup, dependencies, local testing
|
||||
- [CI/CD Workflow](CI-CD-Workflow.-) — How CI works, release process, branch protection
|
||||
- [Testing Strategy](Testing-Strategy.-) — Unit, integration, and Molecule tests
|
||||
- [Decision Log](Decision-Log.-) — Key technical decisions and rationale
|
||||
- [Contributing Guide](Contributing-Guide.-) — Coding standards, PR workflow, commit rules
|
||||
|
||||
## Quick Links
|
||||
|
||||
- [Repository](https://git.oblachno.oblachno.fyi/oblachno-oss/grm)
|
||||
- [CI/CD Pipeline](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions)
|
||||
- [Changelog](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/src/branch/master/CHANGELOG.md)
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"index.md": "Home",
|
||||
"user/getting-started.md": "Getting-Started",
|
||||
"user/installation.md": "Installation",
|
||||
"user/cli-commands.md": "CLI-Commands",
|
||||
"user/troubleshooting.md": "Troubleshooting",
|
||||
"user/faq.md": "FAQ",
|
||||
"tech/architecture.md": "Architecture",
|
||||
"tech/development-setup.md": "Development-Setup",
|
||||
"tech/ci-cd-workflow.md": "CI-CD-Workflow",
|
||||
"tech/testing-strategy.md": "Testing-Strategy",
|
||||
"tech/decision-log.md": "Decision-Log",
|
||||
"tech/contributing.md": "Contributing-Guide"
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
# Architecture
|
||||
|
||||
GRM consists of two layers:
|
||||
|
||||
1. **Python CLI** (`src/gitea_runner_manager/`) — built with Click, handles argument parsing, environment loading, i18n translations, and delegates to Ansible via the `ansible-playbook` subprocess.
|
||||
2. **Ansible Role** (`ansible/roles/gitea-runner/`) — idempotent role that creates a dedicated system user, sets up rootless Docker, installs the runner binary, creates a systemd user service, and registers the runner with Gitea.
|
||||
|
||||
## Component Tree
|
||||
|
||||
```
|
||||
grm install <host>
|
||||
└── RunnerManager.install()
|
||||
└── ansible-playbook ansible/install-runner.yml
|
||||
└── role: gitea-runner
|
||||
├── user_setup.yml (create per-runner system user + lingering)
|
||||
├── rootless_docker.yml (rootless Docker setup under runner user)
|
||||
├── install_runner.yml (download binary, config, register, service)
|
||||
├── prune.yml (Docker prune timer)
|
||||
└── integration_test.yml (validate service is active)
|
||||
```
|
||||
|
||||
The Ansible role task execution order (from `AGENTS.md`):
|
||||
|
||||
```
|
||||
main.yml → systemd_check → user_setup → rootless_docker → install_runner → prune → integration_test
|
||||
```
|
||||
|
||||
- `install_runner.yml` handles: download, config, validate, register, service
|
||||
- `main.yml` handles: prune, integration_test (NOT install_runner — avoids duplicates)
|
||||
- `systemctl --user` tasks must be guarded by `docker_rootless_setup`
|
||||
- Template creation tasks are NOT guarded by `docker_rootless_setup` (they just create files)
|
||||
|
||||
## Per-Runner Isolation
|
||||
|
||||
Each runner runs as a systemd user service under a dedicated system user (`grm-<name>`). Each instance has fully isolated resources:
|
||||
|
||||
- **User**: `grm-<name>` (dedicated system user with lingering enabled)
|
||||
- **Home**: `/home/grm-<name>/`
|
||||
- **Data**: `/var/lib/gitea-runner/<name>/`
|
||||
- **Config**: `/etc/gitea-runner/<name>/`
|
||||
- **Service**: `gitea-runner.service` (systemd user service)
|
||||
- **Docker socket**: `/run/user/<UID>/docker.sock` (rootless, per-runner)
|
||||
|
||||
## Component Interactions
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
CLI["Python CLI<br/>src/gitea_runner_manager/<br/>(Click)"]
|
||||
RM["RunnerManager<br/>runner_manager.py"]
|
||||
EXEC["Executor<br/>executor.py"]
|
||||
REG["Registry<br/>registry.py<br/>~/.local/share/grm/runners.json"]
|
||||
ANS["ansible-playbook subprocess"]
|
||||
ROLE["Ansible Role<br/>ansible/roles/gitea-runner/"]
|
||||
USER["user_setup.yml<br/>create system user + lingering"]
|
||||
DOCKER["rootless_docker.yml<br/>rootless Docker setup"]
|
||||
INSTALL["install_runner.yml<br/>download, config, register, service"]
|
||||
PRUNE["prune.yml<br/>Docker prune timer"]
|
||||
TEST["integration_test.yml<br/>validate service active"]
|
||||
GITEA["Gitea instance<br/>registration + API"]
|
||||
SYSTEMD["systemd user service<br/>gitea-runner.service"]
|
||||
|
||||
CLI --> RM
|
||||
RM --> REG
|
||||
RM --> EXEC
|
||||
EXEC -->|subprocess| ANS
|
||||
ANS --> ROLE
|
||||
ROLE --> USER
|
||||
ROLE --> DOCKER
|
||||
ROLE --> INSTALL
|
||||
ROLE --> PRUNE
|
||||
ROLE --> TEST
|
||||
INSTALL -->|register| GITEA
|
||||
INSTALL --> SYSTEMD
|
||||
DOCKER --> SYSTEMD
|
||||
```
|
||||
|
||||
## Additional Components
|
||||
|
||||
From `AGENTS.md`, the project also includes:
|
||||
|
||||
- **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
|
||||
|
||||
## Python Modules
|
||||
|
||||
The Python CLI layer (`src/gitea_runner_manager/`) consists of the following modules:
|
||||
|
||||
| Module | Description |
|
||||
|--------|-------------|
|
||||
| `cli.py` | Click-based CLI entry point — defines all commands (install, update, start, stop, enable, disable, status, remove, list) |
|
||||
| `runner_manager.py` | Ansible orchestration + registry integration — delegates to executor and manages runner lifecycle |
|
||||
| `executor.py` | Ansible subprocess execution — runs `ansible-playbook` with extra-vars via temp JSON files |
|
||||
| `registry.py` | Local JSON runner registry at `~/.local/share/grm/runners.json` — stores connection metadata |
|
||||
| `i18n.py` | Internationalization translations (en, bg, de, ru, zh) |
|
||||
| `exceptions.py` | Custom exceptions (`GRMError`, `APIError`) |
|
||||
| `api_clients.py` | Gitea and Vikunja API client classes for CI automation scripts |
|
||||
| `config.py` | Configuration constants (API URLs, repo owner/name, project IDs) — overridable via environment variables |
|
||||
@@ -0,0 +1,313 @@
|
||||
# CI/CD Workflow
|
||||
|
||||
Every change to master goes through a mandatory PR workflow. No exceptions.
|
||||
|
||||
## PR Workflow
|
||||
|
||||
### 1. Create Vikunja Task
|
||||
|
||||
Create a task in Vikunja project 6 to get a `GRM-N` identifier.
|
||||
|
||||
### 2. Create Branch
|
||||
|
||||
```bash
|
||||
git checkout master && git pull
|
||||
git checkout -b GRM-N-short-description
|
||||
```
|
||||
|
||||
### 3. Implement Changes
|
||||
|
||||
- Write code following conventions
|
||||
- Write/update tests (100% coverage required)
|
||||
- Update documentation (CHANGELOG, README, AGENTS.md as needed)
|
||||
|
||||
### 4. Commit (Conventional Commits)
|
||||
|
||||
Branch commits use conventional commit format (no `GRM-N:` prefix):
|
||||
|
||||
```
|
||||
feat: add new feature
|
||||
fix: resolve bug
|
||||
docs: update README
|
||||
```
|
||||
|
||||
### 5. Push and Create PR
|
||||
|
||||
- **PR title format**: `GRM-N: <vikunja task title>` (must match the Vikunja task title exactly)
|
||||
- PR body: summary of changes, `Closes GRM-N`
|
||||
- Add `ready-to-merge` label **only after review is complete**
|
||||
|
||||
### 6. Review the PR (Mandatory — Before Adding ready-to-merge Label)
|
||||
|
||||
Review the full diff (`git diff master...HEAD`) focusing on:
|
||||
|
||||
- **Functional completeness**: Does the code do what it claims? Are all requirements met?
|
||||
- **Edge cases**: Are boundary conditions, empty inputs, error paths handled?
|
||||
- **Technical excellence**:
|
||||
- Architecture compliance and evolution
|
||||
- Single Responsibility Principle (SRP)
|
||||
- Deduplication (no copy-paste, single source of truth)
|
||||
- Code smells detection and removal
|
||||
- Best industry practices
|
||||
- Industry-grade code quality
|
||||
- Reusability
|
||||
- Clean code
|
||||
- Readability
|
||||
- Maintainability
|
||||
- Extensibility
|
||||
- **Performance**: No unnecessary allocations, O(n) vs O(n²), efficient data structures
|
||||
- **Security**: No secrets in logs/process list, input validation, no injection vectors
|
||||
- **User experience**: Clear error messages, intuitive CLI flags, helpful output
|
||||
- **Documentation**: Completeness and relevance of docs, CHANGELOG entries, AGENTS.md updates
|
||||
|
||||
Post review comments using `devx.ci.review_pr`:
|
||||
|
||||
```bash
|
||||
REPO_TOKEN=<token> python -m devx.ci.review_pr <pr_number> <owner/repo> \
|
||||
--event REQUEST_CHANGES \
|
||||
--body "Review summary" \
|
||||
--comments-json comments.json
|
||||
```
|
||||
|
||||
### 7. Address Review Comments
|
||||
|
||||
Fix each comment one by one, commit, and push. Re-review until satisfied.
|
||||
|
||||
### 8. Approve and Merge
|
||||
|
||||
Once all comments are addressed:
|
||||
|
||||
```bash
|
||||
REPO_TOKEN=<token> python -m devx.ci.review_pr <pr_number> <owner/repo> \
|
||||
--event APPROVE \
|
||||
--body "All comments addressed. LGTM."
|
||||
```
|
||||
|
||||
Then add the `ready-to-merge` label. The auto-merge workflow will:
|
||||
|
||||
1. **Validate** PR title format and match against Vikunja task title
|
||||
2. **Check** that at least one APPROVE review exists
|
||||
3. Wait for all CI checks to pass
|
||||
4. Squash-merge with title: `GRM-N <conventional commit message>` (space-separated)
|
||||
5. The post-merge workflow marks the Vikunja task as done
|
||||
6. The release workflow automatically versions, tags, and publishes
|
||||
|
||||
### 9. Post-Merge Automation
|
||||
|
||||
After the squash-merge:
|
||||
|
||||
- The **post-merge workflow** (`.gitea/workflows/post-merge.yml`) triggers on push to `master` and runs `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).
|
||||
|
||||
## Branch Protection (Required Gitea Settings)
|
||||
|
||||
Configure the following branch protection rules for `master` in Gitea repo settings:
|
||||
|
||||
- **Require pull request**: No direct pushes to master
|
||||
- **Require approval review**: At least 1 `APPROVE` review before merge
|
||||
- **Require status checks**: CI quality + molecule tests must pass
|
||||
- **Block force pushes**: No history rewriting on master
|
||||
|
||||
The auto-merge workflow enforces the APPROVE review check programmatically as a defense-in-depth measure, but branch protection is the primary gate.
|
||||
|
||||
## CI Path Filtering
|
||||
|
||||
The CI workflow (`.gitea/workflows/ci.yml`) includes a `detect-changes` job that checks whether any files under `ansible/` or `.ansible-lint` have changed. If no Ansible files are changed, molecule tests are skipped — this prevents non-Ansible changes (e.g., Python scripts, workflow YAML, docs) from being blocked by molecule test infrastructure flakiness.
|
||||
|
||||
The `detect-changes` job:
|
||||
|
||||
- For pull requests: compares `origin/master` against the PR head SHA
|
||||
- For pushes to master: compares `HEAD~1` against `HEAD`
|
||||
- Outputs `ansible-changed` as `true` or `false`
|
||||
|
||||
The `molecule-tests` job depends on both `quality` and `detect-changes`, and only runs if `ansible-changed == 'true'`.
|
||||
|
||||
CI triggers only on `opened` and `synchronize` PR events (not `labeled`).
|
||||
|
||||
## CI Quality Job
|
||||
|
||||
The `quality` job in `.gitea/workflows/ci.yml` runs:
|
||||
|
||||
1. `make setup` — full environment setup
|
||||
2. `make lint-all` — ruff + pyright + bandit + ansible-lint + checkmake
|
||||
3. `make pytest-cov` — unit tests with 100% coverage enforcement
|
||||
4. `python -m devx.tools.check_test_speed --max-seconds 10` — verify unit tests run fast
|
||||
5. `PYTHONPATH=src python -m devx.ci.release --dry-run` — release dry-run validation
|
||||
|
||||
## Automated Release Pipeline
|
||||
|
||||
After a PR is merged to master, the release pipeline runs automatically.
|
||||
|
||||
### Release Workflow (`.gitea/workflows/release.yml`)
|
||||
|
||||
- Triggers on push to `master`
|
||||
- Sets up full dev environment (`make setup`) so lint and tests can run
|
||||
- Installs git-cliff (version 2.13.0)
|
||||
- Configures git as `grm-ci-bot`
|
||||
- Runs `devx.ci.release` which uses **git-cliff** to:
|
||||
- Calculate the next semver version from conventional commits since the last tag
|
||||
- Update `__version__` in `src/gitea_runner_manager/__init__.py` (single source of truth)
|
||||
- Update `CHANGELOG.md` with the new version section
|
||||
- **Run `make lint-ruff` and `make pytest-cov`** to verify the release is healthy
|
||||
- If lint or tests fail, **abort immediately** — no commit, no tag
|
||||
- Commit with `release: vX.Y.Z` prefix (cleaner than `chore(release):`)
|
||||
- Create an annotated tag `vX.Y.Z` on the release commit
|
||||
- Push both the commit and tag to master
|
||||
- `--skip-tests` flag bypasses test verification (emergency use only, not recommended)
|
||||
- Loops are prevented by `has_unreleased_changes` — after a release commit is tagged, the next run finds no unreleased changes and exits
|
||||
- On failure, creates a Gitea issue via `devx.ci.notify_failure`
|
||||
|
||||
### Publish Workflow (`.gitea/workflows/publish.yml`)
|
||||
|
||||
- Triggers on tag push (`v*`)
|
||||
- Installs git-cliff (version 2.13.0)
|
||||
- Installs build tools (`build`, `twine`, `requests`, `python-dotenv`, `click`)
|
||||
- Validates `PYPI_TOKEN` is set (warns if missing)
|
||||
- Builds the Python package
|
||||
- Optionally publishes to PyPI (if `PYPI_TOKEN` is set)
|
||||
- Creates a Gitea release with git-cliff-generated release notes
|
||||
- Uses `devx.ci.publish` for build and publish orchestration
|
||||
- On failure, creates a Gitea issue via `devx.ci.notify_failure`
|
||||
|
||||
### Auto-Merge Workflow (`.gitea/workflows/auto-merge.yml`)
|
||||
|
||||
- Triggers on `pull_request` labeled events
|
||||
- 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
|
||||
|
||||
### Post-Merge Workflow (`.gitea/workflows/post-merge.yml`)
|
||||
|
||||
- Triggers on push to `master`
|
||||
- Runs `devx.ci.post_merge` with the latest commit message and commit SHA
|
||||
- Marks the corresponding Vikunja task as done
|
||||
|
||||
### Smart CI: User-Facing vs Workflow-Only Changes
|
||||
|
||||
Not all changes require the full CI pipeline or a new release. The project uses
|
||||
`devx.ci.classify_changes` to classify changed files into two categories:
|
||||
|
||||
**User-facing paths** (tool changes → release needed):
|
||||
- `src/gitea_runner_manager/**` — Python CLI source
|
||||
- `ansible/**` — Ansible role
|
||||
- `pyproject.toml` — Package metadata
|
||||
|
||||
**Workflow-only paths** (infrastructure → no release needed):
|
||||
- `.gitea/workflows/**`, `docs/**`, `tests/**`
|
||||
- `AGENTS.md`, `README.md`, `CHANGELOG.md`, `Makefile`, `cliff.toml`, etc.
|
||||
|
||||
**CI behavior based on classification:**
|
||||
- **Molecule tests**: Only run when `ansible/` or `.ansible-lint` files change
|
||||
- **Release dry-run**: Only runs when user-facing files change (separate `release-dry-run` job)
|
||||
- **Quality job** (lint, unit tests, coverage, doc-coverage): Always runs
|
||||
- **Release workflow**: `release.py` calls `classify_changes` to check if any
|
||||
user-facing files changed since the last tag. If not, the release is skipped
|
||||
entirely — no version bump, no tag, no publish.
|
||||
|
||||
### Dynamic Runner Discovery
|
||||
|
||||
Molecule tests are distributed across available Gitea Actions runners
|
||||
dynamically. The `discover-runners` job runs `devx.molecule.discover_runners` which queries the Gitea API for
|
||||
registered runners at three levels (repo, org, instance) and generates
|
||||
a matrix of runner indices. If the API query fails (e.g., no admin
|
||||
access for instance-level runners), it falls back to the
|
||||
`MOLECULE_RUNNERS` repo variable, then to a default of 3.
|
||||
|
||||
The `molecule-tests` job uses `fromJSON()` to consume the dynamic
|
||||
matrix, and passes the runner count to `python -m devx.molecule.distribute_molecule
|
||||
--max-runners` so test pairs are evenly distributed.
|
||||
|
||||
When adding or removing Gitea runners:
|
||||
1. If runners are registered at the repo/org level, they're auto-detected
|
||||
2. If runners are at the instance level, update the `MOLECULE_RUNNERS` repo variable
|
||||
3. The workflow automatically scales the matrix to match available runners
|
||||
|
||||
### Molecule Test Distribution
|
||||
|
||||
`devx.molecule.distribute_molecule` discovers all molecule scenarios
|
||||
under `ansible/roles/*/molecule/` and crosses them with the supported
|
||||
OS platform matrix (defined in `devx.molecule.platforms`), then splits
|
||||
the resulting test pairs evenly across the requested number of runners.
|
||||
Each pair is encoded as `scenario|platform_name|platform_image|platform_command`.
|
||||
|
||||
`devx.molecule.molecule_ci_guard` runs the actual molecule test for a
|
||||
given test pair, with CI context (Gitea URL, token, run ID) for
|
||||
reporting results back to the commit status API.
|
||||
|
||||
### Commit Message Validation
|
||||
|
||||
`devx.ci.validate_commit_msg` validates that commit messages
|
||||
follow the conventional commit format (`feat:`, `fix:`, `docs:`, etc.).
|
||||
It is used by the pre-commit hook to enforce conventional commits on
|
||||
feature branches.
|
||||
|
||||
### Release Commit Detection
|
||||
|
||||
The `detect-type` job in the post-merge workflow runs
|
||||
`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 detected, all post-merge jobs (release, sync-wiki, badges, vikunja)
|
||||
are skipped — the tag push triggers the publish workflow instead.
|
||||
|
||||
### Badge Generation and Push
|
||||
|
||||
The `badges` job in the post-merge workflow runs
|
||||
`devx.ci.push_badges` which:
|
||||
1. Fetches the latest master and hard-resets to it (picks up release commits)
|
||||
2. Generates quality badge SVG files via `devx.tools.generate_badges`
|
||||
3. Creates an orphan `badges` branch
|
||||
4. Copies SVG files to the branch root
|
||||
5. Force-pushes the branch to the remote
|
||||
|
||||
The badges job depends on the `release` job and uses `if: always()` so it
|
||||
runs even if release fails or is skipped. This ensures the version badge
|
||||
always reflects the actual state of the repository after any release
|
||||
commits have been pushed.
|
||||
|
||||
## git-cliff Commit Preprocessing
|
||||
|
||||
Merge commits on master have the format `GRM-N <conventional commit>`. The `GRM-N ` prefix is not a valid conventional commit prefix, so `cliff.toml` includes a `commit_preprocessors` entry that strips it before parsing:
|
||||
|
||||
```toml
|
||||
commit_preprocessors = [
|
||||
# Strip GRM-N task ID prefix from merge commits so git-cliff sees conventional commits
|
||||
{ pattern = "^GRM-\\d+\\s+", replace = "" },
|
||||
]
|
||||
```
|
||||
|
||||
This ensures all merged work appears in the changelog.
|
||||
|
||||
### git-cliff Configuration Highlights (`cliff.toml`)
|
||||
|
||||
- `conventional_commits = true` — parse conventional commit format
|
||||
- `filter_unconventional = true` — skip non-conventional commits
|
||||
- `render_always = true` — always render the changelog
|
||||
- `trim = true` — trim whitespace
|
||||
- Commit parsers group commits into: Features, Bug Fixes, Documentation, Performance, Refactor, Styling, Testing, Miscellaneous Tasks, Security, Revert, Other
|
||||
- `chore(release): prepare for`, `chore(deps.*)`, `chore(pr)`, `chore(pull)` commits are skipped
|
||||
- `sort_commits = "oldest"` — oldest commits first
|
||||
|
||||
## Version Bumping Rules (git-cliff)
|
||||
|
||||
| Commit type | Version bump |
|
||||
|-------------|-------------|
|
||||
| `feat:` | minor (0.X.0) |
|
||||
| `fix:` | patch (0.0.X) |
|
||||
| `feat!:` or `BREAKING CHANGE` | minor (pre-1.0: major would be 1.0.0) |
|
||||
| `chore:`, `ci:`, `docs:` | no bump (excluded by cliff.toml) |
|
||||
|
||||
From `cliff.toml` `[bump]` section:
|
||||
|
||||
- `features_always_bump_minor = true`
|
||||
- `breaking_always_bump_major = false`
|
||||
- `initial_tag = "0.1.0"`
|
||||
|
||||
The version source is `__version__` in `src/gitea_runner_manager/__init__.py`, read by setuptools via `dynamic = ["version"]` in `pyproject.toml`. The release script only updates `__init__.py` — no need to touch `pyproject.toml`. `grm --version` reports this version.
|
||||
|
||||
## Title Format Summary
|
||||
|
||||
| What | Format | Example |
|
||||
|------|--------|---------|
|
||||
| Branch name | `GRM-N-short-description` | `GRM-33-add-pr-review-step` |
|
||||
| Branch commits | `<conventional commit>` | `feat: add review script` |
|
||||
| PR title | `GRM-N: <vikunja task title>` | `GRM-33: Add mandatory PR review step` |
|
||||
| Merge commit | `GRM-N <conventional commit>` | `GRM-33 feat: add review script` |
|
||||
@@ -0,0 +1,100 @@
|
||||
# Contributing Guide
|
||||
|
||||
## Key Conventions
|
||||
|
||||
- Python 3.12+ required (ruff/pyright target `py312`)
|
||||
- 100% test coverage required (`--cov-fail-under=100`)
|
||||
- Conventional commits on feature branches (no `GRM-N:` prefix)
|
||||
- Branch names must include `GRM-N` task ID
|
||||
- Line length: 120 chars
|
||||
- Secrets are passed via temp JSON files, never on the command line (CWE-214)
|
||||
- CI triggers only on `opened` and `synchronize` PR events (not `labeled`)
|
||||
|
||||
## Code Style Rules
|
||||
|
||||
- **Python version**: 3.12+ (ruff and pyright target `py312`)
|
||||
- **Line length**: 120 characters
|
||||
- **Test coverage**: 100% required (`--cov-fail-under=100`)
|
||||
- **Secrets handling**: Secrets are passed via temp JSON files with `0600` permissions, never on the command line (CWE-214). Extra-vars are written to a temporary JSON file and passed via `--extra-vars @tempfile`, which is deleted after execution. This prevents secrets from being visible in the process list (`ps aux`).
|
||||
- **Linting**: `make lint-all` runs ruff + pyright + bandit + ansible-lint + checkmake
|
||||
|
||||
## Commit Rules
|
||||
|
||||
Branch commits use conventional commit format (no `GRM-N:` prefix):
|
||||
|
||||
```
|
||||
feat: add new feature
|
||||
fix: resolve bug
|
||||
docs: update README
|
||||
```
|
||||
|
||||
### Version Bumping Rules
|
||||
|
||||
| Commit type | Version bump |
|
||||
|-------------|-------------|
|
||||
| `feat:` | minor (0.X.0) |
|
||||
| `fix:` | patch (0.0.X) |
|
||||
| `feat!:` or `BREAKING CHANGE` | minor (pre-1.0: major would be 1.0.0) |
|
||||
| `chore:`, `ci:`, `docs:` | no bump (excluded by cliff.toml) |
|
||||
|
||||
## Branch Naming
|
||||
|
||||
| What | Format | Example |
|
||||
|------|--------|---------|
|
||||
| Branch name | `GRM-N-short-description` | `GRM-33-add-pr-review-step` |
|
||||
| Branch commits | `<conventional commit>` | `feat: add review script` |
|
||||
| PR title | `GRM-N: <vikunja task title>` | `GRM-33: Add mandatory PR review step` |
|
||||
| Merge commit | `GRM-N <conventional commit>` | `GRM-33 feat: add review script` |
|
||||
|
||||
## PR Workflow Summary
|
||||
|
||||
Every change to master goes through this workflow. No exceptions.
|
||||
|
||||
1. **Create Vikunja task** — get a `GRM-N` identifier (Vikunja project 6)
|
||||
2. **Create branch** — `GRM-N-short-description`
|
||||
3. **Implement** — write code, tests (100% coverage), update docs
|
||||
4. **Commit** — conventional commits (no `GRM-N:` prefix on branch)
|
||||
5. **Push & create PR** — title: `GRM-N: <vikunja task title>`, body: summary + `Closes GRM-N`
|
||||
6. **Review** — review the full diff focusing on: functional completeness, edge cases, technical excellence (architecture, SRP, deduplication, code smells, best practices, code quality, reusability, clean code, readability, maintainability, extensibility), performance, security, UX, documentation completeness/relevance. Post review comments via `devx.ci.review_pr`.
|
||||
7. **Address comments** — fix each comment, commit, push, re-review
|
||||
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
|
||||
|
||||
### Branch Protection (Required Gitea Settings)
|
||||
|
||||
Configure the following branch protection rules for `master` in Gitea repo settings:
|
||||
|
||||
- **Require pull request**: No direct pushes to master
|
||||
- **Require approval review**: At least 1 `APPROVE` review before merge
|
||||
- **Require status checks**: CI quality + molecule tests must pass
|
||||
- **Block force pushes**: No history rewriting on master
|
||||
|
||||
The auto-merge workflow enforces the APPROVE review check programmatically as a defense-in-depth measure, but branch protection is the primary gate.
|
||||
|
||||
## Build & Test Commands
|
||||
|
||||
```bash
|
||||
make setup # Create venv, install deps, set up hooks
|
||||
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake
|
||||
make pytest-cov # Unit tests with 100% coverage enforcement
|
||||
make test-unit # Unit tests without coverage
|
||||
make molecule # All 6 scenarios on Ubuntu 22.04
|
||||
make molecule-all # All 6 scenarios on all 4 supported OSes
|
||||
make test-all # pytest-cov + molecule
|
||||
```
|
||||
|
||||
## Ansible Role Conventions
|
||||
|
||||
```
|
||||
main.yml → systemd_check → user_setup → rootless_docker → install_runner → prune → integration_test
|
||||
```
|
||||
|
||||
- `install_runner.yml` handles: download, config, validate, register, service
|
||||
- `main.yml` handles: prune, integration_test (NOT install_runner — avoids duplicates)
|
||||
- `systemctl --user` tasks must be guarded by `docker_rootless_setup`
|
||||
- Template creation tasks are NOT guarded by `docker_rootless_setup` (they just create files)
|
||||
|
||||
## Known Issues
|
||||
|
||||
- `ansible-lint` may warn about `command-instead-of-module` for `systemctl --user` calls — this is expected (systemd module doesn't support user services) and skipped in `.ansible-lint`
|
||||
- Molecule Docker driver may print "Event loop is closed" warnings on interrupt — harmless
|
||||
@@ -0,0 +1,75 @@
|
||||
# Decision Log
|
||||
|
||||
Key technical decisions for the GRM project, extracted from `CHANGELOG.md` and `AGENTS.md`.
|
||||
|
||||
---
|
||||
|
||||
## ADR-001: Dynamic Versioning via `__init__.py`
|
||||
|
||||
**Date:** 2026-06-21 (v0.2.0 unreleased)
|
||||
|
||||
**Decision:** Use `dynamic = ["version"]` in `pyproject.toml` with setuptools `attr` to source the version from `__version__` in `src/gitea_runner_manager/__init__.py`.
|
||||
|
||||
**Rationale:** `__init__.py` is the single source of truth for the version. The release script (`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)
|
||||
|
||||
---
|
||||
|
||||
## ADR-002: Rootless Docker per Runner
|
||||
|
||||
**Date:** Project inception (documented in README Architecture)
|
||||
|
||||
**Decision:** Each runner instance runs in an isolated rootless Docker environment under a dedicated system user (`grm-<name>`), with its own Docker socket at `/run/user/<UID>/docker.sock`.
|
||||
|
||||
**Rationale:** Rootless Docker per-runner avoids conflicts with the host's Docker installation and enables true parallel execution of multiple runners on the same host. Each instance has fully isolated resources: user, home, data directory, config directory, systemd user service, and Docker socket. This is a core feature of GRM — enabling multiple isolated runners on the same host.
|
||||
|
||||
**Source:** `README.md` (Architecture, Features), `AGENTS.md` (Architecture)
|
||||
|
||||
---
|
||||
|
||||
## ADR-003: Conventional Commits + git-cliff for Automated Versioning
|
||||
|
||||
**Date:** 2026-06-21 (v0.2.0 unreleased)
|
||||
|
||||
**Decision:** Use conventional commits on feature branches and git-cliff (`cliff.toml`) to calculate the next semver version from commit history, generate the changelog, and automate releases.
|
||||
|
||||
**Rationale:** `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`
|
||||
|
||||
---
|
||||
|
||||
## ADR-004: Enforce Tests Pass Before Tagging a Release
|
||||
|
||||
**Date:** 2026-06-21 (v0.2.2)
|
||||
|
||||
**Decision:** The release workflow runs `make lint-ruff` and `make pytest-cov` before creating a release commit or tag. If lint or tests fail, the release aborts immediately — no commit, no tag.
|
||||
|
||||
**Rationale:** This ensures every tagged release is healthy. A `--skip-tests` flag exists for emergency use only but is not recommended. This decision was made as a bug fix after identifying that releases could be tagged without verifying test health. Loops are prevented by `has_unreleased_changes` — after a release commit is tagged, the next run finds no unreleased changes and exits.
|
||||
|
||||
**Source:** `CHANGELOG.md` (0.2.2 — Bug Fixes: "Enforce tests pass before tagging a release"), `AGENTS.md` (Automated Release Pipeline)
|
||||
|
||||
---
|
||||
|
||||
## ADR-005: Branch Protection + Auto-Merge Workflow
|
||||
|
||||
**Date:** 2026-06-21 (v0.2.0 unreleased)
|
||||
|
||||
**Decision:** Require branch protection on `master` (require pull request, require approval review, require status checks, block force pushes) and use an auto-merge workflow that programmatically enforces the APPROVE review check.
|
||||
|
||||
**Rationale:** Branch protection is the primary gate — no direct pushes to master, at least 1 APPROVE review before merge, CI quality + molecule tests must pass, and no history rewriting. The auto-merge workflow (`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)
|
||||
|
||||
---
|
||||
|
||||
## ADR-006: Path-Based CI Filtering for Molecule Tests
|
||||
|
||||
**Date:** 2026-06-21 (v0.2.0 unreleased)
|
||||
|
||||
**Decision:** The CI workflow includes a `detect-changes` job that checks whether any files under `ansible/` or `.ansible-lint` have changed. If no Ansible files are changed, molecule tests are skipped.
|
||||
|
||||
**Rationale:** This prevents non-Ansible changes (e.g., Python scripts, workflow YAML, docs) from being blocked by molecule test infrastructure flakiness. Molecule tests are only relevant when Ansible files change. The `molecule-tests` job depends on both `quality` and `detect-changes`, and only runs if `ansible-changed == 'true'`. CI triggers only on `opened` and `synchronize` PR events (not `labeled`) to avoid redundant runs.
|
||||
|
||||
**Source:** `AGENTS.md` (CI Path Filtering), `.gitea/workflows/ci.yml` (detect-changes job)
|
||||
@@ -0,0 +1,108 @@
|
||||
# Development Setup
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
.
|
||||
├── src/gitea_runner_manager/ # Python CLI source
|
||||
│ ├── cli.py # Click commands
|
||||
│ ├── runner_manager.py # Ansible orchestration + registry integration
|
||||
│ ├── executor.py # Ansible subprocess execution
|
||||
│ ├── registry.py # Local JSON runner registry
|
||||
│ ├── i18n.py # Translations (en, bg, de, ru, zh)
|
||||
│ └── exceptions.py # Custom exceptions
|
||||
├── ansible/
|
||||
│ ├── roles/gitea-runner/ # Main Ansible role
|
||||
│ │ ├── defaults/main.yml # Default variables
|
||||
│ │ ├── tasks/ # Task files
|
||||
│ │ ├── templates/ # Jinja2 templates
|
||||
│ │ └── molecule/ # Test scenarios
|
||||
│ ├── install-runner.yml # Install playbook
|
||||
│ ├── update-runner.yml # Update playbook
|
||||
│ ├── start-runner.yml # Start playbook
|
||||
│ ├── stop-runner.yml # Stop playbook
|
||||
│ ├── enable-runner.yml # Enable playbook
|
||||
│ ├── disable-runner.yml # Disable playbook
|
||||
│ ├── status-runner.yml # Status playbook
|
||||
│ └── remove-runner.yml # Remove playbook
|
||||
├── tests/
|
||||
│ ├── unit/ # Unit tests
|
||||
│ └── integration/ # Integration tests
|
||||
├── Makefile # Build & test automation
|
||||
└── pyproject.toml # Python project metadata
|
||||
```
|
||||
|
||||
## Setup Development Environment
|
||||
|
||||
```bash
|
||||
make setup # Creates venv, installs deps, sets up hooks
|
||||
source .venv/bin/activate
|
||||
```
|
||||
|
||||
The `make setup` target (from the `Makefile`):
|
||||
|
||||
- Verifies Python 3.12+ is installed
|
||||
- Creates a virtualenv in `.venv`
|
||||
- Installs/updates `pip`, `setuptools`, and `wheel`
|
||||
- Creates `.env` from `.env.example` if not present
|
||||
- Generates shell activation scripts (`activate.sh`, `activate.fish`, `activate.zsh`)
|
||||
- Installs `checkmake` via `devx.tools.install_checkmake`
|
||||
- Runs `python -m devx.tools.setup` to install dependencies and hooks
|
||||
|
||||
### Developer Quick Start
|
||||
|
||||
```bash
|
||||
git clone https://git.oblachno.oblachno.com/oblachno/gitea-runner-manager.git
|
||||
cd gitea-runner-manager
|
||||
pyenv install 3.12
|
||||
pyenv local 3.12
|
||||
make setup
|
||||
```
|
||||
|
||||
### Configure Gitea Credentials
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# Edit .env:
|
||||
# GITEA_URL=https://git.example.com
|
||||
# GITEA_REGISTRATION_TOKEN=your-registration-token
|
||||
```
|
||||
|
||||
`GITEA_REGISTRATION_TOKEN` is the runner registration token obtained from your Gitea instance (Admin → Actions → Runners → Create Registration Token).
|
||||
|
||||
#### Admin API Token (optional)
|
||||
|
||||
Set `GITEA_ADMIN_TOKEN` to enable informational API checks during integration test. This is **optional** — the test primarily verifies the runner by checking:
|
||||
|
||||
1. **`.runner` registration file** exists and contains valid JSON (proves successful registration)
|
||||
2. **Systemd user service** is active (proves daemon is polling for jobs)
|
||||
|
||||
API checks, if enabled, are purely informational and do not affect pass/fail.
|
||||
|
||||
## Running Linters
|
||||
|
||||
```bash
|
||||
make lint # Python (ruff + pyright + bandit)
|
||||
make lint-bandit # Security scan only
|
||||
make ansible-lint # Ansible
|
||||
make makefile-lint # Makefile
|
||||
```
|
||||
|
||||
The full lint target (`make lint-all`) runs all of the above:
|
||||
|
||||
```bash
|
||||
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake
|
||||
```
|
||||
|
||||
Individual lint targets from the `Makefile`:
|
||||
|
||||
| Target | Description |
|
||||
|--------|-------------|
|
||||
| `lint-ruff` | `ruff check src/ tests/` |
|
||||
| `lint-format` | `ruff format --check src/ tests/` |
|
||||
| `typecheck` | `pyright` |
|
||||
| `lint-bandit` | `bandit -r src/` |
|
||||
| `ansible-lint` | `ansible-lint ansible/` |
|
||||
| `makefile-lint` | `checkmake Makefile` |
|
||||
| `lint` | ruff + format check + pyright + bandit |
|
||||
| `lint-all` | lint + ansible-lint + makefile-lint |
|
||||
@@ -0,0 +1,87 @@
|
||||
# Testing Strategy
|
||||
|
||||
## Unit Tests
|
||||
|
||||
```bash
|
||||
make test-unit
|
||||
```
|
||||
|
||||
Runs pytest with 100% coverage requirement.
|
||||
|
||||
From the `Makefile`:
|
||||
|
||||
- `test-unit` — `pytest tests/unit/ -v --no-cov` (unit tests without coverage)
|
||||
- `pytest-cov` — `pytest tests/unit/ -v --cov=src/gitea_runner_manager --cov=scripts --cov-report=term-missing --cov-fail-under=100` (unit tests with 100% coverage enforcement)
|
||||
|
||||
The coverage requirement is `--cov-fail-under=100` — 100% test coverage is required.
|
||||
|
||||
## Molecule Tests
|
||||
|
||||
```bash
|
||||
make molecule # Quick: all 6 scenarios on Ubuntu 22.04
|
||||
make molecule-all # Full: all 6 scenarios on all 4 supported OSes
|
||||
```
|
||||
|
||||
Runs six scenarios:
|
||||
|
||||
- **default** — Rootless Docker runner installation
|
||||
- **multi-instance** — Two isolated runner instances on the same host
|
||||
- **lifecycle** — Stop, disable, re-enable, and start sequence
|
||||
- **template-content** — Verify rendered systemd user service and prune templates
|
||||
- **deregister** — Runner deregistration
|
||||
- **update** — Runner binary update
|
||||
|
||||
All scenarios test idempotence (second run produces zero changes).
|
||||
|
||||
### Platforms
|
||||
|
||||
4 platforms are tested: `ubuntu-2204`, `ubuntu-2404`, `debian-12`, `archlinux`.
|
||||
|
||||
The platform list is defined in `devx.molecule.distribute_molecule` (single source of truth).
|
||||
|
||||
### CI Test Distribution
|
||||
|
||||
CI runs all 6 scenarios × 4 platforms (24 test pairs) distributed across 3 parallel runners.
|
||||
|
||||
From `.gitea/workflows/ci.yml`, the `molecule-tests` job uses a matrix of `runner-index: [0, 1, 2]` and calls `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
|
||||
|
||||
```bash
|
||||
make test-integration
|
||||
```
|
||||
|
||||
Tests the full CLI lifecycle commands end-to-end (mocked executor boundary).
|
||||
|
||||
From the `Makefile`:
|
||||
|
||||
- `test-integration` — `pytest tests/integration/ -v --no-cov`
|
||||
|
||||
## Full Test Suite
|
||||
|
||||
```bash
|
||||
make test-all # Runs unit tests + linters + molecule
|
||||
```
|
||||
|
||||
From the `Makefile`:
|
||||
|
||||
- `test-all` — `pytest-cov + molecule` (unit tests with coverage + all 6 molecule scenarios on Ubuntu 22.04)
|
||||
|
||||
## Build & Test Commands Summary
|
||||
|
||||
From `AGENTS.md`:
|
||||
|
||||
```bash
|
||||
make setup # Create venv, install deps, set up hooks
|
||||
make lint-all # ruff + pyright + bandit + ansible-lint + checkmake
|
||||
make pytest-cov # Unit tests with 100% coverage enforcement
|
||||
make test-unit # Unit tests without coverage
|
||||
make molecule # All 6 scenarios on Ubuntu 22.04
|
||||
make molecule-all # All 6 scenarios on all 4 supported OSes
|
||||
make test-all # pytest-cov + molecule
|
||||
```
|
||||
|
||||
## Known Issues
|
||||
|
||||
- `ansible-lint` may warn about `command-instead-of-module` for `systemctl --user` calls — this is expected (systemd module doesn't support user services) and skipped in `.ansible-lint`
|
||||
- Molecule Docker driver may print "Event loop is closed" warnings on interrupt — harmless
|
||||
@@ -0,0 +1,233 @@
|
||||
# CLI Commands
|
||||
|
||||
GRM provides the following CLI commands for managing Gitea Actions runners. The base command is `grm`.
|
||||
|
||||
## install
|
||||
|
||||
Install and configure a Gitea Runner on a remote host.
|
||||
|
||||
```bash
|
||||
grm install <host> [options]
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `host` | Remote host (IP address or hostname) |
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Short | Default | Description |
|
||||
|--------|-------|---------|-------------|
|
||||
| `--user` | `-u` | `GITEA_RUNNER_USER` env or current login | SSH user |
|
||||
| `--key` | `-k` | `GITEA_RUNNER_KEY` env | Path to SSH private key |
|
||||
| `--name` | `-n` | hostname | Gitea Runner name |
|
||||
| `--token` | `-t` | `GITEA_REGISTRATION_TOKEN` env | Registration token |
|
||||
| `--url` | — | `GITEA_URL` env | Gitea URL |
|
||||
| `--admin-token` | `-a` | `REPO_TOKEN` env | Gitea admin API token for integration test |
|
||||
| `--integration-retries` | `-r` | `3` (`GITEA_INTEGRATION_RETRIES` env) | Integration test API retries |
|
||||
| `--labels` | `-l` | `GITEA_RUNNER_LABELS` env | Runner labels for Gitea Actions. Example: `docker:docker://alpine:latest` |
|
||||
| `--ask-become-pass/--no-ask-become-pass` | — | `--ask-become-pass` | Prompt for sudo password (default) or skip it |
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner
|
||||
```
|
||||
|
||||
## update
|
||||
|
||||
Update the Gitea Runner binary on a remote host.
|
||||
|
||||
```bash
|
||||
grm update <host> [options]
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `host` | Remote host (IP address or hostname) |
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Short | Default | Description |
|
||||
|--------|-------|---------|-------------|
|
||||
| `--user` | `-u` | `GITEA_RUNNER_USER` env or current login | SSH user |
|
||||
| `--key` | `-k` | `GITEA_RUNNER_KEY` env | Path to SSH private key |
|
||||
| `--version` | `-v` | — | Specific Gitea Runner version |
|
||||
| `--ask-become-pass/--no-ask-become-pass` | — | `--ask-become-pass` | Prompt for sudo password (default) or skip it |
|
||||
|
||||
## start
|
||||
|
||||
Start a registered Gitea Runner.
|
||||
|
||||
```bash
|
||||
grm start <runner_name> [options]
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `runner_name` | Name of the registered runner |
|
||||
|
||||
**Options (common lifecycle options):**
|
||||
|
||||
| Option | Short | Description |
|
||||
|--------|-------|-------------|
|
||||
| `--host` | — | Override host from registry |
|
||||
| `--user` | `-u` | Override user from registry |
|
||||
| `--key` | `-k` | Override SSH key from registry |
|
||||
| `--ask-become-pass/--no-ask-become-pass` | — | Prompt for sudo password (default) or skip it |
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
grm start prod-runner
|
||||
# Override stored values:
|
||||
grm start prod-runner --host 192.168.1.11 --user root
|
||||
```
|
||||
|
||||
## stop
|
||||
|
||||
Stop a registered Gitea Runner.
|
||||
|
||||
```bash
|
||||
grm stop <runner_name> [options]
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `runner_name` | Name of the registered runner |
|
||||
|
||||
**Options (common lifecycle options):**
|
||||
|
||||
| Option | Short | Description |
|
||||
|--------|-------|-------------|
|
||||
| `--host` | — | Override host from registry |
|
||||
| `--user` | `-u` | Override user from registry |
|
||||
| `--key` | `-k` | Override SSH key from registry |
|
||||
| `--ask-become-pass/--no-ask-become-pass` | — | Prompt for sudo password (default) or skip it |
|
||||
|
||||
## enable
|
||||
|
||||
Enable a registered Gitea Runner to start on boot.
|
||||
|
||||
```bash
|
||||
grm enable <runner_name> [options]
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `runner_name` | Name of the registered runner |
|
||||
|
||||
**Options (common lifecycle options):**
|
||||
|
||||
| Option | Short | Description |
|
||||
|--------|-------|-------------|
|
||||
| `--host` | — | Override host from registry |
|
||||
| `--user` | `-u` | Override user from registry |
|
||||
| `--key` | `-k` | Override SSH key from registry |
|
||||
| `--ask-become-pass/--no-ask-become-pass` | — | Prompt for sudo password (default) or skip it |
|
||||
|
||||
## disable
|
||||
|
||||
Disable a registered Gitea Runner and deregister it.
|
||||
|
||||
```bash
|
||||
grm disable <runner_name> [options]
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `runner_name` | Name of the registered runner |
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Short | Default | Description |
|
||||
|--------|-------|---------|-------------|
|
||||
| `--host` | — | from registry | Override host from registry |
|
||||
| `--user` | `-u` | from registry | Override user from registry |
|
||||
| `--key` | `-k` | from registry | Override SSH key from registry |
|
||||
| `--token` | `-t` | `GITEA_REGISTRATION_TOKEN` env | Registration token |
|
||||
| `--url` | — | `GITEA_URL` env | Gitea URL |
|
||||
| `--ask-become-pass/--no-ask-become-pass` | — | `--ask-become-pass` | Prompt for sudo password (default) or skip it |
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
grm disable prod-runner --token <token>
|
||||
```
|
||||
|
||||
## status
|
||||
|
||||
Check the status of a registered Gitea Runner.
|
||||
|
||||
```bash
|
||||
grm status <runner_name> [options]
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `runner_name` | Name of the registered runner |
|
||||
|
||||
**Options (common lifecycle options):**
|
||||
|
||||
| Option | Short | Description |
|
||||
|--------|-------|-------------|
|
||||
| `--host` | — | Override host from registry |
|
||||
| `--user` | `-u` | Override user from registry |
|
||||
| `--key` | `-k` | Override SSH key from registry |
|
||||
| `--ask-become-pass/--no-ask-become-pass` | — | Prompt for sudo password (default) or skip it |
|
||||
|
||||
## remove
|
||||
|
||||
Remove a registered Gitea Runner completely.
|
||||
|
||||
```bash
|
||||
grm remove <runner_name> [options]
|
||||
```
|
||||
|
||||
**Arguments:**
|
||||
|
||||
| Argument | Description |
|
||||
|----------|-------------|
|
||||
| `runner_name` | Name of the registered runner |
|
||||
|
||||
**Options:**
|
||||
|
||||
| Option | Short | Default | Description |
|
||||
|--------|-------|---------|-------------|
|
||||
| `--host` | — | from registry | Override host from registry |
|
||||
| `--user` | `-u` | from registry | Override user from registry |
|
||||
| `--key` | `-k` | from registry | Override SSH key from registry |
|
||||
| `--token` | `-t` | `GITEA_REGISTRATION_TOKEN` env | Registration token |
|
||||
| `--url` | — | `GITEA_URL` env | Gitea URL |
|
||||
| `--force` | `-f` | — | Skip remote cleanup and only remove the local registry entry |
|
||||
| `--ask-become-pass/--no-ask-become-pass` | — | `--ask-become-pass` | Prompt for sudo password (default) or skip it |
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
grm remove prod-runner --token <token>
|
||||
```
|
||||
|
||||
## list
|
||||
|
||||
List all registered runners with live status.
|
||||
|
||||
```bash
|
||||
grm list
|
||||
```
|
||||
|
||||
This command takes no arguments or options. It displays a table with columns: NAME, HOST, USER, LABELS, STATUS for all runners stored in the local registry at `~/.local/share/grm/runners.json`.
|
||||
@@ -0,0 +1,39 @@
|
||||
# FAQ
|
||||
|
||||
### How do I obtain the Gitea registration token?
|
||||
|
||||
There are three levels of registration tokens, depending on which repositories the runner should serve:
|
||||
|
||||
- **Instance-level** — Site Administration → Actions → Runners → Create Registration Token. The runner will handle jobs from all repositories.
|
||||
- **Organization-level** — Organization → Settings → Actions → Runners → Create Registration Token. The runner will only handle jobs from repositories in that organization.
|
||||
- **Repository-level** — Repository → Settings → Actions → Runners → Create Registration Token. The runner will only handle jobs from that specific repository.
|
||||
|
||||
Set the token as `GITEA_REGISTRATION_TOKEN` in your `.env` file or pass it via `--token` on the command line.
|
||||
|
||||
### What is the REPO_TOKEN and do I need it?
|
||||
|
||||
`REPO_TOKEN` is a Gitea admin API token used for optional post-install verification. When set, GRM queries the Gitea API after installation to confirm the runner appears in the runner list. This is purely informational — the integration test passes/fails based on the `.runner` file and systemd service, not the API check.
|
||||
|
||||
To generate one: Settings → Applications → Generate New Token, with the `admin` scope (or at minimum `read:user`, `read:repository`, `read:admin`).
|
||||
|
||||
If you skip it, GRM will still verify the runner correctly — it just won't show the extra API confirmation.
|
||||
|
||||
### How do I skip the sudo password prompt for automation?
|
||||
|
||||
Configure passwordless sudo on the remote host and pass `--no-ask-become-pass` to the CLI command. This is recommended for CI/CD pipelines.
|
||||
|
||||
### Can I run multiple runners on the same host?
|
||||
|
||||
Yes. Each runner instance is fully isolated with its own system user (`grm-<name>`), rootless Docker daemon, data directory, and systemd user service. Install additional runners with different `--name` values and manage them independently by name.
|
||||
|
||||
### Why does my runner appear offline after installation?
|
||||
|
||||
Check that `GITEA_URL` and `GITEA_REGISTRATION_TOKEN` are correct, verify the runner service is running with `sudo -u grm-<name> systemctl --user status gitea-runner`, and check the logs for registration errors. You can also confirm the runner appears as **Online** in the Gitea UI under **Actions → Runners**.
|
||||
|
||||
### What does the "Event loop is closed" warning mean?
|
||||
|
||||
This is a harmless cleanup traceback from Molecule's Docker driver when the test process is interrupted. It does not indicate a test failure.
|
||||
|
||||
### Where are runner connection details stored?
|
||||
|
||||
GRM stores each runner's connection details (host, user, SSH key, Gitea URL) in a local JSON registry at `~/.local/share/grm/runners.json`. After installation, lifecycle commands work by runner name only — you can override any stored value by passing the corresponding flag.
|
||||
@@ -0,0 +1,137 @@
|
||||
# Getting Started
|
||||
|
||||
## Developer Setup
|
||||
|
||||
```bash
|
||||
git clone https://git.oblachno.oblachno.fyi/oblachno-oss/grm.git
|
||||
cd grm
|
||||
git checkout $(git describe --tags --abbrev=0) # Checkout latest stable release
|
||||
pyenv install 3.12
|
||||
pyenv local 3.12
|
||||
make setup
|
||||
```
|
||||
|
||||
> **Important:** Always checkout the latest release tag before running `make setup`. The `master` branch may contain unreleased changes that are not yet stable. The `git describe --tags --abbrev=0` command automatically selects the most recent tagged release. To see all available releases, run `git tag --sort=-version:refname` or check the [releases page](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/releases).
|
||||
|
||||
## Configure Gitea Credentials
|
||||
|
||||
GRM needs two tokens from your Gitea instance: a **registration token** (required) and an **admin API token** (optional, for post-install verification).
|
||||
|
||||
### Step 1: Get the Registration Token
|
||||
|
||||
The registration token tells Gitea to accept the runner when it connects.
|
||||
|
||||
1. Log in to your Gitea instance as an administrator
|
||||
2. Navigate to **Site Administration → Actions → Runners**
|
||||
3. Click **Create Registration Token**
|
||||
4. Copy the token — it starts with `GR`
|
||||
|
||||
> **Note:** There are three levels of registration tokens:
|
||||
> - **Instance-level** (Site Administration → Actions → Runners) — registers a runner for all repositories
|
||||
> - **Organization-level** (Organization → Settings → Actions → Runners) — registers a runner for repos in that organization
|
||||
> - **Repository-level** (Repository → Settings → Actions → Runners) — registers a runner for a single repository
|
||||
>
|
||||
> Use instance-level tokens for shared runners, and repo-level tokens for dedicated runners.
|
||||
|
||||
### Step 2: Get the Admin API Token (optional)
|
||||
|
||||
The admin API token enables post-install API checks that verify the runner appears in Gitea's runner list. This is purely informational — the integration test primarily verifies the runner by checking:
|
||||
|
||||
1. **`.runner` registration file** exists and contains valid JSON (proves successful registration)
|
||||
2. **Systemd user service** is active (proves daemon is polling for jobs)
|
||||
|
||||
To get an admin API token:
|
||||
|
||||
1. Go to **Settings → Applications → Generate New Token**
|
||||
2. Give it a name (e.g., "GRM Install Verification")
|
||||
3. Select the **admin** scope (or at minimum: `read:user`, `read:repository`, `read:admin`)
|
||||
4. Click **Generate Token** and copy it immediately (it won't be shown again)
|
||||
|
||||
### Step 3: Create the `.env` File
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` with your tokens:
|
||||
|
||||
```bash
|
||||
# Your Gitea instance URL
|
||||
GITEA_URL=https://git.example.com
|
||||
|
||||
# Registration token from Step 1
|
||||
GITEA_REGISTRATION_TOKEN=GRxxxxxxxxxxxxxxxxxx
|
||||
|
||||
# Admin API token from Step 2 (optional)
|
||||
REPO_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
```
|
||||
|
||||
### Environment Variables Reference
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `GITEA_URL` | Yes | Gitea instance URL (e.g., `https://git.example.com`) |
|
||||
| `GITEA_REGISTRATION_TOKEN` | Yes | Runner registration token from Gitea admin panel |
|
||||
| `REPO_TOKEN` | No | Admin API token for post-install verification |
|
||||
| `GITEA_INTEGRATION_RETRIES` | No | API check retries (default: 3) |
|
||||
| `GITEA_RUNNER_USER` | No | Default SSH user (overrides `--user`) |
|
||||
| `GITEA_RUNNER_KEY` | No | Default SSH key path (overrides `--key`) |
|
||||
| `GITEA_RUNNER_LABELS` | No | Default runner labels (overrides `--labels`) |
|
||||
| `GRM_LANG` | No | UI language: `en`, `bg`, `de`, `ru`, `zh` (default: `en`) |
|
||||
|
||||
## Install a Runner
|
||||
|
||||
Using the CLI (you will be prompted for the sudo password by default):
|
||||
|
||||
```bash
|
||||
grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner
|
||||
```
|
||||
|
||||
> **Automation tip:** Configure passwordless sudo on the remote host and pass `--no-ask-become-pass` to skip the password prompt. This is recommended for CI/CD pipelines.
|
||||
|
||||
Using Make:
|
||||
|
||||
```bash
|
||||
make install HOST=192.168.1.10 USER=ubuntu KEY=~/.ssh/id_ed25519 NAME=prod-runner
|
||||
```
|
||||
|
||||
## Verify Runner
|
||||
|
||||
The installer performs an automated integration test that verifies:
|
||||
|
||||
1. **`.runner` file exists** with valid JSON containing `id`, `uuid`, `token`, `address` — this proves successful registration with Gitea
|
||||
2. **Systemd user service is active** — this proves the daemon is polling for jobs
|
||||
|
||||
You can also check the Gitea UI under **Actions → Runners** to confirm the runner appears as **Online**.
|
||||
|
||||
Optional: If `REPO_TOKEN` is set, the installer will also query the Gitea API and report whether the runner appears in the admin or repo runners list. This is purely informational.
|
||||
|
||||
## View Logs
|
||||
|
||||
**GRM application logs** (Python CLI output):
|
||||
|
||||
```bash
|
||||
# Application log file (all messages including DEBUG)
|
||||
cat ~/.local/state/grm/logs/grm.log
|
||||
|
||||
# Enable debug logging in the current session
|
||||
GRM_LOG_LEVEL=DEBUG grm install 192.168.1.10 --user ubuntu --name prod-runner
|
||||
```
|
||||
|
||||
**Runner logs** (on the remote host):
|
||||
|
||||
```bash
|
||||
# Runner logs (via systemd user service)
|
||||
sudo -u grm-<name> journalctl --user -u gitea-runner -f
|
||||
```
|
||||
|
||||
The GRM application writes to two destinations:
|
||||
|
||||
| Destination | Level | Content |
|
||||
|-------------|-------|---------|
|
||||
| Console (stdout) | `GRM_LOG_LEVEL` (default: INFO) | Colorised user-facing messages and operation reports |
|
||||
| `~/.local/state/grm/logs/grm.log` | DEBUG | All messages with timestamps and severity |
|
||||
|
||||
Set `GRM_LOG_LEVEL` to one of `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL` to control console verbosity. The log file always captures everything at DEBUG level regardless of the console setting.
|
||||
|
||||
Console output is automatically colorised via ``click.echo``: operation headers in bright cyan, completed steps in green, failures in red, and status updates in yellow.
|
||||
@@ -0,0 +1,58 @@
|
||||
# Installation
|
||||
|
||||
> **Before you start:** Make sure you have cloned the repo and checked out the latest stable release tag. See [Getting Started](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Getting-Started.-) for setup instructions. Do not run from `master` — it may contain unreleased changes.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **SSH key authentication** — The remote host must be reachable via SSH using the user specified with `--user` and the private key specified with `--key`. GRM uses Ansible under the hood, which connects to the target host over SSH to execute all installation and configuration tasks. Without valid SSH credentials, Ansible cannot establish a connection and the deployment will fail.
|
||||
- **Sudo access** — GRM requires root privileges on the remote host to create system users, install packages, and configure rootless Docker. By default, you will be prompted interactively for the sudo password. For automation or uninterrupted workflows, configure passwordless sudo on the remote host and pass `--no-ask-become-pass`.
|
||||
- **Gitea registration token** — You need a runner registration token from your Gitea instance. See [Getting Started](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/wiki/Getting-Started.-) for detailed instructions on obtaining tokens.
|
||||
|
||||
## Supported Operating Systems
|
||||
|
||||
- Arch Linux
|
||||
- Ubuntu 22.04 / 24.04
|
||||
- Debian 12
|
||||
|
||||
All supported OSes are tested in CI via molecule scenarios on every PR.
|
||||
|
||||
## Quick Start Install
|
||||
|
||||
Using the CLI (you will be prompted for the sudo password by default):
|
||||
|
||||
```bash
|
||||
grm install 192.168.1.10 --user ubuntu --key ~/.ssh/id_ed25519 --name prod-runner
|
||||
```
|
||||
|
||||
> **Automation tip:** Configure passwordless sudo on the remote host and pass `--no-ask-become-pass` to skip the password prompt. This is recommended for CI/CD pipelines.
|
||||
|
||||
## Make Install
|
||||
|
||||
Using Make:
|
||||
|
||||
```bash
|
||||
make install HOST=192.168.1.10 USER=ubuntu KEY=~/.ssh/id_ed25519 NAME=prod-runner
|
||||
```
|
||||
|
||||
## Runner Registry
|
||||
|
||||
After installation, GRM stores each runner's connection details (host, user, SSH key, Gitea URL) in a local JSON registry at `~/.local/share/grm/runners.json`. This means you rarely need to repeat connection arguments:
|
||||
|
||||
```bash
|
||||
# List all registered runners with live systemd status
|
||||
grm list
|
||||
```
|
||||
|
||||
## Multiple Instances on the Same Host
|
||||
|
||||
Each runner instance is fully isolated with its own system user, rootless Docker daemon, data directory, and systemd user service:
|
||||
|
||||
```bash
|
||||
# Install two runners on the same host
|
||||
grm install 192.168.1.10 --user ubuntu --name workflow-runner
|
||||
grm install 192.168.1.10 --user ubuntu --name build-runner
|
||||
|
||||
# Manage them independently by name
|
||||
grm stop workflow-runner
|
||||
grm status build-runner
|
||||
```
|
||||
@@ -0,0 +1,52 @@
|
||||
# Troubleshooting
|
||||
|
||||
## "Event loop is closed" warning
|
||||
|
||||
This is a harmless cleanup traceback from Molecule's Docker driver when the test process is interrupted. It does not indicate a test failure.
|
||||
|
||||
## Runner appears offline after installation
|
||||
|
||||
- Check that the `GITEA_URL` and `GITEA_REGISTRATION_TOKEN` environment variables are correct.
|
||||
- Verify the registration token has not expired — generate a new one from Gitea if needed (Site Administration → Actions → Runners → Create Registration Token).
|
||||
- Verify the runner service is running: `sudo -u grm-<name> systemctl --user status gitea-runner`.
|
||||
- Check logs for registration errors.
|
||||
|
||||
## Integration test fails
|
||||
|
||||
The test checks two things:
|
||||
|
||||
1. **`.runner` file missing or invalid** — Registration failed. Check:
|
||||
- `GITEA_URL` and `GITEA_REGISTRATION_TOKEN` are correct
|
||||
- The registration token is valid and has not expired
|
||||
- Runner logs for registration errors
|
||||
- The `.runner` file should exist at `/var/lib/gitea-runner/<name>/.runner`
|
||||
|
||||
2. **Service not running** — Daemon failed to start. Check:
|
||||
- `sudo -u grm-<name> systemctl --user status gitea-runner`
|
||||
- Logs for connection errors
|
||||
|
||||
## Rootless Docker: service fails to start
|
||||
|
||||
- Check the service status: `sudo -u grm-<name> systemctl --user status gitea-runner`.
|
||||
- Verify the rootless Docker daemon is running: `sudo -u grm-<name> systemctl --user status docker`.
|
||||
- Verify the Docker socket exists: `ls /run/user/$(id -u grm-<name>)/docker.sock`.
|
||||
- Check logs: `sudo -u grm-<name> journalctl --user -u gitea-runner -f`.
|
||||
- Ensure lingering is enabled for the runner user: `loginctl show-user grm-<name> | grep Linger`.
|
||||
|
||||
## Common Issues Reference Table
|
||||
|
||||
| Symptom | Likely Cause | Solution |
|
||||
|---------|-------------|----------|
|
||||
| Pre-commit rejects commit message | Missing conventional format or GRM-N prefix present | Use `feat: description` format without `GRM-N:` |
|
||||
| `make molecule` fails with `runner_name is undefined` | Verify playbook missing variable | Fixed in Phase 1.1; ensure you're on latest master |
|
||||
| CI molecule job fails | Docker not available on runner host | Ensure Gitea runner host has Docker installed and running |
|
||||
| Auto-merge doesn't trigger | Label not exactly `ready-to-merge` or CI checks not all green | Verify label spelling; check CI status |
|
||||
| Vikunja task not updated after merge | VIKUNJA_TOKEN expired or task ID missing from commit | Regenerate token; verify merge commit has `GRM-N:` prefix |
|
||||
| Post-merge can't find Vikunja task | Task not in project 6 or identifier mismatch | Verify task exists in Vikunja project 6 with correct identifier |
|
||||
| `make pytest-cov` fails | Coverage below 100% | Add tests for new code paths |
|
||||
| `devx.tools.configure_repo` fails | REPO_TOKEN missing or invalid | Set token with repo admin scope and re-run |
|
||||
| `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 |
|
||||
| `remove-runner.yml` leaves lingering enabled | Old version didn't disable lingering | Fixed: now runs `loginctl disable-linger` and removes subuid/subgid |
|
||||
| apt cache update always reports `changed` | `cache_valid_time: 0` forced update every run | Fixed: changed to `cache_valid_time: 3600` |
|
||||
| Prune/service templates created even when `docker_rootless_setup: false` | Template tasks not guarded | Fixed: template creation now guarded by `docker_rootless_setup` |
|
||||
+3
-2
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# pre-commit hook: fail if unit tests take longer than 2 seconds.
|
||||
# pre-commit hook: fail if unit tests take longer than 10 seconds.
|
||||
# Aligned with CI timeout (ci.yml uses --max-seconds 10).
|
||||
set -e
|
||||
python3 scripts/check_test_speed.py
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 10
|
||||
|
||||
+3
-2
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
# pre-push hook: fail if unit tests take longer than 2 seconds.
|
||||
# pre-push hook: fail if unit tests take longer than 10 seconds.
|
||||
# Aligned with CI timeout (ci.yml uses --max-seconds 10).
|
||||
set -e
|
||||
python3 scripts/check_test_speed.py
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 10
|
||||
|
||||
+51
-7
@@ -27,16 +27,29 @@ grm = "gitea_runner_manager.cli:cli"
|
||||
version = {attr = "gitea_runner_manager.__version__"}
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
# Minimal deps for CI scripts that only need click/dotenv/requests
|
||||
# (detect-changes, discover-runners, pr-review, sync-wiki, badges, etc.)
|
||||
ci = [
|
||||
"pytest>=9.1.0",
|
||||
"pytest-cov>=7.1.0",
|
||||
]
|
||||
# Lint and type-checking tools (quality job)
|
||||
lint = [
|
||||
"ruff>=0.15.17",
|
||||
"pyright>=1.1.410",
|
||||
"bandit>=1.8.2",
|
||||
"pip-audit>=2.10",
|
||||
"pre-commit>=4.6.0",
|
||||
"ansible-lint>=26.4.0",
|
||||
]
|
||||
# Molecule testing (molecule-tests job)
|
||||
molecule = [
|
||||
"molecule>=26.4.0",
|
||||
"molecule-docker>=2.1.0",
|
||||
"ansible-lint>=26.4.0",
|
||||
"bandit>=1.8.2",
|
||||
"pre-commit>=4.6.0",
|
||||
]
|
||||
# Full dev environment (local development, includes everything)
|
||||
dev = [
|
||||
"gitea-runner-manager[ci,lint,molecule]",
|
||||
# Non-Python dev dependency: checkmake (Makefile linter)
|
||||
# Install via: go install github.com/checkmake/checkmake/cmd/checkmake@latest
|
||||
]
|
||||
@@ -49,8 +62,8 @@ gitea_runner_manager = ["translations.json"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src", "."]
|
||||
addopts = "--cov=src/gitea_runner_manager --cov=scripts --cov-report=term-missing --cov-fail-under=100"
|
||||
pythonpath = ["src"]
|
||||
addopts = "--cov=src/gitea_runner_manager --cov-report=term-missing --cov-fail-under=100"
|
||||
markers = [
|
||||
"integration: marks tests as integration tests (not counted in coverage)",
|
||||
]
|
||||
@@ -68,6 +81,37 @@ quote-style = "double"
|
||||
indent-style = "space"
|
||||
|
||||
[tool.pyright]
|
||||
include = ["src", "scripts"]
|
||||
include = ["src"]
|
||||
pythonVersion = "3.12"
|
||||
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,288 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Auto-merge PR by extracting task ID from branch and constructing merge title.
|
||||
|
||||
Waits for CI checks to complete before attempting the merge.
|
||||
|
||||
PR title format: ``GRM-N: <vikunja task title>``
|
||||
Merge commit format: ``GRM-N <conventional commit message>``
|
||||
|
||||
The conventional commit message is taken from the first commit on the PR
|
||||
branch (the branch HEAD). 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/auto_merge.py <branch> <pr_title> <repo> <pr_number> [label_name]
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
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 gitea_runner_manager.i18n import _
|
||||
|
||||
READY_TO_MERGE = "ready-to-merge"
|
||||
MAX_WAIT_SECONDS = 900 # 15 minutes
|
||||
POLL_INTERVAL_SECONDS = 30
|
||||
|
||||
# PR title: GRM-N: <vikunja task title>
|
||||
PR_TITLE_RE = re.compile(r"^GRM-\d+:\s+.+")
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
def extract_task_id(branch: str) -> str:
|
||||
"""Extract GRM-N task identifier from branch name."""
|
||||
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 (skip validation).
|
||||
"""
|
||||
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
|
||||
return ""
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
vikunja_title = get_vikunja_task_title(task_id)
|
||||
if not vikunja_title:
|
||||
click.echo(_("Warning: could not fetch Vikunja task title, 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 has_approval_review(client: GiteaClient, pr_number: str) -> bool:
|
||||
"""Check whether the PR has at least one APPROVE review."""
|
||||
reviews = client.get_pr_reviews(pr_number)
|
||||
return any(r.get("state") == "APPROVED" for r in reviews)
|
||||
|
||||
|
||||
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 ""
|
||||
|
||||
|
||||
def has_ready_to_merge_label(client: GiteaClient, pr_number: str) -> bool:
|
||||
"""Check whether the PR has the ready-to-merge label via the API."""
|
||||
labels = client.get_pr_labels(pr_number)
|
||||
return any(label.get("name") == READY_TO_MERGE for label in labels)
|
||||
|
||||
|
||||
def wait_for_ci(
|
||||
client: GiteaClient, sha: str, max_wait: int = MAX_WAIT_SECONDS, poll_interval: int = POLL_INTERVAL_SECONDS
|
||||
) -> bool:
|
||||
"""Poll commit statuses until all CI checks are complete (not pending).
|
||||
|
||||
Returns True if all checks are successful, False if any failed or timed out.
|
||||
"""
|
||||
elapsed = 0
|
||||
while elapsed < max_wait:
|
||||
statuses = client.get_commit_status(sha)
|
||||
if not statuses:
|
||||
click.echo(_("No CI checks reported yet, waiting..."))
|
||||
time.sleep(poll_interval)
|
||||
elapsed += poll_interval
|
||||
continue
|
||||
|
||||
# Deduplicate by context — keep the latest status per context.
|
||||
latest: dict[str, dict[str, object]] = {}
|
||||
for s in statuses:
|
||||
ctx = s.get("context", "")
|
||||
if ctx not in latest or s.get("updated_at", "") > latest[ctx].get("updated_at", ""):
|
||||
latest[ctx] = s
|
||||
|
||||
ci_statuses = {ctx: s for ctx, s in latest.items() if ctx.startswith("CI /")}
|
||||
if not ci_statuses:
|
||||
click.echo(_("No CI checks found yet, waiting..."))
|
||||
time.sleep(poll_interval)
|
||||
elapsed += poll_interval
|
||||
continue
|
||||
|
||||
pending = [ctx for ctx, s in ci_statuses.items() if s.get("status") in ("pending", "waiting")]
|
||||
if not pending:
|
||||
# All CI checks are complete — check if they all succeeded.
|
||||
failed = [ctx for ctx, s in ci_statuses.items() if s.get("status") not in ("success", "ok")]
|
||||
if failed:
|
||||
click.echo(_("CI checks failed: {failed}", failed=", ".join(sorted(failed))))
|
||||
return False
|
||||
click.echo(_("All CI checks passed."))
|
||||
return True
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
"Waiting for CI checks: {pending} ({elapsed}s elapsed)",
|
||||
pending=", ".join(sorted(pending)),
|
||||
elapsed=elapsed,
|
||||
)
|
||||
)
|
||||
time.sleep(poll_interval)
|
||||
elapsed += poll_interval
|
||||
|
||||
click.echo(_("Timed out waiting for CI checks after {max_wait}s.", max_wait=max_wait))
|
||||
return False
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("branch")
|
||||
@click.argument("pr_title")
|
||||
@click.argument("repo")
|
||||
@click.argument("pr_number")
|
||||
@click.argument("label_name", required=False, default="")
|
||||
def main(branch: str, pr_title: str, repo: str, pr_number: str, label_name: 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)
|
||||
|
||||
# Gitea Actions may not populate github.event.label.name; fall back to API check.
|
||||
if label_name != READY_TO_MERGE and not has_ready_to_merge_label(client, pr_number):
|
||||
click.echo(_("Label '{label}' is not '{rtm}', skipping.", label=label_name, rtm=READY_TO_MERGE))
|
||||
return
|
||||
|
||||
task_id = extract_task_id(branch)
|
||||
if not task_id:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Oops! No task ID (GRM-N) found in branch name '{branch}'.",
|
||||
branch=branch,
|
||||
)
|
||||
)
|
||||
|
||||
validate_pr_title(pr_title, task_id)
|
||||
validate_pr_title_matches_vikunja(pr_title, task_id)
|
||||
|
||||
# Enforce APPROVE review before merge (Gap 2 fix)
|
||||
if not has_approval_review(client, pr_number):
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Cannot merge: PR #{pr_number} has no APPROVE review. "
|
||||
"Please review and approve before adding the ready-to-merge label.",
|
||||
pr_number=pr_number,
|
||||
)
|
||||
)
|
||||
click.echo(_("PR has at least one APPROVE review."))
|
||||
|
||||
# Wait for CI checks to complete before attempting merge.
|
||||
pr = client.get_pr(pr_number)
|
||||
sha = pr.get("head", {}).get("sha", "")
|
||||
if sha:
|
||||
click.echo(_("Waiting for CI checks on commit {sha}...", sha=sha[:8]))
|
||||
if not wait_for_ci(client, sha):
|
||||
raise click.ClickException(
|
||||
_("Cannot merge: CI checks did not pass. Please fix failing checks and re-label.")
|
||||
)
|
||||
else:
|
||||
click.echo(_("Warning: could not determine PR head SHA, proceeding without CI wait."))
|
||||
|
||||
# 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:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Merge failed with HTTP {status}: {message}\nPlease check the PR is ready and you have merge rights.",
|
||||
status=e.status,
|
||||
message=e.message,
|
||||
)
|
||||
) from None
|
||||
|
||||
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,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 gitea_runner_manager.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,94 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Configure GRM repository: branch protection + labels via Gitea REST API.
|
||||
|
||||
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,
|
||||
LABEL_CONFIG,
|
||||
REPO_NAME,
|
||||
REPO_OWNER,
|
||||
REPO_SETTINGS_CONFIG,
|
||||
)
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from gitea_runner_manager.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)"))
|
||||
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("")
|
||||
label_name = cast(str, LABEL_CONFIG["name"])
|
||||
click.echo(_("Creating {label} label...", label=label_name))
|
||||
result = client.ensure_label(
|
||||
name=cast(str, LABEL_CONFIG["name"]),
|
||||
color=cast(str, LABEL_CONFIG["color"]),
|
||||
description=cast(str, LABEL_CONFIG["description"]),
|
||||
)
|
||||
if result is None:
|
||||
click.echo(_(" Label '{label}' already exists.", label=label_name))
|
||||
else:
|
||||
click.echo(_(" Label '{label}' created.", label=label_name))
|
||||
|
||||
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,159 +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 0 --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 gitea_runner_manager.i18n import _
|
||||
|
||||
DEFAULT_MAX_RUNNERS = 3
|
||||
MOLECULE_ROOT = Path("ansible/roles/gitea-runner/molecule")
|
||||
|
||||
#: Supported OS platform matrix.
|
||||
#: Each entry maps a short name to (image, command).
|
||||
#: The command must be systemd since rootless Docker requires loginctl/systemctl --user.
|
||||
PLATFORMS: list[dict[str, str]] = [
|
||||
{"name": "ubuntu-2204", "image": "geerlingguy/docker-ubuntu2204-ansible:latest", "command": "/lib/systemd/systemd"},
|
||||
{"name": "ubuntu-2404", "image": "geerlingguy/docker-ubuntu2404-ansible:latest", "command": "/lib/systemd/systemd"},
|
||||
{"name": "debian-12", "image": "geerlingguy/docker-debian12-ansible:latest", "command": "/lib/systemd/systemd"},
|
||||
{"name": "archlinux", "image": "marcstraube/archlinux-ansible:latest", "command": "/usr/lib/systemd/systemd"},
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestPair:
|
||||
"""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]
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--runner-index",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Zero-based runner index. 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.",
|
||||
)
|
||||
def cli(runner_index: int | None, max_runners: int, list_all: bool, list_platforms: 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
|
||||
assigned = pairs_for_runner(pairs, runner_index, max_runners)
|
||||
click.echo(" ".join(p.encode() for p in assigned))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -1,71 +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,35 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run all molecule scenarios on all supported OS platforms.
|
||||
# Used by `make molecule-all`. Sequential — CI uses parallel matrix instead.
|
||||
# Platform list is sourced from scripts/distribute_molecule.py to avoid duplication.
|
||||
set -euo pipefail
|
||||
|
||||
MOLECULE_BIN="$(realpath "${BIN:-.venv/bin}/molecule")"
|
||||
ROLE_DIR="$(cd "$(dirname "$0")/.." && pwd)/ansible/roles/gitea-runner"
|
||||
SCRIPTS_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
# Read platforms from distribute_molecule.py (single source of truth)
|
||||
PLATFORMS_OUTPUT="$("$MOLECULE_BIN" python "${SCRIPTS_DIR}/distribute_molecule.py" --list-platforms 2>/dev/null || \
|
||||
python3 "${SCRIPTS_DIR}/distribute_molecule.py" --list-platforms)"
|
||||
|
||||
for p in $PLATFORMS_OUTPUT; do
|
||||
IFS="|" read -r name image command <<< "$p"
|
||||
export MOLECULE_PLATFORM_NAME="$name" MOLECULE_PLATFORM_IMAGE="$image"
|
||||
if [ -n "$command" ]; then
|
||||
export MOLECULE_PLATFORM_COMMAND="$command"
|
||||
else
|
||||
unset MOLECULE_PLATFORM_COMMAND
|
||||
fi
|
||||
echo "=== Platform: $name ==="
|
||||
for s in default multi-instance lifecycle template-content deregister update; do
|
||||
echo "--- Scenario: $s on $name ---"
|
||||
(
|
||||
cd "$ROLE_DIR"
|
||||
if [ "$s" = "default" ]; then
|
||||
ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true ANSIBLE_INJECT_INVOCATION=1 "$MOLECULE_BIN" test
|
||||
else
|
||||
ANSIBLE_ALLOW_BROKEN_CONDITIONALS=true ANSIBLE_INJECT_INVOCATION=1 "$MOLECULE_BIN" test -s "$s"
|
||||
fi
|
||||
)
|
||||
done
|
||||
done
|
||||
@@ -1,226 +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 gitea_runner_manager.i18n import _
|
||||
|
||||
POLL_INTERVAL = 10
|
||||
|
||||
|
||||
def get_running_jobs(
|
||||
gitea_url: str, owner: str, repo: str, token: str, run_id: int
|
||||
) -> list[dict]:
|
||||
"""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
|
||||
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,76 +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.
|
||||
|
||||
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 os
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from gitea_runner_manager.api_clients import GiteaClient
|
||||
from gitea_runner_manager.config import GITEA_API_URL
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from gitea_runner_manager.i18n import _
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
@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."))
|
||||
|
||||
owner, repo_name = repo.split("/")
|
||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
||||
|
||||
title = f"[CI] {workflow} workflow failed (run #{run_id})"
|
||||
body = (
|
||||
f"The **{workflow}** workflow failed.\n\n"
|
||||
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:
|
||||
# Look up label IDs by name (Gitea API expects integer IDs, not strings)
|
||||
label_ids: list[int] = []
|
||||
for label in client.list_labels():
|
||||
if label.get("name") == "bug":
|
||||
label_ids.append(int(label["id"]))
|
||||
break
|
||||
issue = client.create_issue(title=title, body=body, labels=label_ids if label_ids else None)
|
||||
except APIError as e:
|
||||
raise click.ClickException(
|
||||
_("Failed to create issue: HTTP {status} — {message}", status=e.status, message=e.message)
|
||||
) from None
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
"Created issue #{issue_id}: {title}",
|
||||
issue_id=issue.get("id", "?"),
|
||||
title=title,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,113 +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 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 gitea_runner_manager.i18n import _
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
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 for {task_id} in project {project_id}.",
|
||||
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")
|
||||
@click.option("--commit-sha", default="", help="Commit SHA")
|
||||
def main(commit_msg: str, commit_sha: str) -> None:
|
||||
token = os.environ.get("VIKUNJA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: VIKUNJA_TOKEN is not set."))
|
||||
|
||||
task_id = extract_task_id(commit_msg)
|
||||
if not task_id:
|
||||
click.echo(_("No task ID in commit message, skipping Vikunja update. All good — nothing to do here!"))
|
||||
return
|
||||
|
||||
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||
vikunja_task_id = 0
|
||||
try:
|
||||
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)
|
||||
|
||||
client.post_comment(vikunja_task_id, html)
|
||||
client.update_task(vikunja_task_id, done=True)
|
||||
except APIError as e:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Vikunja API error: HTTP {status} — {message}",
|
||||
status=e.status,
|
||||
message=e.message,
|
||||
)
|
||||
) from None
|
||||
|
||||
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,140 +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.
|
||||
|
||||
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 gitea_runner_manager.api_clients import GiteaClient
|
||||
from gitea_runner_manager.config import GITEA_API_URL
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from gitea_runner_manager.i18n import _
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
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."))
|
||||
|
||||
owner, repo_name = repo.split("/")
|
||||
client = GiteaClient(GITEA_API_URL, gitea_token, owner, repo_name)
|
||||
|
||||
release_body = generate_release_notes(tag)
|
||||
|
||||
try:
|
||||
client.create_release(
|
||||
tag=tag,
|
||||
body=release_body,
|
||||
)
|
||||
except APIError as e:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Release creation failed with HTTP {status}: {message}",
|
||||
status=e.status,
|
||||
message=e.message,
|
||||
)
|
||||
) from None
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
"Nice! Gitea release {tag} created.",
|
||||
tag=tag,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,277 +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.
|
||||
|
||||
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]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from gitea_runner_manager.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 conventional commits since the last tag.
|
||||
|
||||
If ``bumped_version`` is provided (from a prior git-cliff call), reuses it
|
||||
to avoid a duplicate subprocess invocation.
|
||||
"""
|
||||
if bumped_version is None:
|
||||
result = run_cmd(
|
||||
["git-cliff", "--bumped-version", "--config", CLIFF_CONFIG],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return False
|
||||
bumped_version = result.stdout.strip().lstrip("v")
|
||||
latest = get_latest_tag()
|
||||
if not latest:
|
||||
return True
|
||||
current = latest.lstrip("v")
|
||||
return bumped_version != current
|
||||
|
||||
|
||||
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.
|
||||
|
||||
If the file doesn't exist, create it with the changelog as the sole content.
|
||||
If it exists, insert the new version section after the header (before the
|
||||
first existing version section).
|
||||
"""
|
||||
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.
|
||||
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", "-m", f"release: v{new_version}"])
|
||||
return True
|
||||
|
||||
|
||||
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.")
|
||||
def main(dry_run: bool) -> None:
|
||||
# Ensure we're on master
|
||||
branch = run_cmd(["git", "rev-parse", "--abbrev-ref", "HEAD"]).stdout.strip()
|
||||
if branch != "master":
|
||||
raise click.ClickException(_("Release must be run on master, currently on '{branch}'.", branch=branch))
|
||||
|
||||
# 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))
|
||||
|
||||
# Commit version + changelog (Gap 11: use 'release:' prefix, not 'chore(release):')
|
||||
committed = commit_release_changes(new_version)
|
||||
if committed:
|
||||
click.echo(_("Created release commit."))
|
||||
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,140 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Post a review on a Gitea pull request.
|
||||
|
||||
Used by the GRM workflow to post structured PR reviews. The review body
|
||||
is provided via --body and inline comments via a JSON file
|
||||
(--comments-json) or stdin (--comments-stdin). This script is a thin
|
||||
CLI wrapper around ``GiteaClient.create_review`` — the actual review
|
||||
analysis is performed by the agent before invoking this tool.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 scripts/review_pr.py <pr_number> <repo> \
|
||||
--event COMMENT \
|
||||
--body "Review body text" \
|
||||
--comments-json comments.json
|
||||
|
||||
The comments JSON file is a list of objects with keys:
|
||||
- path: file path in the repo
|
||||
- body: comment text
|
||||
- new_position: line number in the new file (1-based)
|
||||
- old_position: (optional) line number in the old file
|
||||
|
||||
Review focus areas (for the reviewer, not enforced by this script):
|
||||
- Functional completeness
|
||||
- Edge cases
|
||||
- Technical excellence: architecture compliance, SRP, deduplication,
|
||||
code smells, best practices, code quality, reusability, clean code,
|
||||
readability, maintainability, extensibility
|
||||
- Performance
|
||||
- Security
|
||||
- User experience
|
||||
- Documentation completeness and relevance
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from gitea_runner_manager.api_clients import GiteaClient
|
||||
from gitea_runner_manager.config import GITEA_API_URL
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from gitea_runner_manager.i18n import _
|
||||
|
||||
load_dotenv(override=True)
|
||||
|
||||
VALID_EVENTS = ("APPROVE", "REQUEST_CHANGES", "COMMENT")
|
||||
|
||||
|
||||
def parse_comments(comments_json: str | None, comments_stdin: bool) -> list[dict[str, Any]]:
|
||||
"""Parse inline comments from a JSON file or stdin."""
|
||||
if comments_json:
|
||||
try:
|
||||
with open(comments_json) as f:
|
||||
data = json.load(f)
|
||||
except json.JSONDecodeError as e:
|
||||
raise click.ClickException(_("Invalid JSON in comments file: {error}", error=str(e))) from None
|
||||
if not isinstance(data, list):
|
||||
raise click.ClickException(_("Comments JSON must be a list of objects."))
|
||||
return data
|
||||
if comments_stdin:
|
||||
raw = sys.stdin.read().strip()
|
||||
if not raw:
|
||||
return []
|
||||
try:
|
||||
data = json.loads(raw)
|
||||
except json.JSONDecodeError as e:
|
||||
raise click.ClickException(_("Invalid JSON on stdin: {error}", error=str(e))) from None
|
||||
if not isinstance(data, list):
|
||||
raise click.ClickException(_("Stdin comments JSON must be a list of objects."))
|
||||
return data
|
||||
return []
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("pr_number")
|
||||
@click.argument("repo")
|
||||
@click.option(
|
||||
"--event",
|
||||
default="COMMENT",
|
||||
type=click.Choice(VALID_EVENTS),
|
||||
help="Review event type: APPROVE, REQUEST_CHANGES, or COMMENT.",
|
||||
)
|
||||
@click.option("--body", default="", help="Top-level review body text.")
|
||||
@click.option("--comments-json", default=None, help="Path to JSON file with inline comments.")
|
||||
@click.option(
|
||||
"--comments-stdin",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Read inline comments JSON from stdin.",
|
||||
)
|
||||
def main(
|
||||
pr_number: str,
|
||||
repo: str,
|
||||
event: str,
|
||||
body: str,
|
||||
comments_json: str | None,
|
||||
comments_stdin: bool,
|
||||
) -> 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)
|
||||
|
||||
comments = parse_comments(comments_json, comments_stdin)
|
||||
|
||||
if event != "APPROVE" and not body and not comments:
|
||||
raise click.ClickException(_("Review body or inline comments are required for event '{event}'.", event=event))
|
||||
|
||||
try:
|
||||
review = client.create_review(pr_number, event=event, body=body, comments=comments)
|
||||
except APIError as e:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Failed to post review: HTTP {status} — {message}",
|
||||
status=e.status,
|
||||
message=e.message,
|
||||
)
|
||||
) from None
|
||||
|
||||
review_id = review.get("id", "?")
|
||||
click.echo(
|
||||
_(
|
||||
"Review #{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(comments),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
|
||||
BIN="${1:-.venv/bin}"
|
||||
|
||||
"$BIN/pip" install -e ".[dev]"
|
||||
"$BIN/ansible-galaxy" collection install -r ansible/requirements.yml
|
||||
"$BIN/pre-commit" install
|
||||
"$BIN/pre-commit" install --hook-type commit-msg
|
||||
"$BIN/pre-commit" install --hook-type pre-push
|
||||
|
||||
echo ""
|
||||
echo "Setup complete."
|
||||
echo "Activate the virtual environment with one of:"
|
||||
echo " source .venv/bin/activate (generic)"
|
||||
echo " source activate.sh (bash)"
|
||||
echo " source activate.fish (fish)"
|
||||
echo " source activate.zsh (zsh)"
|
||||
|
||||
# Verification
|
||||
"$BIN/grm" --version 2>/dev/null || true
|
||||
"$BIN/pre-commit" --version 2>/dev/null || true
|
||||
@@ -1,91 +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 gitea_runner_manager.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")
|
||||
def main(commit_msg_file: str) -> None:
|
||||
with open(commit_msg_file) as f:
|
||||
msg = f.read().strip()
|
||||
|
||||
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,3 +1,3 @@
|
||||
"""Gitea Runner Manager — lean CLI for managing Gitea Actions runners."""
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.6.2"
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
@@ -12,6 +13,11 @@ from .exceptions import APIError
|
||||
|
||||
logger = logging.getLogger("grm")
|
||||
|
||||
# Retry configuration for transient errors (429, 5xx, connection errors)
|
||||
MAX_RETRIES = 3
|
||||
RETRY_BACKOFF_BASE = 2 # seconds: 2, 4, 8
|
||||
RETRY_STATUS_CODES = {429, 500, 502, 503, 504}
|
||||
|
||||
|
||||
def _parse_error(e: requests.HTTPError) -> tuple[int, str]:
|
||||
"""Extract status code and message from an HTTPError response."""
|
||||
@@ -25,6 +31,16 @@ def _parse_error(e: requests.HTTPError) -> tuple[int, str]:
|
||||
return status, message
|
||||
|
||||
|
||||
def _is_retryable(e: Exception) -> bool:
|
||||
"""Check if an exception is a transient error worth retrying."""
|
||||
if isinstance(e, requests.ConnectionError):
|
||||
return True
|
||||
if isinstance(e, requests.HTTPError):
|
||||
status, _ = _parse_error(e)
|
||||
return status in RETRY_STATUS_CODES
|
||||
return isinstance(e, requests.Timeout)
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
"""Low-level Gitea REST API client with connection pooling."""
|
||||
|
||||
@@ -45,13 +61,48 @@ class GiteaClient:
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
|
||||
url = self._url(path)
|
||||
try:
|
||||
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as e:
|
||||
status, message = _parse_error(e)
|
||||
raise APIError(status, message) from e
|
||||
return response
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except requests.HTTPError as e:
|
||||
status, message = _parse_error(e)
|
||||
if _is_retryable(e) and attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
||||
logger.warning(
|
||||
"Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)",
|
||||
status,
|
||||
method,
|
||||
path,
|
||||
wait,
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
last_exc = e
|
||||
continue
|
||||
raise APIError(status, message) from e
|
||||
except (requests.ConnectionError, requests.Timeout) as e:
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
||||
logger.warning(
|
||||
"Connection error on %s %s, retrying in %ds (attempt %d/%d)",
|
||||
method,
|
||||
path,
|
||||
wait,
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
last_exc = e
|
||||
continue
|
||||
raise APIError(0, str(e)) from e
|
||||
# Should not reach here, but just in case
|
||||
if last_exc: # pragma: no cover
|
||||
raise APIError(0, str(last_exc)) from last_exc
|
||||
raise APIError(0, "Max retries exceeded") # pragma: no cover
|
||||
|
||||
# -- repo settings --
|
||||
|
||||
@@ -129,9 +180,16 @@ class GiteaClient:
|
||||
self._request("POST", f"/pulls/{pr_number}/merge", json=payload)
|
||||
|
||||
def get_commit_status(self, sha: str) -> list[dict[str, Any]]:
|
||||
"""Fetch all status check contexts reported for a commit."""
|
||||
r = self._request("GET", f"/commits/{sha}/statuses")
|
||||
return r.json()
|
||||
"""Fetch all status check contexts reported for a commit.
|
||||
|
||||
Uses the combined status endpoint (/commits/{sha}/status) which
|
||||
returns one entry per context (the latest), deduplicated server-side.
|
||||
The plural endpoint (/commits/{sha}/statuses) returns every historical
|
||||
entry including stale "pending" ones that never got updated.
|
||||
"""
|
||||
r = self._request("GET", f"/commits/{sha}/status")
|
||||
data = r.json()
|
||||
return data.get("statuses", [])
|
||||
|
||||
def get_pr(self, pr_number: str | int) -> dict[str, Any]:
|
||||
"""Fetch pull request details including mergeable state."""
|
||||
@@ -163,12 +221,15 @@ class GiteaClient:
|
||||
"""Post a review on a pull request.
|
||||
|
||||
Args:
|
||||
event: ``APPROVE``, ``REQUEST_CHANGES``, or ``COMMENT``.
|
||||
event: ``APPROVED``, ``REQUEST_CHANGES``, or ``COMMENT``.
|
||||
body: Top-level review body text.
|
||||
comments: Line-level comments with ``path``, ``body``,
|
||||
``new_position`` (and optionally ``old_position``).
|
||||
"""
|
||||
payload: dict[str, Any] = {"event": event, "body": body}
|
||||
# Map common event names to Gitea API values
|
||||
event_map = {"APPROVE": "APPROVED", "REQUEST_CHANGES": "REQUEST_CHANGES", "COMMENT": "COMMENT"}
|
||||
gitea_event = event_map.get(event, event)
|
||||
payload: dict[str, Any] = {"event": gitea_event, "body": body}
|
||||
if comments:
|
||||
payload["comments"] = comments
|
||||
r = self._request("POST", f"/pulls/{pr_number}/reviews", json=payload)
|
||||
@@ -192,6 +253,32 @@ class GiteaClient:
|
||||
r = self._request("POST", "/releases", json=payload)
|
||||
return r.json()
|
||||
|
||||
def get_release_by_tag(self, tag: str) -> dict[str, Any] | None:
|
||||
"""Fetch a release by its tag name. Returns None if not found."""
|
||||
try:
|
||||
r = self._request("GET", f"/releases/tags/{tag}")
|
||||
return r.json()
|
||||
except APIError:
|
||||
return None
|
||||
|
||||
def create_release_idempotent(
|
||||
self,
|
||||
tag: str,
|
||||
name: str = "",
|
||||
body: str = "",
|
||||
draft: bool = False,
|
||||
prerelease: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Create a release, or return the existing one if it already exists.
|
||||
|
||||
This is idempotent — safe to call multiple times for the same tag.
|
||||
"""
|
||||
existing = self.get_release_by_tag(tag)
|
||||
if existing:
|
||||
logger.info("Release for tag %s already exists (ID %s), skipping creation.", tag, existing.get("id"))
|
||||
return existing
|
||||
return self.create_release(tag=tag, name=name, body=body, draft=draft, prerelease=prerelease)
|
||||
|
||||
|
||||
class VikunjaClient:
|
||||
"""Low-level Vikunja REST API client with connection pooling."""
|
||||
@@ -203,13 +290,47 @@ class VikunjaClient:
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
|
||||
url = f"{self._base_url}{path}"
|
||||
try:
|
||||
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
|
||||
response.raise_for_status()
|
||||
except requests.HTTPError as e:
|
||||
status, message = _parse_error(e)
|
||||
raise APIError(status, message) from e
|
||||
return response
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except requests.HTTPError as e:
|
||||
status, message = _parse_error(e)
|
||||
if _is_retryable(e) and attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
||||
logger.warning(
|
||||
"Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)",
|
||||
status,
|
||||
method,
|
||||
path,
|
||||
wait,
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
last_exc = e
|
||||
continue
|
||||
raise APIError(status, message) from e
|
||||
except (requests.ConnectionError, requests.Timeout) as e:
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
||||
logger.warning(
|
||||
"Connection error on %s %s, retrying in %ds (attempt %d/%d)",
|
||||
method,
|
||||
path,
|
||||
wait,
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
last_exc = e
|
||||
continue
|
||||
raise APIError(0, str(e)) from e
|
||||
if last_exc: # pragma: no cover
|
||||
raise APIError(0, str(last_exc)) from last_exc
|
||||
raise APIError(0, "Max retries exceeded") # pragma: no cover
|
||||
|
||||
def list_tasks(self, **params: Any) -> list[dict[str, Any]]:
|
||||
r = self._request("GET", "/tasks", params=params)
|
||||
|
||||
@@ -21,13 +21,15 @@ DEFAULT_PER_PAGE = 50
|
||||
|
||||
BRANCH_PROTECTION_CONFIG: dict[str, object] = {
|
||||
"branch_name": "master",
|
||||
"enable_push": False,
|
||||
"enable_push": True,
|
||||
"enable_push_whitelist": True,
|
||||
"push_whitelist_usernames": ["emil"],
|
||||
"enable_status_check": True,
|
||||
"status_check_contexts": [
|
||||
"CI / quality (pull_request)",
|
||||
"CI / molecule-tests (0) (pull_request)",
|
||||
"CI / molecule-tests (1) (pull_request)",
|
||||
"CI / molecule-tests (2) (pull_request)",
|
||||
"CI / molecule-tests (3) (pull_request)",
|
||||
],
|
||||
"required_approvals": 0,
|
||||
"dismiss_stale_approvals": True,
|
||||
@@ -36,12 +38,6 @@ BRANCH_PROTECTION_CONFIG: dict[str, object] = {
|
||||
"block_on_official_review_requests": True,
|
||||
}
|
||||
|
||||
LABEL_CONFIG: dict[str, object] = {
|
||||
"name": "ready-to-merge",
|
||||
"color": "2ecc71",
|
||||
"description": "Auto-merge PR when all CI checks pass",
|
||||
}
|
||||
|
||||
REPO_SETTINGS_CONFIG: dict[str, object] = {
|
||||
"default_delete_branch_after_merge": True,
|
||||
}
|
||||
|
||||
@@ -1,38 +1,17 @@
|
||||
{
|
||||
"Installing Gitea Runner on {host}": {
|
||||
"en": "Installing Gitea Runner on {host}",
|
||||
"bg": "Инсталиране на Gitea Runner на {host}",
|
||||
"de": "Gitea Runner wird auf {host} installiert",
|
||||
"ru": "Установка Gitea Runner на {host}",
|
||||
"zh": "正在 {host} 上安装 Gitea Runner"
|
||||
"=== Operation Report ===": {
|
||||
"en": "=== Operation Report ===",
|
||||
"bg": "=== Отчет за операцията ===",
|
||||
"de": "=== Operationsbericht ===",
|
||||
"ru": "=== Отчет об операции ===",
|
||||
"zh": "=== 操作报告 ==="
|
||||
},
|
||||
"Updating Gitea Runner on {host}": {
|
||||
"en": "Updating Gitea Runner on {host}",
|
||||
"bg": "Актуализиране на Gitea Runner на {host}",
|
||||
"de": "Gitea Runner wird auf {host} aktualisiert",
|
||||
"ru": "Обновление Gitea Runner на {host}",
|
||||
"zh": "正在 {host} 上更新 Gitea Runner"
|
||||
},
|
||||
"Running Ansible playbook": {
|
||||
"en": "Running Ansible playbook",
|
||||
"bg": "Изпълнение на Ansible playbook",
|
||||
"de": "Ansible-Playbook wird ausgeführt",
|
||||
"ru": "Выполнение Ansible playbook",
|
||||
"zh": "正在运行 Ansible playbook"
|
||||
},
|
||||
"Full log: {log_file}": {
|
||||
"en": "Full log: {log_file}",
|
||||
"bg": "Пълен лог: {log_file}",
|
||||
"de": "Vollständiges Log: {log_file}",
|
||||
"ru": "Полный лог: {log_file}",
|
||||
"zh": "完整日志: {log_file}"
|
||||
},
|
||||
"Done. See full log: {log_file}": {
|
||||
"en": "Done. See full log: {log_file}",
|
||||
"bg": "Готово. Вижте пълния лог: {log_file}",
|
||||
"de": "Fertig. Siehe vollständiges Log: {log_file}",
|
||||
"ru": "Готово. См. полный лог: {log_file}",
|
||||
"zh": "完成。查看完整日志: {log_file}"
|
||||
"Ad-hoc command failed on {host}: {stderr}": {
|
||||
"en": "Ad-hoc command failed on {host}: {stderr}",
|
||||
"bg": "Ad-hoc командата неуспешна на {host}: {stderr}",
|
||||
"de": "Ad-hoc-Befehl auf {host} fehlgeschlagen: {stderr}",
|
||||
"ru": "Ad-hoc команда не удалась на {host}: {stderr}",
|
||||
"zh": "Ad-hoc 命令在 {host} 上失败: {stderr}"
|
||||
},
|
||||
"Ansible failed with exit code {code}. See full log: {log_file}": {
|
||||
"en": "Ansible failed with exit code {code}. See full log: {log_file}",
|
||||
@@ -41,166 +20,12 @@
|
||||
"ru": "Ansible завершился с кодом {code}. См. лог: {log_file}",
|
||||
"zh": "Ansible 失败,退出码 {code}。查看日志: {log_file}"
|
||||
},
|
||||
"GITEA_REGISTRATION_TOKEN must be set (or pass --token)": {
|
||||
"en": "GITEA_REGISTRATION_TOKEN must be set (or pass --token)",
|
||||
"bg": "GITEA_REGISTRATION_TOKEN трябва да е зададен (или подайте --token)",
|
||||
"de": "GITEA_REGISTRATION_TOKEN muss gesetzt sein (oder --token übergeben)",
|
||||
"ru": "GITEA_REGISTRATION_TOKEN должен быть задан (или передайте --token)",
|
||||
"zh": "必须设置 GITEA_REGISTRATION_TOKEN(或传递 --token)"
|
||||
},
|
||||
"Playbook not found: {playbook}": {
|
||||
"en": "Playbook not found: {playbook}",
|
||||
"bg": "Playbook не е намерен: {playbook}",
|
||||
"de": "Playbook nicht gefunden: {playbook}",
|
||||
"ru": "Playbook не найден: {playbook}",
|
||||
"zh": "未找到 Playbook: {playbook}"
|
||||
},
|
||||
"GITEA_URL must be set (or pass --url)": {
|
||||
"en": "GITEA_URL must be set (or pass --url)",
|
||||
"bg": "GITEA_URL трябва да е зададен (или подайте --url)",
|
||||
"de": "GITEA_URL muss gesetzt sein (oder --url übergeben)",
|
||||
"ru": "GITEA_URL должен быть задан (или передайте --url)",
|
||||
"zh": "必须设置 GITEA_URL(或传递 --url)"
|
||||
},
|
||||
"Installation failed: {error}": {
|
||||
"en": "Installation failed: {error}",
|
||||
"bg": "Инсталацията неуспешна: {error}",
|
||||
"de": "Installation fehlgeschlagen: {error}",
|
||||
"ru": "Установка не удалась: {error}",
|
||||
"zh": "安装失败: {error}"
|
||||
},
|
||||
"Update failed: {error}": {
|
||||
"en": "Update failed: {error}",
|
||||
"bg": "Актуализацията неуспешна: {error}",
|
||||
"de": "Update fehlgeschlagen: {error}",
|
||||
"ru": "Обновление не удалось: {error}",
|
||||
"zh": "更新失败: {error}"
|
||||
},
|
||||
"SSH user": {
|
||||
"en": "SSH user",
|
||||
"bg": "SSH потребител",
|
||||
"de": "SSH-Benutzer",
|
||||
"ru": "SSH пользователь",
|
||||
"zh": "SSH 用户"
|
||||
},
|
||||
"Path to SSH private key": {
|
||||
"en": "Path to SSH private key",
|
||||
"bg": "Път към SSH частен ключ",
|
||||
"de": "Pfad zum SSH-Private-Key",
|
||||
"ru": "Путь к SSH приватному ключу",
|
||||
"zh": "SSH 私钥路径"
|
||||
},
|
||||
"Gitea Runner name (default: host)": {
|
||||
"en": "Gitea Runner name (default: host)",
|
||||
"bg": "Име на Gitea Runner (по подразбиране: host)",
|
||||
"de": "Gitea Runner-Name (Standard: host)",
|
||||
"ru": "Имя Gitea Runner (по умолчанию: host)",
|
||||
"zh": "Gitea Runner 名称(默认: host)"
|
||||
},
|
||||
"Registration token (env: GITEA_REGISTRATION_TOKEN)": {
|
||||
"en": "Registration token (env: GITEA_REGISTRATION_TOKEN)",
|
||||
"bg": "Регистрационен токен (env: GITEA_REGISTRATION_TOKEN)",
|
||||
"de": "Registrierungstoken (env: GITEA_REGISTRATION_TOKEN)",
|
||||
"ru": "Токен регистрации (env: GITEA_REGISTRATION_TOKEN)",
|
||||
"zh": "注册令牌(环境变量: GITEA_REGISTRATION_TOKEN)"
|
||||
},
|
||||
"Gitea URL (env: GITEA_URL)": {
|
||||
"en": "Gitea URL (env: GITEA_URL)",
|
||||
"bg": "Gitea URL (env: GITEA_URL)",
|
||||
"de": "Gitea-URL (env: GITEA_URL)",
|
||||
"ru": "URL Gitea (env: GITEA_URL)",
|
||||
"zh": "Gitea URL(环境变量: GITEA_URL)"
|
||||
},
|
||||
"Gitea Runner deployment mode (default: docker)": {
|
||||
"en": "Gitea Runner deployment mode (default: docker)",
|
||||
"bg": "Режим на разполагане на Gitea Runner (по подразбиране: docker)",
|
||||
"de": "Gitea Runner-Bereitstellungsmodus (Standard: docker)",
|
||||
"ru": "Режим развёртывания Gitea Runner (по умолчанию: docker)",
|
||||
"zh": "Gitea Runner 部署模式(默认: docker)"
|
||||
},
|
||||
"Gitea admin API token for integration test (env: REPO_TOKEN)": {
|
||||
"en": "Gitea admin API token for integration test (env: REPO_TOKEN)",
|
||||
"bg": "Gitea admin API токен за интеграционен тест (env: REPO_TOKEN)",
|
||||
"de": "Gitea-Admin-API-Token für Integrationstest (env: REPO_TOKEN)",
|
||||
"ru": "Токен админ API Gitea для интеграционного теста (env: REPO_TOKEN)",
|
||||
"zh": "Gitea 管理员 API 令牌,用于集成测试(环境变量: REPO_TOKEN)"
|
||||
},
|
||||
"Integration test API retries (default: 3, env: GITEA_INTEGRATION_RETRIES)": {
|
||||
"en": "Integration test API retries (default: 3, env: GITEA_INTEGRATION_RETRIES)",
|
||||
"bg": "Повторни опити за интеграционен тест API (по подразбиране: 3, env: GITEA_INTEGRATION_RETRIES)",
|
||||
"de": "API-Wiederholungen für Integrationstest (Standard: 3, env: GITEA_INTEGRATION_RETRIES)",
|
||||
"ru": "Повторы API интеграционного теста (по умолчанию: 3, env: GITEA_INTEGRATION_RETRIES)",
|
||||
"zh": "集成测试 API 重试次数(默认: 3,环境变量: GITEA_INTEGRATION_RETRIES)"
|
||||
},
|
||||
"Runner labels for Gitea Actions (env: GITEA_RUNNER_LABELS). Example: docker:docker://alpine:latest": {
|
||||
"en": "Runner labels for Gitea Actions (env: GITEA_RUNNER_LABELS). Example: docker:docker://alpine:latest",
|
||||
"bg": "Етикети на runner за Gitea Actions (env: GITEA_RUNNER_LABELS). Пример: docker:docker://alpine:latest",
|
||||
"de": "Runner-Labels für Gitea Actions (env: GITEA_RUNNER_LABELS). Beispiel: docker:docker://alpine:latest",
|
||||
"ru": "Метки runner для Gitea Actions (env: GITEA_RUNNER_LABELS). Пример: docker:docker://alpine:latest",
|
||||
"zh": "Gitea Actions 的 runner 标签(环境变量: GITEA_RUNNER_LABELS)。示例: docker:docker://alpine:latest"
|
||||
},
|
||||
"Prompt for sudo password (default)": {
|
||||
"en": "Prompt for sudo password (default)",
|
||||
"bg": "Подканване за sudo парола (по подразбиране)",
|
||||
"de": "Nach sudo-Passwort fragen (Standard)",
|
||||
"ru": "Запросить пароль sudo (по умолчанию)",
|
||||
"zh": "提示输入 sudo 密码(默认)"
|
||||
},
|
||||
"Do not prompt for sudo password": {
|
||||
"en": "Do not prompt for sudo password",
|
||||
"bg": "Без подканване за sudo парола",
|
||||
"de": "Nicht nach sudo-Passwort fragen",
|
||||
"ru": "Не запрашивать пароль sudo",
|
||||
"zh": "不提示输入 sudo 密码"
|
||||
},
|
||||
"Specific Gitea Runner version": {
|
||||
"en": "Specific Gitea Runner version",
|
||||
"bg": "Конкретна версия на Gitea Runner",
|
||||
"de": "Spezifische Gitea Runner-Version",
|
||||
"ru": "Конкретная версия Gitea Runner",
|
||||
"zh": "指定 Gitea Runner 版本"
|
||||
},
|
||||
"Install and configure a Gitea Runner on a remote host.": {
|
||||
"en": "Install and configure a Gitea Runner on a remote host.",
|
||||
"bg": "Инсталиране и конфигуриране на Gitea Runner на отдалечен хост.",
|
||||
"de": "Gitea Runner auf einem Remote-Host installieren und konfigurieren.",
|
||||
"ru": "Установить и настроить Gitea Runner на удалённом хосте.",
|
||||
"zh": "在远程主机上安装并配置 Gitea Runner。"
|
||||
},
|
||||
"Update the Gitea Runner binary on a remote host.": {
|
||||
"en": "Update the Gitea Runner binary on a remote host.",
|
||||
"bg": "Актуализиране на Gitea Runner двоичния файл на отдалечен хост.",
|
||||
"de": "Gitea Runner-Binary auf einem Remote-Host aktualisieren.",
|
||||
"ru": "Обновить бинарный файл Gitea Runner на удалённом хосте.",
|
||||
"zh": "在远程主机上更新 Gitea Runner 二进制文件。"
|
||||
},
|
||||
"Starting Gitea Runner {name} on {host}": {
|
||||
"en": "Starting Gitea Runner {name} on {host}",
|
||||
"bg": "Стартиране на Gitea Runner {name} на {host}",
|
||||
"de": "Starte Gitea Runner {name} auf {host}",
|
||||
"ru": "Запуск Gitea Runner {name} на {host}",
|
||||
"zh": "正在 {host} 上启动 Gitea Runner {name}"
|
||||
},
|
||||
"Stopping Gitea Runner {name} on {host}": {
|
||||
"en": "Stopping Gitea Runner {name} on {host}",
|
||||
"bg": "Спиране на Gitea Runner {name} на {host}",
|
||||
"de": "Stoppe Gitea Runner {name} auf {host}",
|
||||
"ru": "Остановка Gitea Runner {name} на {host}",
|
||||
"zh": "正在 {host} 上停止 Gitea Runner {name}"
|
||||
},
|
||||
"Enabling Gitea Runner {name} on {host}": {
|
||||
"en": "Enabling Gitea Runner {name} on {host}",
|
||||
"bg": "Активиране на Gitea Runner {name} на {host}",
|
||||
"de": "Aktiviere Gitea Runner {name} auf {host}",
|
||||
"ru": "Включение Gitea Runner {name} на {host}",
|
||||
"zh": "正在 {host} 上启用 Gitea Runner {name}"
|
||||
},
|
||||
"Disabling Gitea Runner {name} on {host}": {
|
||||
"en": "Disabling Gitea Runner {name} on {host}",
|
||||
"bg": "Деактивиране на Gitea Runner {name} на {host}",
|
||||
"de": "Deaktiviere Gitea Runner {name} auf {host}",
|
||||
"ru": "Отключение Gitea Runner {name} на {host}",
|
||||
"zh": "正在 {host} 上禁用 Gitea Runner {name}"
|
||||
"Check the status of a registered Gitea Runner.": {
|
||||
"en": "Check the status of a registered Gitea Runner.",
|
||||
"bg": "Проверка на състоянието на регистриран Gitea Runner.",
|
||||
"de": "Status eines registrierten Gitea Runners prüfen.",
|
||||
"ru": "Проверить состояние зарегистрированного Gitea Runner.",
|
||||
"zh": "检查已注册的 Gitea Runner 状态。"
|
||||
},
|
||||
"Checking status of Gitea Runner {name} on {host}": {
|
||||
"en": "Checking status of Gitea Runner {name} on {host}",
|
||||
@@ -216,62 +41,6 @@
|
||||
"ru": "Проверка состояния Gitea Runner {name} на {host} как {user} (требуется sudo)",
|
||||
"zh": "正在检查 {host} 上 Gitea Runner {name} 的状态(用户 {user},需要 sudo)"
|
||||
},
|
||||
"Removing Gitea Runner {name} from {host}": {
|
||||
"en": "Removing Gitea Runner {name} from {host}",
|
||||
"bg": "Премахване на Gitea Runner {name} от {host}",
|
||||
"de": "Entferne Gitea Runner {name} von {host}",
|
||||
"ru": "Удаление Gitea Runner {name} с {host}",
|
||||
"zh": "正在从 {host} 移除 Gitea Runner {name}"
|
||||
},
|
||||
"Start failed: {error}": {
|
||||
"en": "Start failed: {error}",
|
||||
"bg": "Стартирането неуспешно: {error}",
|
||||
"de": "Start fehlgeschlagen: {error}",
|
||||
"ru": "Запуск не удался: {error}",
|
||||
"zh": "启动失败: {error}"
|
||||
},
|
||||
"Stop failed: {error}": {
|
||||
"en": "Stop failed: {error}",
|
||||
"bg": "Спирането неуспешно: {error}",
|
||||
"de": "Stop fehlgeschlagen: {error}",
|
||||
"ru": "Остановка не удалась: {error}",
|
||||
"zh": "停止失败: {error}"
|
||||
},
|
||||
"Enable failed: {error}": {
|
||||
"en": "Enable failed: {error}",
|
||||
"bg": "Активирането неуспешно: {error}",
|
||||
"de": "Aktivierung fehlgeschlagen: {error}",
|
||||
"ru": "Включение не удалось: {error}",
|
||||
"zh": "启用失败: {error}"
|
||||
},
|
||||
"Disable failed: {error}": {
|
||||
"en": "Disable failed: {error}",
|
||||
"bg": "Деактивирането неуспешно: {error}",
|
||||
"de": "Deaktivierung fehlgeschlagen: {error}",
|
||||
"ru": "Отключение не удалось: {error}",
|
||||
"zh": "禁用失败: {error}"
|
||||
},
|
||||
"Status check failed: {error}": {
|
||||
"en": "Status check failed: {error}",
|
||||
"bg": "Проверката на състоянието неуспешна: {error}",
|
||||
"de": "Statusprüfung fehlgeschlagen: {error}",
|
||||
"ru": "Проверка состояния не удалась: {error}",
|
||||
"zh": "状态检查失败: {error}"
|
||||
},
|
||||
"Remove failed: {error}": {
|
||||
"en": "Remove failed: {error}",
|
||||
"bg": "Премахването неуспешно: {error}",
|
||||
"de": "Entfernung fehlgeschlagen: {error}",
|
||||
"ru": "Удаление не удалось: {error}",
|
||||
"zh": "移除失败: {error}"
|
||||
},
|
||||
"Gitea Runner Manager — manage Gitea Actions runners.": {
|
||||
"en": "Gitea Runner Manager — manage Gitea Actions runners.",
|
||||
"bg": "Gitea Runner Manager — управление на Gitea Actions runners.",
|
||||
"de": "Gitea Runner Manager — Gitea Actions Runner verwalten.",
|
||||
"ru": "Gitea Runner Manager — управление Gitea Actions runners.",
|
||||
"zh": "Gitea Runner Manager — 管理 Gitea Actions runners。"
|
||||
},
|
||||
"Command: {cmd}": {
|
||||
"en": "Command: {cmd}",
|
||||
"bg": "Команда: {cmd}",
|
||||
@@ -279,27 +48,6 @@
|
||||
"ru": "Команда: {cmd}",
|
||||
"zh": "命令: {cmd}"
|
||||
},
|
||||
"Start a registered Gitea Runner.": {
|
||||
"en": "Start a registered Gitea Runner.",
|
||||
"bg": "Стартиране на регистриран Gitea Runner.",
|
||||
"de": "Einen registrierten Gitea Runner starten.",
|
||||
"ru": "Запустить зарегистрированный Gitea Runner.",
|
||||
"zh": "启动已注册的 Gitea Runner。"
|
||||
},
|
||||
"Stop a registered Gitea Runner.": {
|
||||
"en": "Stop a registered Gitea Runner.",
|
||||
"bg": "Спиране на регистриран Gitea Runner.",
|
||||
"de": "Einen registrierten Gitea Runner stoppen.",
|
||||
"ru": "Остановить зарегистрированный Gitea Runner.",
|
||||
"zh": "停止已注册的 Gitea Runner。"
|
||||
},
|
||||
"Enable a registered Gitea Runner to start on boot.": {
|
||||
"en": "Enable a registered Gitea Runner to start on boot.",
|
||||
"bg": "Разрешаване на регистриран Gitea Runner да стартира при зареждане.",
|
||||
"de": "Einen registrierten Gitea Runner für den Autostart aktivieren.",
|
||||
"ru": "Включить автозапуск зарегистрированного Gitea Runner.",
|
||||
"zh": "启用已注册的 Gitea Runner 开机自启。"
|
||||
},
|
||||
"Disable a registered Gitea Runner and deregister it.": {
|
||||
"en": "Disable a registered Gitea Runner and deregister it.",
|
||||
"bg": "Деактивиране на регистриран Gitea Runner и дерегистриране.",
|
||||
@@ -307,54 +55,138 @@
|
||||
"ru": "Отключить и дерегистрировать зарегистрированный Gitea Runner.",
|
||||
"zh": "禁用并注销已注册的 Gitea Runner。"
|
||||
},
|
||||
"Check the status of a registered Gitea Runner.": {
|
||||
"en": "Check the status of a registered Gitea Runner.",
|
||||
"bg": "Проверка на състоянието на регистриран Gitea Runner.",
|
||||
"de": "Status eines registrierten Gitea Runners prüfen.",
|
||||
"ru": "Проверить состояние зарегистрированного Gitea Runner.",
|
||||
"zh": "检查已注册的 Gitea Runner 状态。"
|
||||
"Disable failed: {error}": {
|
||||
"en": "Disable failed: {error}",
|
||||
"bg": "Деактивирането неуспешно: {error}",
|
||||
"de": "Deaktivierung fehlgeschlagen: {error}",
|
||||
"ru": "Отключение не удалось: {error}",
|
||||
"zh": "禁用失败: {error}"
|
||||
},
|
||||
"Remove a registered Gitea Runner completely.": {
|
||||
"en": "Remove a registered Gitea Runner completely.",
|
||||
"bg": "Пълно премахване на регистриран Gitea Runner.",
|
||||
"de": "Einen registrierten Gitea Runner vollständig entfernen.",
|
||||
"ru": "Полностью удалить зарегистрированный Gitea Runner.",
|
||||
"zh": "完全移除已注册的 Gitea Runner。"
|
||||
"Disabling Gitea Runner {name} on {host}": {
|
||||
"en": "Disabling Gitea Runner {name} on {host}",
|
||||
"bg": "Деактивиране на Gitea Runner {name} на {host}",
|
||||
"de": "Deaktiviere Gitea Runner {name} auf {host}",
|
||||
"ru": "Отключение Gitea Runner {name} на {host}",
|
||||
"zh": "正在 {host} 上禁用 Gitea Runner {name}"
|
||||
},
|
||||
"Override host from registry": {
|
||||
"en": "Override host from registry",
|
||||
"bg": "Замяна на хоста от регистъра",
|
||||
"de": "Host aus Registrierung überschreiben",
|
||||
"ru": "Переопределить хост из реестра",
|
||||
"zh": "覆盖注册表中的主机"
|
||||
"Done. See full log: {log_file}": {
|
||||
"en": "Done. See full log: {log_file}",
|
||||
"bg": "Готово. Вижте пълния лог: {log_file}",
|
||||
"de": "Fertig. Siehe vollständiges Log: {log_file}",
|
||||
"ru": "Готово. См. полный лог: {log_file}",
|
||||
"zh": "完成。查看完整日志: {log_file}"
|
||||
},
|
||||
"Override user from registry": {
|
||||
"en": "Override user from registry",
|
||||
"bg": "Замяна на потребителя от регистъра",
|
||||
"de": "Benutzer aus Registrierung überschreiben",
|
||||
"ru": "Переопределить пользователя из реестра",
|
||||
"zh": "覆盖注册表中的用户"
|
||||
"Enable a registered Gitea Runner to start on boot.": {
|
||||
"en": "Enable a registered Gitea Runner to start on boot.",
|
||||
"bg": "Разрешаване на регистриран Gitea Runner да стартира при зареждане.",
|
||||
"de": "Einen registrierten Gitea Runner für den Autostart aktivieren.",
|
||||
"ru": "Включить автозапуск зарегистрированного Gitea Runner.",
|
||||
"zh": "启用已注册的 Gitea Runner 开机自启。"
|
||||
},
|
||||
"Override SSH key from registry": {
|
||||
"en": "Override SSH key from registry",
|
||||
"bg": "Замяна на SSH ключа от регистъра",
|
||||
"de": "SSH-Schlüssel aus Registrierung überschreiben",
|
||||
"ru": "Переопределить SSH ключ из реестра",
|
||||
"zh": "覆盖注册表中的 SSH 密钥"
|
||||
"Enable failed: {error}": {
|
||||
"en": "Enable failed: {error}",
|
||||
"bg": "Активирането неуспешно: {error}",
|
||||
"de": "Aktivierung fehlgeschlagen: {error}",
|
||||
"ru": "Включение не удалось: {error}",
|
||||
"zh": "启用失败: {error}"
|
||||
},
|
||||
"Override deployment mode from registry": {
|
||||
"en": "Override deployment mode from registry",
|
||||
"bg": "Замяна на режима на внедряване от регистъра",
|
||||
"de": "Bereitstellungsmodus aus Registrierung überschreiben",
|
||||
"ru": "Переопределить режим развертывания из реестра",
|
||||
"zh": "覆盖注册表中的部署模式"
|
||||
"Enabling Gitea Runner {name} on {host}": {
|
||||
"en": "Enabling Gitea Runner {name} on {host}",
|
||||
"bg": "Активиране на Gitea Runner {name} на {host}",
|
||||
"de": "Aktiviere Gitea Runner {name} auf {host}",
|
||||
"ru": "Включение Gitea Runner {name} на {host}",
|
||||
"zh": "正在 {host} 上启用 Gitea Runner {name}"
|
||||
},
|
||||
"Runner '{name}' not found in registry. Use 'grm install' first or provide --host and --user.": {
|
||||
"en": "Runner '{name}' not found in registry. Use 'grm install' first or provide --host and --user.",
|
||||
"bg": "Runner '{name}' не е намерен в регистъра. Използвайте 'grm install' първо или подайте --host и --user.",
|
||||
"de": "Runner '{name}' nicht in Registrierung gefunden. Verwenden Sie zuerst 'grm install' oder geben Sie --host und --user an.",
|
||||
"ru": "Runner '{name}' не найден в реестре. Сначала используйте 'grm install' или укажите --host и --user.",
|
||||
"zh": "注册表中未找到 Runner '{name}'。请先使用 'grm install' 或提供 --host 和 --user。"
|
||||
"Full log: {log_file}": {
|
||||
"en": "Full log: {log_file}",
|
||||
"bg": "Пълен лог: {log_file}",
|
||||
"de": "Vollständiges Log: {log_file}",
|
||||
"ru": "Полный лог: {log_file}",
|
||||
"zh": "完整日志: {log_file}"
|
||||
},
|
||||
"GITEA_REGISTRATION_TOKEN must be set (or pass --token)": {
|
||||
"en": "GITEA_REGISTRATION_TOKEN must be set (or pass --token)",
|
||||
"bg": "GITEA_REGISTRATION_TOKEN трябва да е зададен (или подайте --token)",
|
||||
"de": "GITEA_REGISTRATION_TOKEN muss gesetzt sein (oder --token übergeben)",
|
||||
"ru": "GITEA_REGISTRATION_TOKEN должен быть задан (или передайте --token)",
|
||||
"zh": "必须设置 GITEA_REGISTRATION_TOKEN(或传递 --token)"
|
||||
},
|
||||
"GITEA_URL must be set (or pass --url)": {
|
||||
"en": "GITEA_URL must be set (or pass --url)",
|
||||
"bg": "GITEA_URL трябва да е зададен (или подайте --url)",
|
||||
"de": "GITEA_URL muss gesetzt sein (oder --url übergeben)",
|
||||
"ru": "GITEA_URL должен быть задан (или передайте --url)",
|
||||
"zh": "必须设置 GITEA_URL(或传递 --url)"
|
||||
},
|
||||
"Gitea Runner Manager — manage Gitea Actions runners.": {
|
||||
"en": "Gitea Runner Manager — manage Gitea Actions runners.",
|
||||
"bg": "Gitea Runner Manager — управление на Gitea Actions runners.",
|
||||
"de": "Gitea Runner Manager — Gitea Actions Runner verwalten.",
|
||||
"ru": "Gitea Runner Manager — управление Gitea Actions runners.",
|
||||
"zh": "Gitea Runner Manager — 管理 Gitea Actions runners。"
|
||||
},
|
||||
"Gitea Runner name (default: host)": {
|
||||
"en": "Gitea Runner name (default: host)",
|
||||
"bg": "Име на Gitea Runner (по подразбиране: host)",
|
||||
"de": "Gitea Runner-Name (Standard: host)",
|
||||
"ru": "Имя Gitea Runner (по умолчанию: host)",
|
||||
"zh": "Gitea Runner 名称(默认: host)"
|
||||
},
|
||||
"Gitea URL (env: GITEA_URL)": {
|
||||
"en": "Gitea URL (env: GITEA_URL)",
|
||||
"bg": "Gitea URL (env: GITEA_URL)",
|
||||
"de": "Gitea-URL (env: GITEA_URL)",
|
||||
"ru": "URL Gitea (env: GITEA_URL)",
|
||||
"zh": "Gitea URL(环境变量: GITEA_URL)"
|
||||
},
|
||||
"Gitea admin API token for integration test (env: REPO_TOKEN)": {
|
||||
"en": "Gitea admin API token for integration test (env: REPO_TOKEN)",
|
||||
"bg": "Gitea admin API токен за интеграционен тест (env: REPO_TOKEN)",
|
||||
"de": "Gitea-Admin-API-Token für Integrationstest (env: REPO_TOKEN)",
|
||||
"ru": "Токен админ API Gitea для интеграционного теста (env: REPO_TOKEN)",
|
||||
"zh": "Gitea 管理员 API 令牌,用于集成测试(环境变量: REPO_TOKEN)"
|
||||
},
|
||||
"HOST": {
|
||||
"en": "HOST",
|
||||
"bg": "ХОСТ",
|
||||
"de": "HOST",
|
||||
"ru": "ХОСТ",
|
||||
"zh": "主机"
|
||||
},
|
||||
"Install and configure a Gitea Runner on a remote host.": {
|
||||
"en": "Install and configure a Gitea Runner on a remote host.",
|
||||
"bg": "Инсталиране и конфигуриране на Gitea Runner на отдалечен хост.",
|
||||
"de": "Gitea Runner auf einem Remote-Host installieren und konfigurieren.",
|
||||
"ru": "Установить и настроить Gitea Runner на удалённом хосте.",
|
||||
"zh": "在远程主机上安装并配置 Gitea Runner。"
|
||||
},
|
||||
"Installation failed: {error}": {
|
||||
"en": "Installation failed: {error}",
|
||||
"bg": "Инсталацията неуспешна: {error}",
|
||||
"de": "Installation fehlgeschlagen: {error}",
|
||||
"ru": "Установка не удалась: {error}",
|
||||
"zh": "安装失败: {error}"
|
||||
},
|
||||
"Installing Gitea Runner on {host}": {
|
||||
"en": "Installing Gitea Runner on {host}",
|
||||
"bg": "Инсталиране на Gitea Runner на {host}",
|
||||
"de": "Gitea Runner wird auf {host} installiert",
|
||||
"ru": "Установка Gitea Runner на {host}",
|
||||
"zh": "正在 {host} 上安装 Gitea Runner"
|
||||
},
|
||||
"Integration test API retries (default: 3, env: GITEA_INTEGRATION_RETRIES)": {
|
||||
"en": "Integration test API retries (default: 3, env: GITEA_INTEGRATION_RETRIES)",
|
||||
"bg": "Повторни опити за интеграционен тест API (по подразбиране: 3, env: GITEA_INTEGRATION_RETRIES)",
|
||||
"de": "API-Wiederholungen für Integrationstest (Standard: 3, env: GITEA_INTEGRATION_RETRIES)",
|
||||
"ru": "Повторы API интеграционного теста (по умолчанию: 3, env: GITEA_INTEGRATION_RETRIES)",
|
||||
"zh": "集成测试 API 重试次数(默认: 3,环境变量: GITEA_INTEGRATION_RETRIES)"
|
||||
},
|
||||
"LABELS": {
|
||||
"en": "LABELS",
|
||||
"bg": "ЕТИКЕТИ",
|
||||
"de": "LABELS",
|
||||
"ru": "МЕТКИ",
|
||||
"zh": "标签"
|
||||
},
|
||||
"List all registered runners with live status.": {
|
||||
"en": "List all registered runners with live status.",
|
||||
@@ -363,13 +195,6 @@
|
||||
"ru": "Список всех зарегистрированных runners с текущим статусом.",
|
||||
"zh": "列出所有已注册 runners 的实时状态。"
|
||||
},
|
||||
"No runners registered. Use 'grm install' to add one.": {
|
||||
"en": "No runners registered. Use 'grm install' to add one.",
|
||||
"bg": "Няма регистрирани runners. Използвайте 'grm install', за да добавите.",
|
||||
"de": "Keine Runner registriert. Verwenden Sie 'grm install', um einen hinzuzufügen.",
|
||||
"ru": "Нет зарегистрированных runners. Используйте 'grm install' чтобы добавить.",
|
||||
"zh": "没有已注册的 runners。使用 'grm install' 添加一个。"
|
||||
},
|
||||
"List failed: {error}": {
|
||||
"en": "List failed: {error}",
|
||||
"bg": "Списъкът неуспешен: {error}",
|
||||
@@ -384,103 +209,75 @@
|
||||
"ru": "ИМЯ",
|
||||
"zh": "名称"
|
||||
},
|
||||
"HOST": {
|
||||
"en": "HOST",
|
||||
"bg": "ХОСТ",
|
||||
"de": "HOST",
|
||||
"ru": "ХОСТ",
|
||||
"zh": "主机"
|
||||
"No runners registered. Use 'grm install' to add one.": {
|
||||
"en": "No runners registered. Use 'grm install' to add one.",
|
||||
"bg": "Няма регистрирани runners. Използвайте 'grm install', за да добавите.",
|
||||
"de": "Keine Runner registriert. Verwenden Sie 'grm install', um einen hinzuzufügen.",
|
||||
"ru": "Нет зарегистрированных runners. Используйте 'grm install' чтобы добавить.",
|
||||
"zh": "没有已注册的 runners。使用 'grm install' 添加一个。"
|
||||
},
|
||||
"USER": {
|
||||
"en": "USER",
|
||||
"bg": "ПОТРЕБИТЕЛ",
|
||||
"de": "BENUTZER",
|
||||
"ru": "ПОЛЬЗОВАТЕЛЬ",
|
||||
"zh": "用户"
|
||||
"Override SSH key from registry": {
|
||||
"en": "Override SSH key from registry",
|
||||
"bg": "Замяна на SSH ключа от регистъра",
|
||||
"de": "SSH-Schlüssel aus Registrierung überschreiben",
|
||||
"ru": "Переопределить SSH ключ из реестра",
|
||||
"zh": "覆盖注册表中的 SSH 密钥"
|
||||
},
|
||||
"MODE": {
|
||||
"en": "MODE",
|
||||
"bg": "РЕЖИМ",
|
||||
"de": "MODUS",
|
||||
"ru": "РЕЖИМ",
|
||||
"zh": "模式"
|
||||
"Override host from registry": {
|
||||
"en": "Override host from registry",
|
||||
"bg": "Замяна на хоста от регистъра",
|
||||
"de": "Host aus Registrierung überschreiben",
|
||||
"ru": "Переопределить хост из реестра",
|
||||
"zh": "覆盖注册表中的主机"
|
||||
},
|
||||
"LABELS": {
|
||||
"en": "LABELS",
|
||||
"bg": "ЕТИКЕТИ",
|
||||
"de": "LABELS",
|
||||
"ru": "МЕТКИ",
|
||||
"zh": "标签"
|
||||
"Override user from registry": {
|
||||
"en": "Override user from registry",
|
||||
"bg": "Замяна на потребителя от регистъра",
|
||||
"de": "Benutzer aus Registrierung überschreiben",
|
||||
"ru": "Переопределить пользователя из реестра",
|
||||
"zh": "覆盖注册表中的用户"
|
||||
},
|
||||
"STATUS": {
|
||||
"en": "STATUS",
|
||||
"bg": "СТАТУС",
|
||||
"de": "STATUS",
|
||||
"ru": "СТАТУС",
|
||||
"zh": "状态"
|
||||
"Path to SSH private key": {
|
||||
"en": "Path to SSH private key",
|
||||
"bg": "Път към SSH частен ключ",
|
||||
"de": "Pfad zum SSH-Private-Key",
|
||||
"ru": "Путь к SSH приватному ключу",
|
||||
"zh": "SSH 私钥路径"
|
||||
},
|
||||
"active": {
|
||||
"en": "active",
|
||||
"bg": "активен",
|
||||
"de": "aktiv",
|
||||
"ru": "активен",
|
||||
"zh": "活跃"
|
||||
"Playbook not found: {playbook}": {
|
||||
"en": "Playbook not found: {playbook}",
|
||||
"bg": "Playbook не е намерен: {playbook}",
|
||||
"de": "Playbook nicht gefunden: {playbook}",
|
||||
"ru": "Playbook не найден: {playbook}",
|
||||
"zh": "未找到 Playbook: {playbook}"
|
||||
},
|
||||
"inactive": {
|
||||
"en": "inactive",
|
||||
"bg": "неактивен",
|
||||
"de": "inaktiv",
|
||||
"ru": "неактивен",
|
||||
"zh": "不活跃"
|
||||
"Prompt for sudo password (default)": {
|
||||
"en": "Prompt for sudo password (default)",
|
||||
"bg": "Подканване за sudo парола (по подразбиране)",
|
||||
"de": "Nach sudo-Passwort fragen (Standard)",
|
||||
"ru": "Запросить пароль sudo (по умолчанию)",
|
||||
"zh": "提示输入 sudo 密码(默认)"
|
||||
},
|
||||
"failed": {
|
||||
"en": "failed",
|
||||
"bg": "неуспешен",
|
||||
"de": "fehlgeschlagen",
|
||||
"ru": "сбой",
|
||||
"zh": "失败"
|
||||
"Registration token (env: GITEA_REGISTRATION_TOKEN)": {
|
||||
"en": "Registration token (env: GITEA_REGISTRATION_TOKEN)",
|
||||
"bg": "Регистрационен токен (env: GITEA_REGISTRATION_TOKEN)",
|
||||
"de": "Registrierungstoken (env: GITEA_REGISTRATION_TOKEN)",
|
||||
"ru": "Токен регистрации (env: GITEA_REGISTRATION_TOKEN)",
|
||||
"zh": "注册令牌(环境变量: GITEA_REGISTRATION_TOKEN)"
|
||||
},
|
||||
"unknown": {
|
||||
"en": "unknown",
|
||||
"bg": "неизвестен",
|
||||
"de": "unbekannt",
|
||||
"ru": "неизвестно",
|
||||
"zh": "未知"
|
||||
"Remove a registered Gitea Runner completely.": {
|
||||
"en": "Remove a registered Gitea Runner completely.",
|
||||
"bg": "Пълно премахване на регистриран Gitea Runner.",
|
||||
"de": "Einen registrierten Gitea Runner vollständig entfernen.",
|
||||
"ru": "Полностью удалить зарегистрированный Gitea Runner.",
|
||||
"zh": "完全移除已注册的 Gitea Runner。"
|
||||
},
|
||||
"=== Operation Report ===": {
|
||||
"en": "=== Operation Report ===",
|
||||
"bg": "=== Отчет за операцията ===",
|
||||
"de": "=== Operationsbericht ===",
|
||||
"ru": "=== Отчет об операции ===",
|
||||
"zh": "=== 操作报告 ==="
|
||||
},
|
||||
"completed": {
|
||||
"en": "completed",
|
||||
"bg": "завършено",
|
||||
"de": "abgeschlossen",
|
||||
"ru": "завершено",
|
||||
"zh": "已完成"
|
||||
},
|
||||
"pending": {
|
||||
"en": "pending",
|
||||
"bg": "чакащо",
|
||||
"de": "ausstehend",
|
||||
"ru": "в ожидании",
|
||||
"zh": "待处理"
|
||||
},
|
||||
"in_progress": {
|
||||
"en": "in progress",
|
||||
"bg": "в процес",
|
||||
"de": "in Bearbeitung",
|
||||
"ru": "в процессе",
|
||||
"zh": "进行中"
|
||||
},
|
||||
"Save runner '{name}' to local registry": {
|
||||
"en": "Save runner '{name}' to local registry",
|
||||
"bg": "Запазване на runner '{name}' в локалния регистър",
|
||||
"de": "Runner '{name}' in lokaler Registrierung speichern",
|
||||
"ru": "Сохранить runner '{name}' в локальном реестре",
|
||||
"zh": "将 runner '{name}' 保存到本地注册表"
|
||||
"Remove failed: {error}": {
|
||||
"en": "Remove failed: {error}",
|
||||
"bg": "Премахването неуспешно: {error}",
|
||||
"de": "Entfernung fehlgeschlagen: {error}",
|
||||
"ru": "Удаление не удалось: {error}",
|
||||
"zh": "移除失败: {error}"
|
||||
},
|
||||
"Remove runner '{name}' from local registry": {
|
||||
"en": "Remove runner '{name}' from local registry",
|
||||
@@ -489,6 +286,58 @@
|
||||
"ru": "Удалить runner '{name}' из локального реестра",
|
||||
"zh": "从本地注册表移除 runner '{name}'"
|
||||
},
|
||||
"Removing Gitea Runner {name} from {host}": {
|
||||
"en": "Removing Gitea Runner {name} from {host}",
|
||||
"bg": "Премахване на Gitea Runner {name} от {host}",
|
||||
"de": "Entferne Gitea Runner {name} von {host}",
|
||||
"ru": "Удаление Gitea Runner {name} с {host}",
|
||||
"zh": "正在从 {host} 移除 Gitea Runner {name}"
|
||||
},
|
||||
"Runner '{name}' not found in registry.": {
|
||||
"en": "Runner '{name}' not found in registry."
|
||||
},
|
||||
"Runner '{name}' not found in registry. Use 'grm install' first or provide --host and --user.": {
|
||||
"en": "Runner '{name}' not found in registry. Use 'grm install' first or provide --host and --user.",
|
||||
"bg": "Runner '{name}' не е намерен в регистъра. Използвайте 'grm install' първо или подайте --host и --user.",
|
||||
"de": "Runner '{name}' nicht in Registrierung gefunden. Verwenden Sie zuerst 'grm install' oder geben Sie --host und --user an.",
|
||||
"ru": "Runner '{name}' не найден в реестре. Сначала используйте 'grm install' или укажите --host и --user.",
|
||||
"zh": "注册表中未找到 Runner '{name}'。请先使用 'grm install' 或提供 --host 和 --user。"
|
||||
},
|
||||
"Runner labels for Gitea Actions (env: GITEA_RUNNER_LABELS). Example: docker:docker://alpine:latest": {
|
||||
"en": "Runner labels for Gitea Actions (env: GITEA_RUNNER_LABELS). Example: docker:docker://alpine:latest",
|
||||
"bg": "Етикети на runner за Gitea Actions (env: GITEA_RUNNER_LABELS). Пример: docker:docker://alpine:latest",
|
||||
"de": "Runner-Labels für Gitea Actions (env: GITEA_RUNNER_LABELS). Beispiel: docker:docker://alpine:latest",
|
||||
"ru": "Метки runner для Gitea Actions (env: GITEA_RUNNER_LABELS). Пример: docker:docker://alpine:latest",
|
||||
"zh": "Gitea Actions 的 runner 标签(环境变量: GITEA_RUNNER_LABELS)。示例: docker:docker://alpine:latest"
|
||||
},
|
||||
"Running Ansible playbook": {
|
||||
"en": "Running Ansible playbook",
|
||||
"bg": "Изпълнение на Ansible playbook",
|
||||
"de": "Ansible-Playbook wird ausgeführt",
|
||||
"ru": "Выполнение Ansible playbook",
|
||||
"zh": "正在运行 Ansible playbook"
|
||||
},
|
||||
"SSH user": {
|
||||
"en": "SSH user",
|
||||
"bg": "SSH потребител",
|
||||
"de": "SSH-Benutzer",
|
||||
"ru": "SSH пользователь",
|
||||
"zh": "SSH 用户"
|
||||
},
|
||||
"STATUS": {
|
||||
"en": "STATUS",
|
||||
"bg": "СТАТУС",
|
||||
"de": "STATUS",
|
||||
"ru": "СТАТУС",
|
||||
"zh": "状态"
|
||||
},
|
||||
"Save runner '{name}' to local registry": {
|
||||
"en": "Save runner '{name}' to local registry",
|
||||
"bg": "Запазване на runner '{name}' в локалния регистър",
|
||||
"de": "Runner '{name}' in lokaler Registrierung speichern",
|
||||
"ru": "Сохранить runner '{name}' в локальном реестре",
|
||||
"zh": "将 runner '{name}' 保存到本地注册表"
|
||||
},
|
||||
"Skip remote cleanup and only remove the local registry entry": {
|
||||
"en": "Skip remote cleanup and only remove the local registry entry",
|
||||
"bg": "Пропуснете отдалеченото почистване и премахнете само локалния запис",
|
||||
@@ -496,257 +345,103 @@
|
||||
"ru": "Пропустить удаленную очистку и удалить только локальную запись реестра",
|
||||
"zh": "跳过远程清理,仅删除本地注册表条目"
|
||||
},
|
||||
"Ad-hoc command failed on {host}: {stderr}": {
|
||||
"en": "Ad-hoc command failed on {host}: {stderr}",
|
||||
"bg": "Ad-hoc командата неуспешна на {host}: {stderr}",
|
||||
"de": "Ad-hoc-Befehl auf {host} fehlgeschlagen: {stderr}",
|
||||
"ru": "Ad-hoc команда не удалась на {host}: {stderr}",
|
||||
"zh": "Ad-hoc 命令在 {host} 上失败: {stderr}"
|
||||
"Specific Gitea Runner version": {
|
||||
"en": "Specific Gitea Runner version",
|
||||
"bg": "Конкретна версия на Gitea Runner",
|
||||
"de": "Spezifische Gitea Runner-Version",
|
||||
"ru": "Конкретная версия Gitea Runner",
|
||||
"zh": "指定 Gitea Runner 版本"
|
||||
},
|
||||
"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。"
|
||||
"Start a registered Gitea Runner.": {
|
||||
"en": "Start a registered Gitea Runner.",
|
||||
"bg": "Стартиране на регистриран Gitea Runner.",
|
||||
"de": "Einen registrierten Gitea Runner starten.",
|
||||
"ru": "Запустить зарегистрированный Gitea Runner.",
|
||||
"zh": "启动已注册的 Gitea Runner。"
|
||||
},
|
||||
"Configuring branch protection for {branch}...": {
|
||||
"en": "Configuring branch protection for {branch}...",
|
||||
"bg": "Конфигуриране на защита на клона {branch}...",
|
||||
"de": "Konfiguriere Branch-Schutz für {branch}...",
|
||||
"ru": "Настройка защиты ветки {branch}...",
|
||||
"zh": "正在配置 {branch} 的分支保护..."
|
||||
"Start failed: {error}": {
|
||||
"en": "Start failed: {error}",
|
||||
"bg": "Стартирането неуспешно: {error}",
|
||||
"de": "Start fehlgeschlagen: {error}",
|
||||
"ru": "Запуск не удался: {error}",
|
||||
"zh": "启动失败: {error}"
|
||||
},
|
||||
" - Direct pushes: BLOCKED (require PR)": {
|
||||
"en": " - Direct pushes: BLOCKED (require PR)",
|
||||
"bg": " - Директни push-ове: БЛОКИРАНИ (изисква PR)",
|
||||
"de": " - Direkte Pushes: BLOCKIERT (PR erforderlich)",
|
||||
"ru": " - Прямые push: ЗАБЛОКИРОВАНЫ (требуется PR)",
|
||||
"zh": " - 直接推送:已阻止(需要 PR)"
|
||||
"Starting Gitea Runner {name} on {host}": {
|
||||
"en": "Starting Gitea Runner {name} on {host}",
|
||||
"bg": "Стартиране на Gitea Runner {name} на {host}",
|
||||
"de": "Starte Gitea Runner {name} auf {host}",
|
||||
"ru": "Запуск Gitea Runner {name} на {host}",
|
||||
"zh": "正在 {host} 上启动 Gitea Runner {name}"
|
||||
},
|
||||
" - Required approvals: {count}": {
|
||||
"en": " - Required approvals: {count}",
|
||||
"bg": " - Необходими одобрения: {count}",
|
||||
"de": " - Erforderliche Genehmigungen: {count}",
|
||||
"ru": " - Требуемые одобрения: {count}",
|
||||
"zh": " - 必需审批数: {count}"
|
||||
"Status check failed: {error}": {
|
||||
"en": "Status check failed: {error}",
|
||||
"bg": "Проверката на състоянието неуспешна: {error}",
|
||||
"de": "Statusprüfung fehlgeschlagen: {error}",
|
||||
"ru": "Проверка состояния не удалась: {error}",
|
||||
"zh": "状态检查失败: {error}"
|
||||
},
|
||||
" - Dismiss stale approvals: yes": {
|
||||
"en": " - Dismiss stale approvals: yes",
|
||||
"bg": " - Анулиране на остарели одобрения: да",
|
||||
"de": " - Veraltete Genehmigungen ablehnen: ja",
|
||||
"ru": " - Отклонять устаревшие одобрения: да",
|
||||
"zh": " - 忽略过时审批: 是"
|
||||
"Stop a registered Gitea Runner.": {
|
||||
"en": "Stop a registered Gitea Runner.",
|
||||
"bg": "Спиране на регистриран Gitea Runner.",
|
||||
"de": "Einen registrierten Gitea Runner stoppen.",
|
||||
"ru": "Остановить зарегистрированный Gitea Runner.",
|
||||
"zh": "停止已注册的 Gitea Runner。"
|
||||
},
|
||||
" - Block outdated branches: yes": {
|
||||
"en": " - Block outdated branches: yes",
|
||||
"bg": " - Блокиране на остарели клонове: да",
|
||||
"de": " - Veraltete Branches blockieren: ja",
|
||||
"ru": " - Блокировать устаревшие ветки: да",
|
||||
"zh": " - 阻止过时分支: 是"
|
||||
"Stop failed: {error}": {
|
||||
"en": "Stop failed: {error}",
|
||||
"bg": "Спирането неуспешно: {error}",
|
||||
"de": "Stop fehlgeschlagen: {error}",
|
||||
"ru": "Остановка не удалась: {error}",
|
||||
"zh": "停止失败: {error}"
|
||||
},
|
||||
" - Block rejected reviews: yes": {
|
||||
"en": " - Block rejected reviews: yes",
|
||||
"bg": " - Блокиране на отхвърлени рецензии: да",
|
||||
"de": " - Abgelehnte Reviews blockieren: ja",
|
||||
"ru": " - Блокировать отклонённые ревью: да",
|
||||
"zh": " - 阻止被拒绝的审查: 是"
|
||||
"Stopping Gitea Runner {name} on {host}": {
|
||||
"en": "Stopping Gitea Runner {name} on {host}",
|
||||
"bg": "Спиране на Gitea Runner {name} на {host}",
|
||||
"de": "Stoppe Gitea Runner {name} auf {host}",
|
||||
"ru": "Остановка Gitea Runner {name} на {host}",
|
||||
"zh": "正在 {host} 上停止 Gitea Runner {name}"
|
||||
},
|
||||
" - Required status checks: {checks}": {
|
||||
"en": " - Required status checks: {checks}",
|
||||
"bg": " - Необходими проверки на състоянието: {checks}",
|
||||
"de": " - Erforderliche Status-Checks: {checks}",
|
||||
"ru": " - Требуемые проверки статуса: {checks}",
|
||||
"zh": " - 必需状态检查: {checks}"
|
||||
"USER": {
|
||||
"en": "USER",
|
||||
"bg": "ПОТРЕБИТЕЛ",
|
||||
"de": "BENUTZER",
|
||||
"ru": "ПОЛЬЗОВАТЕЛЬ",
|
||||
"zh": "用户"
|
||||
},
|
||||
"Creating {label} label...": {
|
||||
"en": "Creating {label} label...",
|
||||
"bg": "Създаване на етикет {label}...",
|
||||
"de": "Erstelle Label {label}...",
|
||||
"ru": "Создание метки {label}...",
|
||||
"zh": "正在创建标签 {label}..."
|
||||
"Update failed: {error}": {
|
||||
"en": "Update failed: {error}",
|
||||
"bg": "Актуализацията неуспешна: {error}",
|
||||
"de": "Update fehlgeschlagen: {error}",
|
||||
"ru": "Обновление не удалось: {error}",
|
||||
"zh": "更新失败: {error}"
|
||||
},
|
||||
" Label '{label}' already exists.": {
|
||||
"en": " Label '{label}' already exists.",
|
||||
"bg": " Етикетът '{label}' вече съществува.",
|
||||
"de": " Label '{label}' existiert bereits.",
|
||||
"ru": " Метка '{label}' уже существует.",
|
||||
"zh": " 标签 '{label}' 已存在。"
|
||||
"Update the Gitea Runner binary on a remote host.": {
|
||||
"en": "Update the Gitea Runner binary on a remote host.",
|
||||
"bg": "Актуализиране на Gitea Runner двоичния файл на отдалечен хост.",
|
||||
"de": "Gitea Runner-Binary auf einem Remote-Host aktualisieren.",
|
||||
"ru": "Обновить бинарный файл Gitea Runner на удалённом хосте.",
|
||||
"zh": "在远程主机上更新 Gitea Runner 二进制文件。"
|
||||
},
|
||||
" Label '{label}' created.": {
|
||||
"en": " Label '{label}' created.",
|
||||
"bg": " Етикетът '{label}' е създаден.",
|
||||
"de": " Label '{label}' erstellt.",
|
||||
"ru": " Метка '{label}' создана.",
|
||||
"zh": " 标签 '{label}' 已创建。"
|
||||
"Updating Gitea Runner on {host}": {
|
||||
"en": "Updating Gitea Runner on {host}",
|
||||
"bg": "Актуализиране на Gitea Runner на {host}",
|
||||
"de": "Gitea Runner wird auf {host} aktualisiert",
|
||||
"ru": "Обновление Gitea Runner на {host}",
|
||||
"zh": "正在 {host} 上更新 Gitea Runner"
|
||||
},
|
||||
"Repository configuration complete.": {
|
||||
"en": "Repository configuration complete.",
|
||||
"bg": "Конфигурирането на хранилището е завършено.",
|
||||
"de": "Repository-Konfiguration abgeschlossen.",
|
||||
"ru": "Конфигурация репозитория завершена.",
|
||||
"zh": "仓库配置完成。"
|
||||
"active": {
|
||||
"en": "active",
|
||||
"bg": "активен",
|
||||
"de": "aktiv",
|
||||
"ru": "активен",
|
||||
"zh": "活跃"
|
||||
},
|
||||
"Configuring repository settings...": {
|
||||
"en": "Configuring repository settings...",
|
||||
"bg": "Конфигуриране на настройките на хранилището...",
|
||||
"de": "Repository-Einstellungen konfigurieren...",
|
||||
"ru": "Настройка параметров репозитория...",
|
||||
"zh": "正在配置仓库设置..."
|
||||
},
|
||||
" - Auto-delete branch after merge: yes": {
|
||||
"en": " - Auto-delete branch after merge: yes",
|
||||
"bg": " - Автоматично изтриване на клон след сливане: да",
|
||||
"de": " - Branch nach Merge automatisch löschen: ja",
|
||||
"ru": " - Автоудаление ветки после слияния: да",
|
||||
"zh": " - 合并后自动删除分支: 是"
|
||||
},
|
||||
"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或者,您可以在 设置 → 分支 中手动配置分支保护。"
|
||||
},
|
||||
"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}"
|
||||
},
|
||||
"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。"
|
||||
},
|
||||
"Oops! PR title must follow conventional commit format.\n Expected: <type>: <description>\n Got: {pr_title}": {
|
||||
"en": "Oops! PR title must follow conventional commit format.\n Expected: <type>: <description>\n Got: {pr_title}",
|
||||
"bg": "Опа! Заглавието на PR трябва да следва конвенционален формат.\n Очаква се: <type>: <description>\n Получено: {pr_title}",
|
||||
"de": "Ups! PR-Titel muss dem konventionellen Commit-Format folgen.\n Erwartet: <type>: <description>\n Erhalten: {pr_title}",
|
||||
"ru": "Ой! Заголовок PR должен соответствовать формату conventional commit.\n Ожидается: <type>: <description>\n Получено: {pr_title}",
|
||||
"zh": "哎呀!PR 标题必须遵循 conventional commit 格式。\n 预期格式: <type>: <description>\n 实际: {pr_title}"
|
||||
},
|
||||
"Oops! No task ID (GRM-N) found in branch name '{branch}'.": {
|
||||
"en": "Oops! No task ID (GRM-N) found in branch name '{branch}'.",
|
||||
"bg": "Опа! Не е намерен идентификатор на задача (GRM-N) в името на клона '{branch}'.",
|
||||
"de": "Ups! Keine Task-ID (GRM-N) im Branch-Namen '{branch}' gefunden.",
|
||||
"ru": "Ой! В названии ветки '{branch}' не найден идентификатор задачи (GRM-N).",
|
||||
"zh": "哎呀!在分支名 '{branch}' 中未找到任务 ID (GRM-N)。"
|
||||
},
|
||||
"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 是否准备就绪且您具有合并权限。"
|
||||
},
|
||||
"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}"
|
||||
},
|
||||
"No task ID in commit message, skipping Vikunja update. All good — nothing to do here!": {
|
||||
"en": "No task ID in commit message, skipping Vikunja update. All good — nothing to do here!",
|
||||
"bg": "Няма идентификатор на задача в съобщението за commit, пропускаме обновяването на Vikunja. Всичко е наред — няма какво да правим!",
|
||||
"de": "Keine Task-ID in der Commit-Nachricht, Vikunja-Update wird übersprungen. Alles gut — nichts zu tun!",
|
||||
"ru": "В сообщении коммита нет ID задачи, пропускаем обновление Vikunja. Всё в порядке — делать нечего!",
|
||||
"zh": "提交消息中没有任务 ID,跳过 Vikunja 更新。一切正常 — 无需操作!"
|
||||
},
|
||||
"Could not find Vikunja task for {task_id} in project {project_id}.": {
|
||||
"en": "Could not find Vikunja task for {task_id} in project {project_id}.",
|
||||
"bg": "Не е намерена задача Vikunja за {task_id} в проект {project_id}.",
|
||||
"de": "Keine Vikunja-Aufgabe für {task_id} in Projekt {project_id} gefunden.",
|
||||
"ru": "Не удалось найти задачу Vikunja для {task_id} в проекте {project_id}.",
|
||||
"zh": "在项目 {project_id} 中找不到 Vikunja 任务 {task_id}。"
|
||||
},
|
||||
"Vikunja API error: HTTP {status} — {message}": {
|
||||
"en": "Vikunja API error: HTTP {status} — {message}",
|
||||
"bg": "Грешка в API на Vikunja: HTTP {status} — {message}",
|
||||
"de": "Vikunja API-Fehler: HTTP {status} — {message}",
|
||||
"ru": "Ошибка API Vikunja: HTTP {status} — {message}",
|
||||
"zh": "Vikunja API 错误: HTTP {status} — {message}"
|
||||
},
|
||||
"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}) 已更新并标记为完成。"
|
||||
},
|
||||
"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}"
|
||||
},
|
||||
"Published to PyPI.": {
|
||||
"en": "Published to PyPI.",
|
||||
"bg": "Публикувано в PyPI.",
|
||||
"de": "In PyPI veröffentlicht.",
|
||||
"ru": "Опубликовано в PyPI.",
|
||||
"zh": "已发布到 PyPI。"
|
||||
},
|
||||
"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。"
|
||||
},
|
||||
"Release creation failed with HTTP {status}: {message}": {
|
||||
"en": "Release creation failed with HTTP {status}: {message}",
|
||||
"bg": "Създаването на release неуспешно с HTTP {status}: {message}",
|
||||
"de": "Release-Erstellung fehlgeschlagen mit HTTP {status}: {message}",
|
||||
"ru": "Создание release не удалось: HTTP {status}: {message}",
|
||||
"zh": "Release 创建失败: HTTP {status}: {message}"
|
||||
},
|
||||
"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} 已创建。"
|
||||
},
|
||||
"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! 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! 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! 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"
|
||||
"completed": {
|
||||
"en": "completed",
|
||||
"bg": "завършено",
|
||||
"de": "abgeschlossen",
|
||||
"ru": "завершено",
|
||||
"zh": "已完成"
|
||||
},
|
||||
"exit code {code}": {
|
||||
"en": "exit code {code}",
|
||||
@@ -755,18 +450,39 @@
|
||||
"ru": "код выхода {code}",
|
||||
"zh": "退出代码 {code}"
|
||||
},
|
||||
"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}"
|
||||
"failed": {
|
||||
"en": "failed",
|
||||
"bg": "неуспешен",
|
||||
"de": "fehlgeschlagen",
|
||||
"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})"
|
||||
"in_progress": {
|
||||
"en": "in progress",
|
||||
"bg": "в процес",
|
||||
"de": "in Bearbeitung",
|
||||
"ru": "в процессе",
|
||||
"zh": "进行中"
|
||||
},
|
||||
"inactive": {
|
||||
"en": "inactive",
|
||||
"bg": "неактивен",
|
||||
"de": "inaktiv",
|
||||
"ru": "неактивен",
|
||||
"zh": "不活跃"
|
||||
},
|
||||
"pending": {
|
||||
"en": "pending",
|
||||
"bg": "чакащо",
|
||||
"de": "ausstehend",
|
||||
"ru": "в ожидании",
|
||||
"zh": "待处理"
|
||||
},
|
||||
"unknown": {
|
||||
"en": "unknown",
|
||||
"bg": "неизвестен",
|
||||
"de": "unbekannt",
|
||||
"ru": "неизвестно",
|
||||
"zh": "未知"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"""Unit tests for api_clients module."""
|
||||
|
||||
import http
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from gitea_runner_manager.api_clients import GiteaClient, VikunjaClient, _parse_error
|
||||
from gitea_runner_manager.api_clients import GiteaClient, VikunjaClient, _is_retryable, _parse_error
|
||||
from gitea_runner_manager.config import (
|
||||
BRANCH_PROTECTION_CONFIG,
|
||||
DEFAULT_PER_PAGE,
|
||||
@@ -25,6 +25,15 @@ def _mock_response(json_data: object | None = None, raise_on_status: bool = Fals
|
||||
return mock
|
||||
|
||||
|
||||
def _mock_http_error(status_code: int, message: str = "") -> requests.HTTPError:
|
||||
"""Create an HTTPError with a proper response attached (for _parse_error)."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.json.return_value = {"message": message or str(status_code)}
|
||||
err = requests.HTTPError(f"{status_code} {message}", response=resp)
|
||||
return err
|
||||
|
||||
|
||||
class TestParseError:
|
||||
def test_json_parse_fallback(self) -> None:
|
||||
mock_response = MagicMock()
|
||||
@@ -202,16 +211,17 @@ class TestGiteaClient:
|
||||
)
|
||||
|
||||
def test_get_commit_status(self) -> None:
|
||||
"""Uses combined status endpoint (/status, not /statuses)."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response([{"context": "CI / quality", "status": "success"}])
|
||||
return_value=_mock_response({"statuses": [{"context": "CI / quality", "status": "success"}]})
|
||||
)
|
||||
|
||||
result = client.get_commit_status("abc123")
|
||||
assert result == [{"context": "CI / quality", "status": "success"}]
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/commits/abc123/statuses",
|
||||
"https://git.example.com/repos/owner/repo/commits/abc123/status",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
@@ -310,6 +320,19 @@ class TestGiteaClient:
|
||||
json={"event": "COMMENT", "body": "Looks good"},
|
||||
)
|
||||
|
||||
def test_create_review_approve_maps_to_approved(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"id": 44, "state": "APPROVED"}))
|
||||
|
||||
result = client.create_review(7, event="APPROVE", body="Good work")
|
||||
assert result["id"] == 44
|
||||
client._session.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://git.example.com/repos/owner/repo/pulls/7/reviews",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json={"event": "APPROVED", "body": "Good work"},
|
||||
)
|
||||
|
||||
def test_create_review_with_inline_comments(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"id": 43}))
|
||||
@@ -350,6 +373,115 @@ class TestGiteaClient:
|
||||
json={"tag_name": "v1.0.0", "name": "v1.0.0", "body": "", "draft": False, "prerelease": False},
|
||||
)
|
||||
|
||||
def test_get_release_by_tag_found(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"id": 1, "tag_name": "v1.0.0"}))
|
||||
result = client.get_release_by_tag("v1.0.0")
|
||||
assert result is not None
|
||||
assert result["id"] == 1
|
||||
|
||||
def test_get_release_by_tag_not_found(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.side_effect = requests.HTTPError("404")
|
||||
mock_resp.status_code = 404
|
||||
client._session.request = MagicMock(return_value=mock_resp)
|
||||
result = client.get_release_by_tag("v9.9.9")
|
||||
assert result is None
|
||||
|
||||
def test_create_release_idempotent_existing(self) -> None:
|
||||
"""If release already exists, should return it without creating a new one."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
existing_response = _mock_response({"id": 42, "tag_name": "v1.0.0"})
|
||||
client._session.request = MagicMock(return_value=existing_response)
|
||||
result = client.create_release_idempotent("v1.0.0")
|
||||
assert result["id"] == 42
|
||||
# Should only call GET (check), not POST (create)
|
||||
assert client._session.request.call_count == 1
|
||||
assert client._session.request.call_args[0][0] == "GET"
|
||||
|
||||
def test_create_release_idempotent_new(self) -> None:
|
||||
"""If release doesn't exist, should create it."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
not_found_resp = MagicMock()
|
||||
not_found_resp.raise_for_status.side_effect = requests.HTTPError("404")
|
||||
not_found_resp.status_code = 404
|
||||
create_resp = _mock_response({"id": 1, "tag_name": "v1.0.0"})
|
||||
client._session.request = MagicMock(side_effect=[not_found_resp, create_resp])
|
||||
result = client.create_release_idempotent("v1.0.0")
|
||||
assert result["id"] == 1
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_request_retries_on_429(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should retry on 429 rate limit with exponential backoff."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
rate_limited = MagicMock()
|
||||
rate_limited.raise_for_status.side_effect = _mock_http_error(429, "rate limited")
|
||||
success = _mock_response({"ok": True})
|
||||
client._session.request = MagicMock(side_effect=[rate_limited, rate_limited, success])
|
||||
result = client._request("GET", "/test")
|
||||
assert result.json() == {"ok": True}
|
||||
assert client._session.request.call_count == 3
|
||||
assert mock_sleep.call_count == 2
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_request_retries_on_503(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should retry on 503 service unavailable."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
unavailable = MagicMock()
|
||||
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
|
||||
success = _mock_response({"ok": True})
|
||||
client._session.request = MagicMock(side_effect=[unavailable, success])
|
||||
result = client._request("GET", "/test")
|
||||
assert result.json() == {"ok": True}
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_request_no_retry_on_404(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should NOT retry on 404 — it's not a transient error."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
not_found = MagicMock()
|
||||
not_found.raise_for_status.side_effect = _mock_http_error(404, "not found")
|
||||
client._session.request = MagicMock(return_value=not_found)
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client._request("GET", "/test")
|
||||
assert exc_info.value.status == 404
|
||||
assert client._session.request.call_count == 1
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_request_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should retry on connection errors."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
success = _mock_response({"ok": True})
|
||||
client._session.request = MagicMock(side_effect=[requests.ConnectionError("refused"), success])
|
||||
result = client._request("GET", "/test")
|
||||
assert result.json() == {"ok": True}
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_request_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should raise APIError after max retries on persistent 503."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
unavailable = MagicMock()
|
||||
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
|
||||
client._session.request = MagicMock(return_value=unavailable)
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client._request("GET", "/test")
|
||||
assert exc_info.value.status == 503
|
||||
assert client._session.request.call_count == 3 # MAX_RETRIES
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_request_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should raise APIError after max retries on persistent connection errors."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(side_effect=requests.ConnectionError("refused"))
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client._request("GET", "/test")
|
||||
assert exc_info.value.status == 0
|
||||
assert client._session.request.call_count == 3 # MAX_RETRIES
|
||||
|
||||
|
||||
class TestVikunjaClient:
|
||||
def test_init_sets_headers(self) -> None:
|
||||
@@ -426,7 +558,9 @@ class TestVikunjaClient:
|
||||
|
||||
def test_http_error_raises_api_error(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(return_value=_mock_response(raise_on_status=True))
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.side_effect = _mock_http_error(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
|
||||
client._session.request = MagicMock(return_value=mock_resp)
|
||||
|
||||
with pytest.raises(APIError):
|
||||
client.list_tasks()
|
||||
@@ -435,8 +569,73 @@ class TestVikunjaClient:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
err = requests.HTTPError("connection failed")
|
||||
err.response = None # type: ignore[assignment]
|
||||
client._session.request = MagicMock(side_effect=err)
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.side_effect = err
|
||||
client._session.request = MagicMock(return_value=mock_resp)
|
||||
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client.list_tasks()
|
||||
assert "connection failed" in str(exc_info.value)
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_vikunja_retries_on_503(self, mock_sleep: MagicMock) -> None:
|
||||
"""VikunjaClient should also retry on 503."""
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
unavailable = MagicMock()
|
||||
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
|
||||
success = _mock_response([{"id": 1}])
|
||||
client._session.request = MagicMock(side_effect=[unavailable, success])
|
||||
result = client.list_tasks()
|
||||
assert len(result) == 1
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_vikunja_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
|
||||
"""VikunjaClient should retry on connection errors."""
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
success = _mock_response([{"id": 1}])
|
||||
client._session.request = MagicMock(side_effect=[requests.ConnectionError("refused"), success])
|
||||
result = client.list_tasks()
|
||||
assert len(result) == 1
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_vikunja_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||
"""VikunjaClient should raise APIError after max retries on persistent 503."""
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
unavailable = MagicMock()
|
||||
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
|
||||
client._session.request = MagicMock(return_value=unavailable)
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client.list_tasks()
|
||||
assert exc_info.value.status == 503
|
||||
assert client._session.request.call_count == 3 # MAX_RETRIES
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_vikunja_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||
"""VikunjaClient should raise APIError after max retries on persistent connection errors."""
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(side_effect=requests.ConnectionError("refused"))
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client.list_tasks()
|
||||
assert exc_info.value.status == 0
|
||||
assert client._session.request.call_count == 3 # MAX_RETRIES
|
||||
|
||||
|
||||
class TestIsRetryable:
|
||||
def test_connection_error_is_retryable(self) -> None:
|
||||
assert _is_retryable(requests.ConnectionError("refused")) is True
|
||||
|
||||
def test_timeout_is_retryable(self) -> None:
|
||||
assert _is_retryable(requests.Timeout("timed out")) is True
|
||||
|
||||
def test_429_is_retryable(self) -> None:
|
||||
err = _mock_http_error(429, "rate limited")
|
||||
assert _is_retryable(err) is True
|
||||
|
||||
def test_404_is_not_retryable(self) -> None:
|
||||
err = _mock_http_error(404, "not found")
|
||||
assert _is_retryable(err) is False
|
||||
|
||||
def test_generic_exception_is_not_retryable(self) -> None:
|
||||
assert _is_retryable(ValueError("oops")) is False
|
||||
|
||||
@@ -1,566 +0,0 @@
|
||||
"""Unit tests for scripts/auto_merge.py."""
|
||||
|
||||
import http
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from gitea_runner_manager.config import CONVENTIONAL_RE, TASK_ID_RE
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from scripts.auto_merge import (
|
||||
PR_TITLE_RE,
|
||||
extract_conventional_msg,
|
||||
extract_task_id,
|
||||
has_approval_review,
|
||||
has_ready_to_merge_label,
|
||||
main,
|
||||
validate_pr_title,
|
||||
validate_pr_title_matches_vikunja,
|
||||
wait_for_ci,
|
||||
)
|
||||
|
||||
CI_SUCCESS = "success"
|
||||
CI_PENDING = "pending"
|
||||
CI_FAILURE = "failure"
|
||||
|
||||
|
||||
def _status(context: str, status: str, updated_at: str = "2026-01-01T00:00:00Z") -> dict[str, str]:
|
||||
return {"context": context, "status": status, "updated_at": updated_at}
|
||||
|
||||
|
||||
def _commit(message: str) -> dict[str, dict[str, str]]:
|
||||
return {"commit": {"message": message}}
|
||||
|
||||
|
||||
class TestRegexes:
|
||||
def test_task_id_re_matches(self) -> None:
|
||||
assert TASK_ID_RE.search("GRM-19-fix-bug")
|
||||
assert TASK_ID_RE.search("feature/GRM-42")
|
||||
|
||||
def test_task_id_re_no_match(self) -> None:
|
||||
assert not TASK_ID_RE.search("feature-no-id")
|
||||
|
||||
def test_conventional_re_matches(self) -> None:
|
||||
assert CONVENTIONAL_RE.match("feat: add feature")
|
||||
assert CONVENTIONAL_RE.match("fix(api): handle timeout")
|
||||
|
||||
def test_conventional_re_rejects(self) -> None:
|
||||
assert not CONVENTIONAL_RE.match("random message")
|
||||
assert not CONVENTIONAL_RE.match("feat:")
|
||||
|
||||
def test_pr_title_re_matches(self) -> None:
|
||||
assert PR_TITLE_RE.match("GRM-19: Some task title")
|
||||
assert PR_TITLE_RE.match("GRM-42: double space title")
|
||||
|
||||
def test_pr_title_re_rejects(self) -> None:
|
||||
assert not PR_TITLE_RE.match("fix: resolve timeout")
|
||||
assert not PR_TITLE_RE.match("GRM-19:No space after colon")
|
||||
assert not PR_TITLE_RE.match("random message")
|
||||
|
||||
|
||||
class TestExtractTaskId:
|
||||
def test_extracts_from_branch(self) -> None:
|
||||
assert extract_task_id("GRM-19-fix-bug") == "GRM-19"
|
||||
|
||||
def test_extracts_from_feature_branch(self) -> None:
|
||||
assert extract_task_id("feature/GRM-42-add-x") == "GRM-42"
|
||||
|
||||
def test_returns_empty_when_missing(self) -> None:
|
||||
assert extract_task_id("feature-no-id") == ""
|
||||
|
||||
|
||||
class TestValidatePrTitle:
|
||||
def test_valid_title_passes(self) -> None:
|
||||
validate_pr_title("GRM-19: Some task title", "GRM-19")
|
||||
|
||||
def test_valid_title_with_scope_passes(self) -> None:
|
||||
validate_pr_title("GRM-42: Add --url option", "GRM-42")
|
||||
|
||||
def test_invalid_format_raises(self) -> None:
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
validate_pr_title("random message", "GRM-19")
|
||||
assert "GRM-N" in str(exc.value)
|
||||
|
||||
def test_invalid_format_conventional_raises(self) -> None:
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
validate_pr_title("fix: resolve timeout", "GRM-19")
|
||||
assert "GRM-N" in str(exc.value)
|
||||
|
||||
def test_task_id_mismatch_raises(self) -> None:
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
validate_pr_title("GRM-42: Some task title", "GRM-19")
|
||||
assert "mismatch" in str(exc.value)
|
||||
|
||||
|
||||
class TestExtractConventionalMsg:
|
||||
def test_returns_newest_conventional(self) -> None:
|
||||
"""Iterates in reverse — picks the newest conventional commit."""
|
||||
commits = [
|
||||
_commit("fix: resolve timeout"),
|
||||
_commit("random message"),
|
||||
_commit("feat: add thing"),
|
||||
]
|
||||
assert extract_conventional_msg(commits) == "feat: add thing"
|
||||
|
||||
def test_falls_back_to_newest_commit(self) -> None:
|
||||
commits = [
|
||||
_commit("another random"),
|
||||
_commit("random message"),
|
||||
]
|
||||
assert extract_conventional_msg(commits) == "random message"
|
||||
|
||||
def test_uses_first_line_only(self) -> None:
|
||||
commits = [_commit("fix: resolve timeout\n\nBody text here")]
|
||||
assert extract_conventional_msg(commits) == "fix: resolve timeout"
|
||||
|
||||
def test_empty_commits_returns_empty(self) -> None:
|
||||
assert extract_conventional_msg([]) == ""
|
||||
|
||||
def test_commit_with_scope(self) -> None:
|
||||
commits = [_commit("feat(api): new endpoint")]
|
||||
assert extract_conventional_msg(commits) == "feat(api): new endpoint"
|
||||
|
||||
def test_missing_commit_key(self) -> None:
|
||||
commits = [{}] # type: ignore[list-item]
|
||||
assert extract_conventional_msg(commits) == ""
|
||||
|
||||
|
||||
class TestHasReadyToMergeLabel:
|
||||
def test_label_present(self) -> None:
|
||||
client = MagicMock()
|
||||
client.get_pr_labels.return_value = [{"name": "bug"}, {"name": "ready-to-merge"}]
|
||||
assert has_ready_to_merge_label(client, "5") is True
|
||||
|
||||
def test_label_absent(self) -> None:
|
||||
client = MagicMock()
|
||||
client.get_pr_labels.return_value = [{"name": "bug"}]
|
||||
assert has_ready_to_merge_label(client, "5") is False
|
||||
|
||||
def test_no_labels(self) -> None:
|
||||
client = MagicMock()
|
||||
client.get_pr_labels.return_value = []
|
||||
assert has_ready_to_merge_label(client, "5") is False
|
||||
|
||||
|
||||
class TestHasApprovalReview:
|
||||
def test_has_approved(self) -> None:
|
||||
client = MagicMock()
|
||||
client.get_pr_reviews.return_value = [{"state": "APPROVED"}, {"state": "COMMENT"}]
|
||||
assert has_approval_review(client, "5") is True
|
||||
|
||||
def test_no_approved(self) -> None:
|
||||
client = MagicMock()
|
||||
client.get_pr_reviews.return_value = [{"state": "COMMENT"}, {"state": "REQUEST_CHANGES"}]
|
||||
assert has_approval_review(client, "5") is False
|
||||
|
||||
def test_no_reviews(self) -> None:
|
||||
client = MagicMock()
|
||||
client.get_pr_reviews.return_value = []
|
||||
assert has_approval_review(client, "5") is False
|
||||
|
||||
|
||||
class TestValidatePrTitleMatchesVikunja:
|
||||
@patch("scripts.auto_merge.get_vikunja_task_title", return_value="")
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_no_vikunja_token_skips(self, mock_get: MagicMock) -> None:
|
||||
"""Should skip validation when VIKUNJA_TOKEN is not set."""
|
||||
validate_pr_title_matches_vikunja("GRM-19: Some title", "GRM-19")
|
||||
|
||||
@patch("scripts.auto_merge.get_vikunja_task_title", return_value="Some task title")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
def test_matching_title_passes(self, mock_get: MagicMock) -> None:
|
||||
validate_pr_title_matches_vikunja("GRM-19: Some task title", "GRM-19")
|
||||
|
||||
@patch("scripts.auto_merge.get_vikunja_task_title", return_value="Some task title")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
def test_mismatched_title_raises(self, mock_get: MagicMock) -> None:
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
validate_pr_title_matches_vikunja("GRM-19: Different title", "GRM-19")
|
||||
assert "does not match" in str(exc.value)
|
||||
|
||||
@patch("scripts.auto_merge.get_vikunja_task_title", return_value="")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
def test_task_not_found_skips(self, mock_get: MagicMock) -> None:
|
||||
"""Should skip validation when Vikunja task is not found."""
|
||||
validate_pr_title_matches_vikunja("GRM-19: Some title", "GRM-19")
|
||||
|
||||
|
||||
class TestGetVikunjaTaskTitle:
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_no_token_returns_empty(self) -> None:
|
||||
from scripts.auto_merge import get_vikunja_task_title
|
||||
|
||||
assert get_vikunja_task_title("GRM-19") == ""
|
||||
|
||||
@patch("scripts.auto_merge.VikunjaClient")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
def test_finds_task(self, mock_client_cls: MagicMock) -> None:
|
||||
from scripts.auto_merge import get_vikunja_task_title
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
{"identifier": "GRM-19", "title": "Some task title"},
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
assert get_vikunja_task_title("GRM-19") == "Some task title"
|
||||
|
||||
@patch("scripts.auto_merge.VikunjaClient")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
def test_task_not_found_returns_empty(self, mock_client_cls: MagicMock) -> None:
|
||||
from scripts.auto_merge import get_vikunja_task_title
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
{"identifier": "GRM-20", "title": "Other task"},
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
assert get_vikunja_task_title("GRM-19") == ""
|
||||
|
||||
@patch("scripts.auto_merge.VikunjaClient")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
def test_paginates_to_find_task(self, mock_client_cls: MagicMock) -> None:
|
||||
from scripts.auto_merge import get_vikunja_task_title
|
||||
|
||||
mock_client = MagicMock()
|
||||
# First page: full page of 50 tasks, no match; second page: match
|
||||
page1 = [{"identifier": f"GRM-{i}", "title": f"task {i}"} for i in range(50)]
|
||||
page2 = [{"identifier": "GRM-99", "title": "Found task"}]
|
||||
mock_client.list_project_tasks.side_effect = [page1, page2]
|
||||
mock_client_cls.return_value = mock_client
|
||||
assert get_vikunja_task_title("GRM-99") == "Found task"
|
||||
|
||||
@patch("scripts.auto_merge.VikunjaClient")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
def test_empty_pages_returns_empty(self, mock_client_cls: MagicMock) -> None:
|
||||
from scripts.auto_merge import get_vikunja_task_title
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = []
|
||||
mock_client_cls.return_value = mock_client
|
||||
assert get_vikunja_task_title("GRM-19") == ""
|
||||
|
||||
|
||||
class TestWaitForCi:
|
||||
def test_all_pass_immediately(self) -> None:
|
||||
client = MagicMock()
|
||||
client.get_commit_status.return_value = [
|
||||
_status("CI / quality (pull_request)", CI_SUCCESS),
|
||||
_status("CI / molecule-tests (0) (pull_request)", CI_SUCCESS),
|
||||
]
|
||||
assert wait_for_ci(client, "abc123", max_wait=10) is True
|
||||
|
||||
def test_waits_then_passes(self) -> None:
|
||||
client = MagicMock()
|
||||
client.get_commit_status.side_effect = [
|
||||
[_status("CI / quality (pull_request)", CI_PENDING)],
|
||||
[_status("CI / quality (pull_request)", CI_SUCCESS)],
|
||||
]
|
||||
with patch("scripts.auto_merge.time.sleep"):
|
||||
assert wait_for_ci(client, "abc123", max_wait=10, poll_interval=5) is True
|
||||
|
||||
def test_fails_on_failed_check(self) -> None:
|
||||
client = MagicMock()
|
||||
client.get_commit_status.return_value = [
|
||||
_status("CI / quality (pull_request)", CI_SUCCESS),
|
||||
_status("CI / molecule-tests (0) (pull_request)", CI_FAILURE),
|
||||
]
|
||||
assert wait_for_ci(client, "abc123", max_wait=10) is False
|
||||
|
||||
def test_times_out(self) -> None:
|
||||
client = MagicMock()
|
||||
client.get_commit_status.return_value = [
|
||||
_status("CI / quality (pull_request)", CI_PENDING),
|
||||
]
|
||||
with patch("scripts.auto_merge.time.sleep"):
|
||||
assert wait_for_ci(client, "abc123", max_wait=5) is False
|
||||
|
||||
def test_no_statuses_waits(self) -> None:
|
||||
client = MagicMock()
|
||||
client.get_commit_status.side_effect = [
|
||||
[],
|
||||
[_status("CI / quality (pull_request)", CI_SUCCESS)],
|
||||
]
|
||||
with patch("scripts.auto_merge.time.sleep"):
|
||||
assert wait_for_ci(client, "abc123", max_wait=10, poll_interval=5) is True
|
||||
|
||||
def test_ignores_non_ci_contexts(self) -> None:
|
||||
client = MagicMock()
|
||||
client.get_commit_status.return_value = [
|
||||
_status("Auto-merge / merge (pull_request)", CI_PENDING),
|
||||
_status("CI / quality (pull_request)", CI_SUCCESS),
|
||||
]
|
||||
assert wait_for_ci(client, "abc123", max_wait=10) is True
|
||||
|
||||
def test_deduplicates_by_latest(self) -> None:
|
||||
client = MagicMock()
|
||||
client.get_commit_status.return_value = [
|
||||
_status("CI / quality (pull_request)", CI_PENDING, "2026-01-01T00:00:00Z"),
|
||||
_status("CI / quality (pull_request)", CI_SUCCESS, "2026-01-01T00:01:00Z"),
|
||||
]
|
||||
assert wait_for_ci(client, "abc123", max_wait=10) is True
|
||||
|
||||
def test_only_non_ci_contexts_waits_then_ci_appears(self) -> None:
|
||||
client = MagicMock()
|
||||
client.get_commit_status.side_effect = [
|
||||
[_status("Auto-merge / merge (pull_request)", CI_PENDING)],
|
||||
[_status("CI / quality (pull_request)", CI_SUCCESS)],
|
||||
]
|
||||
with patch("scripts.auto_merge.time.sleep"):
|
||||
assert wait_for_ci(client, "abc123", max_wait=10, poll_interval=5) is True
|
||||
|
||||
|
||||
def _mock_pr(sha: str = "abc123def456") -> dict[str, object]:
|
||||
return {"head": {"sha": sha}}
|
||||
|
||||
|
||||
def _mock_ci_passing() -> list[dict[str, str]]:
|
||||
return [_status("CI / quality (pull_request)", CI_SUCCESS)]
|
||||
|
||||
|
||||
def _mock_commits() -> list[dict[str, dict[str, str]]]:
|
||||
return [_commit("fix: resolve timeout")]
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.auto_merge.has_approval_review", return_value=True)
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
def test_successful_flow_with_label_arg(
|
||||
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
|
||||
) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_client.get_pr.return_value = _mock_pr()
|
||||
mock_client.get_commit_status.return_value = _mock_ci_passing()
|
||||
mock_client.get_pr_commits.return_value = _mock_commits()
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["GRM-19-fix-bug", "GRM-19: Some task title", "owner/repo", "7", "ready-to-merge"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "squash-merged" in result.output
|
||||
mock_client.merge_pr.assert_called_once_with("7", "GRM-19 fix: resolve timeout")
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.auto_merge.has_approval_review", return_value=True)
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
def test_successful_flow_label_fallback(
|
||||
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
|
||||
) -> None:
|
||||
"""Label not passed via arg, but PR has ready-to-merge via API."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
|
||||
mock_client.get_pr.return_value = _mock_pr()
|
||||
mock_client.get_commit_status.return_value = _mock_ci_passing()
|
||||
mock_client.get_pr_commits.return_value = _mock_commits()
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["GRM-19-fix-bug", "GRM-19: Some task title", "owner/repo", "7"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "squash-merged" in result.output
|
||||
mock_client.merge_pr.assert_called_once_with("7", "GRM-19 fix: resolve timeout")
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.auto_merge.has_approval_review", return_value=True)
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
def test_wrong_label_skips_merge(
|
||||
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
|
||||
) -> None:
|
||||
"""Label is not ready-to-merge and PR doesn't have it via API either."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_labels.return_value = [{"name": "bug"}]
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["GRM-19-fix-bug", "GRM-19: Some task title", "owner/repo", "7", "bug"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "skipping" in result.output
|
||||
mock_client.merge_pr.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.auto_merge.has_approval_review", return_value=True)
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
def test_empty_label_falls_back_to_api(
|
||||
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
|
||||
) -> None:
|
||||
"""Gitea Actions doesn't populate label name, but API shows ready-to-merge."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
|
||||
mock_client.get_pr.return_value = _mock_pr()
|
||||
mock_client.get_commit_status.return_value = _mock_ci_passing()
|
||||
mock_client.get_pr_commits.return_value = _mock_commits()
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["GRM-19-fix-bug", "GRM-19: Some task title", "owner/repo", "7", ""],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "squash-merged" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
|
||||
def test_missing_token_exits(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["branch", "title", "owner/repo", "1"])
|
||||
assert result.exit_code == 1
|
||||
assert "REPO_TOKEN" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
def test_missing_task_id_exits(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["feature-no-id", "GRM-19: bug", "owner/repo", "1"])
|
||||
assert result.exit_code == 1
|
||||
assert "task ID" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
def test_invalid_pr_title_exits(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["GRM-19-fix", "random title", "owner/repo", "1"])
|
||||
assert result.exit_code == 1
|
||||
assert "GRM-N" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
def test_pr_title_task_id_mismatch_exits(self, mock_client_cls: MagicMock) -> None:
|
||||
"""PR title has a different task ID than the branch."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["GRM-19-fix", "GRM-42: Some task title", "owner/repo", "1"])
|
||||
assert result.exit_code == 1
|
||||
assert "mismatch" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.auto_merge.has_approval_review", return_value=False)
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
def test_no_approval_review_blocks_merge(
|
||||
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
|
||||
) -> None:
|
||||
"""No APPROVE review — merge should be blocked."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: Some task title", "owner/repo", "1"])
|
||||
assert result.exit_code == 1
|
||||
assert "APPROVE review" in result.output
|
||||
mock_client.merge_pr.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.auto_merge.has_approval_review", return_value=True)
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
def test_empty_commits_exits(
|
||||
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
|
||||
) -> None:
|
||||
"""PR has no commits — cannot extract conventional message."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
|
||||
mock_client.get_pr.return_value = _mock_pr()
|
||||
mock_client.get_commit_status.return_value = _mock_ci_passing()
|
||||
mock_client.get_pr_commits.return_value = []
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: Some task title", "owner/repo", "1"])
|
||||
assert result.exit_code == 1
|
||||
assert "conventional commit" in result.output
|
||||
mock_client.merge_pr.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.auto_merge.has_approval_review", return_value=True)
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
def test_merge_pr_failure_raises_click(
|
||||
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
|
||||
) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
|
||||
mock_client.get_pr.return_value = _mock_pr()
|
||||
mock_client.get_commit_status.return_value = _mock_ci_passing()
|
||||
mock_client.get_pr_commits.return_value = _mock_commits()
|
||||
mock_client.merge_pr.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: Some task title", "owner/repo", "1"])
|
||||
assert result.exit_code == 1
|
||||
assert "HTTP" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.auto_merge.has_approval_review", return_value=True)
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
def test_merge_pr_json_parse_failure(
|
||||
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
|
||||
) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
|
||||
mock_client.get_pr.return_value = _mock_pr()
|
||||
mock_client.get_commit_status.return_value = _mock_ci_passing()
|
||||
mock_client.get_pr_commits.return_value = _mock_commits()
|
||||
mock_client.merge_pr.side_effect = APIError(http.HTTPStatus.BAD_GATEWAY, "bad gateway")
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: Some task title", "owner/repo", "1"])
|
||||
assert result.exit_code == 1
|
||||
assert str(http.HTTPStatus.BAD_GATEWAY) in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.auto_merge.has_approval_review", return_value=True)
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
def test_ci_failure_blocks_merge(
|
||||
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
|
||||
) -> None:
|
||||
"""CI checks fail — merge should not be attempted."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
|
||||
mock_client.get_pr.return_value = _mock_pr()
|
||||
mock_client.get_commit_status.return_value = [
|
||||
_status("CI / quality (pull_request)", CI_FAILURE),
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: Some task title", "owner/repo", "1"])
|
||||
assert result.exit_code == 1
|
||||
assert "CI checks did not pass" in result.output
|
||||
mock_client.merge_pr.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("scripts.auto_merge.has_approval_review", return_value=True)
|
||||
@patch("scripts.auto_merge.GiteaClient")
|
||||
def test_no_sha_proceeds_without_wait(
|
||||
self, mock_client_cls: MagicMock, mock_approval: MagicMock, mock_vikunja: MagicMock
|
||||
) -> None:
|
||||
"""PR head SHA missing — should proceed without waiting."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_labels.return_value = [{"name": "ready-to-merge"}]
|
||||
mock_client.get_pr.return_value = {"head": {}}
|
||||
mock_client.get_pr_commits.return_value = _mock_commits()
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["GRM-19-fix", "GRM-19: Some task title", "owner/repo", "1"])
|
||||
assert result.exit_code == 0
|
||||
assert "squash-merged" in result.output
|
||||
@@ -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)
|
||||
@@ -4,6 +4,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from gitea_runner_manager import __version__
|
||||
from gitea_runner_manager.cli import cli
|
||||
|
||||
|
||||
@@ -12,7 +13,7 @@ class TestCLI:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--version"])
|
||||
assert result.exit_code == 0
|
||||
assert "0.1.0" in result.output
|
||||
assert __version__ in result.output
|
||||
|
||||
@patch("gitea_runner_manager.cli.RunnerManager")
|
||||
def test_install(self, mock_manager_class: MagicMock) -> None:
|
||||
|
||||
@@ -6,7 +6,6 @@ from gitea_runner_manager.config import (
|
||||
DEFAULT_PER_PAGE,
|
||||
DEFAULT_TIMEOUT,
|
||||
GITEA_API_URL,
|
||||
LABEL_CONFIG,
|
||||
REPO_NAME,
|
||||
REPO_OWNER,
|
||||
TASK_ID_RE,
|
||||
@@ -46,14 +45,12 @@ class TestConfigConstants:
|
||||
|
||||
def test_branch_protection_config(self) -> None:
|
||||
assert BRANCH_PROTECTION_CONFIG["branch_name"] == "master"
|
||||
assert BRANCH_PROTECTION_CONFIG["enable_push"] is False
|
||||
assert BRANCH_PROTECTION_CONFIG["enable_push"] is True
|
||||
assert BRANCH_PROTECTION_CONFIG["enable_push_whitelist"] is True
|
||||
assert "emil" in BRANCH_PROTECTION_CONFIG["push_whitelist_usernames"]
|
||||
assert BRANCH_PROTECTION_CONFIG["required_approvals"] == 0
|
||||
contexts = BRANCH_PROTECTION_CONFIG["status_check_contexts"]
|
||||
assert isinstance(contexts, list)
|
||||
assert len(contexts) == 4
|
||||
assert "CI / quality (pull_request)" in contexts
|
||||
assert any("molecule-tests" in c for c in contexts)
|
||||
|
||||
def test_label_config(self) -> None:
|
||||
assert LABEL_CONFIG["name"] == "ready-to-merge"
|
||||
assert LABEL_CONFIG["color"] == "2ecc71"
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
"""Unit tests for scripts/configure_repo.py."""
|
||||
|
||||
import http
|
||||
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_handle_http_error_403(self) -> None:
|
||||
err = APIError(http.HTTPStatus.FORBIDDEN, "Forbidden")
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
_handle_http_error(err)
|
||||
msg = str(exc.value)
|
||||
assert "admin rights" in msg
|
||||
assert "Settings → Branches" in msg
|
||||
|
||||
def test_handle_http_error_other(self) -> None:
|
||||
err = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "Internal Server Error")
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
_handle_http_error(err)
|
||||
assert str(http.HTTPStatus.INTERNAL_SERVER_ERROR) in str(exc.value)
|
||||
|
||||
def test_handle_http_error_json_parse_fails(self) -> None:
|
||||
err = APIError(http.HTTPStatus.BAD_GATEWAY, "bad gateway")
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
_handle_http_error(err)
|
||||
assert str(http.HTTPStatus.BAD_GATEWAY) in str(exc.value)
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_main_missing_token(self) -> None:
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
main()
|
||||
assert "REPO_TOKEN" in str(exc.value)
|
||||
|
||||
def test_main_success(self) -> None:
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
with patch("scripts.configure_repo.GiteaClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
main()
|
||||
|
||||
mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
|
||||
mock_client.ensure_label.assert_called_once()
|
||||
mock_client.update_repo_settings.assert_called_once_with(REPO_SETTINGS_CONFIG)
|
||||
|
||||
def test_main_label_already_exists(self) -> None:
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
with patch("scripts.configure_repo.GiteaClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.ensure_label.return_value = None
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
main()
|
||||
|
||||
mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
|
||||
mock_client.ensure_label.assert_called_once()
|
||||
mock_client.update_repo_settings.assert_called_once_with(REPO_SETTINGS_CONFIG)
|
||||
|
||||
def test_main_api_error(self) -> None:
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
with patch("scripts.configure_repo.GiteaClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.ensure_branch_protection.side_effect = APIError(http.HTTPStatus.FORBIDDEN, "Forbidden")
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
main()
|
||||
assert "HTTP" in str(exc.value)
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
with patch("scripts.configure_repo.GiteaClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
import scripts.configure_repo as cr
|
||||
|
||||
with open(cr.__file__) as f:
|
||||
source = f.read()
|
||||
# Remove __main__ block so exec doesn't call main() before we inject the mock
|
||||
source = source.replace('if __name__ == "__main__":\n main()\n', "")
|
||||
namespace = dict(cr.__dict__)
|
||||
exec(compile(source, cr.__file__, "exec"), namespace)
|
||||
namespace["GiteaClient"] = mock_client_cls
|
||||
namespace["main"]()
|
||||
mock_client.ensure_branch_protection.assert_called_once_with("master", BRANCH_PROTECTION_CONFIG)
|
||||
mock_client.update_repo_settings.assert_called_once_with(REPO_SETTINGS_CONFIG)
|
||||
@@ -1,202 +0,0 @@
|
||||
"""Unit tests for scripts/distribute_molecule.py."""
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
|
||||
from scripts.distribute_molecule import (
|
||||
MOLECULE_ROOT,
|
||||
PLATFORMS,
|
||||
TestPair,
|
||||
build_pairs,
|
||||
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.distribute_molecule import cli
|
||||
|
||||
root = tmp_path / "molecule"
|
||||
(root / "alpha").mkdir(parents=True)
|
||||
(root / "beta").mkdir(parents=True)
|
||||
with patch("scripts.distribute_molecule.MOLECULE_ROOT", root):
|
||||
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.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.distribute_molecule import cli
|
||||
|
||||
root = tmp_path / "molecule"
|
||||
for s in ["a", "b", "c"]:
|
||||
(root / s).mkdir(parents=True)
|
||||
with patch("scripts.distribute_molecule.MOLECULE_ROOT", root):
|
||||
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.distribute_molecule import cli
|
||||
|
||||
root = tmp_path / "molecule"
|
||||
(root / "alpha").mkdir(parents=True)
|
||||
with patch("scripts.distribute_molecule.MOLECULE_ROOT", root):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--runner-index", "0", "--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
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
import scripts.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,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,428 +0,0 @@
|
||||
"""Unit tests for scripts/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.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.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.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.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.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.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.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.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("scripts.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
|
||||
patch("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.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.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("scripts.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("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.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("scripts.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
|
||||
patch("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.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("scripts.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("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.molecule_ci_guard.POLL_INTERVAL", 0.01),
|
||||
patch("scripts.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||
patch("scripts.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||
patch("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.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,102 +0,0 @@
|
||||
"""Unit tests for scripts/notify_failure.py."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from scripts.notify_failure import main
|
||||
|
||||
|
||||
class TestNotifyFailure:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.notify_failure.GiteaClient")
|
||||
def test_creates_issue_with_labels(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_labels.return_value = [{"id": 5, "name": "bug"}]
|
||||
mock_client.create_issue.return_value = {"id": 42}
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
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_client.create_issue.assert_called_once()
|
||||
call_kwargs = mock_client.create_issue.call_args
|
||||
assert call_kwargs.kwargs["labels"] == [5]
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.notify_failure.GiteaClient")
|
||||
def test_creates_issue_without_bug_label(self, mock_client_cls: MagicMock) -> None:
|
||||
"""When 'bug' label doesn't exist, create issue without labels."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_labels.return_value = [{"id": 1, "name": "enhancement"}]
|
||||
mock_client.create_issue.return_value = {"id": 43}
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
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_client.create_issue.assert_called_once()
|
||||
call_kwargs = mock_client.create_issue.call_args
|
||||
assert call_kwargs.kwargs.get("labels") is None
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.notify_failure.GiteaClient")
|
||||
def test_api_error_raises(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_labels.return_value = []
|
||||
mock_client.create_issue.side_effect = APIError(403, "forbidden")
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"--repo",
|
||||
"owner/repo",
|
||||
"--run-id",
|
||||
"125",
|
||||
"--workflow",
|
||||
"release",
|
||||
"--commit",
|
||||
"abc",
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 1
|
||||
assert "403" 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 == 1
|
||||
assert "REPO_TOKEN" in result.output
|
||||
@@ -1,177 +0,0 @@
|
||||
"""Unit tests for scripts/post_merge.py."""
|
||||
|
||||
import http
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from scripts.post_merge import (
|
||||
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:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = []
|
||||
with pytest.raises(click.ClickException) as exc:
|
||||
resolve_task_id(mock_client, "GRM-99")
|
||||
assert "Could not find" in str(exc.value)
|
||||
|
||||
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) as exc:
|
||||
resolve_task_id(mock_client, "GRM-99")
|
||||
assert "Could not find" in str(exc.value)
|
||||
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.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.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_skips(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["fix: resolve bug"])
|
||||
assert result.exit_code == 0
|
||||
assert "skipping" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("scripts.post_merge.VikunjaClient")
|
||||
def test_resolve_failure_propagates(self, mock_client_cls: MagicMock) -> None:
|
||||
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 == 1
|
||||
assert "Could not find" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("scripts.post_merge.VikunjaClient")
|
||||
def test_post_comment_failure_raises_click(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
{"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 == 1
|
||||
assert "HTTP" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
@patch("scripts.post_merge.VikunjaClient")
|
||||
def test_mark_done_failure_raises_click(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [
|
||||
{"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 == 1
|
||||
assert "HTTP" in result.output
|
||||
@@ -1,207 +0,0 @@
|
||||
"""Unit tests for scripts/publish.py."""
|
||||
|
||||
import http
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from scripts.publish import (
|
||||
build_package,
|
||||
generate_release_notes,
|
||||
main,
|
||||
publish_to_pypi,
|
||||
)
|
||||
|
||||
|
||||
class TestGenerateReleaseNotes:
|
||||
@patch("scripts.publish.subprocess.run")
|
||||
@patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
||||
def test_generates_from_git_cliff(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="## v1.0.0\n- feat: x")
|
||||
result = generate_release_notes("v1.0.0")
|
||||
assert "## v1.0.0" in result
|
||||
assert "feat: x" in result
|
||||
|
||||
@patch("scripts.publish.subprocess.run")
|
||||
@patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
||||
def test_strips_whitespace(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout=" changelog \n")
|
||||
result = generate_release_notes("v1.0.0")
|
||||
assert result == "changelog"
|
||||
|
||||
@patch("scripts.publish.subprocess.run")
|
||||
@patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
||||
def test_falls_back_on_failure(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stdout="")
|
||||
result = generate_release_notes("v1.0.0")
|
||||
assert "Release v1.0.0" in result
|
||||
assert "CHANGELOG.md" in result
|
||||
|
||||
@patch("scripts.publish.subprocess.run")
|
||||
@patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
||||
def test_falls_back_on_empty_output(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout=" ")
|
||||
result = generate_release_notes("v1.0.0")
|
||||
assert "Release v1.0.0" in result
|
||||
|
||||
@patch("scripts.publish.subprocess.run", side_effect=FileNotFoundError)
|
||||
@patch("scripts.publish.shutil.which", return_value="/usr/local/bin/git-cliff")
|
||||
def test_falls_back_on_file_not_found(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
result = generate_release_notes("v1.0.0")
|
||||
assert "Release v1.0.0" in result
|
||||
assert "CHANGELOG.md" in result
|
||||
|
||||
@patch("scripts.publish.shutil.which", return_value=None)
|
||||
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.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.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.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.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.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.publish.GiteaClient")
|
||||
@patch("scripts.publish.publish_to_pypi")
|
||||
@patch("scripts.publish.build_package")
|
||||
def test_full_flow_with_pypi(
|
||||
self,
|
||||
mock_build: MagicMock,
|
||||
mock_publish: MagicMock,
|
||||
mock_client_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
assert "Gitea release v1.0.0 created" in result.output
|
||||
mock_build.assert_called_once()
|
||||
mock_publish.assert_called_once_with("pypi-tok")
|
||||
mock_client_cls.return_value.create_release.assert_called_once()
|
||||
# Verify release body uses git-cliff notes
|
||||
call_args = mock_client_cls.return_value.create_release.call_args
|
||||
assert call_args.kwargs["body"] == "Release notes"
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True)
|
||||
@patch("scripts.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.publish.GiteaClient")
|
||||
@patch("scripts.publish.build_package")
|
||||
def test_without_pypi(
|
||||
self,
|
||||
mock_build: MagicMock,
|
||||
mock_client_cls: MagicMock,
|
||||
mock_notes: MagicMock,
|
||||
) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 0
|
||||
mock_build.assert_called_once()
|
||||
mock_client_cls.return_value.create_release.assert_called_once()
|
||||
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.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.publish.GiteaClient")
|
||||
@patch("scripts.publish.publish_to_pypi")
|
||||
@patch("scripts.publish.build_package")
|
||||
def test_build_failure_raises_click(
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_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.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.publish.GiteaClient")
|
||||
@patch("scripts.publish.publish_to_pypi")
|
||||
@patch("scripts.publish.build_package")
|
||||
def test_publish_failure_raises_click(
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_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.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.publish.GiteaClient")
|
||||
@patch("scripts.publish.publish_to_pypi")
|
||||
@patch("scripts.publish.build_package")
|
||||
def test_release_failure_raises_click(
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock, mock_notes: MagicMock
|
||||
) -> None:
|
||||
mock_client = MagicMock()
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
|
||||
mock_client.create_release.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 1
|
||||
assert "HTTP" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
|
||||
@patch("scripts.publish.generate_release_notes", return_value="Release notes")
|
||||
@patch("scripts.publish.GiteaClient")
|
||||
@patch("scripts.publish.publish_to_pypi")
|
||||
@patch("scripts.publish.build_package")
|
||||
def test_release_json_parse_failure(
|
||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_client_cls: MagicMock, mock_notes: MagicMock
|
||||
) -> None:
|
||||
mock_client = MagicMock()
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
|
||||
mock_client.create_release.side_effect = APIError(http.HTTPStatus.BAD_GATEWAY, "bad gateway")
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
assert result.exit_code == 1
|
||||
assert str(http.HTTPStatus.BAD_GATEWAY) in result.output
|
||||
@@ -1,389 +0,0 @@
|
||||
"""Unit tests for scripts/release.py."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from scripts.release import (
|
||||
commit_release_changes,
|
||||
create_and_push_tag,
|
||||
get_bumped_version,
|
||||
get_changelog,
|
||||
get_latest_tag,
|
||||
has_unreleased_changes,
|
||||
main,
|
||||
run_cmd,
|
||||
tag_exists,
|
||||
update_changelog,
|
||||
update_init_version,
|
||||
)
|
||||
|
||||
|
||||
class TestRunCmd:
|
||||
@patch("scripts.release.subprocess.run")
|
||||
def test_success(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr="", stdout="")
|
||||
result = run_cmd(["echo", "hi"])
|
||||
assert result.returncode == 0
|
||||
mock_run.assert_called_once()
|
||||
|
||||
@patch("scripts.release.subprocess.run")
|
||||
def test_failure_raises(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr="err", stdout="")
|
||||
with pytest.raises(click.ClickException):
|
||||
run_cmd(["false"])
|
||||
|
||||
@patch("scripts.release.subprocess.run")
|
||||
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.release.run_cmd")
|
||||
def test_returns_tag(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.1.0\n")
|
||||
assert get_latest_tag() == "v0.1.0"
|
||||
|
||||
@patch("scripts.release.run_cmd")
|
||||
def test_no_tags_returns_empty(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="")
|
||||
assert get_latest_tag() == ""
|
||||
|
||||
|
||||
class TestTagExists:
|
||||
@patch("scripts.release.run_cmd")
|
||||
def test_exists(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.1.0\n")
|
||||
assert tag_exists("v0.1.0") is True
|
||||
|
||||
@patch("scripts.release.run_cmd")
|
||||
def test_not_exists(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="")
|
||||
assert tag_exists("v0.2.0") is False
|
||||
|
||||
|
||||
class TestGetBumpedVersion:
|
||||
@patch("scripts.release.run_cmd")
|
||||
def test_returns_version(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="0.2.0\n")
|
||||
assert get_bumped_version() == "0.2.0"
|
||||
|
||||
@patch("scripts.release.run_cmd")
|
||||
def test_strips_v_prefix(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.2.0\n")
|
||||
assert get_bumped_version() == "0.2.0"
|
||||
|
||||
@patch("scripts.release.run_cmd")
|
||||
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.release.run_cmd")
|
||||
def test_returns_changelog(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="## 0.2.0\n- fix\n")
|
||||
assert get_changelog("0.2.0") == "## 0.2.0\n- fix"
|
||||
|
||||
@patch("scripts.release.run_cmd")
|
||||
def test_strips_whitespace(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout=" text \n")
|
||||
assert get_changelog("0.2.0") == "text"
|
||||
|
||||
|
||||
class TestHasUnreleasedChanges:
|
||||
@patch("scripts.release.get_latest_tag")
|
||||
def test_with_bumped_version_no_changes(self, mock_latest: MagicMock) -> None:
|
||||
mock_latest.return_value = "v0.2.0"
|
||||
assert has_unreleased_changes(bumped_version="0.2.0") is False
|
||||
|
||||
@patch("scripts.release.get_latest_tag")
|
||||
def test_with_bumped_version_has_changes(self, mock_latest: MagicMock) -> None:
|
||||
mock_latest.return_value = "v0.2.0"
|
||||
assert has_unreleased_changes(bumped_version="0.3.0") is True
|
||||
|
||||
@patch("scripts.release.get_latest_tag")
|
||||
def test_with_bumped_version_no_tags(self, mock_latest: MagicMock) -> None:
|
||||
mock_latest.return_value = ""
|
||||
assert has_unreleased_changes(bumped_version="0.1.0") is True
|
||||
|
||||
@patch("scripts.release.get_latest_tag")
|
||||
@patch("scripts.release.run_cmd")
|
||||
def test_without_bumped_version_no_changes(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.2.0\n")
|
||||
mock_latest.return_value = "v0.2.0"
|
||||
assert has_unreleased_changes() is False
|
||||
|
||||
@patch("scripts.release.get_latest_tag")
|
||||
@patch("scripts.release.run_cmd")
|
||||
def test_without_bumped_version_has_changes(self, mock_run_cmd: MagicMock, mock_latest: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="v0.3.0\n")
|
||||
mock_latest.return_value = "v0.2.0"
|
||||
assert has_unreleased_changes() is True
|
||||
|
||||
@patch("scripts.release.run_cmd")
|
||||
def test_cliff_fails_returns_false(self, mock_run_cmd: MagicMock) -> None:
|
||||
mock_run_cmd.return_value = MagicMock(returncode=1, stdout="")
|
||||
assert has_unreleased_changes() is False
|
||||
|
||||
|
||||
class TestUpdateInitVersion:
|
||||
def test_updates_version(self, tmp_path, monkeypatch) -> None:
|
||||
init_file = tmp_path / "__init__.py"
|
||||
init_file.write_text('__version__ = "0.1.0"\n')
|
||||
monkeypatch.setattr("scripts.release.INIT_FILE", str(init_file))
|
||||
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.release.INIT_FILE", str(init_file))
|
||||
update_init_version("0.1.0")
|
||||
assert '__version__ = "0.1.0"' in init_file.read_text()
|
||||
|
||||
def test_no_version_raises(self, tmp_path, monkeypatch) -> None:
|
||||
init_file = tmp_path / "__init__.py"
|
||||
init_file.write_text('"""module"""\n')
|
||||
monkeypatch.setattr("scripts.release.INIT_FILE", str(init_file))
|
||||
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.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.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.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
|
||||
|
||||
|
||||
class TestCommitReleaseChanges:
|
||||
@patch("scripts.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", "-m", "release: v0.2.0"] in calls
|
||||
|
||||
@patch("scripts.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", "-m", "release: v0.1.0"] not in calls
|
||||
|
||||
|
||||
class TestCreateAndPushTag:
|
||||
@patch("scripts.release.tag_exists", return_value=False)
|
||||
@patch("scripts.release.run_cmd")
|
||||
def test_creates_tag(self, mock_run_cmd: MagicMock, mock_tag_exists: MagicMock) -> None:
|
||||
create_and_push_tag("0.2.0", "changelog", dry_run=False)
|
||||
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
|
||||
assert ["git", "tag", "-a", "v0.2.0", "-m", "Release v0.2.0\n\nchangelog"] in calls
|
||||
assert ["git", "push", "origin", "v0.2.0"] in calls
|
||||
|
||||
@patch("scripts.release.tag_exists", return_value=False)
|
||||
@patch("scripts.release.run_cmd")
|
||||
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.release.tag_exists", return_value=True)
|
||||
@patch("scripts.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.release.tag_exists", return_value=True)
|
||||
@patch("scripts.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 TestMain:
|
||||
@patch.dict("os.environ", {})
|
||||
@patch("scripts.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.release.has_unreleased_changes", return_value=False)
|
||||
@patch("scripts.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("scripts.release.run_cmd")
|
||||
def test_no_unreleased_changes(self, mock_run_cmd: MagicMock, mock_bumped: MagicMock, mock_has: MagicMock) -> None:
|
||||
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.release.create_and_push_tag")
|
||||
@patch("scripts.release.commit_release_changes")
|
||||
@patch("scripts.release.update_changelog")
|
||||
@patch("scripts.release.update_init_version")
|
||||
@patch("scripts.release.get_changelog", return_value="")
|
||||
@patch("scripts.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("scripts.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("scripts.release.has_unreleased_changes", return_value=True)
|
||||
@patch("scripts.release.run_cmd")
|
||||
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,
|
||||
) -> 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.release.create_and_push_tag")
|
||||
@patch("scripts.release.commit_release_changes")
|
||||
@patch("scripts.release.update_changelog")
|
||||
@patch("scripts.release.update_init_version")
|
||||
@patch("scripts.release.get_changelog", return_value="changelog")
|
||||
@patch("scripts.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("scripts.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("scripts.release.has_unreleased_changes", return_value=True)
|
||||
@patch("scripts.release.run_cmd")
|
||||
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,
|
||||
) -> 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.release.create_and_push_tag", return_value=True)
|
||||
@patch("scripts.release.commit_release_changes", return_value=True)
|
||||
@patch("scripts.release.update_changelog")
|
||||
@patch("scripts.release.update_init_version")
|
||||
@patch("scripts.release.get_changelog", return_value="changelog")
|
||||
@patch("scripts.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("scripts.release.get_bumped_version", return_value="0.2.0")
|
||||
@patch("scripts.release.has_unreleased_changes", return_value=True)
|
||||
@patch("scripts.release.run_cmd")
|
||||
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,
|
||||
) -> 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_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.release.create_and_push_tag", return_value=False)
|
||||
@patch("scripts.release.commit_release_changes", return_value=False)
|
||||
@patch("scripts.release.update_changelog")
|
||||
@patch("scripts.release.update_init_version")
|
||||
@patch("scripts.release.get_changelog", return_value="changelog")
|
||||
@patch("scripts.release.get_latest_tag", return_value="v0.1.0")
|
||||
@patch("scripts.release.get_bumped_version", return_value="0.1.0")
|
||||
@patch("scripts.release.has_unreleased_changes", return_value=True)
|
||||
@patch("scripts.release.run_cmd")
|
||||
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,
|
||||
) -> 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)
|
||||
@@ -1,170 +0,0 @@
|
||||
"""Unit tests for scripts/review_pr.py."""
|
||||
|
||||
import http
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from gitea_runner_manager.exceptions import APIError
|
||||
from scripts.review_pr import main, parse_comments
|
||||
|
||||
|
||||
class TestParseComments:
|
||||
def test_parse_from_json_file(self, tmp_path) -> None:
|
||||
comments = [{"path": "a.py", "body": "fix", "new_position": 1}]
|
||||
f = tmp_path / "comments.json"
|
||||
f.write_text(json.dumps(comments))
|
||||
assert parse_comments(str(f), False) == comments
|
||||
|
||||
def test_parse_from_stdin(self) -> None:
|
||||
comments = [{"path": "a.py", "body": "fix", "new_position": 1}]
|
||||
with patch("scripts.review_pr.sys.stdin") as mock_stdin:
|
||||
mock_stdin.read.return_value = json.dumps(comments)
|
||||
assert parse_comments(None, True) == comments
|
||||
|
||||
def test_no_comments_returns_empty(self) -> None:
|
||||
assert parse_comments(None, False) == []
|
||||
|
||||
def test_invalid_json_file_raises(self, tmp_path) -> None:
|
||||
f = tmp_path / "comments.json"
|
||||
f.write_text("not json{")
|
||||
with pytest.raises(click.ClickException):
|
||||
parse_comments(str(f), False)
|
||||
|
||||
def test_non_list_json_raises(self, tmp_path) -> None:
|
||||
f = tmp_path / "comments.json"
|
||||
f.write_text(json.dumps({"path": "a.py"}))
|
||||
with pytest.raises(click.ClickException):
|
||||
parse_comments(str(f), False)
|
||||
|
||||
def test_stdin_non_list_raises(self) -> None:
|
||||
with patch("scripts.review_pr.sys.stdin") as mock_stdin:
|
||||
mock_stdin.read.return_value = json.dumps({"path": "a.py"})
|
||||
with pytest.raises(click.ClickException):
|
||||
parse_comments(None, True)
|
||||
|
||||
def test_stdin_invalid_json_raises(self) -> None:
|
||||
with patch("scripts.review_pr.sys.stdin") as mock_stdin:
|
||||
mock_stdin.read.return_value = "not json{"
|
||||
with pytest.raises(click.ClickException):
|
||||
parse_comments(None, True)
|
||||
|
||||
def test_stdin_empty_returns_empty(self) -> None:
|
||||
with patch("scripts.review_pr.sys.stdin") as mock_stdin:
|
||||
mock_stdin.read.return_value = " "
|
||||
assert parse_comments(None, True) == []
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.review_pr.GiteaClient")
|
||||
def test_successful_comment_review(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.create_review.return_value = {"id": 42}
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["5", "owner/repo", "--event", "COMMENT", "--body", "LGTM"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Review #42" in result.output
|
||||
mock_client.create_review.assert_called_once_with("5", event="COMMENT", body="LGTM", comments=[])
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.review_pr.GiteaClient")
|
||||
def test_successful_approve_review(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.create_review.return_value = {"id": 7}
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["5", "owner/repo", "--event", "APPROVE"])
|
||||
assert result.exit_code == 0
|
||||
assert "Review #7" in result.output
|
||||
mock_client.create_review.assert_called_once_with("5", event="APPROVE", body="", comments=[])
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.review_pr.GiteaClient")
|
||||
def test_successful_with_inline_comments(self, mock_client_cls: MagicMock, tmp_path) -> None:
|
||||
comments = [{"path": "a.py", "body": "fix", "new_position": 1}]
|
||||
f = tmp_path / "comments.json"
|
||||
f.write_text(json.dumps(comments))
|
||||
mock_client = MagicMock()
|
||||
mock_client.create_review.return_value = {"id": 9}
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["5", "owner/repo", "--comments-json", str(f)],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_client.create_review.assert_called_once_with("5", event="COMMENT", body="", comments=comments)
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.review_pr.GiteaClient")
|
||||
def test_successful_with_stdin_comments(self, mock_client_cls: MagicMock) -> None:
|
||||
comments = [{"path": "a.py", "body": "fix", "new_position": 1}]
|
||||
mock_client = MagicMock()
|
||||
mock_client.create_review.return_value = {"id": 11}
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["5", "owner/repo", "--comments-stdin"],
|
||||
input=json.dumps(comments),
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_client.create_review.assert_called_once_with("5", event="COMMENT", body="", comments=comments)
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
|
||||
def test_missing_token_exits(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["5", "owner/repo", "--body", "x"])
|
||||
assert result.exit_code == 1
|
||||
assert "REPO_TOKEN" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.review_pr.GiteaClient")
|
||||
def test_no_body_or_comments_for_comment_event(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["5", "owner/repo", "--event", "COMMENT"])
|
||||
assert result.exit_code == 1
|
||||
assert "required" in result.output
|
||||
mock_client.create_review.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.review_pr.GiteaClient")
|
||||
def test_no_body_or_comments_for_request_changes(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["5", "owner/repo", "--event", "REQUEST_CHANGES"])
|
||||
assert result.exit_code == 1
|
||||
assert "required" in result.output
|
||||
mock_client.create_review.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.review_pr.GiteaClient")
|
||||
def test_api_error_raises_click(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.create_review.side_effect = APIError(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["5", "owner/repo", "--body", "x"])
|
||||
assert result.exit_code == 1
|
||||
assert "HTTP" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("scripts.review_pr.GiteaClient")
|
||||
def test_invalid_event_choice(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["5", "owner/repo", "--event", "Bogus"])
|
||||
assert result.exit_code != 0
|
||||
mock_client.create_review.assert_not_called()
|
||||
@@ -1,153 +0,0 @@
|
||||
"""Unit tests for scripts/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.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.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.validate_commit_msg.get_branch", return_value="GRM-19"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [msg_path])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_accepts_valid_master_commit(self) -> None:
|
||||
msg_path = self._write_msg("GRM-19: feat: add feature")
|
||||
with patch("scripts.validate_commit_msg.get_branch", return_value="master"):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [msg_path])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_rejects_master_without_task_id(self) -> None:
|
||||
msg_path = self._write_msg("feat: add feature")
|
||||
with patch("scripts.validate_commit_msg.get_branch", return_value="master"):
|
||||
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.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.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.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_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.validate_commit_msg.get_branch", return_value="master"):
|
||||
import scripts.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