Compare commits

...
46 Commits
Author SHA1 Message Date
devx-ci-bot 9170546a31 release: v0.22.0
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / publish (push) Has been skipped
Build Images / detect-type (push) Successful in 33s
Post-merge / badges (push) Successful in 24s
Build Images / build-and-push (push) Has been skipped
Build Images / cleanup (push) Has been skipped
2026-06-27 15:06:08 +00:00
emil 91f59076a1 DEVX-81: feat: document CI_GITEA_TOKEN scopes and add CI_GITEA_USERNAME to env var table
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / vikunja (push) Successful in 11s
Post-merge / configure-repo (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 17s
Post-merge / release (push) Successful in 20s
Post-merge / badges (push) Successful in 26s
Post-merge / publish (push) Successful in 14s
2026-06-27 15:05:37 +00:00
gitea-actions-bot ca310fe442 chore: update badge URLs to commit c8ba6417 [skip ci] 2026-06-27 15:00:55 +00:00
emil decba5ec77 DEVX-80: chore: update badge URLs to commit 20bcfe96 [skip ci] 2026-06-27 14:56:57 +00:00
gitea-actions-bot 082df1d893 chore: update badge URLs to commit 20bcfe96 [skip ci] 2026-06-27 13:47:51 +00:00
gitea-actions-bot 08f58e551b chore: update badge URLs to commit 8203528c [skip ci] 2026-06-27 13:47:06 +00:00
devx-ci-bot 66bace57d9 release: v0.21.2
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / publish (push) Has been skipped
Build Images / detect-type (push) Successful in 32s
Build Images / build-and-push (push) Has been skipped
Build Images / cleanup (push) Has been skipped
Post-merge / badges (push) Successful in 31s
2026-06-27 13:46:57 +00:00
emil 5c8d74015e DEVX-79: fix: gate auto-merge on release-dry-run and unmask failures
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / sync-wiki (push) Successful in 17s
Post-merge / vikunja (push) Successful in 12s
Post-merge / configure-repo (push) Successful in 11s
Post-merge / release (push) Successful in 30s
Post-merge / badges (push) Successful in 35s
Post-merge / publish (push) Successful in 18s
2026-06-27 13:46:16 +00:00
gitea-actions-bot 48eec986f6 chore: update badge URLs to commit 3a45f8e1 [skip ci] 2026-06-27 13:28:35 +00:00
emil 1fc1cfb23f DEVX-78: style: compact devx-setup-image to pass checkmake maxbodylength (5 lines)
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 12s
Post-merge / release (push) Successful in 16s
Build Images / detect-type (push) Successful in 35s
Post-merge / vikunja (push) Successful in 13s
Post-merge / sync-wiki (push) Successful in 24s
Post-merge / publish (push) Has been skipped
Post-merge / configure-repo (push) Successful in 12s
Post-merge / badges (push) Successful in 36s
Build Images / build-and-push (push) Successful in 3m23s
Build Images / cleanup (push) Successful in 1m0s
2026-06-27 13:27:41 +00:00
gitea-actions-bot f24b6914f6 chore: update badge URLs to commit b687262f [skip ci] 2026-06-27 13:24:52 +00:00
gitea-actions-bot 84d02ca221 chore: update badge URLs to commit 6043e937 [skip ci] 2026-06-27 13:24:13 +00:00
devx-ci-bot 18ac8f4ba9 release: v0.21.1
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Build Images / detect-type (push) Successful in 36s
Post-merge / publish (push) Has been skipped
Build Images / build-and-push (push) Has been skipped
Build Images / cleanup (push) Has been skipped
Post-merge / badges (push) Successful in 32s
2026-06-27 13:24:07 +00:00
emil 9fb9be9c35 DEVX-77: fix: devx-setup-image configures Gitea PyPI registry and shows pip errors
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / sync-wiki (push) Successful in 19s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / vikunja (push) Successful in 11s
Post-merge / release (push) Successful in 27s
Build Images / detect-type (push) Successful in 39s
Post-merge / badges (push) Successful in 30s
Post-merge / publish (push) Successful in 16s
Build Images / build-and-push (push) Successful in 3m17s
Build Images / cleanup (push) Successful in 1m15s
2026-06-27 13:23:29 +00:00
gitea-actions-bot 9a46723391 chore: update badge URLs to commit f7a68467 [skip ci] 2026-06-27 13:05:31 +00:00
devx-ci-bot 5828d3f07b release: v0.21.0
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / publish (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Build Images / detect-type (push) Successful in 38s
Build Images / build-and-push (push) Has been skipped
Post-merge / badges (push) Successful in 28s
Build Images / cleanup (push) Has been skipped
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
2026-06-27 13:04:50 +00:00
gitea-actions-bot 4232f4baee chore: update badge URLs to commit 88ae1247 [skip ci] 2026-06-27 13:04:46 +00:00
emil a9fd1a47af DEVX-76: feat: add --auto-login to publish, extract configure_tea_login to gitea_cli
Post-merge / publish (push) Successful in 17s
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 10s
Build Images / build-and-push (push) Successful in 3m6s
Post-merge / vikunja (push) Successful in 11s
Build Images / cleanup (push) Successful in 54s
Post-merge / sync-wiki (push) Successful in 18s
Build Images / detect-type (push) Successful in 33s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / badges (push) Successful in 27s
Post-merge / release (push) Successful in 32s
2026-06-27 13:04:06 +00:00
gitea-actions-bot ecd10241fb chore: update badge URLs to commit 022fdc32 [skip ci] 2026-06-27 08:02:52 +00:00
emil 5fb497d108 DEVX-75: fix: remove tag fallback step from release workflow
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / release (push) Successful in 10s
Post-merge / sync-wiki (push) Successful in 16s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / vikunja (push) Successful in 10s
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 25s
2026-06-27 08:02:04 +00:00
gitea-actions-bot 7d4c32c761 chore: update badge URLs to commit 894ff869 [skip ci] 2026-06-27 07:47:37 +00:00
emil cb8af53c26 DEVX-74: fix: publish job uses setup-release for build + tea login
Post-merge / badges (push) Successful in 26s
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / release (push) Successful in 11s
Post-merge / vikunja (push) Successful in 11s
Post-merge / publish (push) Has been skipped
Post-merge / configure-repo (push) Successful in 9s
Post-merge / sync-wiki (push) Successful in 18s
2026-06-27 07:46:55 +00:00
gitea-actions-bot 954ede87a7 chore: update badge URLs to commit 5e80905f [skip ci] 2026-06-27 07:43:15 +00:00
gitea-actions-bot eb30faf027 chore: update badge URLs to commit 2979764a [skip ci] 2026-06-27 07:42:40 +00:00
devx-ci-bot 6c4157b5c6 release: v0.20.3
Post-merge / detect-type (push) Successful in 10s
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / publish (push) Has been skipped
Build Images / detect-type (push) Successful in 40s
Build Images / build-and-push (push) Has been skipped
Post-merge / badges (push) Successful in 29s
Build Images / cleanup (push) Has been skipped
2026-06-27 07:42:30 +00:00
emil ea7ddb2036 DEVX-73: fix: release publish failures and duplicate release commits
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / sync-wiki (push) Successful in 17s
Post-merge / vikunja (push) Successful in 12s
Build Images / detect-type (push) Successful in 38s
Post-merge / release (push) Successful in 25s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / badges (push) Successful in 31s
Post-merge / publish (push) Failing after 15s
Build Images / build-and-push (push) Successful in 2m47s
Build Images / cleanup (push) Successful in 51s
2026-06-27 07:41:50 +00:00
gitea-actions-bot ef63ada2f0 chore: update badge URLs to commit bc706211 [skip ci] 2026-06-27 07:21:27 +00:00
gitea-actions-bot cdd5f5a8da chore: update badge URLs to commit a0473322 [skip ci] 2026-06-27 07:20:49 +00:00
devx-ci-bot 63ae375b4b release: v0.20.2
Post-merge / detect-type (push) Successful in 15s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 34s
2026-06-27 07:20:10 +00:00
emil 8862ea4639 DEVX-72: ci: add hadolint Dockerfile linter to CI
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Successful in 8s
Build Images / detect-type (push) Successful in 40s
Post-merge / release (push) Failing after 26s
Build Images / build-and-push (push) Successful in 3m6s
Build Images / cleanup (push) Successful in 49s
Post-merge / sync-wiki (push) Successful in 23s
Post-merge / vikunja (push) Successful in 14s
Post-merge / configure-repo (push) Successful in 14s
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 35s
2026-06-27 07:19:31 +00:00
gitea-actions-bot 8ca0a1b208 chore: update badge URLs to commit 55d2bb5c [skip ci] 2026-06-27 03:08:47 +00:00
emil 670f5a099a DEVX-71: ci: use pre-built tier images in CI workflows
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / vikunja (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 18s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 26s
Post-merge / release (push) Successful in 19s
2026-06-27 03:08:06 +00:00
gitea-actions-bot a5277a0790 chore: update badge URLs to commit 177dda82 [skip ci] 2026-06-27 02:42:47 +00:00
devx-ci-bot 203d16b19d release: v0.20.2
Post-merge / detect-type (push) Successful in 37s
Post-merge / validate-commit-msg (push) Has been skipped
Build Images / detect-type (push) Successful in 40s
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Build Images / cleanup (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Build Images / build-and-push (push) Has been skipped
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 57s
2026-06-27 04:41:07 +02:00
gitea-actions-bot 12871fb343 chore: update badge URLs to commit 917159e3 [skip ci] 2026-06-27 04:40:59 +02:00
emil a585ef09b6 DEVX-70: fix: correct sed substitution in ci-full Dockerfile
Build Images / detect-type (push) Successful in 35s
Post-merge / detect-type (push) Successful in 33s
Post-merge / validate-commit-msg (push) Successful in 37s
Post-merge / sync-wiki (push) Successful in 53s
Post-merge / vikunja (push) Successful in 45s
Post-merge / badges (push) Successful in 1m24s
Post-merge / configure-repo (push) Successful in 41s
Post-merge / release (push) Successful in 1m37s
Post-merge / publish (push) Failing after 51s
Build Images / build-and-push (push) Successful in 2m59s
Build Images / cleanup (push) Successful in 1m13s
2026-06-27 02:38:53 +00:00
gitea-actions-bot 92aea7df10 chore: update badge URLs to commit d8f48949 [skip ci] 2026-06-27 04:29:12 +02:00
gitea-actions-bot ee3ad74634 chore: update badge URLs to commit ac4f73ef [skip ci] 2026-06-27 04:27:29 +02:00
devx-ci-bot 626ea67b28 release: v0.20.1
Post-merge / badges (push) Successful in 56s
Build Images / detect-type (push) Successful in 45s
Build Images / cleanup (push) Has been skipped
Build Images / build-and-push (push) Has been skipped
Post-merge / detect-type (push) Successful in 42s
Post-merge / sync-wiki (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / publish (push) Has been skipped
2026-06-27 02:26:36 +00:00
emil 87c3fa6634 DEVX-69: fix: correct image references in tier Dockerfiles
Build Images / detect-type (push) Successful in 50s
Post-merge / detect-type (push) Successful in 48s
Post-merge / sync-wiki (push) Successful in 56s
Post-merge / validate-commit-msg (push) Successful in 1m2s
Post-merge / release (push) Successful in 1m21s
Post-merge / vikunja (push) Successful in 49s
Post-merge / configure-repo (push) Successful in 48s
Post-merge / badges (push) Successful in 1m10s
Post-merge / publish (push) Failing after 51s
Build Images / build-and-push (push) Failing after 3m7s
Build Images / cleanup (push) Has been skipped
2026-06-27 02:24:24 +00:00
gitea-actions-bot 048d161192 chore: update badge URLs to commit d600d21c [skip ci] 2026-06-27 01:37:07 +00:00
gitea-actions-bot 762eee4a55 chore: update badge URLs to commit d0ea4c99 [skip ci] 2026-06-27 03:36:02 +02:00
devx-ci-bot d84958fab7 release: v0.20.0
Build Images / detect-type (push) Successful in 41s
Build Images / build-and-push (push) Has been skipped
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / detect-type (push) Successful in 40s
Build Images / cleanup (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / publish (push) Has been skipped
Post-merge / badges (push) Successful in 50s
2026-06-27 01:35:31 +00:00
emil c0238e75df DEVX-68: feat: add pre-built Docker runner images and tested image build/push tools
Build Images / detect-type (push) Successful in 33s
Build Images / build-and-push (push) Failing after 2m54s
Build Images / cleanup (push) Has been skipped
Post-merge / detect-type (push) Successful in 32s
Post-merge / configure-repo (push) Successful in 40s
Post-merge / sync-wiki (push) Successful in 42s
Post-merge / release (push) Successful in 53s
Post-merge / vikunja (push) Successful in 1m1s
Post-merge / validate-commit-msg (push) Successful in 1m8s
Post-merge / badges (push) Successful in 1m19s
Post-merge / publish (push) Failing after 38s
2026-06-27 01:34:05 +00:00
gitea-actions-bot d4ddbd7e7a chore: update badge URLs to commit fd23e5b2 [skip ci] 2026-06-27 00:01:53 +00:00
gitea-actions-bot 08b993fc4a chore: update badge URLs to commit ebe8b016 [skip ci] 2026-06-27 01:58:56 +02:00
54 changed files with 2647 additions and 685 deletions
+20
View File
@@ -0,0 +1,20 @@
.venv/
.git/
.gitea/
tests/
docs/
*.egg-info/
__pycache__/
htmlcov/
.coverage
dist/
build/
*.md
!README.md
.env
.env.example
activate.sh
activate.fish
activate.zsh
hooks/
.devin/
+1 -1
View File
@@ -1,6 +1,6 @@
# Gitea API token (required for CI scripts that interact with Gitea) # Gitea API token (required for CI scripts that interact with Gitea)
# Create at: https://git.oblachno.oblachno.fyi/user/settings/applications # Create at: https://git.oblachno.oblachno.fyi/user/settings/applications
REPO_TOKEN= CI_GITEA_TOKEN=
# Vikunja API token (required for post-merge task updates) # Vikunja API token (required for post-merge task updates)
# Create at: https://work.oblachno.oblachno.fyi/settings/tokens # Create at: https://work.oblachno.oblachno.fyi/settings/tokens
+131
View File
@@ -0,0 +1,131 @@
name: Build Images
# Builds and pushes pre-built Docker runner images to the Gitea registry.
# These images eliminate the 40-120s setup tax on every CI job by baking
# devx and all dependencies into the image.
#
# Triggers:
# - On push to master (after post-merge release completes)
# - Manually via workflow_dispatch
#
# The workflow builds 3 tier images in sequence:
# ci-base → ci-quality → ci-full
#
# Each tier builds FROM the previous one, so they must be built in order.
# After pushing, a cleanup job removes old versions (keeps last 2 + latest).
on:
push:
branches: [master]
paths:
- docker/**
- pyproject.toml
- src/devx/**
workflow_dispatch:
jobs:
detect-type:
runs-on: docker
timeout-minutes: 5
outputs:
is-release: ${{ steps.check.outputs.is-release }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Set up environment
run: make setup-ci
- name: Check if this is a release commit
id: check
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 -m devx.ci.detect_release_commit
build-and-push:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up environment
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
run: make setup-release
- name: Docker registry login
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: |
. .venv/bin/activate
echo "$CI_GITEA_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$CI_GITEA_USERNAME" --password-stdin
- name: Build and push tier images
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
PYTHONPATH: src
run: |
. .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH"
# Build ci-base first (it's the base for ci-quality and ci-full)
python3 -m devx.tools.build_image \
--dockerfile docker/ci-base/Dockerfile \
--name oblachno-oss/runner-images/ci-base \
--tag latest \
--registry git.oblachno.oblachno.fyi \
--push --pull
# Build ci-quality (FROM ci-base-latest)
python3 -m devx.tools.build_image \
--dockerfile docker/ci-quality/Dockerfile \
--name oblachno-oss/runner-images/ci-quality \
--tag latest \
--registry git.oblachno.oblachno.fyi \
--push
# Build ci-full (FROM ci-quality-latest)
python3 -m devx.tools.build_image \
--dockerfile docker/ci-full/Dockerfile \
--name oblachno-oss/runner-images/ci-full \
--tag latest \
--registry git.oblachno.oblachno.fyi \
--push
- name: Notify on failure
if: failure()
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.notify_failure \
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "build-images/build-and-push" \
--commit "${{ github.sha }}"
cleanup:
needs: [build-and-push]
if: always() && needs.build-and-push.result == 'success'
runs-on: docker
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Set up environment
run: make setup-ci
- name: Clean up old image versions
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 -m devx.tools.clean_images \
--owner oblachno-oss \
--name oblachno-oss/runner-images/ci-base \
--name oblachno-oss/runner-images/ci-quality \
--name oblachno-oss/runner-images/ci-full \
--keep 2
+34 -11
View File
@@ -8,11 +8,15 @@ on:
jobs: jobs:
quality: quality:
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
timeout-minutes: 10 timeout-minutes: 10
defaults:
run:
shell: bash
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up environment - name: Set up environment
run: make setup-quality run: make setup-image
- name: Lint all - name: Lint all
run: | run: |
. .venv/bin/activate . .venv/bin/activate
@@ -60,7 +64,11 @@ jobs:
detect-changes: detect-changes:
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10 timeout-minutes: 10
defaults:
run:
shell: bash
outputs: outputs:
user-facing-changed: ${{ steps.detect.outputs.user-facing-changed }} user-facing-changed: ${{ steps.detect.outputs.user-facing-changed }}
steps: steps:
@@ -68,7 +76,7 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up environment - name: Set up environment
run: make setup-ci run: make setup-image
- name: Detect changed paths - name: Detect changed paths
id: detect id: detect
env: env:
@@ -84,32 +92,42 @@ jobs:
needs: [quality, detect-changes] needs: [quality, detect-changes]
if: needs.detect-changes.outputs.user-facing-changed == 'true' if: needs.detect-changes.outputs.user-facing-changed == 'true'
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 10 timeout-minutes: 10
defaults:
run:
shell: bash
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up environment - name: Set up environment
run: make setup-release env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
run: make setup-image
- name: Release dry-run validation - name: Release dry-run validation
env: env:
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate . .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release --dry-run || true python3 -m devx.ci.release --dry-run
pr-review: pr-review:
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request'
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10 timeout-minutes: 10
defaults:
run:
shell: bash
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up environment - name: Set up environment
run: make setup-ci run: make setup-image
- name: Run automated PR review - name: Run automated PR review
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
set -euo pipefail set -euo pipefail
@@ -122,24 +140,29 @@ jobs:
# Auto-merge runs after all CI checks pass. It reads the task ID # Auto-merge runs after all CI checks pass. It reads the task ID
# from the branch name, validates the PR title, and squash-merges. # from the branch name, validates the PR title, and squash-merges.
# Uses always() so it runs even when detect-changes skips (no user-facing changes). # Uses always() so it runs even when detect-changes skips (no user-facing changes).
needs: [quality, detect-changes, pr-review] needs: [quality, detect-changes, pr-review, release-dry-run]
if: >- if: >-
always() && always() &&
github.event_name == 'pull_request' && github.event_name == 'pull_request' &&
needs.quality.result == 'success' && needs.quality.result == 'success' &&
needs.pr-review.result == 'success' needs.pr-review.result == 'success' &&
(needs.release-dry-run.result == 'success' || needs.release-dry-run.result == 'skipped')
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10 timeout-minutes: 10
defaults:
run:
shell: bash
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.REPO_TOKEN }} token: ${{ secrets.CI_GITEA_TOKEN }}
- name: Set up environment - name: Set up environment
run: make setup-ci run: make setup-image
- name: Squash merge with task ID - name: Squash merge with task ID
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }} VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
DEVX_VIKUNJA_PROJECT_ID: "8" DEVX_VIKUNJA_PROJECT_ID: "8"
PYTHONPATH: src PYTHONPATH: src
+53 -30
View File
@@ -33,7 +33,11 @@ on:
jobs: jobs:
detect-type: detect-type:
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10 timeout-minutes: 10
defaults:
run:
shell: bash
outputs: outputs:
is-release: ${{ steps.check.outputs.is-release }} is-release: ${{ steps.check.outputs.is-release }}
steps: steps:
@@ -41,7 +45,7 @@ jobs:
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Set up environment - name: Set up environment
run: make setup-ci run: make setup-image
- name: Check if this is a release commit - name: Check if this is a release commit
id: check id: check
env: env:
@@ -54,13 +58,17 @@ jobs:
needs: [detect-type] needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false' if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 5 timeout-minutes: 5
defaults:
run:
shell: bash
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Set up environment - name: Set up environment
run: make setup-ci run: make setup-image
- name: Validate latest commit message - name: Validate latest commit message
env: env:
PYTHONPATH: src PYTHONPATH: src
@@ -74,18 +82,22 @@ jobs:
needs: [detect-type] needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false' if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 15 timeout-minutes: 15
defaults:
run:
shell: bash
outputs: outputs:
tag: ${{ steps.release-tag.outputs.tag }} tag: ${{ steps.release-tag.outputs.tag }}
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.REPO_TOKEN }} token: ${{ secrets.CI_GITEA_TOKEN }}
- name: Set up environment - name: Set up environment
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
run: make setup-release run: make setup-image
- name: Configure git - name: Configure git
run: | run: |
git config user.name "devx-ci-bot" git config user.name "devx-ci-bot"
@@ -98,17 +110,10 @@ jobs:
. .venv/bin/activate . .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release python3 -m devx.ci.release
- name: Extract tag (fallback if GITHUB_OUTPUT not set)
if: steps.release-tag.outputs.tag == ''
run: |
tag=$(git describe --tags --abbrev=0 2>/dev/null || true)
if [ -n "$tag" ]; then
echo "tag=$tag" >> "$GITHUB_OUTPUT"
fi
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
@@ -123,27 +128,29 @@ jobs:
needs: [release] needs: [release]
if: needs.release.outputs.tag != '' if: needs.release.outputs.tag != ''
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
timeout-minutes: 10 timeout-minutes: 10
defaults:
run:
shell: bash
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up environment - name: Set up environment
env: run: make setup-image EXTRAS=release
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
run: make setup-release
- name: Build and publish release - name: Build and publish release
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate . .venv/bin/activate
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.publish "${{ needs.release.outputs.tag }}" "${{ github.repository }}" python3 -m devx.ci.publish "${{ needs.release.outputs.tag }}" "${{ github.repository }}" --auto-login
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
@@ -158,16 +165,20 @@ jobs:
needs: [detect-type] needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false' if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10 timeout-minutes: 10
defaults:
run:
shell: bash
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up environment - name: Set up environment
run: make setup-ci run: make setup-image
- name: Sync documentation to wiki - name: Sync documentation to wiki
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate . .venv/bin/activate
@@ -175,7 +186,7 @@ jobs:
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
@@ -189,19 +200,23 @@ jobs:
needs: [detect-type] needs: [detect-type]
if: always() if: always()
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
timeout-minutes: 10 timeout-minutes: 10
defaults:
run:
shell: bash
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
ref: master ref: master
token: ${{ secrets.REPO_TOKEN }} token: ${{ secrets.CI_GITEA_TOKEN }}
- name: Fetch latest master - name: Fetch latest master
run: | run: |
git fetch origin master git fetch origin master
git reset --hard origin/master git reset --hard origin/master
- name: Set up environment - name: Set up environment
run: make setup-ci run: make setup-image
- name: Generate and push badges - name: Generate and push badges
env: env:
PRE_COMMIT_ALLOW_NO_CONFIG: "1" PRE_COMMIT_ALLOW_NO_CONFIG: "1"
@@ -211,7 +226,7 @@ jobs:
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
@@ -225,13 +240,17 @@ jobs:
needs: [detect-type] needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false' if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10 timeout-minutes: 10
defaults:
run:
shell: bash
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up environment - name: Set up environment
run: make setup-ci run: make setup-image
- name: Update Vikunja task - name: Update Vikunja task
env: env:
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }} VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
@@ -243,7 +262,7 @@ jobs:
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
@@ -257,14 +276,18 @@ jobs:
needs: [detect-type] needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false' if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10 timeout-minutes: 10
defaults:
run:
shell: bash
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up environment - name: Set up environment
run: make setup-ci run: make setup-image
- name: Ensure branch protection and labels - name: Ensure branch protection and labels
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate . .venv/bin/activate
@@ -272,7 +295,7 @@ jobs:
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
REPO_TOKEN: ${{ secrets.REPO_TOKEN }} CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
+14
View File
@@ -0,0 +1,14 @@
# Hadolint configuration for devx Dockerfiles
# https://github.com/hadolint/hadolint#configure
ignored:
- DL3008 # Don't require pinning apt package versions
- DL3013 # Don't require pinning pip package versions
- DL3018 # Don't require pinning apk package versions
- DL3007 # Using latest is intentional for tier images (rebuilt on every merge)
- SC2102 # False positive: pip extras [release,molecule,deploy] look like shell ranges
trustedRegistries:
- git.oblachno.oblachno.fyi
- docker.io
- gitea/runner-images
+49 -3
View File
@@ -17,7 +17,7 @@ make clean # Remove caches, build artifacts, coverage data
`make setup` automatically installs all development tools: `make setup` automatically installs all development tools:
- **Python deps** via `python -m devx.tools.setup` (pip install -e .[dev], pre-commit hooks) - **Python deps** via `python -m devx.tools.setup` (pip install -e .[dev], pre-commit hooks)
- **actionlint, git-cliff, act_runner, tea** via `python -m devx.tools.install_tools` (CI/CD tools to ~/.local/bin) - **actionlint, git-cliff, act_runner, tea** via `python -m devx.tools.install_tools` (CI/CD tools to ~/.local/bin)
- **tea CLI login** via `python -m devx.tools.setup` (configures `tea login` from `.env` `REPO_TOKEN`) - **tea CLI login** via `python -m devx.tools.setup` (configures `tea login` from `.env` `CI_GITEA_TOKEN`)
## Workflow Verification (Before Push) ## Workflow Verification (Before Push)
@@ -74,6 +74,9 @@ src/devx/
├── tools/ # Developer tooling modules (run locally or by CI) ├── tools/ # Developer tooling modules (run locally or by CI)
│ ├── setup.py # Environment setup (venv, deps, hooks) │ ├── setup.py # Environment setup (venv, deps, hooks)
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea │ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea
│ ├── install_checkmake.py # Install checkmake (Makefile linter)
│ ├── build_image.py # Build and push Docker images to Gitea registry
│ ├── clean_images.py # Clean up old Docker image versions from Gitea registry
│ ├── check_test_speed.py # Measure unit test execution time │ ├── check_test_speed.py # Measure unit test execution time
│ ├── check_mutable_globals.py # Detect module-level mutable globals (test isolation bugs) │ ├── check_mutable_globals.py # Detect module-level mutable globals (test isolation bugs)
│ ├── check_pyproject_deps.py # Validate pyproject.toml deps have documentation comments │ ├── check_pyproject_deps.py # Validate pyproject.toml deps have documentation comments
@@ -260,7 +263,7 @@ so `.:src` is not needed. The `src` directory is the sole import root.
The `tea` Gitea CLI tool is used for Gitea API interactions. It is installed The `tea` Gitea CLI tool is used for Gitea API interactions. It is installed
by `python -m devx.tools.install_tools` and configured by by `python -m devx.tools.install_tools` and configured by
`python -m devx.tools.setup` (login profile from `.env` `REPO_TOKEN`). `python -m devx.tools.setup` (login profile from `.env` `CI_GITEA_TOKEN`).
**`devx.gitea_cli.TeaCLI`** — Python wrapper around `tea` CLI with JSON output parsing: **`devx.gitea_cli.TeaCLI`** — Python wrapper around `tea` CLI with JSON output parsing:
- `create_issue()` — Create issues with labels - `create_issue()` — Create issues with labels
@@ -355,7 +358,7 @@ devx uses environment variables with `.env` file fallback for configuration.
| `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) | | `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) |
| `DEVX_VIKUNJA_PROJECT_ID` | `6` | Vikunja project ID | | `DEVX_VIKUNJA_PROJECT_ID` | `6` | Vikunja project ID |
| `DEVX_LANG` | `en` | Language for i18n (en, bg) | | `DEVX_LANG` | `en` | Language for i18n (en, bg) |
| `REPO_TOKEN` | (from .env) | Gitea API token | | `CI_GITEA_TOKEN` | (from .env) | Gitea API token |
| `VIKUNJA_TOKEN` | (from .env) | Vikunja API token | | `VIKUNJA_TOKEN` | (from .env) | Vikunja API token |
### Per-Project Overrides ### Per-Project Overrides
@@ -424,6 +427,11 @@ projects.
| `devx-check-test-speed` | Verify test suite timing | | `devx-check-test-speed` | Verify test suite timing |
| `devx-pre-push` | Run lint + tests before push | | `devx-pre-push` | Run lint + tests before push |
| `devx-clean` | Remove caches, build artifacts, coverage data | | `devx-clean` | Remove caches, build artifacts, coverage data |
| `devx-setup-image` | Link /opt/venv + install project (for pre-built image CI jobs) |
| `devx-build-images` | Build Docker images from manifest (no push) |
| `devx-push-images` | Build and push Docker images to Gitea registry |
| `devx-build-images-dry-run` | Show what would be built/pushed |
| `devx-clean-images` | Delete old image versions (keep last 2 + latest) |
**Variables** (set BEFORE including devx.mak): **Variables** (set BEFORE including devx.mak):
- `DEVX_PYTHON` — Python executable (default: `python3`) - `DEVX_PYTHON` — Python executable (default: `python3`)
@@ -433,6 +441,44 @@ projects.
- `DEVX_COV_PKG` — coverage package (default: `src/devx`) - `DEVX_COV_PKG` — coverage package (default: `src/devx`)
- `DEVX_TEST_PATHS` — pytest paths (default: `tests/`) - `DEVX_TEST_PATHS` — pytest paths (default: `tests/`)
- `DEVX_PR_BASE` — PR base branch (default: `master`) - `DEVX_PR_BASE` — PR base branch (default: `master`)
- `DEVX_GITEA_REGISTRY` — registry URL (default: `git.oblachno.oblachno.fyi`)
- `DEVX_IMAGE_MANIFEST` — path to JSON manifest (default: `docker/images.json`)
- `DEVX_IMAGE_OWNER` — package owner for cleanup (default: `oblachno-oss`)
## Pre-built Docker Runner Images
devx builds and publishes three tier images to the Gitea container registry
to eliminate the 40-120s setup tax on every CI job:
| Image | Contains | Used by jobs |
|-------|----------|-------------|
| `ci-base-latest` | Python 3.12 + devx[ci] + tea | detect-changes, detect-type, validate-commit-msg, pr-review, auto-merge, sync-wiki, vikunja, configure-repo |
| `ci-quality-latest` | ci-base + devx[lint] + actionlint + checkmake | quality, badges |
| `ci-full-latest` | ci-quality + devx[release,molecule,deploy] + git-cliff + OpenTofu | release, publish, release-dry-run, molecule-tests, deploy jobs |
**Build process** (in `build-images.yml` workflow):
1. `ci-base` builds FROM `gitea/runner-images:ubuntu-latest`
2. `ci-quality` builds FROM `ci-base-latest`
3. `ci-full` builds FROM `ci-quality-latest`
Each image is tagged `latest` and pushed to
`git.oblachno.oblachno.fyi/oblachno-oss/runner-images:<tier>-latest`.
**Using images in workflows**:
```yaml
jobs:
quality:
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
steps:
- uses: actions/checkout@v4
- name: Set up environment
run: make setup-image # links /opt/venv, installs project (no-deps)
```
**Image build/push tools** (tested Python modules):
- `devx.tools.build_image` — Build and push Docker images from Dockerfile or manifest
- `devx.tools.clean_images` — Delete old image versions via Gitea API (keep last N + latest)
**Usage in project Makefile**: **Usage in project Makefile**:
```makefile ```makefile
+55
View File
@@ -2,6 +2,61 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [0.22.0] - 2026-06-27
### Features
- Document CI_GITEA_TOKEN scopes and add CI_GITEA_USERNAME to env var table
## [0.21.2] - 2026-06-27
### Bug Fixes
- Gate auto-merge on release-dry-run and unmask failures
## [0.21.1] - 2026-06-27
### Bug Fixes
- Devx-setup-image configures Gitea PyPI registry and shows pip errors
## [0.21.0] - 2026-06-27
### Features
- Add --auto-login to publish, extract configure_tea_login to gitea_cli
### Bug Fixes
- Publish job uses setup-release for build + tea login
- Remove tag fallback step from release workflow
## [0.20.3] - 2026-06-27
### Bug Fixes
- Release publish failures and duplicate release commits
## [0.20.2] - 2026-06-27
## [0.20.2] - 2026-06-27
### Bug Fixes
- Correct sed substitution in ci-full Dockerfile
## [0.20.1] - 2026-06-27
### Bug Fixes
- Correct image references in tier Dockerfiles
## [0.20.0] - 2026-06-27
### Features
- Add pre-built Docker runner images and tested image build/push tools
## [0.19.3] - 2026-06-26 ## [0.19.3] - 2026-06-26
### Refactor ### Refactor
+32 -4
View File
@@ -1,4 +1,4 @@
.PHONY: all setup setup-ci setup-quality setup-release install update lint lint-all test test-unit pytest-cov clean install-tools install-hooks activate-scripts checkmake check-mutable-globals check-dep-docs check-test-speed .PHONY: all setup setup-ci setup-quality setup-release setup-image install update lint lint-all lint-dockerfiles test test-unit pytest-cov clean install-tools install-hooks activate-scripts checkmake check-mutable-globals check-dep-docs check-test-speed build-images push-images build-images-dry-run clean-images
PYTHON := python3 PYTHON := python3
VENV := .venv VENV := .venv
@@ -25,10 +25,15 @@ setup-quality: $(VENV)/bin/activate .env install-tools
# Setup for release jobs (needs git-cliff, tea, lint tools) # Setup for release jobs (needs git-cliff, tea, lint tools)
setup-release: $(VENV)/bin/activate .env setup-release: $(VENV)/bin/activate .env
@$(BIN)/pip install -e '.[ci,lint]' 2>/dev/null; \ @$(BIN)/pip install -e '.[ci,lint,release]' 2>/dev/null; \
$(BIN)/python -m devx.tools.install_tools --tool git-cliff --tool tea; \ $(BIN)/python -m devx.tools.install_tools --tool git-cliff --tool tea; \
export PATH="$(HOME)/.local/bin:$$PATH"; \ export PATH="$(HOME)/.local/bin:$$PATH"; \
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,lint" --no-pre-commit $(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,lint,release" --no-pre-commit
# Setup for pre-built image jobs (deps already in image, just link venv + install project)
setup-image:
@if [ -d /opt/venv ]; then ln -sf /opt/venv .venv; . .venv/bin/activate && pip install -e . --no-deps 2>/dev/null; \
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
.env: .env:
@if [ ! -f .env ]; then cp .env.example .env; echo "Created .env from .env.example — please edit it."; fi @if [ ! -f .env ]; then cp .env.example .env; echo "Created .env from .env.example — please edit it."; fi
@@ -91,9 +96,18 @@ create-pr: devx-create-pr
push-with-pr: devx-push-with-pr push-with-pr: devx-push-with-pr
git-push: devx-push git-push: devx-push
lint-all: lint workflow-lint lint-all: lint workflow-lint lint-dockerfiles
@echo "[lint-all] All linting checks passed." @echo "[lint-all] All linting checks passed."
lint-dockerfiles:
@echo "[lint-dockerfiles] Linting Dockerfiles with hadolint..."
@if command -v hadolint >/dev/null 2>&1; then \
find docker -name 'Dockerfile*' -exec hadolint {} +; \
echo "[lint-dockerfiles] All Dockerfiles passed."; \
else \
echo "[lint-dockerfiles] hadolint not found — skipping (install with: pip install hadolint or download from GitHub)"; \
fi
test-unit: devx-test-unit test-unit: devx-test-unit
pytest-cov: devx-pytest-cov pytest-cov: devx-pytest-cov
@@ -105,3 +119,17 @@ pre-push: lint-all pytest-cov
clean: devx-clean clean: devx-clean
@echo "[clean] Done." @echo "[clean] Done."
# ── Docker image management ──────────────────────────────────────────────────
build-images: devx-build-images
@echo "[build-images] Done."
push-images: devx-push-images
@echo "[push-images] Done."
build-images-dry-run: devx-build-images-dry-run
@echo "[build-images-dry-run] Done."
clean-images: devx-clean-images
@echo "[clean-images] Done."
+21 -7
View File
@@ -16,12 +16,12 @@ quality badges.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a45dd92c1fdec2a80c5af7d2f47c6325057bb3ca/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c8ba6417503aafcc1e25d2b402d89c9981a41107/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a45dd92c1fdec2a80c5af7d2f47c6325057bb3ca/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c8ba6417503aafcc1e25d2b402d89c9981a41107/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a45dd92c1fdec2a80c5af7d2f47c6325057bb3ca/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c8ba6417503aafcc1e25d2b402d89c9981a41107/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a45dd92c1fdec2a80c5af7d2f47c6325057bb3ca/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c8ba6417503aafcc1e25d2b402d89c9981a41107/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a45dd92c1fdec2a80c5af7d2f47c6325057bb3ca/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c8ba6417503aafcc1e25d2b402d89c9981a41107/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a45dd92c1fdec2a80c5af7d2f47c6325057bb3ca/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c8ba6417503aafcc1e25d2b402d89c9981a41107/python.svg)](https://www.python.org/downloads/)
## Why devx? ## Why devx?
@@ -326,10 +326,24 @@ The config system loads `.env` automatically via `python-dotenv`.
| `DEVX_DOCS_DIR` | `docs` | Documentation directory (used by sync_wiki) | | `DEVX_DOCS_DIR` | `docs` | Documentation directory (used by sync_wiki) |
| `DEVX_STATUS_CHECKS` | `CI / quality (pull_request)` | Comma-separated status check contexts | | `DEVX_STATUS_CHECKS` | `CI / quality (pull_request)` | Comma-separated status check contexts |
| `DEVX_PYPI_REGISTRY_URL` | — | Gitea PyPI registry URL (used by publish) | | `DEVX_PYPI_REGISTRY_URL` | — | Gitea PyPI registry URL (used by publish) |
| `REPO_TOKEN` | — | Gitea API token | | `CI_GITEA_TOKEN` | — | Gitea API token (see scopes below) |
| `CI_GITEA_USERNAME` | — | Gitea username for registry authentication |
| `VIKUNJA_TOKEN` | — | Vikunja API token | | `VIKUNJA_TOKEN` | — | Vikunja API token |
| `PYPI_TOKEN` | — | Standard PyPI token (takes precedence over Gitea registry) | | `PYPI_TOKEN` | — | Standard PyPI token (takes precedence over Gitea registry) |
#### CI_GITEA_TOKEN scopes
The `CI_GITEA_TOKEN` is a single Gitea Personal Access Token used across all
workflows. It requires these scopes:
| Scope | Purpose |
|-------|---------|
| `read:repository` | Read repos, PRs, issues, branches |
| `write:repository` | Push commits, merge PRs, create tags/releases, create issues, set branch protection, push wiki |
| `read:package` | Pull packages from Gitea PyPI registry, pull Docker images |
| `write:package` | Publish packages to Gitea PyPI registry, push Docker images |
| `read:organization` | Query org-level runners for molecule test distribution |
### Per-project overrides ### Per-project overrides
Projects using devx can override the default API URLs and language by setting Projects using devx can override the default API URLs and language by setting
+26
View File
@@ -0,0 +1,26 @@
# ci-base — lightweight image for CI jobs that only need devx core + tea.
#
# Used by: detect-type, detect-changes, validate-commit-msg, pr-review,
# auto-merge, sync-wiki, vikunja, configure-repo, discover-runners,
# molecule-report, discover-integration-runners
#
# Jobs using this image: setup is instant (ln -s /opt/venv .venv)
# No pip install needed — devx and all deps are pre-installed.
FROM gitea/runner-images:ubuntu-latest
# Create a virtual environment with all deps pre-installed
RUN python3 -m venv /opt/venv
ENV PATH="/opt/venv/bin:/root/.local/bin:$PATH"
# Install devx from local source (build context = devx repo root)
COPY . /tmp/devx
RUN pip install --no-cache-dir --upgrade pip setuptools wheel \
&& pip install --no-cache-dir /tmp/devx[ci] \
&& rm -rf /tmp/devx
# Install tea CLI (for Gitea API operations in CI)
RUN python3 -m devx.tools.install_tools --tool tea
# Workspace directory (actions/checkout mounts repo here)
WORKDIR /workspace
+26
View File
@@ -0,0 +1,26 @@
# ci-full — heaviest image, includes everything for release, molecule, deploy.
#
# Used by: release, publish, release-dry-run, molecule-tests,
# provision-infra, deploy-observability, provision-zitadel,
# deploy-customer, integration-tests
#
# Layers on top of ci-quality: adds release tools, molecule, deploy deps,
# git-cliff, and OpenTofu.
FROM git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# Install devx[release,molecule,deploy] from local source
COPY . /tmp/devx
RUN pip install --no-cache-dir /tmp/devx[release,molecule,deploy] \
&& rm -rf /tmp/devx
# Install git-cliff (changelog generator for release job)
RUN python3 -m devx.tools.install_tools --tool git-cliff
# Install OpenTofu (for infra deploy jobs)
RUN ARCH=$(uname -m | sed 's/x86_64/amd64/') \
&& VERSION=1.12.3 \
&& curl -fsSL "https://github.com/opentofu/opentofu/releases/download/v${VERSION}/tofu_${VERSION}_$(uname -s | tr '[:upper:]' '[:lower:]')_${ARCH}.tar.gz" \
| tar -xz -C /usr/local/bin tofu
+22
View File
@@ -0,0 +1,22 @@
# ci-quality — image for lint, type-checking, badge generation.
#
# Used by: quality (lint-all + pytest-cov + checks), badges (generate_badges
# runs ruff/pyright/bandit to produce quality badge)
#
# Layers on top of ci-base: adds lint tools + actionlint + checkmake.
FROM git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
# Install devx[lint] from local source (adds ruff, pyright, bandit, etc.)
COPY . /tmp/devx
RUN pip install --no-cache-dir /tmp/devx[lint] \
&& rm -rf /tmp/devx
# Install CI/CD binary tools
RUN python3 -m devx.tools.install_tools --tool actionlint \
&& python3 -m devx.tools.install_checkmake
# Install hadolint (Dockerfile linter)
RUN curl -fsSL "https://github.com/hadolint/hadolint/releases/download/v2.12.0/hadolint-Linux-x86_64" \
-o /usr/local/bin/hadolint \
&& chmod +x /usr/local/bin/hadolint
+20
View File
@@ -0,0 +1,20 @@
[
{
"name": "oblachno-oss/runner-images/ci-base",
"dockerfile": "docker/ci-base/Dockerfile",
"context": ".",
"tags": ["latest"]
},
{
"name": "oblachno-oss/runner-images/ci-quality",
"dockerfile": "docker/ci-quality/Dockerfile",
"context": ".",
"tags": ["latest"]
},
{
"name": "oblachno-oss/runner-images/ci-full",
"dockerfile": "docker/ci-full/Dockerfile",
"context": ".",
"tags": ["latest"]
}
]
+7 -7
View File
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a45dd92c1fdec2a80c5af7d2f47c6325057bb3ca/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c8ba6417503aafcc1e25d2b402d89c9981a41107/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a45dd92c1fdec2a80c5af7d2f47c6325057bb3ca/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c8ba6417503aafcc1e25d2b402d89c9981a41107/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a45dd92c1fdec2a80c5af7d2f47c6325057bb3ca/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c8ba6417503aafcc1e25d2b402d89c9981a41107/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a45dd92c1fdec2a80c5af7d2f47c6325057bb3ca/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c8ba6417503aafcc1e25d2b402d89c9981a41107/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a45dd92c1fdec2a80c5af7d2f47c6325057bb3ca/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c8ba6417503aafcc1e25d2b402d89c9981a41107/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/a45dd92c1fdec2a80c5af7d2f47c6325057bb3ca/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c8ba6417503aafcc1e25d2b402d89c9981a41107/python.svg)](https://www.python.org/downloads/)
## Overview ## Overview
@@ -149,7 +149,7 @@ fallback. Key variables:
| `DEVX_REPO_NAME` | **(must be set)** | Repository name | | `DEVX_REPO_NAME` | **(must be set)** | Repository name |
| `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) | | `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) |
| `DEVX_LANG` | `en` | Language for i18n (en, bg, de, ru, zh, pl) | | `DEVX_LANG` | `en` | Language for i18n (en, bg, de, ru, zh, pl) |
| `REPO_TOKEN` | — | Gitea API token | | `CI_GITEA_TOKEN` | — | Gitea API token |
| `VIKUNJA_TOKEN` | — | Vikunja API token | | `VIKUNJA_TOKEN` | — | Vikunja API token |
See [AGENTS.md](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/AGENTS.md) See [AGENTS.md](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/AGENTS.md)
+2 -2
View File
@@ -245,7 +245,7 @@ for retrying on git push failures.
Creates a Gitea issue when a CI workflow fails. Uses the `tea` CLI for issue Creates a Gitea issue when a CI workflow fails. Uses the `tea` CLI for issue
creation with failure labels. Supports `--auto-login` to configure the tea creation with failure labels. Supports `--auto-login` to configure the tea
CLI login profile from `REPO_TOKEN` and `DEVX_GITEA_API_URL` before creating CLI login profile from `CI_GITEA_TOKEN` and `DEVX_GITEA_API_URL` before creating
the issue. the issue.
### `post_merge.py` ### `post_merge.py`
@@ -569,7 +569,7 @@ push_badges.py:
The `tea` Gitea CLI tool is used for Gitea API interactions where tea provides The `tea` Gitea CLI tool is used for Gitea API interactions where tea provides
reliable, official support. It is installed by reliable, official support. It is installed by
`python -m devx.tools.install_tools` and configured by `python -m devx.tools.install_tools` and configured by
`python -m devx.tools.setup` (login profile from `.env` `REPO_TOKEN`). `python -m devx.tools.setup` (login profile from `.env` `CI_GITEA_TOKEN`).
`devx.gitea_cli.TeaCLI` wraps tea with JSON output parsing. Operations that `devx.gitea_cli.TeaCLI` wraps tea with JSON output parsing. Operations that
tea does not support (wiki management, commit status, runner discovery, tea does not support (wiki management, commit status, runner discovery,
+2 -2
View File
@@ -305,7 +305,7 @@ post-merge workflow when it creates and pushes a new version tag.
and the project itself and the project itself
2. **Install CI tools** — git-cliff and tea via 2. **Install CI tools** — git-cliff and tea via
`python -m devx.tools.install_tools` `python -m devx.tools.install_tools`
3. **Configure tea login** — `tea login add` using `REPO_TOKEN` 3. **Configure tea login** — `tea login add` using `CI_GITEA_TOKEN`
4. **Build and publish** — `python -m devx.ci.publish <tag> <owner/repo>`: 4. **Build and publish** — `python -m devx.ci.publish <tag> <owner/repo>`:
- Build the package with `python -m build` - Build the package with `python -m build`
- Publish to the Gitea PyPI registry (default) using `twine upload - Publish to the Gitea PyPI registry (default) using `twine upload
@@ -377,7 +377,7 @@ python -m devx.ci.pr_review <pr_number> <owner/repo>
Creates a Gitea issue when a CI workflow fails. Uses the tea CLI for issue Creates a Gitea issue when a CI workflow fails. Uses the tea CLI for issue
creation with failure labels. Supports `--auto-login` to configure the tea creation with failure labels. Supports `--auto-login` to configure the tea
CLI login profile from `REPO_TOKEN`. CLI login profile from `CI_GITEA_TOKEN`.
```bash ```bash
python -m devx.ci.notify_failure --repo <owner/repo> --run-id <id> \ python -m devx.ci.notify_failure --repo <owner/repo> --run-id <id> \
+3 -3
View File
@@ -148,7 +148,7 @@ devx ci integration-guard -- -x -v --tb=short test_a.py
Environment variables: Environment variables:
- `GITEA_URL` — base URL of the Gitea instance - `GITEA_URL` — base URL of the Gitea instance
- `REPO_TOKEN` — API token with repo access - `CI_GITEA_TOKEN` — API token with repo access
- `RUN_ID` — workflow run ID (`GITHUB_RUN_ID`) - `RUN_ID` — workflow run ID (`GITHUB_RUN_ID`)
- `JOB_NAME` — base job name (`GITHUB_JOB`) - `JOB_NAME` — base job name (`GITHUB_JOB`)
- `MATRIX_INDEX` — current matrix index (runner-index) - `MATRIX_INDEX` — current matrix index (runner-index)
@@ -171,7 +171,7 @@ Options:
- `--run-id <id>` — CI run ID (required) - `--run-id <id>` — CI run ID (required)
- `--workflow <name>` — workflow name (required) - `--workflow <name>` — workflow name (required)
- `--commit <sha>` — commit SHA (required) - `--commit <sha>` — commit SHA (required)
- `--auto-login` — configure tea CLI login from `REPO_TOKEN` before creating - `--auto-login` — configure tea CLI login from `CI_GITEA_TOKEN` before creating
the issue the issue
### `devx ci post-merge` ### `devx ci post-merge`
@@ -462,7 +462,7 @@ Options:
Environment variables: Environment variables:
- `GITEA_URL` — base URL of the Gitea instance - `GITEA_URL` — base URL of the Gitea instance
- `REPO_TOKEN` — API token with repo access - `CI_GITEA_TOKEN` — API token with repo access
- `RUN_ID` — workflow run ID (`GITHUB_RUN_ID`) - `RUN_ID` — workflow run ID (`GITHUB_RUN_ID`)
- `JOB_NAME` — base job name (`GITHUB_JOB`) - `JOB_NAME` — base job name (`GITHUB_JOB`)
- `MATRIX_INDEX` — current matrix index (runner-index) - `MATRIX_INDEX` — current matrix index (runner-index)
+20 -7
View File
@@ -26,14 +26,13 @@ devx = "devx.cli:cli"
version = {attr = "devx.__version__"} version = {attr = "devx.__version__"}
[project.optional-dependencies] [project.optional-dependencies]
# Minimal deps for CI scripts that only need click/dotenv/requests # Test runners (pytest + coverage + parallel execution)
ci = [ ci = [
"pytest>=9.1.0", "pytest>=9.1.0",
"pytest-cov>=7.1.0", "pytest-cov>=7.1.0",
"build>=1.5.0", "pytest-xdist>=3.8",
"twine>=6.2.0",
] ]
# Lint and type-checking tools (quality job) # Lint and type-checking tools (quality job, badge generation)
lint = [ lint = [
"ruff>=0.15.17", "ruff>=0.15.17",
"pyright>=1.1.410", "pyright>=1.1.410",
@@ -41,16 +40,30 @@ lint = [
"pip-audit>=2.10", "pip-audit>=2.10",
"pre-commit>=4.6.0", "pre-commit>=4.6.0",
] ]
# Molecule testing (optional — for projects with Ansible roles) # Release tools (build + publish to PyPI/Gitea registry)
release = [
"build>=1.5.0",
"twine>=6.2.0",
]
# Molecule testing (for projects with Ansible roles)
molecule = [ molecule = [
"molecule>=26.4.0", "molecule>=26.4.0",
"molecule-docker>=2.1.0", "molecule-docker>=2.1.0",
"ansible-lint>=26.4.0", "ansible-lint>=26.4.0",
"ansible>=14.0.0", "ansible-core>=2.15,<2.17",
]
# Deploy tools (for infra staging/production deployments)
deploy = [
"ansible-core>=2.15,<2.17",
"boto3>=1.34",
"docker>=7.0",
"jinja2>=3.1",
"pyyaml>=6.0",
"cryptography>=41.0",
] ]
# Full dev environment (local development) # Full dev environment (local development)
dev = [ dev = [
"devx[ci,lint]", "devx[ci,lint,release,molecule]",
"build>=1.3.0", "build>=1.3.0",
"twine>=6.2.0", "twine>=6.2.0",
] ]
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects.""" """devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.19.3" __version__ = "0.22.0"
+3 -3
View File
@@ -17,7 +17,7 @@ This allows the PR title to be a human-friendly Vikunja task title
while the squashed commit follows conventional commits. while the squashed commit follows conventional commits.
Usage: Usage:
REPO_TOKEN=<token> python3 -m devx.ci.auto_merge <branch> <pr_title> <repo> <pr_number> CI_GITEA_TOKEN=<token> python3 -m devx.ci.auto_merge <branch> <pr_title> <repo> <pr_number>
""" """
import os import os
@@ -196,9 +196,9 @@ def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
@click.argument("repo") @click.argument("repo")
@click.argument("pr_number") @click.argument("pr_number")
def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None: def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
token = os.environ.get("REPO_TOKEN", "") token = os.environ.get("CI_GITEA_TOKEN", "")
if not token: if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set.")) raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
# Validate PR number is an integer # Validate PR number is an integer
try: try:
+7 -5
View File
@@ -15,7 +15,7 @@ Exit code 1 = NOT ready — fix issues before pushing.
Usage:: Usage::
# CI (with VIKUNJA_TOKEN and REPO_TOKEN): # CI (with VIKUNJA_TOKEN and CI_GITEA_TOKEN):
python3 -m devx.ci.check_auto_merge_ready \\ python3 -m devx.ci.check_auto_merge_ready \\
--branch "$HEAD_REF" \\ --branch "$HEAD_REF" \\
--pr-title "$PR_TITLE" \\ --pr-title "$PR_TITLE" \\
@@ -34,7 +34,7 @@ skipped (with a warning) — this allows local pre-push hooks to run
without CI secrets. In CI, the token is always set and the check is without CI secrets. In CI, the token is always set and the check is
mandatory. mandatory.
If ``REPO_TOKEN`` is not set and ``--pr-number`` is not provided, only If ``CI_GITEA_TOKEN`` is not set and ``--pr-number`` is not provided, only
branch-name and PR-title-format checks run (local mode). branch-name and PR-title-format checks run (local mode).
""" """
@@ -98,9 +98,9 @@ def is_branch_behind_master(branch: str) -> bool:
def get_pr_title_from_gitea(repo: str, pr_number: int) -> str | None: def get_pr_title_from_gitea(repo: str, pr_number: int) -> str | None:
"""Fetch the PR title from the Gitea API. """Fetch the PR title from the Gitea API.
Returns ``None`` if ``REPO_TOKEN`` is not set or the PR cannot be fetched. Returns ``None`` if ``CI_GITEA_TOKEN`` is not set or the PR cannot be fetched.
""" """
token = os.environ.get("REPO_TOKEN", "") token = os.environ.get("CI_GITEA_TOKEN", "")
if not token or "/" not in repo: if not token or "/" not in repo:
return None return None
owner, repo_name = repo.split("/", 1) owner, repo_name = repo.split("/", 1)
@@ -189,7 +189,9 @@ def cli(
if pr_title is None: if pr_title is None:
# Local mode without PR — only validate branch name # Local mode without PR — only validate branch name
if pr_number is not None: if pr_number is not None:
raise click.ClickException(_("Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).")) raise click.ClickException(
_("Could not fetch PR title from Gitea (CI_GITEA_TOKEN not set or PR not found).")
)
click.echo("[pre-merge-check] No PR title provided — running branch-name-only check (local mode).") click.echo("[pre-merge-check] No PR title provided — running branch-name-only check (local mode).")
click.echo("[pre-merge-check] Branch name OK. Push to create PR, then CI will validate the title.") click.echo("[pre-merge-check] Branch name OK. Push to create PR, then CI will validate the title.")
return return
+1 -1
View File
@@ -148,7 +148,7 @@ def main(
output_indices: bool, output_indices: bool,
github_output: bool, github_output: bool,
) -> None: ) -> None:
token = os.environ.get("REPO_TOKEN", "") token = os.environ.get("CI_GITEA_TOKEN", "")
if owner is None: if owner is None:
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss") owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss")
+3 -3
View File
@@ -17,7 +17,7 @@ Usage::
Environment variables: Environment variables:
GITEA_URL Base URL of the Gitea instance. GITEA_URL Base URL of the Gitea instance.
REPO_TOKEN API token with repo access. CI_GITEA_TOKEN API token with repo access.
RUN_ID Workflow run ID (GITHUB_RUN_ID). RUN_ID Workflow run ID (GITHUB_RUN_ID).
JOB_NAME Base job name (GITHUB_JOB), e.g. "integration-tests". JOB_NAME Base job name (GITHUB_JOB), e.g. "integration-tests".
MATRIX_INDEX Current matrix index (runner-index). MATRIX_INDEX Current matrix index (runner-index).
@@ -49,7 +49,7 @@ POLL_INTERVAL = 10
def cli(pytest_args: tuple[str, ...]) -> None: def cli(pytest_args: tuple[str, ...]) -> None:
"""Run pytest with cross-runner failure detection.""" """Run pytest with cross-runner failure detection."""
gitea_url = os.environ.get("GITEA_URL", "") gitea_url = os.environ.get("GITEA_URL", "")
token = os.environ.get("REPO_TOKEN", "") token = os.environ.get("CI_GITEA_TOKEN", "")
run_id = int(os.environ.get("RUN_ID", "0")) run_id = int(os.environ.get("RUN_ID", "0"))
job_name = os.environ.get("JOB_NAME", "integration-tests") job_name = os.environ.get("JOB_NAME", "integration-tests")
current_index = int(os.environ.get("MATRIX_INDEX", "0")) current_index = int(os.environ.get("MATRIX_INDEX", "0"))
@@ -59,7 +59,7 @@ def cli(pytest_args: tuple[str, ...]) -> None:
owner, repo = "oblachno-oss", "devx" owner, repo = "oblachno-oss", "devx"
if not all([gitea_url, token, run_id]): if not all([gitea_url, token, run_id]):
click.echo(_("GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.")) click.echo(_("GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
stop_event = threading.Event() stop_event = threading.Event()
failed_event = threading.Event() failed_event = threading.Event()
+7 -52
View File
@@ -6,7 +6,7 @@ otherwise go unnoticed in the Actions tab. Uses the ``tea`` Gitea CLI
for issue creation tea must be installed and configured. for issue creation tea must be installed and configured.
Usage: Usage:
REPO_TOKEN=<token> python3 -m devx.ci.notify_failure \ CI_GITEA_TOKEN=<token> python3 -m devx.ci.notify_failure \
--repo <owner/repo> \ --repo <owner/repo> \
--run-id <run_id> \ --run-id <run_id> \
--workflow <workflow_name> \ --workflow <workflow_name> \
@@ -14,7 +14,7 @@ Usage:
--auto-login --auto-login
With ``--auto-login``, the script configures the tea CLI login profile With ``--auto-login``, the script configures the tea CLI login profile
from ``REPO_TOKEN`` and ``DEVX_GITEA_API_URL`` before creating the issue, from ``CI_GITEA_TOKEN`` and ``DEVX_GITEA_API_URL`` before creating the issue,
eliminating the need for a separate ``tea login add`` step in the workflow. eliminating the need for a separate ``tea login add`` step in the workflow.
""" """
@@ -22,14 +22,12 @@ from __future__ import annotations
import logging import logging
import os import os
import shutil
import subprocess # nosec B404
import click import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.config import GITEA_API_URL from devx.config import GITEA_API_URL
from devx.gitea_cli import TeaCLI, TeaCLIError from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login
from devx.i18n import _ from devx.i18n import _
load_dotenv() load_dotenv()
@@ -37,49 +35,6 @@ load_dotenv()
logger = logging.getLogger("devx") logger = logging.getLogger("devx")
def _configure_tea_login(login_name: str = "devx") -> None:
"""Configure tea CLI login from REPO_TOKEN and DEVX_GITEA_API_URL.
Idempotent: if a login with the same name already exists, it is not re-added.
Skips silently if tea is not installed or REPO_TOKEN is not set.
"""
tea_bin = shutil.which("tea")
if tea_bin is None:
click.echo("notify_failure: tea not installed — skipping login configuration.")
return
token = os.environ.get("REPO_TOKEN", "")
if not token:
click.echo("notify_failure: REPO_TOKEN not set — skipping login configuration.")
return
gitea_url = GITEA_API_URL.replace("/api/v1", "")
result = subprocess.run( # nosec B603
[tea_bin, "login", "list", "--output", "simple"],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0 and login_name in result.stdout:
click.echo(f"notify_failure: tea login '{login_name}' already configured.")
return
click.echo(f"notify_failure: configuring tea login '{login_name}' for {gitea_url}...")
subprocess.run( # nosec B603
[tea_bin, "login", "add", "--name", login_name, "--url", gitea_url, "--token", token],
capture_output=True,
text=True,
check=False,
)
subprocess.run( # nosec B603
[tea_bin, "login", "default", login_name],
capture_output=True,
text=True,
check=False,
)
def _create_issue_via_tea(repo: str, title: str, body: str) -> int: def _create_issue_via_tea(repo: str, title: str, body: str) -> int:
"""Create issue via tea CLI. Returns issue index. """Create issue via tea CLI. Returns issue index.
@@ -116,15 +71,15 @@ def _create_issue_via_tea(repo: str, title: str, body: str) -> int:
"--auto-login", "--auto-login",
is_flag=True, is_flag=True,
default=False, default=False,
help="Configure tea CLI login from REPO_TOKEN before creating the issue.", help="Configure tea CLI login from CI_GITEA_TOKEN before creating the issue.",
) )
def main(repo: str, run_id: str, workflow: str, commit: str, auto_login: bool) -> None: def main(repo: str, run_id: str, workflow: str, commit: str, auto_login: bool) -> None:
token = os.environ.get("REPO_TOKEN", "") token = os.environ.get("CI_GITEA_TOKEN", "")
if not token: if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set.")) raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
if auto_login: if auto_login:
_configure_tea_login() configure_tea_login()
title = f"[CI] {workflow} workflow failed (run #{run_id})" title = f"[CI] {workflow} workflow failed (run #{run_id})"
body = ( body = (
+3 -3
View File
@@ -17,7 +17,7 @@ Checks performed:
8. Commit conventions conventional commit format on branch commits 8. Commit conventions conventional commit format on branch commits
Usage: Usage:
REPO_TOKEN=<token> python3 -m devx.ci.pr_review <pr_number> <owner/repo> CI_GITEA_TOKEN=<token> python3 -m devx.ci.pr_review <pr_number> <owner/repo>
""" """
from __future__ import annotations from __future__ import annotations
@@ -526,9 +526,9 @@ def post_review(client: GiteaClient, pr_number: str, result: ReviewResult) -> di
@click.option("--dry-run", is_flag=True, default=False, help="Print review without posting.") @click.option("--dry-run", is_flag=True, default=False, help="Print review without posting.")
def main(pr_number: str, repo: str, dry_run: bool) -> None: def main(pr_number: str, repo: str, dry_run: bool) -> None:
"""Run automated PR review and post results to Gitea.""" """Run automated PR review and post results to Gitea."""
token = os.environ.get("REPO_TOKEN", "") token = os.environ.get("CI_GITEA_TOKEN", "")
if not token: if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set.")) raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
owner, repo_name = repo.split("/") owner, repo_name = repo.split("/")
client = GiteaClient(GITEA_API_URL, token, owner, repo_name) client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
+17 -6
View File
@@ -9,14 +9,14 @@ Publishing destinations (checked in order):
``DEVX_PYPI_REGISTRY_URL`` env var is set, or ``GITEA_API_URL`` ``DEVX_PYPI_REGISTRY_URL`` env var is set, or ``GITEA_API_URL``
is converted to a packages URL). Uses ``twine upload is converted to a packages URL). Uses ``twine upload
--repository-url <url> -u <token> -p <token>`` with the --repository-url <url> -u <token> -p <token>`` with the
``REPO_TOKEN`` as both username and password. ``CI_GITEA_TOKEN`` as both username and password.
2. **Standard PyPI** if ``PYPI_TOKEN`` is set. Uses the standard 2. **Standard PyPI** if ``PYPI_TOKEN`` is set. Uses the standard
``twine upload -u __token__ -p <token>`` flow. ``twine upload -u __token__ -p <token>`` flow.
3. **Skip** if neither is configured, only the Gitea release is created. 3. **Skip** if neither is configured, only the Gitea release is created.
Usage: Usage:
REPO_TOKEN=<token> [PYPI_TOKEN=<token>] python3 -m devx.ci.publish <tag> <repo> CI_GITEA_TOKEN=<token> [PYPI_TOKEN=<token>] python3 -m devx.ci.publish <tag> <repo>
REPO_TOKEN=<token> python3 -m devx.ci.publish <tag> <repo> --registry-url https://git.example.com/api/packages/owner/pypi CI_GITEA_TOKEN=<token> python3 -m devx.ci.publish <tag> <repo> --registry-url https://git.example.com/api/packages/owner/pypi
""" """
import os import os
@@ -29,7 +29,7 @@ import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.config import GITEA_API_URL from devx.config import GITEA_API_URL
from devx.gitea_cli import TeaCLI, TeaCLIError from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login
from devx.i18n import _ from devx.i18n import _
load_dotenv() load_dotenv()
@@ -221,12 +221,20 @@ def is_release_commit(tag: str) -> bool:
help="Auto-detect latest tag and check if HEAD is a release commit. " help="Auto-detect latest tag and check if HEAD is a release commit. "
"Skips publish if no tag or HEAD is not a release commit for that tag.", "Skips publish if no tag or HEAD is not a release commit for that tag.",
) )
@click.option(
"--auto-login",
is_flag=True,
default=False,
help="Configure tea CLI login from CI_GITEA_TOKEN before creating the Gitea release. "
"Eliminates the need for a separate tea login step in containerized CI jobs.",
)
def main( def main(
tag: str | None, tag: str | None,
repo: str | None, repo: str | None,
registry_url: str | None, registry_url: str | None,
skip_build: bool, skip_build: bool,
from_tag: bool, from_tag: bool,
auto_login: bool,
) -> None: ) -> None:
if repo is None: if repo is None:
repo = os.environ.get("GITHUB_REPOSITORY", "") repo = os.environ.get("GITHUB_REPOSITORY", "")
@@ -245,9 +253,9 @@ def main(
if not tag: if not tag:
raise click.ClickException(_("Tag is required (or use --from-tag).")) raise click.ClickException(_("Tag is required (or use --from-tag)."))
gitea_token = os.environ.get("REPO_TOKEN", "") gitea_token = os.environ.get("CI_GITEA_TOKEN", "")
if not gitea_token: if not gitea_token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set.")) raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
pypi_token = os.environ.get("PYPI_TOKEN", "") pypi_token = os.environ.get("PYPI_TOKEN", "")
@@ -287,6 +295,9 @@ def main(
tea = TeaCLI(repo=repo) tea = TeaCLI(repo=repo)
if auto_login:
configure_tea_login()
# Check if release already exists (idempotent — avoids failure when # Check if release already exists (idempotent — avoids failure when
# called multiple times, e.g. by both post-merge and publish workflows) # called multiple times, e.g. by both post-merge and publish workflows)
try: try:
+15 -1
View File
@@ -29,7 +29,7 @@ version. This prevents duplicate release commits (a common issue when CI
checkouts don't fetch tags) and ensures tag/version/commit alignment. checkouts don't fetch tags) and ensures tag/version/commit alignment.
Usage: Usage:
REPO_TOKEN=<token> python3 -m devx.ci.release [--dry-run] [--skip-tests] CI_GITEA_TOKEN=<token> python3 -m devx.ci.release [--dry-run] [--skip-tests]
python3 -m devx.ci.release --verify # Check tag/version/release alignment python3 -m devx.ci.release --verify # Check tag/version/release alignment
""" """
@@ -669,6 +669,20 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
return return
current_tag = get_latest_tag() current_tag = get_latest_tag()
# If the bumped version equals the current tag version, there's nothing
# new to release. git-cliff didn't bump because the commits since the last
# tag don't warrant a version change (e.g., only ci:/chore: commits).
# Creating a release commit with the same version would cause a tag
# conflict.
if current_tag and current_tag.lstrip("v") == new_version:
click.echo(
_(
"Version stays at v{version} — no version bump from git-cliff. "
"Commits since last tag don't warrant a new release. Skipping.",
version=new_version,
)
)
return
click.echo( click.echo(
_( _(
"Bumping version: {current} -> v{new_version}", "Bumping version: {current} -> v{new_version}",
+3 -3
View File
@@ -14,7 +14,7 @@ Gitea 1.26 wiki API endpoints (all use content_base64, NOT content):
- Delete: DELETE /repos/{owner}/{repo}/wiki/page/{sub_url} - Delete: DELETE /repos/{owner}/{repo}/wiki/page/{sub_url}
Usage: Usage:
REPO_TOKEN=<token> python3 -m devx.ci.sync_wiki [--dry-run] [--repo owner/repo] CI_GITEA_TOKEN=<token> python3 -m devx.ci.sync_wiki [--dry-run] [--repo owner/repo]
""" """
from __future__ import annotations from __future__ import annotations
@@ -225,9 +225,9 @@ def verify_wiki_integrity(
help="Full integrity check: verify page count, missing pages, stale pages, and content. Implies --verify.", help="Full integrity check: verify page count, missing pages, stale pages, and content. Implies --verify.",
) )
def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None: def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
token = os.environ.get("REPO_TOKEN", "") token = os.environ.get("CI_GITEA_TOKEN", "")
if not token: if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set.")) raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
if repo is None: if repo is None:
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss") owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss")
+52
View File
@@ -40,15 +40,67 @@ Usage::
from __future__ import annotations from __future__ import annotations
import json import json
import os
import shutil import shutil
import subprocess # nosec B404 import subprocess # nosec B404
from typing import Any from typing import Any
import click
from devx.config import GITEA_API_URL
from devx.i18n import _
class TeaCLIError(Exception): class TeaCLIError(Exception):
"""Raised when a tea CLI command fails.""" """Raised when a tea CLI command fails."""
def configure_tea_login(login_name: str = "devx") -> None:
"""Configure tea CLI login from CI_GITEA_TOKEN and DEVX_GITEA_API_URL.
Idempotent: if a login with the same name already exists, it is not re-added.
Skips silently if tea is not installed or CI_GITEA_TOKEN is not set.
Used by CI scripts (publish, notify_failure) that need tea login but
run in containerized environments where ``make setup`` was not called.
"""
tea_bin = shutil.which("tea")
if tea_bin is None:
click.echo(_("tea not installed — skipping login configuration."))
return
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
click.echo(_("CI_GITEA_TOKEN not set — skipping login configuration."))
return
gitea_url = GITEA_API_URL.replace("/api/v1", "")
result = subprocess.run( # nosec B603
[tea_bin, "login", "list", "--output", "simple"],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0 and login_name in result.stdout:
click.echo(_("tea login '{name}' already configured.", name=login_name))
return
click.echo(_("Configuring tea login '{name}' for {url}...", name=login_name, url=gitea_url))
subprocess.run( # nosec B603
[tea_bin, "login", "add", "--name", login_name, "--url", gitea_url, "--token", token],
capture_output=True,
text=True,
check=False,
)
subprocess.run( # nosec B603
[tea_bin, "login", "default", login_name],
capture_output=True,
text=True,
check=False,
)
class TeaCLI: class TeaCLI:
"""Wrapper around the ``tea`` Gitea CLI tool. """Wrapper around the ``tea`` Gitea CLI tool.
+73 -11
View File
@@ -54,11 +54,11 @@ DEVX_WORKFLOW_DIR ?= .gitea/workflows
# PIP_INSTALL — helper to run pip with Gitea private PyPI registry configured. # PIP_INSTALL — helper to run pip with Gitea private PyPI registry configured.
# Usage: $(DEVX_PIP_INSTALL) install -e '.[ci,lint]' # Usage: $(DEVX_PIP_INSTALL) install -e '.[ci,lint]'
# GITEA_PYPI_USER can be set in .env, as an env var, or as a Make variable. # CI_GITEA_USERNAME can be set in .env, as an env var, or as a Make variable.
DEVX_PIP_INSTALL := if [ -z "$$REPO_TOKEN" ]; then . ./.env 2>/dev/null; fi; \ DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
REPO_TOKEN="$${REPO_TOKEN:-$$GITEA_REGISTRY_TOKEN}"; \ CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \
_PYPI_USER="$${DEVX_GITEA_PYPI_USER:-$${GITEA_PYPI_USER}}"; \ _PYPI_USER="$${CI_GITEA_USERNAME:-emil}"; \
if [ -n "$$REPO_TOKEN" ] && [ -n "$$_PYPI_USER" ]; then export PIP_EXTRA_INDEX_URL="https://$$_PYPI_USER:$$REPO_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \ if [ -n "$$CI_GITEA_TOKEN" ] && [ -n "$$_PYPI_USER" ]; then export PIP_EXTRA_INDEX_URL="https://$$_PYPI_USER:$$CI_GITEA_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \
$(DEVX_BIN)/pip $(DEVX_BIN)/pip
.PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr devx-check-config .PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr devx-check-config
@@ -69,6 +69,7 @@ DEVX_PIP_INSTALL := if [ -z "$$REPO_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
.PHONY: devx-clean devx-pre-push .PHONY: devx-clean devx-pre-push
.PHONY: devx-check-mutable-globals devx-check-dep-docs devx-check-test-coverage devx-check-docs devx-check-test-speed .PHONY: devx-check-mutable-globals devx-check-dep-docs devx-check-test-coverage devx-check-docs devx-check-test-speed
.PHONY: devx-test-unit devx-pytest-cov .PHONY: devx-test-unit devx-pytest-cov
.PHONY: devx-setup-image
# ── Vikunja task and PR management ──────────────────────────────────────────── # ── Vikunja task and PR management ────────────────────────────────────────────
@@ -95,12 +96,12 @@ devx-push-with-pr: devx-push devx-create-pr
# ── Environment setup ───────────────────────────────────────────────────────── # ── Environment setup ─────────────────────────────────────────────────────────
# Configure Gitea private PyPI registry so pip can find devx and other # Configure Gitea private PyPI registry so pip can find devx and other
# private packages. In CI, REPO_TOKEN is set as a secret. Locally, it's in .env. # private packages. In CI, CI_GITEA_TOKEN is set as a secret. Locally, it's in .env.
devx-configure-gitea-pypi: devx-configure-gitea-pypi:
@if [ -z "$$REPO_TOKEN" ]; then . ./.env 2>/dev/null; fi; \ @if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
REPO_TOKEN="$${REPO_TOKEN:-$$GITEA_REGISTRY_TOKEN}"; \ CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \
if [ -z "$$REPO_TOKEN" ]; then echo "[configure-gitea-pypi] REPO_TOKEN not set — skipping (devx must be on public PyPI)"; exit 0; fi; \ if [ -z "$$CI_GITEA_TOKEN" ]; then echo "[configure-gitea-pypi] CI_GITEA_TOKEN not set — skipping (devx must be on public PyPI)"; exit 0; fi; \
echo "[configure-gitea-pypi] Gitea PyPI registry configured (REPO_TOKEN present)." echo "[configure-gitea-pypi] Gitea PyPI registry configured (CI_GITEA_TOKEN present)."
# Create .env from .env.example if it doesn't exist # Create .env from .env.example if it doesn't exist
devx-env: devx-env:
@@ -173,7 +174,7 @@ devx-workflow-check: devx-workflow-lint devx-workflow-dryrun
# Notify on CI failure — creates a Gitea issue via devx.ci.notify_failure. # Notify on CI failure — creates a Gitea issue via devx.ci.notify_failure.
# Usage: make devx-notify-failure WORKFLOW=post-merge/release # Usage: make devx-notify-failure WORKFLOW=post-merge/release
# Requires: REPO_TOKEN, GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_SHA # Requires: CI_GITEA_TOKEN, GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_SHA
devx-notify-failure: devx-notify-failure:
@. $(DEVX_VENV)/bin/activate 2>/dev/null || true; \ @. $(DEVX_VENV)/bin/activate 2>/dev/null || true; \
export PATH="$(HOME)/.local/bin:$$PATH"; \ export PATH="$(HOME)/.local/bin:$$PATH"; \
@@ -249,3 +250,64 @@ devx-clean:
@find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true @find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
@find . -type f -name "*.pyc" -delete 2>/dev/null || true @find . -type f -name "*.pyc" -delete 2>/dev/null || true
@rm -rf .coverage htmlcov/ dist/ build/ *.egg-info/ .molecule/ 2>/dev/null || true @rm -rf .coverage htmlcov/ dist/ build/ *.egg-info/ .molecule/ 2>/dev/null || true
# ── Pre-built image setup ─────────────────────────────────────────────────────
#
# When running inside a pre-built Docker runner image (ci-base, ci-quality,
# ci-full), all deps are already installed in /opt/venv. This target links
# the venv and installs the project itself (with optional extras).
#
# Usage:
# make devx-setup-image (runtime deps only)
# make devx-setup-image EXTRAS=lint (runtime + lint deps)
# make devx-setup-image EXTRAS=ci,lint (runtime + ci + lint deps)
#
# Falls back to setup-ci if /opt/venv is not present (local dev).
# Note: the fallback target name is project-specific (setup-ci, not
# devx-setup-ci) — each project defines its own setup-ci target.
devx-setup-image:
@if [ -d /opt/venv ]; then ln -sf /opt/venv $(DEVX_VENV); . $(DEVX_BIN)/activate; \
_U="$${CI_GITEA_USERNAME:-emil}"; \
if [ -n "$$CI_GITEA_TOKEN" ]; then export PIP_EXTRA_INDEX_URL="https://$$_U:$$CI_GITEA_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \
pip install -e .$(if $(EXTRAS),[$(EXTRAS)],); \
echo "[devx-setup-image] Linked /opt/venv$(if $(EXTRAS), with [$(EXTRAS)],)."; \
else echo "[devx-setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
# ── Docker image build / push / cleanup ───────────────────────────────────────
#
# Variables:
# DEVX_GITEA_REGISTRY — registry URL (default: git.oblachno.oblachno.fyi)
# DEVX_IMAGE_MANIFEST — path to JSON manifest (default: docker/images.json)
# DEVX_IMAGE_OWNER — package owner for cleanup (default: oblachno-oss)
DEVX_GITEA_REGISTRY ?= git.oblachno.oblachno.fyi
DEVX_IMAGE_MANIFEST ?= docker/images.json
DEVX_IMAGE_OWNER ?= oblachno-oss
# Build all images from manifest (no push)
devx-build-images:
@$(DEVX_PYTHON) -m devx.tools.build_image --manifest $(DEVX_IMAGE_MANIFEST) --pull
# Build and push all images to the Gitea registry
devx-push-images:
@$(DEVX_PYTHON) -m devx.tools.build_image \
--manifest $(DEVX_IMAGE_MANIFEST) \
--registry $(DEVX_GITEA_REGISTRY) \
--push --pull
# Dry-run: show what would be built/pushed
devx-build-images-dry-run:
@$(DEVX_PYTHON) -m devx.tools.build_image \
--manifest $(DEVX_IMAGE_MANIFEST) \
--registry $(DEVX_GITEA_REGISTRY) \
--push --dry-run
# Clean up old image versions (keep last 2 + latest)
devx-clean-images:
@$(DEVX_PYTHON) -m devx.tools.clean_images \
--owner $(DEVX_IMAGE_OWNER) \
--name oblachno-oss/runner-images/ci-base \
--name oblachno-oss/runner-images/ci-quality \
--name oblachno-oss/runner-images/ci-full \
--keep 2
+1 -1
View File
@@ -142,7 +142,7 @@ def main(
output_indices: bool, output_indices: bool,
github_output: bool, github_output: bool,
) -> None: ) -> None:
token = os.environ.get("REPO_TOKEN", "") token = os.environ.get("CI_GITEA_TOKEN", "")
if owner is None: if owner is None:
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss") owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss")
+3 -3
View File
@@ -22,7 +22,7 @@ Usage::
Environment variables: Environment variables:
GITEA_URL Base URL of the Gitea instance. GITEA_URL Base URL of the Gitea instance.
REPO_TOKEN API token with repo access. CI_GITEA_TOKEN API token with repo access.
RUN_ID Workflow run ID (GITHUB_RUN_ID). RUN_ID Workflow run ID (GITHUB_RUN_ID).
JOB_NAME Base job name (GITHUB_JOB), e.g. "molecule-tests". JOB_NAME Base job name (GITHUB_JOB), e.g. "molecule-tests".
MATRIX_INDEX Current matrix index (runner-index). MATRIX_INDEX Current matrix index (runner-index).
@@ -163,7 +163,7 @@ def resolve_role_dir(role: str, roles_root: Path | None, repo_root: Path) -> Pat
def cli(pairs: tuple[str, ...], roles_root: Path | None) -> None: def cli(pairs: tuple[str, ...], roles_root: Path | None) -> None:
"""Run molecule pairs sequentially, stop if another CI runner fails.""" """Run molecule pairs sequentially, stop if another CI runner fails."""
gitea_url = os.environ.get("GITEA_URL", "") gitea_url = os.environ.get("GITEA_URL", "")
token = os.environ.get("REPO_TOKEN", "") token = os.environ.get("CI_GITEA_TOKEN", "")
run_id = int(os.environ.get("RUN_ID", "0")) run_id = int(os.environ.get("RUN_ID", "0"))
job_name = os.environ.get("JOB_NAME", "molecule-tests") job_name = os.environ.get("JOB_NAME", "molecule-tests")
current_index = int(os.environ.get("MATRIX_INDEX", "0")) current_index = int(os.environ.get("MATRIX_INDEX", "0"))
@@ -173,7 +173,7 @@ def cli(pairs: tuple[str, ...], roles_root: Path | None) -> None:
owner, repo = "oblachno-oss", "devx" owner, repo = "oblachno-oss", "devx"
if not all([gitea_url, token, run_id]): if not all([gitea_url, token, run_id]):
click.echo(_("GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.")) click.echo(_("GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
# When devx is installed as a pip package, __file__ resolves to the # When devx is installed as a pip package, __file__ resolves to the
# site-packages directory, not the repo root. Use GITHUB_WORKSPACE # site-packages directory, not the repo root. Use GITHUB_WORKSPACE
+329
View File
@@ -0,0 +1,329 @@
#!/usr/bin/env python3
"""Build and push Docker images to a Gitea container registry.
Replaces raw ``docker build`` / ``docker push`` shell commands with a
tested Python tool. Supports:
- Building from any Dockerfile with a configurable context directory
- Tagging with multiple tags (e.g. ``latest`` + version)
- Optional push to a Gitea registry (with login)
- Dry-run mode (prints commands without executing)
Usage::
# Build a single image
python3 -m devx.tools.build_image \\
--dockerfile docker/ci-base/Dockerfile \\
--tag ci-base:latest \\
--tag ci-base:0.19.3
# Build and push to registry
python3 -m devx.tools.build_image \\
--dockerfile docker/ci-base/Dockerfile \\
--tag ci-base:latest \\
--tag ci-base:0.19.3 \\
--registry git.oblachno.oblachno.fyi \\
--push
# Build multiple images (from a manifest file)
python3 -m devx.tools.build_image --manifest docker/images.json --push
The manifest file is a JSON list of dicts, each with:
- ``name``: image name (e.g. ``ci-base``)
- ``dockerfile``: path to Dockerfile (relative to repo root)
- ``context``: build context directory (optional, defaults to repo root)
- ``tags``: list of tags (optional, defaults to ``["latest"]``)
Registry authentication uses ``CI_GITEA_TOKEN`` and ``CI_GITEA_USERNAME``
environment variables, matching the existing CI workflow patterns.
"""
from __future__ import annotations
import json
import os
import subprocess # nosec B404
from dataclasses import dataclass, field
from pathlib import Path
import click
from devx.i18n import _
@dataclass
class ImageSpec:
"""Specification for a single Docker image to build."""
name: str
dockerfile: str
context: str = "."
tags: list[str] = field(default_factory=lambda: ["latest"])
@classmethod
def from_dict(cls, data: dict[str, object]) -> ImageSpec:
"""Create an ImageSpec from a dict (e.g. from a JSON manifest)."""
name = str(data.get("name", ""))
if not name:
raise ValueError(_("Image manifest entry missing 'name'"))
dockerfile = str(data.get("dockerfile", ""))
if not dockerfile:
raise ValueError(_("Image manifest entry missing 'dockerfile'"))
context = str(data.get("context", "."))
tags_raw = data.get("tags", ["latest"])
if not isinstance(tags_raw, list):
raise ValueError(_("Image 'tags' must be a list"))
tags = [str(t) for t in tags_raw] if tags_raw else ["latest"]
return cls(name=name, dockerfile=dockerfile, context=context, tags=tags)
def load_manifest(path: str | Path) -> list[ImageSpec]:
"""Load a JSON manifest file describing images to build.
The file must contain a JSON list of dicts with at least ``name`` and
``dockerfile`` keys. ``context`` and ``tags`` are optional.
Returns a list of :class:`ImageSpec` instances.
"""
p = Path(path)
if not p.is_file():
raise click.ClickException(_("Manifest file not found: {path}", path=p))
with p.open() as f: # noqa: PTH123
data = json.load(f)
if not isinstance(data, list):
raise click.ClickException(_("Manifest must be a JSON list"))
return [ImageSpec.from_dict(entry) for entry in data]
def build_full_tag(registry: str | None, name: str, tag: str) -> str:
"""Build a full image tag, optionally prefixed with a registry.
>>> build_full_tag(None, "ci-base", "latest")
'ci-base:latest'
>>> build_full_tag("git.example.com", "ci-base", "0.1.0")
'git.example.com/ci-base:0.1.0'
"""
if registry:
return f"{registry}/{name}:{tag}"
return f"{name}:{tag}"
def registry_login(
registry: str,
username: str,
token: str,
*,
dry_run: bool = False,
) -> bool:
"""Log in to a Docker registry.
Returns True on success, False on failure.
In dry-run mode, prints the command without executing.
"""
cmd = ["docker", "login", registry, "-u", username, "--password-stdin"]
if dry_run:
click.echo(f"[dry-run] {' '.join(cmd)}")
return True
result = subprocess.run( # nosec B603
cmd,
input=token,
text=True,
capture_output=True,
check=False,
)
if result.returncode != 0:
click.echo(
_("Registry login failed: {error}", error=result.stderr.strip()),
err=True,
)
return False
click.echo(f"Logged in to {registry}")
return True
def build_image(
spec: ImageSpec,
registry: str | None = None,
*,
dry_run: bool = False,
pull: bool = False,
) -> bool:
"""Build a Docker image from a Dockerfile.
Tags the image with all specified tags, optionally prefixed with the
registry. Returns True on success, False on failure.
"""
if not Path(spec.dockerfile).is_file():
click.echo(
_("Dockerfile not found: {path}", path=spec.dockerfile),
err=True,
)
return False
full_tags = [build_full_tag(registry, spec.name, t) for t in spec.tags]
cmd = ["docker", "build"]
if pull:
cmd.append("--pull")
for ft in full_tags:
cmd.extend(["-t", ft])
cmd.extend(["-f", spec.dockerfile, spec.context])
if dry_run:
click.echo(f"[dry-run] {' '.join(cmd)}")
return True
click.echo(f"Building {spec.name} ({len(full_tags)} tag(s))...")
result = subprocess.run( # nosec B603
cmd,
check=False,
)
if result.returncode != 0:
click.echo(_("Build failed for {name}", name=spec.name), err=True)
return False
click.echo(f"Built {spec.name}")
return True
def push_image(
spec: ImageSpec,
registry: str,
*,
dry_run: bool = False,
) -> bool:
"""Push all tags of a Docker image to the registry.
Returns True if all pushes succeed, False if any fail.
"""
full_tags = [build_full_tag(registry, spec.name, t) for t in spec.tags]
all_ok = True
for ft in full_tags:
cmd = ["docker", "push", ft]
if dry_run:
click.echo(f"[dry-run] {' '.join(cmd)}")
continue
click.echo(f"Pushing {ft}...")
result = subprocess.run( # nosec B603
cmd,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
click.echo(
_("Push failed for {tag}: {error}", tag=ft, error=result.stderr.strip()),
err=True,
)
all_ok = False
else:
click.echo(f"Pushed {ft}")
return all_ok
def _get_registry_creds() -> tuple[str, str]:
"""Get registry credentials from environment variables."""
token = os.environ.get("CI_GITEA_TOKEN", "")
username = os.environ.get("CI_GITEA_USERNAME", "")
return username, token
@click.command()
@click.option(
"--dockerfile",
"dockerfile",
default=None,
help="Path to Dockerfile (for single-image build).",
)
@click.option(
"--context",
"context",
default=".",
help="Build context directory (for single-image build).",
)
@click.option(
"--name",
"name",
default=None,
help="Image name (for single-image build).",
)
@click.option(
"--tag",
"tags",
multiple=True,
help="Tag(s) for the image. Can be repeated. Defaults to 'latest'.",
)
@click.option(
"--manifest",
"manifest",
default=None,
help="Path to JSON manifest file listing images to build.",
)
@click.option(
"--registry",
"registry",
default=None,
help="Registry URL (e.g. git.example.com). If set with --push, images are tagged and pushed there.",
)
@click.option(
"--push",
is_flag=True,
default=False,
help="Push images to the registry after building.",
)
@click.option(
"--dry-run",
is_flag=True,
default=False,
help="Print commands without executing.",
)
@click.option(
"--pull",
is_flag=True,
default=False,
help="Pass --pull to docker build (always fetch latest base image).",
)
def main(
dockerfile: str | None,
context: str,
name: str | None,
tags: tuple[str, ...],
manifest: str | None,
registry: str | None,
push: bool,
dry_run: bool,
pull: bool,
) -> None:
"""Build and optionally push Docker images to a Gitea registry."""
if manifest:
specs = load_manifest(manifest)
elif dockerfile and name:
tag_list = list(tags) if tags else ["latest"]
specs = [ImageSpec(name=name, dockerfile=dockerfile, context=context, tags=tag_list)]
else:
raise click.ClickException(_("Provide --manifest or both --dockerfile and --name"))
if push:
if not registry:
raise click.ClickException(_("--push requires --registry"))
username, token = _get_registry_creds()
if not token or not username:
raise click.ClickException(
_("Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars")
)
if not registry_login(registry, username, token, dry_run=dry_run):
raise click.ClickException(_("Registry login failed"))
failed: list[str] = []
for spec in specs:
if not build_image(spec, registry, dry_run=dry_run, pull=pull):
failed.append(spec.name)
continue
if push and not push_image(spec, registry, dry_run=dry_run): # type: ignore[arg-type]
failed.append(spec.name)
if failed:
raise click.ClickException(_("Failed images: {names}", names=", ".join(failed)))
click.echo(f"\nDone. {len(specs)} image(s) processed.")
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
+221
View File
@@ -0,0 +1,221 @@
#!/usr/bin/env python3
"""Clean up old Docker images from a Gitea container registry.
Queries the Gitea API for all versions of a package (container type) and
deletes all but the most recent N versions. The ``latest`` tag is always
preserved if present.
Usage::
# Clean up ci-base images, keep last 2 versions
python3 -m devx.tools.clean_images \\
--owner oblachno-oss \\
--name ci-base \\
--keep 2
# Clean up multiple images
python3 -m devx.tools.clean_images \\
--owner oblachno-oss \\
--name ci-base \\
--name ci-quality \\
--name ci-full \\
--keep 2
# Dry run (list what would be deleted)
python3 -m devx.tools.clean_images \\
--owner oblachno-oss \\
--name ci-base \\
--keep 2 \\
--dry-run
Authentication uses ``CI_GITEA_TOKEN`` environment variable.
"""
from __future__ import annotations
import os
from typing import Any
import click
import requests
from devx.config import GITEA_API_URL
from devx.i18n import _
def list_package_versions(
api_url: str,
owner: str,
name: str,
token: str,
*,
timeout: int = 30,
) -> list[dict[str, Any]]:
"""List all versions of a container package from the Gitea API.
Returns a list of version dicts, each containing at least ``version``
and ``created_at`` fields.
"""
url = f"{api_url}/packages/{owner}?type=container&name={name}"
headers = {"Authorization": f"token {token}"}
all_versions: list[dict[str, Any]] = []
page = 1
while True:
resp = requests.get(
f"{url}&page={page}&limit=50",
headers=headers,
timeout=timeout,
)
resp.raise_for_status()
data = resp.json()
if not data:
break
all_versions.extend(data)
if len(data) < 50:
break
page += 1
return all_versions
def delete_package_version(
api_url: str,
owner: str,
name: str,
version: str,
token: str,
*,
timeout: int = 30,
) -> bool:
"""Delete a specific version of a container package.
Returns True on success, False on failure.
"""
url = f"{api_url}/packages/{owner}/{name}/{version}"
headers = {"Authorization": f"token {token}"}
resp = requests.delete(url, headers=headers, timeout=timeout)
return resp.status_code in (204, 200)
def sort_versions_by_date(
versions: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Sort package versions by creation date, newest first.
Falls back to version string comparison if created_at is missing.
"""
def _sort_key(v: dict[str, Any]) -> str:
return str(v.get("created_at", v.get("version", "")))
return sorted(versions, key=_sort_key, reverse=True)
def select_for_deletion(
versions: list[dict[str, Any]],
keep: int,
) -> list[dict[str, Any]]:
"""Select versions to delete, keeping the most recent ``keep`` versions.
Versions named ``latest`` are always preserved.
"""
sorted_versions = sort_versions_by_date(versions)
to_delete = sorted_versions[keep:]
# Always preserve 'latest' tag
to_delete = [v for v in to_delete if v.get("version") != "latest"]
return to_delete
@click.command()
@click.option(
"--owner",
required=True,
help="Package owner (user or org).",
)
@click.option(
"--name",
"names",
multiple=True,
required=True,
help="Package name(s). Can be repeated.",
)
@click.option(
"--keep",
default=2,
type=int,
show_default=True,
help="Number of recent versions to keep (excluding 'latest').",
)
@click.option(
"--dry-run",
is_flag=True,
default=False,
help="List versions that would be deleted without actually deleting.",
)
@click.option(
"--api-url",
default=None,
help="Gitea API URL (defaults to DEVX_GITEA_API_URL or built-in default).",
)
def main(
owner: str,
names: tuple[str, ...],
keep: int,
dry_run: bool,
api_url: str | None,
) -> None:
"""Clean up old Docker image versions from a Gitea registry."""
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
raise click.ClickException(_("CI_GITEA_TOKEN environment variable required"))
base_url = api_url or GITEA_API_URL
total_deleted = 0
total_kept = 0
for name in names:
click.echo(f"\n{'=' * 60}")
click.echo(f"Package: {owner}/{name}")
click.echo(f"{'=' * 60}")
try:
versions = list_package_versions(base_url, owner, name, token)
except requests.RequestException as exc:
click.echo(
_("Failed to list versions for {name}: {error}", name=name, error=exc),
err=True,
)
continue
if not versions:
click.echo(_("No versions found."))
continue
click.echo(f"Found {len(versions)} version(s):")
for v in sort_versions_by_date(versions):
click.echo(f" {v.get('version', '?')} (created: {v.get('created_at', '?')})")
to_delete = select_for_deletion(versions, keep)
kept_count = len(versions) - len(to_delete)
click.echo(f"\nKeeping {kept_count}, would delete {len(to_delete)}")
if dry_run:
for v in to_delete:
click.echo(f" [dry-run] Would delete: {v.get('version', '?')}")
total_kept += kept_count
continue
deleted_count = 0
for v in to_delete:
version = str(v.get("version", ""))
if delete_package_version(base_url, owner, name, version, token):
click.echo(f" Deleted: {version}")
deleted_count += 1
else:
click.echo(f" FAILED to delete: {version}", err=True)
total_deleted += deleted_count
total_kept += kept_count
click.echo(f"\nDone. Deleted {total_deleted}, kept {total_kept}.")
if __name__ == "__main__": # pragma: no cover
main() # pragma: no cover
+4 -4
View File
@@ -6,8 +6,8 @@ The ``tea`` CLI is used for label creation if available, with a
fallback to ``GiteaClient`` if tea is not installed. fallback to ``GiteaClient`` if tea is not installed.
Usage: Usage:
REPO_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo CI_GITEA_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo
REPO_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo --owner my-org CI_GITEA_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo --owner my-org
""" """
from __future__ import annotations from __future__ import annotations
@@ -102,7 +102,7 @@ def configure_repo(
api_url: Gitea API base URL. If None, uses ``GITEA_API_URL`` from config. api_url: Gitea API base URL. If None, uses ``GITEA_API_URL`` from config.
""" """
if not token: if not token:
raise click.ClickException(_("ERROR: REPO_TOKEN is not set.")) raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
url = api_url or GITEA_API_URL url = api_url or GITEA_API_URL
client = GiteaClient(url, token, owner, repo) client = GiteaClient(url, token, owner, repo)
@@ -148,7 +148,7 @@ def configure_repo(
) )
def main(repo: str | None, owner: str | None, branch: str, api_url: str | None) -> None: def main(repo: str | None, owner: str | None, branch: str, api_url: str | None) -> None:
"""Configure branch protection and repository settings via the Gitea API.""" """Configure branch protection and repository settings via the Gitea API."""
token = os.environ.get("REPO_TOKEN", "") token = os.environ.get("CI_GITEA_TOKEN", "")
if repo is None: if repo is None:
repo = os.environ.get("DEVX_REPO_NAME", "") repo = os.environ.get("DEVX_REPO_NAME", "")
+2 -2
View File
@@ -123,9 +123,9 @@ def create_pr(
), ),
) )
token = os.environ.get("REPO_TOKEN", "") token = os.environ.get("CI_GITEA_TOKEN", "")
if not token: if not token:
raise click.ClickException(_("REPO_TOKEN is not set. Required to create a PR.")) raise click.ClickException(_("CI_GITEA_TOKEN is not set. Required to create a PR."))
vikunja_title = get_vikunja_task_title(task_id) vikunja_title = get_vikunja_task_title(task_id)
pr_title = f"{task_id}: {vikunja_title}" pr_title = f"{task_id}: {vikunja_title}"
+4 -4
View File
@@ -64,19 +64,19 @@ def _install_ansible_collections(bin_dir: str) -> None:
def _configure_tea_login() -> None: def _configure_tea_login() -> None:
"""Configure tea CLI login from .env if REPO_TOKEN is set. """Configure tea CLI login from .env if CI_GITEA_TOKEN is set.
Idempotent: if a login with the same name already exists, it is not re-added. Idempotent: if a login with the same name already exists, it is not re-added.
Skips if tea is not installed or REPO_TOKEN is not set. Skips if tea is not installed or CI_GITEA_TOKEN is not set.
""" """
tea_bin = shutil.which("tea") tea_bin = shutil.which("tea")
if tea_bin is None: if tea_bin is None:
click.echo("tea: not installed — run 'make install-tools' to install it.") click.echo("tea: not installed — run 'make install-tools' to install it.")
return return
token = os.environ.get("REPO_TOKEN", "") token = os.environ.get("CI_GITEA_TOKEN", "")
if not token: if not token:
click.echo("tea: REPO_TOKEN not set — skipping login configuration.") click.echo("tea: CI_GITEA_TOKEN not set — skipping login configuration.")
return return
api_url = os.environ.get("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1") api_url = os.environ.get("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
+566 -390
View File
@@ -343,6 +343,14 @@
"ru": " Updated: {title}", "ru": " Updated: {title}",
"zh": " Updated: {title}" "zh": " Updated: {title}"
}, },
"--push requires --registry": {
"bg": "--push requires --registry",
"de": "--push requires --registry",
"en": "--push requires --registry",
"pl": "--push requires --registry",
"ru": "--push requires --registry",
"zh": "--push requires --registry"
},
"--skip-build: skipping package build and PyPI publish.": { "--skip-build: skipping package build and PyPI publish.": {
"bg": "--skip-build: skipping package build and PyPI publish.", "bg": "--skip-build: skipping package build and PyPI publish.",
"de": "--skip-build: skipping package build and PyPI publish.", "de": "--skip-build: skipping package build and PyPI publish.",
@@ -367,6 +375,14 @@
"ru": "API poll warning: {exc}", "ru": "API poll warning: {exc}",
"zh": "API poll warning: {exc}" "zh": "API poll warning: {exc}"
}, },
"Additional directory to scan (default: scripts, tests). Can be repeated.": {
"bg": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"de": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"en": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"pl": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"ru": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"zh": "Additional directory to scan (default: scripts, tests). Can be repeated."
},
"All molecule tests passed.": { "All molecule tests passed.": {
"bg": "All molecule tests passed.", "bg": "All molecule tests passed.",
"de": "All molecule tests passed.", "de": "All molecule tests passed.",
@@ -383,6 +399,22 @@
"ru": "Another molecule runner failed. Stopping this runner early.", "ru": "Another molecule runner failed. Stopping this runner early.",
"zh": "Another molecule runner failed. Stopping this runner early." "zh": "Another molecule runner failed. Stopping this runner early."
}, },
"Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description": {
"bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание",
"de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung",
"en": "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description",
"pl": "Gałąź '{branch}' nie zawiera ID zadania.\n Oczekiwany format: {prefix}-N-krótki-opis",
"ru": "Ветка '{branch}' не содержит ID задачи.\n Ожидаемый формат: {prefix}-N-краткое-описание",
"zh": "分支 '{branch}' 不包含任务 ID。\n 预期格式: {prefix}-N-简短描述"
},
"Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description\n Example: {prefix}-42-add-feature\n Fix: rename the branch or create a Vikunja task first:\n python -m devx.tools.create_task --title \"Task title\"": {
"bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание\n Пример: {prefix}-42-add-feature\n Решение: преименувайте клона или създайте Vikunja задача:\n python -m devx.tools.create_task --title \"Заглавие на задача\"",
"de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung\n Beispiel: {prefix}-42-add-feature\n Fix: Branch umbenennen oder Vikunja-Task erstellen:\n python -m devx.tools.create_task --title \"Task-Titel\"",
"en": "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description\n Example: {prefix}-42-add-feature\n Fix: rename the branch or create a Vikunja task first:\n python -m devx.tools.create_task --title \"Task title\"",
"pl": "Gałąź '{branch}' nie zawiera ID zadania.\n Oczekiwany format: {prefix}-N-krótki-opis\n Przykład: {prefix}-42-add-feature\n Naprawa: zmień nazwę gałęzi lub utwórz zadanie Vikunja:\n python -m devx.tools.create_task --title \"Tytuł zadania\"",
"ru": "Ветка '{branch}' не содержит ID задачи.\n Ожидаемый формат: {prefix}-N-краткое-описание\n Пример: {prefix}-42-add-feature\n Исправление: переименуйте ветку или создайте задачу Vikunja:\n python -m devx.tools.create_task --title \"Заголовок задачи\"",
"zh": "分支 '{branch}' 不包含任务 ID。\n 预期格式: {prefix}-N-简短描述\n 示例: {prefix}-42-add-feature\n 修复: 重命名分支或先创建 Vikunja 任务:\n python -m devx.tools.create_task --title \"任务标题\""
},
"Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.": { "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.": {
"bg": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.", "bg": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"de": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.", "de": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
@@ -391,6 +423,38 @@
"ru": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.", "ru": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"zh": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label." "zh": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label."
}, },
"Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master": {
"bg": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
"de": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
"en": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
"pl": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
"ru": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
"zh": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master"
},
"Branch name (e.g., DEVX-256-fix-foo)": {
"bg": "Branch name (e.g., DEVX-256-fix-foo)",
"de": "Branch name (e.g., DEVX-256-fix-foo)",
"en": "Branch name (e.g., DEVX-256-fix-foo)",
"pl": "Branch name (e.g., DEVX-256-fix-foo)",
"ru": "Branch name (e.g., DEVX-256-fix-foo)",
"zh": "Branch name (e.g., DEVX-256-fix-foo)"
},
"Branch name must contain a task ID.": {
"bg": "Branch name must contain a task ID.",
"de": "Branch name must contain a task ID.",
"en": "Branch name must contain a task ID.",
"pl": "Branch name must contain a task ID.",
"ru": "Branch name must contain a task ID.",
"zh": "Branch name must contain a task ID."
},
"Build failed for {name}": {
"bg": "Build failed for {name}",
"de": "Build failed for {name}",
"en": "Build failed for {name}",
"pl": "Build failed for {name}",
"ru": "Build failed for {name}",
"zh": "Build failed for {name}"
},
"Bumping version: {current} -> v{new_version}": { "Bumping version: {current} -> v{new_version}": {
"bg": "Bumping version: {current} -> v{new_version}", "bg": "Bumping version: {current} -> v{new_version}",
"de": "Bumping version: {current} -> v{new_version}", "de": "Bumping version: {current} -> v{new_version}",
@@ -399,6 +463,14 @@
"ru": "Bumping version: {current} -> v{new_version}", "ru": "Bumping version: {current} -> v{new_version}",
"zh": "Bumping version: {current} -> v{new_version}" "zh": "Bumping version: {current} -> v{new_version}"
}, },
"Check that changed files have corresponding tests": {
"bg": "Check that changed files have corresponding tests",
"de": "Check that changed files have corresponding tests",
"en": "Check that changed files have corresponding tests",
"pl": "Check that changed files have corresponding tests",
"ru": "Check that changed files have corresponding tests",
"zh": "Check that changed files have corresponding tests"
},
"Checking CLI command documentation...": { "Checking CLI command documentation...": {
"bg": "Checking CLI command documentation...", "bg": "Checking CLI command documentation...",
"de": "Checking CLI command documentation...", "de": "Checking CLI command documentation...",
@@ -423,6 +495,14 @@
"ru": "Comparing {base}..{head} ({count} files changed)", "ru": "Comparing {base}..{head} ({count} files changed)",
"zh": "Comparing {base}..{head} ({count} files changed)" "zh": "Comparing {base}..{head} ({count} files changed)"
}, },
"Configuration OK: [tool.devx] present, devx versions consistent.": {
"bg": "Конфигурацията е OK: [tool.devx] присъства, версиите на devx са консистентни.",
"de": "Konfiguration OK: [tool.devx] vorhanden, devx-Versionen konsistent.",
"en": "Configuration OK: [tool.devx] present, devx versions consistent.",
"pl": "Konfiguracja OK: [tool.devx] obecne, wersje devx spójne.",
"ru": "Конфигурация OK: [tool.devx] присутствует, версии devx согласованы.",
"zh": "配置正常: [tool.devx] 已存在, devx 版本一致。"
},
"Configuring branch protection for {branch}...": { "Configuring branch protection for {branch}...": {
"bg": "Конфигуриране на защита на клона {branch}...", "bg": "Конфигуриране на защита на клона {branch}...",
"de": "Konfiguriere Branch-Schutz für {branch}...", "de": "Konfiguriere Branch-Schutz für {branch}...",
@@ -439,13 +519,13 @@
"ru": "Настройка параметров репозитория...", "ru": "Настройка параметров репозитория...",
"zh": "正在配置仓库设置..." "zh": "正在配置仓库设置..."
}, },
"Configuration OK: [tool.devx] present, devx versions consistent.": { "Could not detect current branch: {error}": {
"bg": "Конфигурацията е OK: [tool.devx] присъства, версиите на devx са консистентни.", "bg": "Не може да се определи текущия клон: {error}",
"de": "Konfiguration OK: [tool.devx] vorhanden, devx-Versionen konsistent.", "de": "Aktueller Branch konnte nicht erkannt werden: {error}",
"en": "Configuration OK: [tool.devx] present, devx versions consistent.", "en": "Could not detect current branch: {error}",
"pl": "Konfiguracja OK: [tool.devx] obecne, wersje devx spójne.", "pl": "Nie można wykryć bieżącej gałęzi: {error}",
"ru": "Конфигурация OK: [tool.devx] присутствует, версии devx согласованы.", "ru": "Не удалось определить текущую ветку: {error}",
"zh": "配置正常: [tool.devx] 已存在, devx 版本一致。" "zh": "无法检测当前分支: {error}"
}, },
"Could not extract conventional commit message from PR commits.": { "Could not extract conventional commit message from PR commits.": {
"bg": "Could not extract conventional commit message from PR commits.", "bg": "Could not extract conventional commit message from PR commits.",
@@ -455,6 +535,22 @@
"ru": "Could not extract conventional commit message from PR commits.", "ru": "Could not extract conventional commit message from PR commits.",
"zh": "Could not extract conventional commit message from PR commits." "zh": "Could not extract conventional commit message from PR commits."
}, },
"Could not fetch PR title from Gitea (CI_GITEA_TOKEN not set or PR not found).": {
"bg": "Could not fetch PR title from Gitea (CI_GITEA_TOKEN not set or PR not found).",
"de": "Could not fetch PR title from Gitea (CI_GITEA_TOKEN not set or PR not found).",
"en": "Could not fetch PR title from Gitea (CI_GITEA_TOKEN not set or PR not found).",
"pl": "Could not fetch PR title from Gitea (CI_GITEA_TOKEN not set or PR not found).",
"ru": "Could not fetch PR title from Gitea (CI_GITEA_TOKEN not set or PR not found).",
"zh": "Could not fetch PR title from Gitea (CI_GITEA_TOKEN not set or PR not found)."
},
"Could not find Vikunja task {task_id} in project {project_id}.": {
"bg": "Не е намерена Vikunja задача {task_id} в проект {project_id}.",
"de": "Vikunja-Task {task_id} in Projekt {project_id} nicht gefunden.",
"en": "Could not find Vikunja task {task_id} in project {project_id}.",
"pl": "Nie znaleziono zadania Vikunja {task_id} w projekcie {project_id}.",
"ru": "Не найдена задача Vikunja {task_id} в проекте {project_id}.",
"zh": "在项目 {project_id} 中找不到 Vikunja 任务 {task_id}。"
},
"Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.": { "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.": {
"bg": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.", "bg": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
"de": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.", "de": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
@@ -479,6 +575,22 @@
"ru": "Could not parse test execution time from output.", "ru": "Could not parse test execution time from output.",
"zh": "Could not parse test execution time from output." "zh": "Could not parse test execution time from output."
}, },
"Created PR #{index}: {title}\n {url}": {
"bg": "Създаден PR #{index}: {title}\n {url}",
"de": "PR erstellt #{index}: {title}\n {url}",
"en": "Created PR #{index}: {title}\n {url}",
"pl": "Utworzono PR #{index}: {title}\n {url}",
"ru": "Создан PR #{index}: {title}\n {url}",
"zh": "已创建 PR #{index}: {title}\n {url}"
},
"Created Vikunja task: {identifier} (id={task_id})": {
"bg": "Създадена Vikunja задача: {identifier} (id={task_id})",
"de": "Vikunja-Task erstellt: {identifier} (id={task_id})",
"en": "Created Vikunja task: {identifier} (id={task_id})",
"pl": "Utworzono zadanie Vikunja: {identifier} (id={task_id})",
"ru": "Создана задача Vikunja: {identifier} (id={task_id})",
"zh": "已创建 Vikunja 任务: {identifier} (id={task_id})"
},
"Created issue #{issue_id}: {title}": { "Created issue #{issue_id}: {title}": {
"bg": "Created issue #{issue_id}: {title}", "bg": "Created issue #{issue_id}: {title}",
"de": "Created issue #{issue_id}: {title}", "de": "Created issue #{issue_id}: {title}",
@@ -495,13 +607,13 @@
"ru": "Created release commit.", "ru": "Created release commit.",
"zh": "Created release commit." "zh": "Created release commit."
}, },
"devx version mismatch across extras: {detail}": { "Dependencies must have documentation comments.": {
"bg": "несъответствие на версията на devx между extras: {detail}", "bg": "Dependencies must have documentation comments.",
"de": "devx-Versionskonflikt zwischen Extras: {detail}", "de": "Dependencies must have documentation comments.",
"en": "devx version mismatch across extras: {detail}", "en": "Dependencies must have documentation comments.",
"pl": "niezgodność wersji devx między extras: {detail}", "pl": "Dependencies must have documentation comments.",
"ru": "несоответствие версии devx между extras: {detail}", "ru": "Dependencies must have documentation comments.",
"zh": "devx 版本在 extras 之间不一致: {detail}" "zh": "Dependencies must have documentation comments."
}, },
"Docker daemon already running": { "Docker daemon already running": {
"bg": "Докер демонът вече работи", "bg": "Докер демонът вече работи",
@@ -527,6 +639,14 @@
"ru": "Docker-демон запущен", "ru": "Docker-демон запущен",
"zh": "Docker 守护进程已启动" "zh": "Docker 守护进程已启动"
}, },
"Dockerfile not found: {path}": {
"bg": "Dockerfile not found: {path}",
"de": "Dockerfile not found: {path}",
"en": "Dockerfile not found: {path}",
"pl": "Dockerfile not found: {path}",
"ru": "Dockerfile not found: {path}",
"zh": "Dockerfile not found: {path}"
},
"Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.": { "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.": {
"bg": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.", "bg": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
"de": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.", "de": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
@@ -535,13 +655,13 @@
"ru": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.", "ru": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
"zh": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently." "zh": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently."
}, },
"ERROR: REPO_TOKEN is not set.": { "ERROR: CI_GITEA_TOKEN is not set.": {
"bg": "ГРЕШКА: REPO_TOKEN не е зададен.", "bg": "ГРЕШКА: CI_GITEA_TOKEN не е зададен.",
"de": "FEHLER: REPO_TOKEN ist nicht gesetzt.", "de": "FEHLER: CI_GITEA_TOKEN ist nicht gesetzt.",
"en": "ERROR: REPO_TOKEN is not set.", "en": "ERROR: CI_GITEA_TOKEN is not set.",
"pl": "BŁĄD: REPO_TOKEN nie jest ustawiony.", "pl": "BŁĄD: CI_GITEA_TOKEN nie jest ustawiony.",
"ru": "ОШИБКА: REPO_TOKEN не задан.", "ru": "ОШИБКА: CI_GITEA_TOKEN не задан.",
"zh": "错误:未设置 REPO_TOKEN。" "zh": "错误:未设置 CI_GITEA_TOKEN。"
}, },
"ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.": { "ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.": {
"bg": "ГРЕШКА: Името на хранилището не е указано. Използвайте --repo или задайте DEVX_REPO_NAME.", "bg": "ГРЕШКА: Името на хранилището не е указано. Използвайте --repo или задайте DEVX_REPO_NAME.",
@@ -575,6 +695,14 @@
"ru": "ERROR: mapping.json not found at {path}", "ru": "ERROR: mapping.json not found at {path}",
"zh": "ERROR: mapping.json not found at {path}" "zh": "ERROR: mapping.json not found at {path}"
}, },
"FAILED: {count} undocumented dependency/ies": {
"bg": "FAILED: {count} undocumented dependency/ies",
"de": "FAILED: {count} undocumented dependency/ies",
"en": "FAILED: {count} undocumented dependency/ies",
"pl": "FAILED: {count} undocumented dependency/ies",
"ru": "FAILED: {count} undocumented dependency/ies",
"zh": "FAILED: {count} undocumented dependency/ies"
},
"FAILED: {pair} exited with code {code}": { "FAILED: {pair} exited with code {code}": {
"bg": "FAILED: {pair} exited with code {code}", "bg": "FAILED: {pair} exited with code {code}",
"de": "FAILED: {pair} exited with code {code}", "de": "FAILED: {pair} exited with code {code}",
@@ -583,6 +711,14 @@
"ru": "FAILED: {pair} exited with code {code}", "ru": "FAILED: {pair} exited with code {code}",
"zh": "FAILED: {pair} exited with code {code}" "zh": "FAILED: {pair} exited with code {code}"
}, },
"Failed images: {names}": {
"bg": "Failed images: {names}",
"de": "Failed images: {names}",
"en": "Failed images: {names}",
"pl": "Failed images: {names}",
"ru": "Failed images: {names}",
"zh": "Failed images: {names}"
},
"Failed to create issue via tea: {error}": { "Failed to create issue via tea: {error}": {
"bg": "Failed to create issue via tea: {error}", "bg": "Failed to create issue via tea: {error}",
"de": "Failed to create issue via tea: {error}", "de": "Failed to create issue via tea: {error}",
@@ -591,6 +727,14 @@
"ru": "Failed to create issue via tea: {error}", "ru": "Failed to create issue via tea: {error}",
"zh": "Failed to create issue via tea: {error}" "zh": "Failed to create issue via tea: {error}"
}, },
"Failed to list versions for {name}: {error}": {
"bg": "Failed to list versions for {name}: {error}",
"de": "Failed to list versions for {name}: {error}",
"en": "Failed to list versions for {name}: {error}",
"pl": "Failed to list versions for {name}: {error}",
"ru": "Failed to list versions for {name}: {error}",
"zh": "Failed to list versions for {name}: {error}"
},
"Found {count} existing wiki pages.": { "Found {count} existing wiki pages.": {
"bg": "Found {count} existing wiki pages.", "bg": "Found {count} existing wiki pages.",
"de": "Found {count} existing wiki pages.", "de": "Found {count} existing wiki pages.",
@@ -599,13 +743,29 @@
"ru": "Found {count} existing wiki pages.", "ru": "Found {count} existing wiki pages.",
"zh": "Found {count} existing wiki pages." "zh": "Found {count} existing wiki pages."
}, },
"GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.": { "Found {count} mutable global(s) — use factory functions or pytest fixtures.": {
"bg": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.", "bg": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
"de": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.", "de": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
"en": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.", "en": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
"pl": "GITEA_URL/REPO_TOKEN/RUN_ID nie ustawione; uruchamianie bez anulowania między runnerami.", "pl": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
"ru": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.", "ru": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
"zh": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation." "zh": "Found {count} mutable global(s) — use factory functions or pytest fixtures."
},
"Found {count} stale documentation reference(s)": {
"bg": "Found {count} stale documentation reference(s)",
"de": "Found {count} stale documentation reference(s)",
"en": "Found {count} stale documentation reference(s)",
"pl": "Found {count} stale documentation reference(s)",
"ru": "Found {count} stale documentation reference(s)",
"zh": "Found {count} stale documentation reference(s)"
},
"GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.": {
"bg": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"de": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"en": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"pl": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID nie ustawione; uruchamianie bez anulowania między runnerami.",
"ru": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"zh": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation."
}, },
"Generated {file} with prefix '{prefix}'.": { "Generated {file} with prefix '{prefix}'.": {
"bg": "Generated {file} with prefix '{prefix}'.", "bg": "Generated {file} with prefix '{prefix}'.",
@@ -687,6 +847,30 @@
"ru": "Хост Docker недоступен, запускается локальный dockerd...", "ru": "Хост Docker недоступен, запускается локальный dockerd...",
"zh": "主机 Docker 不可用,正在启动本地 dockerd..." "zh": "主机 Docker 不可用,正在启动本地 dockerd..."
}, },
"Image 'tags' must be a list": {
"bg": "Image 'tags' must be a list",
"de": "Image 'tags' must be a list",
"en": "Image 'tags' must be a list",
"pl": "Image 'tags' must be a list",
"ru": "Image 'tags' must be a list",
"zh": "Image 'tags' must be a list"
},
"Image manifest entry missing 'dockerfile'": {
"bg": "Image manifest entry missing 'dockerfile'",
"de": "Image manifest entry missing 'dockerfile'",
"en": "Image manifest entry missing 'dockerfile'",
"pl": "Image manifest entry missing 'dockerfile'",
"ru": "Image manifest entry missing 'dockerfile'",
"zh": "Image manifest entry missing 'dockerfile'"
},
"Image manifest entry missing 'name'": {
"bg": "Image manifest entry missing 'name'",
"de": "Image manifest entry missing 'name'",
"en": "Image manifest entry missing 'name'",
"pl": "Image manifest entry missing 'name'",
"ru": "Image manifest entry missing 'name'",
"zh": "Image manifest entry missing 'name'"
},
"Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}": { "Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}": {
"bg": "Инфраструктурен commit (без идентификатор на задача DEVX-N), пропускаме обновяването на Vikunja: {msg}", "bg": "Инфраструктурен commit (без идентификатор на задача DEVX-N), пропускаме обновяването на Vikunja: {msg}",
"de": "Infrastruktur-Commit (keine DEVX-N Task-ID), Vikunja-Update wird übersprungen: {msg}", "de": "Infrastruktur-Commit (keine DEVX-N Task-ID), Vikunja-Update wird übersprungen: {msg}",
@@ -735,6 +919,22 @@
"ru": "Lint passed.", "ru": "Lint passed.",
"zh": "Lint passed." "zh": "Lint passed."
}, },
"Manifest file not found: {path}": {
"bg": "Manifest file not found: {path}",
"de": "Manifest file not found: {path}",
"en": "Manifest file not found: {path}",
"pl": "Manifest file not found: {path}",
"ru": "Manifest file not found: {path}",
"zh": "Manifest file not found: {path}"
},
"Manifest must be a JSON list": {
"bg": "Manifest must be a JSON list",
"de": "Manifest must be a JSON list",
"en": "Manifest must be a JSON list",
"pl": "Manifest must be a JSON list",
"ru": "Manifest must be a JSON list",
"zh": "Manifest must be a JSON list"
},
"Mapped file {file} is empty. Update the content or remove from mapping.json.": { "Mapped file {file} is empty. Update the content or remove from mapping.json.": {
"bg": "Mapped file {file} is empty. Update the content or remove from mapping.json.", "bg": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
"de": "Mapped file {file} is empty. Update the content or remove from mapping.json.", "de": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
@@ -775,6 +975,14 @@
"ru": "Директория molecule не найдена: {path}", "ru": "Директория molecule не найдена: {path}",
"zh": "未找到 molecule 目录: {path}" "zh": "未找到 molecule 目录: {path}"
}, },
"Next steps:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-short-description\n 3. Implement changes, commit with conventional commit format\n 4. git push -u origin HEAD\n 5. make create-pr (creates PR with title: {identifier}: {title})": {
"bg": "Следващи стъпки:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-кратко-описание\n 3. Имплементирайте промените, commit с conventional commit формат\n 4. git push -u origin HEAD\n 5. make create-pr (създава PR с заглавие: {identifier}: {title})",
"de": "Nächste Schritte:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-kurz-beschreibung\n 3. Änderungen implementieren, mit Conventional-Commit-Format committen\n 4. git push -u origin HEAD\n 5. make create-pr (erstellt PR mit Titel: {identifier}: {title})",
"en": "Next steps:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-short-description\n 3. Implement changes, commit with conventional commit format\n 4. git push -u origin HEAD\n 5. make create-pr (creates PR with title: {identifier}: {title})",
"pl": "Następne kroki:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-krótki-opis\n 3. Wprowadź zmiany, commituj w formacie conventional commit\n 4. git push -u origin HEAD\n 5. make create-pr (tworzy PR z tytułem: {identifier}: {title})",
"ru": "Следующие шаги:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-краткое-описание\n 3. Реализуйте изменения, коммитьте в conventional commit формате\n 4. git push -u origin HEAD\n 5. make create-pr (создаёт PR с заголовком: {identifier}: {title})",
"zh": "后续步骤:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-简短描述\n 3. 实现更改,使用 conventional commit 格式提交\n 4. git push -u origin HEAD\n 5. make create-pr (创建 PR,标题: {identifier}: {title})"
},
"Nice! Gitea release {tag} created.": { "Nice! Gitea release {tag} created.": {
"bg": "Отлично! Gitea release {tag} е създаден.", "bg": "Отлично! Gitea release {tag} е създаден.",
"de": "Prima! Gitea-Release {tag} erstellt.", "de": "Prima! Gitea-Release {tag} erstellt.",
@@ -847,6 +1055,14 @@
"ru": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.", "ru": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
"zh": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID." "zh": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID."
}, },
"No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.": {
"bg": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
"de": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
"en": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
"pl": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
"ru": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
"zh": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description."
},
"No unreleased changes found. Nothing to release.": { "No unreleased changes found. Nothing to release.": {
"bg": "No unreleased changes found. Nothing to release.", "bg": "No unreleased changes found. Nothing to release.",
"de": "No unreleased changes found. Nothing to release.", "de": "No unreleased changes found. Nothing to release.",
@@ -863,6 +1079,14 @@
"ru": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.", "ru": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
"zh": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release." "zh": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release."
}, },
"No versions found.": {
"bg": "No versions found.",
"de": "No versions found.",
"en": "No versions found.",
"pl": "No versions found.",
"ru": "No versions found.",
"zh": "No versions found."
},
"Note: Self-approval not allowed. Posting COMMENT instead.": { "Note: Self-approval not allowed. Posting COMMENT instead.": {
"bg": "Note: Self-approval not allowed. Posting COMMENT instead.", "bg": "Note: Self-approval not allowed. Posting COMMENT instead.",
"de": "Note: Self-approval not allowed. Posting COMMENT instead.", "de": "Note: Self-approval not allowed. Posting COMMENT instead.",
@@ -871,6 +1095,14 @@
"ru": "Note: Self-approval not allowed. Posting COMMENT instead.", "ru": "Note: Self-approval not allowed. Posting COMMENT instead.",
"zh": "Note: Self-approval not allowed. Posting COMMENT instead." "zh": "Note: Self-approval not allowed. Posting COMMENT instead."
}, },
"Only check staged files (for pre-commit)": {
"bg": "Only check staged files (for pre-commit)",
"de": "Only check staged files (for pre-commit)",
"en": "Only check staged files (for pre-commit)",
"pl": "Only check staged files (for pre-commit)",
"ru": "Only check staged files (for pre-commit)",
"zh": "Only check staged files (for pre-commit)"
},
"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": { "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", "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", "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",
@@ -959,6 +1191,22 @@
"ru": "PASSED: {pair}", "ru": "PASSED: {pair}",
"zh": "PASSED: {pair}" "zh": "PASSED: {pair}"
}, },
"PR already exists: #{index} — {url}": {
"bg": "PR вече съществува: #{index} — {url}",
"de": "PR existiert bereits: #{index} — {url}",
"en": "PR already exists: #{index} — {url}",
"pl": "PR już istnieje: #{index} — {url}",
"ru": "PR уже существует: #{index} — {url}",
"zh": "PR 已存在: #{index} — {url}"
},
"PR number (to fetch title from Gitea)": {
"bg": "PR number (to fetch title from Gitea)",
"de": "PR number (to fetch title from Gitea)",
"en": "PR number (to fetch title from Gitea)",
"pl": "PR number (to fetch title from Gitea)",
"ru": "PR number (to fetch title from Gitea)",
"zh": "PR number (to fetch title from Gitea)"
},
"PR number must be an integer, got: {pr_number}": { "PR number must be an integer, got: {pr_number}": {
"bg": "PR number must be an integer, got: {pr_number}", "bg": "PR number must be an integer, got: {pr_number}",
"de": "PR number must be an integer, got: {pr_number}", "de": "PR number must be an integer, got: {pr_number}",
@@ -967,6 +1215,14 @@
"ru": "PR number must be an integer, got: {pr_number}", "ru": "PR number must be an integer, got: {pr_number}",
"zh": "PR number must be an integer, got: {pr_number}" "zh": "PR number must be an integer, got: {pr_number}"
}, },
"PR title (auto-fetched if --pr-number given)": {
"bg": "PR title (auto-fetched if --pr-number given)",
"de": "PR title (auto-fetched if --pr-number given)",
"en": "PR title (auto-fetched if --pr-number given)",
"pl": "PR title (auto-fetched if --pr-number given)",
"ru": "PR title (auto-fetched if --pr-number given)",
"zh": "PR title (auto-fetched if --pr-number given)"
},
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}": { "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}": {
"bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}", "bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
"de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}", "de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
@@ -975,6 +1231,30 @@
"ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}", "ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
"zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}" "zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}"
}, },
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}": {
"bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
"de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
"en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
"pl": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
"ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
"zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}"
},
"PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}": {
"bg": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
"de": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
"en": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
"pl": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
"ru": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
"zh": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}"
},
"PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}": {
"bg": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
"de": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
"en": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
"pl": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
"ru": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
"zh": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}"
},
"PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.": { "PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.": {
"bg": "PYPI_TOKEN не е зададен и няма конфигуриран URL на registry — пропускаме публикуването в PyPI. Без притеснения, просто ще създадем Gitea release.", "bg": "PYPI_TOKEN не е зададен и няма конфигуриран URL на registry — пропускаме публикуването в PyPI. Без притеснения, просто ще създадем Gitea release.",
"de": "PYPI_TOKEN nicht gesetzt und keine Registry-URL konfiguriert — PyPI-Veröffentlichung wird übersprungen. Keine Sorge, wir erstellen einfach das Gitea-Release.", "de": "PYPI_TOKEN nicht gesetzt und keine Registry-URL konfiguriert — PyPI-Veröffentlichung wird übersprungen. Keine Sorge, wir erstellen einfach das Gitea-Release.",
@@ -991,6 +1271,14 @@
"ru": "Извлечён owner={owner}, repo={repo} из DEVX_REPO_NAME", "ru": "Извлечён owner={owner}, repo={repo} из DEVX_REPO_NAME",
"zh": "从 DEVX_REPO_NAME 解析 owner={owner}, repo={repo}" "zh": "从 DEVX_REPO_NAME 解析 owner={owner}, repo={repo}"
}, },
"Path to pyproject.toml (default: pyproject.toml in CWD).": {
"bg": "Path to pyproject.toml (default: pyproject.toml in CWD).",
"de": "Path to pyproject.toml (default: pyproject.toml in CWD).",
"en": "Path to pyproject.toml (default: pyproject.toml in CWD).",
"pl": "Path to pyproject.toml (default: pyproject.toml in CWD).",
"ru": "Path to pyproject.toml (default: pyproject.toml in CWD).",
"zh": "Path to pyproject.toml (default: pyproject.toml in CWD)."
},
"Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.": { "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.": {
"bg": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.", "bg": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
"de": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.", "de": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
@@ -999,6 +1287,38 @@
"ru": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.", "ru": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
"zh": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit." "zh": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit."
}, },
"Pre-merge validation failed.": {
"bg": "Pre-merge validation failed.",
"de": "Pre-merge validation failed.",
"en": "Pre-merge validation failed.",
"pl": "Pre-merge validation failed.",
"ru": "Pre-merge validation failed.",
"zh": "Pre-merge validation failed."
},
"Pre-push check passed: task {task_id} exists.": {
"bg": "Pre-push проверката премина: задача {task_id} съществува.",
"de": "Pre-push-Prüfung bestanden: Task {task_id} existiert.",
"en": "Pre-push check passed: task {task_id} exists.",
"pl": "Sprawdzanie pre-push zakończone: zadanie {task_id} istnieje.",
"ru": "Pre-push проверка пройдена: задача {task_id} существует.",
"zh": "Pre-push 检查通过: 任务 {task_id} 存在。"
},
"Print warnings but always exit 0": {
"bg": "Print warnings but always exit 0",
"de": "Print warnings but always exit 0",
"en": "Print warnings but always exit 0",
"pl": "Print warnings but always exit 0",
"ru": "Print warnings but always exit 0",
"zh": "Print warnings but always exit 0"
},
"Provide --manifest or both --dockerfile and --name": {
"bg": "Provide --manifest or both --dockerfile and --name",
"de": "Provide --manifest or both --dockerfile and --name",
"en": "Provide --manifest or both --dockerfile and --name",
"pl": "Provide --manifest or both --dockerfile and --name",
"ru": "Provide --manifest or both --dockerfile and --name",
"zh": "Provide --manifest or both --dockerfile and --name"
},
"Provide a commit message file or use --git.": { "Provide a commit message file or use --git.": {
"bg": "Provide a commit message file or use --git.", "bg": "Provide a commit message file or use --git.",
"de": "Provide a commit message file or use --git.", "de": "Provide a commit message file or use --git.",
@@ -1031,6 +1351,14 @@
"ru": "Publishing release {tag}...", "ru": "Publishing release {tag}...",
"zh": "Publishing release {tag}..." "zh": "Publishing release {tag}..."
}, },
"Push failed for {tag}: {error}": {
"bg": "Push failed for {tag}: {error}",
"de": "Push failed for {tag}: {error}",
"en": "Push failed for {tag}: {error}",
"pl": "Push failed for {tag}: {error}",
"ru": "Push failed for {tag}: {error}",
"zh": "Push failed for {tag}: {error}"
},
"Pushed release commit to master.": { "Pushed release commit to master.": {
"bg": "Pushed release commit to master.", "bg": "Pushed release commit to master.",
"de": "Pushed release commit to master.", "de": "Pushed release commit to master.",
@@ -1047,6 +1375,54 @@
"ru": "Публикация в PyPI не удалась (некритично — продолжаем создание Gitea release):\n{error}", "ru": "Публикация в PyPI не удалась (некритично — продолжаем создание Gitea release):\n{error}",
"zh": "PyPI 发布失败(非致命 — 继续创建 Gitea release):\n{error}" "zh": "PyPI 发布失败(非致命 — 继续创建 Gitea release):\n{error}"
}, },
"REPO argument is required (or set GITHUB_REPOSITORY env var).": {
"bg": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"de": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"en": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"pl": "Argument REPO jest wymagany (lub ustaw zmienną GITHUB_REPOSITORY).",
"ru": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"zh": "REPO argument is required (or set GITHUB_REPOSITORY env var)."
},
"CI_GITEA_TOKEN environment variable required": {
"bg": "CI_GITEA_TOKEN environment variable required",
"de": "CI_GITEA_TOKEN environment variable required",
"en": "CI_GITEA_TOKEN environment variable required",
"pl": "CI_GITEA_TOKEN environment variable required",
"ru": "CI_GITEA_TOKEN environment variable required",
"zh": "CI_GITEA_TOKEN environment variable required"
},
"CI_GITEA_TOKEN is not set. Required to create a PR.": {
"bg": "CI_GITEA_TOKEN не е зададен. Необходим за създаване на PR.",
"de": "CI_GITEA_TOKEN nicht gesetzt. Erforderlich zum Erstellen eines PR.",
"en": "CI_GITEA_TOKEN is not set. Required to create a PR.",
"pl": "CI_GITEA_TOKEN nie jest ustawiony. Wymagany do utworzenia PR.",
"ru": "CI_GITEA_TOKEN не установлен. Требуется для создания PR.",
"zh": "CI_GITEA_TOKEN 未设置。创建 PR 所需。"
},
"Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars": {
"bg": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars",
"de": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars",
"en": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars",
"pl": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars",
"ru": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars",
"zh": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars"
},
"Registry login failed": {
"bg": "Registry login failed",
"de": "Registry login failed",
"en": "Registry login failed",
"pl": "Registry login failed",
"ru": "Registry login failed",
"zh": "Registry login failed"
},
"Registry login failed: {error}": {
"bg": "Registry login failed: {error}",
"de": "Registry login failed: {error}",
"en": "Registry login failed: {error}",
"pl": "Registry login failed: {error}",
"ru": "Registry login failed: {error}",
"zh": "Registry login failed: {error}"
},
"Release creation failed: {error}": { "Release creation failed: {error}": {
"bg": "Release creation failed: {error}", "bg": "Release creation failed: {error}",
"de": "Release creation failed: {error}", "de": "Release creation failed: {error}",
@@ -1079,6 +1455,30 @@
"ru": "Конфигурация репозитория завершена.", "ru": "Конфигурация репозитория завершена.",
"zh": "仓库配置完成。" "zh": "仓库配置完成。"
}, },
"Repository in owner/name format": {
"bg": "Repository in owner/name format",
"de": "Repository in owner/name format",
"en": "Repository in owner/name format",
"pl": "Repository in owner/name format",
"ru": "Repository in owner/name format",
"zh": "Repository in owner/name format"
},
"Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var.": {
"bg": "Името на хранилището не е зададено. Използвайте DEVX_REPO_NAME или GITHUB_REPOSITORY env var.",
"de": "Repository-Name nicht gesetzt. Verwende DEVX_REPO_NAME oder GITHUB_REPOSITORY env var.",
"en": "Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var.",
"pl": "Nazwa repozytorium nie jest ustawiona. Użyj DEVX_REPO_NAME lub GITHUB_REPOSITORY env var.",
"ru": "Имя репозитория не установлено. Используйте DEVX_REPO_NAME или GITHUB_REPOSITORY env var.",
"zh": "仓库名称未设置。使用 DEVX_REPO_NAME 或 GITHUB_REPOSITORY 环境变量。"
},
"Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.": {
"bg": "Собственикът на хранилището не е зададен. Използвайте --owner или DEVX_REPO_OWNER env var.",
"de": "Repository-Owner nicht gesetzt. Verwende --owner oder DEVX_REPO_OWNER env var.",
"en": "Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.",
"pl": "Właściciel repozytorium nie jest ustawiony. Użyj --owner lub DEVX_REPO_OWNER env var.",
"ru": "Владелец репозитория не установлен. Используйте --owner или DEVX_REPO_OWNER env var.",
"zh": "仓库所有者未设置。使用 --owner 或 DEVX_REPO_OWNER 环境变量。"
},
"Roles directory not found: {path}": { "Roles directory not found: {path}": {
"bg": "Roles directory not found: {path}", "bg": "Roles directory not found: {path}",
"de": "Roles directory not found: {path}", "de": "Roles directory not found: {path}",
@@ -1119,6 +1519,22 @@
"ru": "Running: {scenario} on {platform}", "ru": "Running: {scenario} on {platform}",
"zh": "Running: {scenario} on {platform}" "zh": "Running: {scenario} on {platform}"
}, },
"Skip Vikunja title match check": {
"bg": "Skip Vikunja title match check",
"de": "Skip Vikunja title match check",
"en": "Skip Vikunja title match check",
"pl": "Skip Vikunja title match check",
"ru": "Skip Vikunja title match check",
"zh": "Skip Vikunja title match check"
},
"Skip branch-behind-master check": {
"bg": "Skip branch-behind-master check",
"de": "Skip branch-behind-master check",
"en": "Skip branch-behind-master check",
"pl": "Skip branch-behind-master check",
"ru": "Skip branch-behind-master check",
"zh": "Skip branch-behind-master check"
},
"Skipping commit push — no staged changes.": { "Skipping commit push — no staged changes.": {
"bg": "Skipping commit push — no staged changes.", "bg": "Skipping commit push — no staged changes.",
"de": "Skipping commit push — no staged changes.", "de": "Skipping commit push — no staged changes.",
@@ -1151,14 +1567,6 @@
"ru": "Tag is required (or use --from-tag).", "ru": "Tag is required (or use --from-tag).",
"zh": "Tag is required (or use --from-tag)." "zh": "Tag is required (or use --from-tag)."
}, },
"REPO argument is required (or set GITHUB_REPOSITORY env var).": {
"bg": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"de": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"en": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"pl": "Argument REPO jest wymagany (lub ustaw zmienną GITHUB_REPOSITORY).",
"ru": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"zh": "REPO argument is required (or set GITHUB_REPOSITORY env var)."
},
"Tag v{version} already existed. Publish workflow should already have been triggered.": { "Tag v{version} already existed. Publish workflow should already have been triggered.": {
"bg": "Tag v{version} already existed. Publish workflow should already have been triggered.", "bg": "Tag v{version} already existed. Publish workflow should already have been triggered.",
"de": "Tag v{version} already existed. Publish workflow should already have been triggered.", "de": "Tag v{version} already existed. Publish workflow should already have been triggered.",
@@ -1255,6 +1663,22 @@
"ru": "Updated {changelog_file}", "ru": "Updated {changelog_file}",
"zh": "Updated {changelog_file}" "zh": "Updated {changelog_file}"
}, },
"VIKUNJA_TOKEN is not set. Required to derive PR title.": {
"bg": "VIKUNJA_TOKEN не е зададен. Необходим за извличане на PR заглавие.",
"de": "VIKUNJA_TOKEN nicht gesetzt. Erforderlich zum Ableiten des PR-Titels.",
"en": "VIKUNJA_TOKEN is not set. Required to derive PR title.",
"pl": "VIKUNJA_TOKEN nie jest ustawiony. Wymagany do pobrania tytułu PR.",
"ru": "VIKUNJA_TOKEN не установлен. Требуется для получения заголовка PR.",
"zh": "VIKUNJA_TOKEN 未设置。推导 PR 标题所需。"
},
"VIKUNJA_TOKEN is not set. Set it in .env or environment.": {
"bg": "VIKUNJA_TOKEN не е зададен. Задайте го в .env или средата.",
"de": "VIKUNJA_TOKEN nicht gesetzt. In .env oder Umgebung setzen.",
"en": "VIKUNJA_TOKEN is not set. Set it in .env or environment.",
"pl": "VIKUNJA_TOKEN nie jest ustawiony. Ustaw go w .env lub środowisku.",
"ru": "VIKUNJA_TOKEN не установлен. Установите его в .env или среде.",
"zh": "VIKUNJA_TOKEN 未设置。在 .env 或环境中设置它。"
},
"VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.": { "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.": {
"bg": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.", "bg": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
"de": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.", "de": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
@@ -1279,6 +1703,14 @@
"ru": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.", "ru": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
"zh": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update." "zh": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update."
}, },
"Vikunja task {task_id} not found in project {project_id}.\n Create it first:\n python -m devx.tools.create_task --title \"Task title\"\n Or check that the task ID in the branch name is correct.": {
"bg": "Vikunja задача {task_id} не е намерена в проект {project_id}.\n Създайте я първо:\n python -m devx.tools.create_task --title \"Заглавие на задача\"\n Или проверете че ID на задачата в името на клона е правилно.",
"de": "Vikunja-Task {task_id} in Projekt {project_id} nicht gefunden.\n Zuerst erstellen:\n python -m devx.tools.create_task --title \"Task-Titel\"\n Oder prüfen, ob die Task-ID im Branch-Namen korrekt ist.",
"en": "Vikunja task {task_id} not found in project {project_id}.\n Create it first:\n python -m devx.tools.create_task --title \"Task title\"\n Or check that the task ID in the branch name is correct.",
"pl": "Zadanie Vikunja {task_id} nie znalezione w projekcie {project_id}.\n Utwórz je najpierw:\n python -m devx.tools.create_task --title \"Tytuł zadania\"\n Lub sprawdź, czy ID zadania w nazwie gałęzi jest poprawne.",
"ru": "Задача Vikunja {task_id} не найдена в проекте {project_id}.\n Сначала создайте её:\n python -m devx.tools.create_task --title \"Заголовок задачи\"\n Или проверьте, что ID задачи в имени ветки корректен.",
"zh": "在项目 {project_id} 中找不到 Vikunja 任务 {task_id}。\n 请先创建:\n python -m devx.tools.create_task --title \"任务标题\"\n 或检查分支名称中的任务 ID 是否正确。"
},
"WARNING: --skip-tests passed — skipping test verification.": { "WARNING: --skip-tests passed — skipping test verification.": {
"bg": "WARNING: --skip-tests passed — skipping test verification.", "bg": "WARNING: --skip-tests passed — skipping test verification.",
"de": "WARNING: --skip-tests passed — skipping test verification.", "de": "WARNING: --skip-tests passed — skipping test verification.",
@@ -1295,6 +1727,14 @@
"ru": "ВНИМАНИЕ: Файл .taskid ({file_id}) устарел и не совпадает с именем ветки ({branch_id}). Удалите .taskid из репозитория — имя ветки — единственный источник истины.", "ru": "ВНИМАНИЕ: Файл .taskid ({file_id}) устарел и не совпадает с именем ветки ({branch_id}). Удалите .taskid из репозитория — имя ветки — единственный источник истины.",
"zh": "警告:.taskid 文件 ({file_id}) 已弃用,与分支名称 ({branch_id}) 不一致。请从仓库中删除 .taskid — 分支名称是唯一的真实来源。" "zh": "警告:.taskid 文件 ({file_id}) 已弃用,与分支名称 ({branch_id}) 不一致。请从仓库中删除 .taskid — 分支名称是唯一的真实来源。"
}, },
"WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.": {
"bg": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не е зададен — пропускане на проверката за съществуване на задача. Задайте го в .env за пълна валидация.",
"de": "WARNUNG: VIKUNJA_TOKEN nicht gesetzt — Task-Existenzprüfung übersprungen. In .env setzen für volle Validierung.",
"en": "WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.",
"pl": "OSTRZEŻENIE: VIKUNJA_TOKEN nie jest ustawiony — pomijanie sprawdzania istnienia zadania. Ustaw w .env, aby włączyć pełną walidację.",
"ru": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не установлен — пропуск проверки существования задачи. Установите в .env для полной проверки.",
"zh": "警告: VIKUNJA_TOKEN 未设置 — 跳过任务存在性检查。在 .env 中设置以启用完整验证。"
},
"Warning: could not fetch tags from origin.": { "Warning: could not fetch tags from origin.": {
"bg": "Warning: could not fetch tags from origin.", "bg": "Warning: could not fetch tags from origin.",
"de": "Warning: could not fetch tags from origin.", "de": "Warning: could not fetch tags from origin.",
@@ -1319,13 +1759,45 @@
"ru": "Wiki verification failed — {failures} page(s) empty or mismatched", "ru": "Wiki verification failed — {failures} page(s) empty or mismatched",
"zh": "Wiki verification failed — {failures} page(s) empty or mismatched" "zh": "Wiki verification failed — {failures} page(s) empty or mismatched"
}, },
"[tool.devx] missing required keys: {keys}": { "Wrote tag {tag} to GITHUB_OUTPUT.": {
"bg": "[tool.devx] липсват задължителни ключове: {keys}", "bg": "Wrote tag {tag} to GITHUB_OUTPUT.",
"de": "[tool.devx] fehlt erforderliche Schlüssel: {keys}", "de": "Wrote tag {tag} to GITHUB_OUTPUT.",
"en": "[tool.devx] missing required keys: {keys}", "en": "Wrote tag {tag} to GITHUB_OUTPUT.",
"pl": "[tool.devx] brak wymaganych kluczy: {keys}", "ru": "Wrote tag {tag} to GITHUB_OUTPUT.",
"ru": "[tool.devx] отсутствуют обязательные ключи: {keys}", "zh": "Wrote tag {tag} to GITHUB_OUTPUT.",
"zh": "[tool.devx] 缺少必需的键: {keys}" "pl": "Wrote tag {tag} to GITHUB_OUTPUT."
},
"[check-dep-docs] Passed: all dependencies are documented": {
"bg": "[check-dep-docs] Passed: all dependencies are documented",
"de": "[check-dep-docs] Passed: all dependencies are documented",
"en": "[check-dep-docs] Passed: all dependencies are documented",
"pl": "[check-dep-docs] Passed: all dependencies are documented",
"ru": "[check-dep-docs] Passed: all dependencies are documented",
"zh": "[check-dep-docs] Passed: all dependencies are documented"
},
"[check-mutable-globals] Passed: no mutable path globals found": {
"bg": "[check-mutable-globals] Passed: no mutable path globals found",
"de": "[check-mutable-globals] Passed: no mutable path globals found",
"en": "[check-mutable-globals] Passed: no mutable path globals found",
"pl": "[check-mutable-globals] Passed: no mutable path globals found",
"ru": "[check-mutable-globals] Passed: no mutable path globals found",
"zh": "[check-mutable-globals] Passed: no mutable path globals found"
},
"[check_agent_docs] Passed: scanned {count} file(s), no stale references": {
"bg": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
"de": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
"en": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
"pl": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
"ru": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
"zh": "[check_agent_docs] Passed: scanned {count} file(s), no stale references"
},
"[check_test_coverage] No changed files to check.": {
"bg": "[check_test_coverage] No changed files to check.",
"de": "[check_test_coverage] No changed files to check.",
"en": "[check_test_coverage] No changed files to check.",
"pl": "[check_test_coverage] No changed files to check.",
"ru": "[check_test_coverage] No changed files to check.",
"zh": "[check_test_coverage] No changed files to check."
}, },
"[dry-run] Would commit: release: v{version}": { "[dry-run] Would commit: release: v{version}": {
"bg": "[dry-run] Would commit: release: v{version}", "bg": "[dry-run] Would commit: release: v{version}",
@@ -1383,6 +1855,14 @@
"ru": "[dry-run] Would update {init}", "ru": "[dry-run] Would update {init}",
"zh": "[dry-run] Would update {init}" "zh": "[dry-run] Would update {init}"
}, },
"[tool.devx] missing required keys: {keys}": {
"bg": "[tool.devx] липсват задължителни ключове: {keys}",
"de": "[tool.devx] fehlt erforderliche Schlüssel: {keys}",
"en": "[tool.devx] missing required keys: {keys}",
"pl": "[tool.devx] brak wymaganych kluczy: {keys}",
"ru": "[tool.devx] отсутствуют обязательные ключи: {keys}",
"zh": "[tool.devx] 缺少必需的键: {keys}"
},
"active": { "active": {
"bg": "активен", "bg": "активен",
"de": "aktiv", "de": "aktiv",
@@ -1399,6 +1879,14 @@
"ru": "завершён", "ru": "завершён",
"zh": "已完成" "zh": "已完成"
}, },
"devx version mismatch across extras: {detail}": {
"bg": "несъответствие на версията на devx между extras: {detail}",
"de": "devx-Versionskonflikt zwischen Extras: {detail}",
"en": "devx version mismatch across extras: {detail}",
"pl": "niezgodność wersji devx między extras: {detail}",
"ru": "несоответствие версии devx между extras: {detail}",
"zh": "devx 版本在 extras 之间不一致: {detail}"
},
"failed": { "failed": {
"bg": "неуспешен", "bg": "неуспешен",
"de": "fehlgeschlagen", "de": "fehlgeschlagen",
@@ -1503,356 +1991,44 @@
"ru": "{file} already exists. Use --force to overwrite.", "ru": "{file} already exists. Use --force to overwrite.",
"zh": "{file} already exists. Use --force to overwrite." "zh": "{file} already exists. Use --force to overwrite."
}, },
"Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description": { "Version stays at v{version} — no version bump from git-cliff. Commits since last tag don't warrant a new release. Skipping.": {
"bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание", "bg": "",
"de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung", "de": "",
"en": "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description", "en": "Version stays at v{version} — no version bump from git-cliff. Commits since last tag don't warrant a new release. Skipping.",
"pl": "Gałąź '{branch}' nie zawiera ID zadania.\n Oczekiwany format: {prefix}-N-krótki-opis", "pl": "",
"ru": "Ветка '{branch}' не содержит ID задачи.\n Ожидаемый формат: {prefix}-N-краткое-описание", "ru": "",
"zh": "分支 '{branch}' 不包含任务 ID。\n 预期格式: {prefix}-N-简短描述" "zh": ""
}, },
"Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description\n Example: {prefix}-42-add-feature\n Fix: rename the branch or create a Vikunja task first:\n python -m devx.tools.create_task --title \"Task title\"": { "tea not installed — skipping login configuration.": {
"bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание\n Пример: {prefix}-42-add-feature\n Решение: преименувайте клона или създайте Vikunja задача:\n python -m devx.tools.create_task --title \"Заглавие на задача\"", "bg": "tea not installed — skipping login configuration.",
"de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung\n Beispiel: {prefix}-42-add-feature\n Fix: Branch umbenennen oder Vikunja-Task erstellen:\n python -m devx.tools.create_task --title \"Task-Titel\"", "de": "tea not installed — skipping login configuration.",
"en": "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description\n Example: {prefix}-42-add-feature\n Fix: rename the branch or create a Vikunja task first:\n python -m devx.tools.create_task --title \"Task title\"", "en": "tea not installed — skipping login configuration.",
"pl": "Gałąź '{branch}' nie zawiera ID zadania.\n Oczekiwany format: {prefix}-N-krótki-opis\n Przykład: {prefix}-42-add-feature\n Naprawa: zmień nazwę gałęzi lub utwórz zadanie Vikunja:\n python -m devx.tools.create_task --title \"Tytuł zadania\"", "pl": "tea not installed — skipping login configuration.",
"ru": "Ветка '{branch}' не содержит ID задачи.\n Ожидаемый формат: {prefix}-N-краткое-описание\n Пример: {prefix}-42-add-feature\n Исправление: переименуйте ветку или создайте задачу Vikunja:\n python -m devx.tools.create_task --title \"Заголовок задачи\"", "ru": "tea not installed — skipping login configuration.",
"zh": "分支 '{branch}' 不包含任务 ID。\n 预期格式: {prefix}-N-简短描述\n 示例: {prefix}-42-add-feature\n 修复: 重命名分支或先创建 Vikunja 任务:\n python -m devx.tools.create_task --title \"任务标题\"" "zh": "tea not installed — skipping login configuration."
}, },
"Could not find Vikunja task {task_id} in project {project_id}.": { "CI_GITEA_TOKEN not set — skipping login configuration.": {
"bg": "Не е намерена Vikunja задача {task_id} в проект {project_id}.", "bg": "CI_GITEA_TOKEN not set — skipping login configuration.",
"de": "Vikunja-Task {task_id} in Projekt {project_id} nicht gefunden.", "de": "CI_GITEA_TOKEN not set — skipping login configuration.",
"en": "Could not find Vikunja task {task_id} in project {project_id}.", "en": "CI_GITEA_TOKEN not set — skipping login configuration.",
"pl": "Nie znaleziono zadania Vikunja {task_id} w projekcie {project_id}.", "pl": "CI_GITEA_TOKEN not set — skipping login configuration.",
"ru": "Не найдена задача Vikunja {task_id} в проекте {project_id}.", "ru": "CI_GITEA_TOKEN not set — skipping login configuration.",
"zh": "在项目 {project_id} 中找不到 Vikunja 任务 {task_id}。" "zh": "CI_GITEA_TOKEN not set — skipping login configuration."
}, },
"Could not detect current branch: {error}": { "tea login '{name}' already configured.": {
"bg": "Не може да се определи текущия клон: {error}", "bg": "tea login '{name}' already configured.",
"de": "Aktueller Branch konnte nicht erkannt werden: {error}", "de": "tea login '{name}' already configured.",
"en": "Could not detect current branch: {error}", "en": "tea login '{name}' already configured.",
"pl": "Nie można wykryć bieżącej gałęzi: {error}", "pl": "tea login '{name}' already configured.",
"ru": "Не удалось определить текущую ветку: {error}", "ru": "tea login '{name}' already configured.",
"zh": "无法检测当前分支: {error}" "zh": "tea login '{name}' already configured."
}, },
"Created PR #{index}: {title}\n {url}": { "Configuring tea login '{name}' for {url}...": {
"bg": "Създаден PR #{index}: {title}\n {url}", "bg": "Configuring tea login '{name}' for {url}...",
"de": "PR erstellt #{index}: {title}\n {url}", "de": "Configuring tea login '{name}' for {url}...",
"en": "Created PR #{index}: {title}\n {url}", "en": "Configuring tea login '{name}' for {url}...",
"pl": "Utworzono PR #{index}: {title}\n {url}", "pl": "Configuring tea login '{name}' for {url}...",
"ru": "Создан PR #{index}: {title}\n {url}", "ru": "Configuring tea login '{name}' for {url}...",
"zh": "已创建 PR #{index}: {title}\n {url}" "zh": "Configuring tea login '{name}' for {url}..."
},
"Created Vikunja task: {identifier} (id={task_id})": {
"bg": "Създадена Vikunja задача: {identifier} (id={task_id})",
"de": "Vikunja-Task erstellt: {identifier} (id={task_id})",
"en": "Created Vikunja task: {identifier} (id={task_id})",
"pl": "Utworzono zadanie Vikunja: {identifier} (id={task_id})",
"ru": "Создана задача Vikunja: {identifier} (id={task_id})",
"zh": "已创建 Vikunja 任务: {identifier} (id={task_id})"
},
"Next steps:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-short-description\n 3. Implement changes, commit with conventional commit format\n 4. git push -u origin HEAD\n 5. make create-pr (creates PR with title: {identifier}: {title})": {
"bg": "Следващи стъпки:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-кратко-описание\n 3. Имплементирайте промените, commit с conventional commit формат\n 4. git push -u origin HEAD\n 5. make create-pr (създава PR с заглавие: {identifier}: {title})",
"de": "Nächste Schritte:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-kurz-beschreibung\n 3. Änderungen implementieren, mit Conventional-Commit-Format committen\n 4. git push -u origin HEAD\n 5. make create-pr (erstellt PR mit Titel: {identifier}: {title})",
"en": "Next steps:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-short-description\n 3. Implement changes, commit with conventional commit format\n 4. git push -u origin HEAD\n 5. make create-pr (creates PR with title: {identifier}: {title})",
"pl": "Następne kroki:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-krótki-opis\n 3. Wprowadź zmiany, commituj w formacie conventional commit\n 4. git push -u origin HEAD\n 5. make create-pr (tworzy PR z tytułem: {identifier}: {title})",
"ru": "Следующие шаги:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-краткое-описание\n 3. Реализуйте изменения, коммитьте в conventional commit формате\n 4. git push -u origin HEAD\n 5. make create-pr (создаёт PR с заголовком: {identifier}: {title})",
"zh": "后续步骤:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-简短描述\n 3. 实现更改,使用 conventional commit 格式提交\n 4. git push -u origin HEAD\n 5. make create-pr (创建 PR,标题: {identifier}: {title})"
},
"PR already exists: #{index} — {url}": {
"bg": "PR вече съществува: #{index} — {url}",
"de": "PR existiert bereits: #{index} — {url}",
"en": "PR already exists: #{index} — {url}",
"pl": "PR już istnieje: #{index} — {url}",
"ru": "PR уже существует: #{index} — {url}",
"zh": "PR 已存在: #{index} — {url}"
},
"Pre-push check passed: task {task_id} exists.": {
"bg": "Pre-push проверката премина: задача {task_id} съществува.",
"de": "Pre-push-Prüfung bestanden: Task {task_id} existiert.",
"en": "Pre-push check passed: task {task_id} exists.",
"pl": "Sprawdzanie pre-push zakończone: zadanie {task_id} istnieje.",
"ru": "Pre-push проверка пройдена: задача {task_id} существует.",
"zh": "Pre-push 检查通过: 任务 {task_id} 存在。"
},
"REPO_TOKEN is not set. Required to create a PR.": {
"bg": "REPO_TOKEN не е зададен. Необходим за създаване на PR.",
"de": "REPO_TOKEN nicht gesetzt. Erforderlich zum Erstellen eines PR.",
"en": "REPO_TOKEN is not set. Required to create a PR.",
"pl": "REPO_TOKEN nie jest ustawiony. Wymagany do utworzenia PR.",
"ru": "REPO_TOKEN не установлен. Требуется для создания PR.",
"zh": "REPO_TOKEN 未设置。创建 PR 所需。"
},
"Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var.": {
"bg": "Името на хранилището не е зададено. Използвайте DEVX_REPO_NAME или GITHUB_REPOSITORY env var.",
"de": "Repository-Name nicht gesetzt. Verwende DEVX_REPO_NAME oder GITHUB_REPOSITORY env var.",
"en": "Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var.",
"pl": "Nazwa repozytorium nie jest ustawiona. Użyj DEVX_REPO_NAME lub GITHUB_REPOSITORY env var.",
"ru": "Имя репозитория не установлено. Используйте DEVX_REPO_NAME или GITHUB_REPOSITORY env var.",
"zh": "仓库名称未设置。使用 DEVX_REPO_NAME 或 GITHUB_REPOSITORY 环境变量。"
},
"Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.": {
"bg": "Собственикът на хранилището не е зададен. Използвайте --owner или DEVX_REPO_OWNER env var.",
"de": "Repository-Owner nicht gesetzt. Verwende --owner oder DEVX_REPO_OWNER env var.",
"en": "Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.",
"pl": "Właściciel repozytorium nie jest ustawiony. Użyj --owner lub DEVX_REPO_OWNER env var.",
"ru": "Владелец репозитория не установлен. Используйте --owner или DEVX_REPO_OWNER env var.",
"zh": "仓库所有者未设置。使用 --owner 或 DEVX_REPO_OWNER 环境变量。"
},
"VIKUNJA_TOKEN is not set. Required to derive PR title.": {
"bg": "VIKUNJA_TOKEN не е зададен. Необходим за извличане на PR заглавие.",
"de": "VIKUNJA_TOKEN nicht gesetzt. Erforderlich zum Ableiten des PR-Titels.",
"en": "VIKUNJA_TOKEN is not set. Required to derive PR title.",
"pl": "VIKUNJA_TOKEN nie jest ustawiony. Wymagany do pobrania tytułu PR.",
"ru": "VIKUNJA_TOKEN не установлен. Требуется для получения заголовка PR.",
"zh": "VIKUNJA_TOKEN 未设置。推导 PR 标题所需。"
},
"VIKUNJA_TOKEN is not set. Set it in .env or environment.": {
"bg": "VIKUNJA_TOKEN не е зададен. Задайте го в .env или средата.",
"de": "VIKUNJA_TOKEN nicht gesetzt. In .env oder Umgebung setzen.",
"en": "VIKUNJA_TOKEN is not set. Set it in .env or environment.",
"pl": "VIKUNJA_TOKEN nie jest ustawiony. Ustaw go w .env lub środowisku.",
"ru": "VIKUNJA_TOKEN не установлен. Установите его в .env или среде.",
"zh": "VIKUNJA_TOKEN 未设置。在 .env 或环境中设置它。"
},
"Vikunja task {task_id} not found in project {project_id}.\n Create it first:\n python -m devx.tools.create_task --title \"Task title\"\n Or check that the task ID in the branch name is correct.": {
"bg": "Vikunja задача {task_id} не е намерена в проект {project_id}.\n Създайте я първо:\n python -m devx.tools.create_task --title \"Заглавие на задача\"\n Или проверете че ID на задачата в името на клона е правилно.",
"de": "Vikunja-Task {task_id} in Projekt {project_id} nicht gefunden.\n Zuerst erstellen:\n python -m devx.tools.create_task --title \"Task-Titel\"\n Oder prüfen, ob die Task-ID im Branch-Namen korrekt ist.",
"en": "Vikunja task {task_id} not found in project {project_id}.\n Create it first:\n python -m devx.tools.create_task --title \"Task title\"\n Or check that the task ID in the branch name is correct.",
"pl": "Zadanie Vikunja {task_id} nie znalezione w projekcie {project_id}.\n Utwórz je najpierw:\n python -m devx.tools.create_task --title \"Tytuł zadania\"\n Lub sprawdź, czy ID zadania w nazwie gałęzi jest poprawne.",
"ru": "Задача Vikunja {task_id} не найдена в проекте {project_id}.\n Сначала создайте её:\n python -m devx.tools.create_task --title \"Заголовок задачи\"\n Или проверьте, что ID задачи в имени ветки корректен.",
"zh": "在项目 {project_id} 中找不到 Vikunja 任务 {task_id}。\n 请先创建:\n python -m devx.tools.create_task --title \"任务标题\"\n 或检查分支名称中的任务 ID 是否正确。"
},
"WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.": {
"bg": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не е зададен — пропускане на проверката за съществуване на задача. Задайте го в .env за пълна валидация.",
"de": "WARNUNG: VIKUNJA_TOKEN nicht gesetzt — Task-Existenzprüfung übersprungen. In .env setzen für volle Validierung.",
"en": "WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.",
"pl": "OSTRZEŻENIE: VIKUNJA_TOKEN nie jest ustawiony — pomijanie sprawdzania istnienia zadania. Ustaw w .env, aby włączyć pełną walidację.",
"ru": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не установлен — пропуск проверки существования задачи. Установите в .env для полной проверки.",
"zh": "警告: VIKUNJA_TOKEN 未设置 — 跳过任务存在性检查。在 .env 中设置以启用完整验证。"
},
"[check-mutable-globals] Passed: no mutable path globals found": {
"bg": "[check-mutable-globals] Passed: no mutable path globals found",
"de": "[check-mutable-globals] Passed: no mutable path globals found",
"en": "[check-mutable-globals] Passed: no mutable path globals found",
"pl": "[check-mutable-globals] Passed: no mutable path globals found",
"ru": "[check-mutable-globals] Passed: no mutable path globals found",
"zh": "[check-mutable-globals] Passed: no mutable path globals found"
},
"[check_agent_docs] Passed: scanned {count} file(s), no stale references": {
"bg": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
"de": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
"en": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
"pl": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
"ru": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
"zh": "[check_agent_docs] Passed: scanned {count} file(s), no stale references"
},
"[check_test_coverage] No changed files to check.": {
"bg": "[check_test_coverage] No changed files to check.",
"de": "[check_test_coverage] No changed files to check.",
"en": "[check_test_coverage] No changed files to check.",
"pl": "[check_test_coverage] No changed files to check.",
"ru": "[check_test_coverage] No changed files to check.",
"zh": "[check_test_coverage] No changed files to check."
},
"Additional directory to scan (default: scripts, tests). Can be repeated.": {
"bg": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"de": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"en": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"pl": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"ru": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"zh": "Additional directory to scan (default: scripts, tests). Can be repeated."
},
"Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master": {
"bg": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
"de": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
"en": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
"pl": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
"ru": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
"zh": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master"
},
"Branch name (e.g., DEVX-256-fix-foo)": {
"bg": "Branch name (e.g., DEVX-256-fix-foo)",
"de": "Branch name (e.g., DEVX-256-fix-foo)",
"en": "Branch name (e.g., DEVX-256-fix-foo)",
"pl": "Branch name (e.g., DEVX-256-fix-foo)",
"ru": "Branch name (e.g., DEVX-256-fix-foo)",
"zh": "Branch name (e.g., DEVX-256-fix-foo)"
},
"Branch name must contain a task ID.": {
"bg": "Branch name must contain a task ID.",
"de": "Branch name must contain a task ID.",
"en": "Branch name must contain a task ID.",
"pl": "Branch name must contain a task ID.",
"ru": "Branch name must contain a task ID.",
"zh": "Branch name must contain a task ID."
},
"Check that changed files have corresponding tests": {
"bg": "Check that changed files have corresponding tests",
"de": "Check that changed files have corresponding tests",
"en": "Check that changed files have corresponding tests",
"pl": "Check that changed files have corresponding tests",
"ru": "Check that changed files have corresponding tests",
"zh": "Check that changed files have corresponding tests"
},
"Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).": {
"bg": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
"de": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
"en": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
"pl": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
"ru": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
"zh": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found)."
},
"Dependencies must have documentation comments.": {
"bg": "Dependencies must have documentation comments.",
"de": "Dependencies must have documentation comments.",
"en": "Dependencies must have documentation comments.",
"pl": "Dependencies must have documentation comments.",
"ru": "Dependencies must have documentation comments.",
"zh": "Dependencies must have documentation comments."
},
"FAILED: {count} undocumented dependency/ies": {
"bg": "FAILED: {count} undocumented dependency/ies",
"de": "FAILED: {count} undocumented dependency/ies",
"en": "FAILED: {count} undocumented dependency/ies",
"pl": "FAILED: {count} undocumented dependency/ies",
"ru": "FAILED: {count} undocumented dependency/ies",
"zh": "FAILED: {count} undocumented dependency/ies"
},
"Found {count} mutable global(s) — use factory functions or pytest fixtures.": {
"bg": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
"de": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
"en": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
"pl": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
"ru": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
"zh": "Found {count} mutable global(s) — use factory functions or pytest fixtures."
},
"Found {count} stale documentation reference(s)": {
"bg": "Found {count} stale documentation reference(s)",
"de": "Found {count} stale documentation reference(s)",
"en": "Found {count} stale documentation reference(s)",
"pl": "Found {count} stale documentation reference(s)",
"ru": "Found {count} stale documentation reference(s)",
"zh": "Found {count} stale documentation reference(s)"
},
"No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.": {
"bg": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
"de": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
"en": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
"pl": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
"ru": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
"zh": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description."
},
"Only check staged files (for pre-commit)": {
"bg": "Only check staged files (for pre-commit)",
"de": "Only check staged files (for pre-commit)",
"en": "Only check staged files (for pre-commit)",
"pl": "Only check staged files (for pre-commit)",
"ru": "Only check staged files (for pre-commit)",
"zh": "Only check staged files (for pre-commit)"
},
"PR number (to fetch title from Gitea)": {
"bg": "PR number (to fetch title from Gitea)",
"de": "PR number (to fetch title from Gitea)",
"en": "PR number (to fetch title from Gitea)",
"pl": "PR number (to fetch title from Gitea)",
"ru": "PR number (to fetch title from Gitea)",
"zh": "PR number (to fetch title from Gitea)"
},
"PR title (auto-fetched if --pr-number given)": {
"bg": "PR title (auto-fetched if --pr-number given)",
"de": "PR title (auto-fetched if --pr-number given)",
"en": "PR title (auto-fetched if --pr-number given)",
"pl": "PR title (auto-fetched if --pr-number given)",
"ru": "PR title (auto-fetched if --pr-number given)",
"zh": "PR title (auto-fetched if --pr-number given)"
},
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}": {
"bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
"de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
"en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
"pl": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
"ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
"zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}"
},
"PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}": {
"bg": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
"de": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
"en": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
"pl": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
"ru": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
"zh": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}"
},
"PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}": {
"bg": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
"de": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
"en": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
"pl": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
"ru": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
"zh": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}"
},
"Path to pyproject.toml (default: pyproject.toml in CWD).": {
"bg": "Path to pyproject.toml (default: pyproject.toml in CWD).",
"de": "Path to pyproject.toml (default: pyproject.toml in CWD).",
"en": "Path to pyproject.toml (default: pyproject.toml in CWD).",
"pl": "Path to pyproject.toml (default: pyproject.toml in CWD).",
"ru": "Path to pyproject.toml (default: pyproject.toml in CWD).",
"zh": "Path to pyproject.toml (default: pyproject.toml in CWD)."
},
"Pre-merge validation failed.": {
"bg": "Pre-merge validation failed.",
"de": "Pre-merge validation failed.",
"en": "Pre-merge validation failed.",
"pl": "Pre-merge validation failed.",
"ru": "Pre-merge validation failed.",
"zh": "Pre-merge validation failed."
},
"Print warnings but always exit 0": {
"bg": "Print warnings but always exit 0",
"de": "Print warnings but always exit 0",
"en": "Print warnings but always exit 0",
"pl": "Print warnings but always exit 0",
"ru": "Print warnings but always exit 0",
"zh": "Print warnings but always exit 0"
},
"Repository in owner/name format": {
"bg": "Repository in owner/name format",
"de": "Repository in owner/name format",
"en": "Repository in owner/name format",
"pl": "Repository in owner/name format",
"ru": "Repository in owner/name format",
"zh": "Repository in owner/name format"
},
"Skip Vikunja title match check": {
"bg": "Skip Vikunja title match check",
"de": "Skip Vikunja title match check",
"en": "Skip Vikunja title match check",
"pl": "Skip Vikunja title match check",
"ru": "Skip Vikunja title match check",
"zh": "Skip Vikunja title match check"
},
"Skip branch-behind-master check": {
"bg": "Skip branch-behind-master check",
"de": "Skip branch-behind-master check",
"en": "Skip branch-behind-master check",
"pl": "Skip branch-behind-master check",
"ru": "Skip branch-behind-master check",
"zh": "Skip branch-behind-master check"
},
"[check-dep-docs] Passed: all dependencies are documented": {
"bg": "[check-dep-docs] Passed: all dependencies are documented",
"de": "[check-dep-docs] Passed: all dependencies are documented",
"en": "[check-dep-docs] Passed: all dependencies are documented",
"pl": "[check-dep-docs] Passed: all dependencies are documented",
"ru": "[check-dep-docs] Passed: all dependencies are documented",
"zh": "[check-dep-docs] Passed: all dependencies are documented"
},
"Wrote tag {tag} to GITHUB_OUTPUT.": {
"bg": "Wrote tag {tag} to GITHUB_OUTPUT.",
"de": "Wrote tag {tag} to GITHUB_OUTPUT.",
"en": "Wrote tag {tag} to GITHUB_OUTPUT.",
"ru": "Wrote tag {tag} to GITHUB_OUTPUT.",
"zh": "Wrote tag {tag} to GITHUB_OUTPUT.",
"pl": "Wrote tag {tag} to GITHUB_OUTPUT."
} }
} }
+11 -11
View File
@@ -217,7 +217,7 @@ class TestRunCmd:
class TestMain: class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja") @patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient") @patch("devx.ci.auto_merge.GiteaClient")
def test_full_merge_flow( def test_full_merge_flow(
@@ -239,14 +239,14 @@ class TestMain:
assert result.exit_code == 0, result.output assert result.exit_code == 0, result.output
mock_client.merge_pr.assert_called_once_with(7, "DEVX-19: fix: resolve timeout") mock_client.merge_pr.assert_called_once_with(7, "DEVX-19: fix: resolve timeout")
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": ""}, clear=True)
def test_no_token_raises(self) -> None: def test_no_token_raises(self) -> None:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["DEVX-19-fix", "DEVX-19: test", "owner/repo", "7"]) result = runner.invoke(main, ["DEVX-19-fix", "DEVX-19: test", "owner/repo", "7"])
assert result.exit_code != 0 assert result.exit_code != 0
assert "REPO_TOKEN" in result.output assert "CI_GITEA_TOKEN" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.GiteaClient") @patch("devx.ci.auto_merge.GiteaClient")
def test_no_task_id_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] def test_no_task_id_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
@@ -256,7 +256,7 @@ class TestMain:
assert result.exit_code != 0 assert result.exit_code != 0
assert "No task ID" in result.output assert "No task ID" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.GiteaClient") @patch("devx.ci.auto_merge.GiteaClient")
def test_invalid_pr_title_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def] def test_invalid_pr_title_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
@@ -266,7 +266,7 @@ class TestMain:
assert result.exit_code != 0 assert result.exit_code != 0
assert "format" in result.output.lower() assert "format" in result.output.lower()
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja") @patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient") @patch("devx.ci.auto_merge.GiteaClient")
def test_merge_behind_master_raises_no_rebase( def test_merge_behind_master_raises_no_rebase(
@@ -298,7 +298,7 @@ class TestMain:
# Must NOT have called merge_pr twice (no retry after rebase) # Must NOT have called merge_pr twice (no retry after rebase)
assert mock_client.merge_pr.call_count == 1 assert mock_client.merge_pr.call_count == 1
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja") @patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient") @patch("devx.ci.auto_merge.GiteaClient")
def test_merge_failure_raises( def test_merge_failure_raises(
@@ -321,7 +321,7 @@ class TestMain:
assert result.exit_code != 0 assert result.exit_code != 0
assert "Merge failed" in result.output assert "Merge failed" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja") @patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient") @patch("devx.ci.auto_merge.GiteaClient")
def test_no_conventional_msg_raises( def test_no_conventional_msg_raises(
@@ -342,7 +342,7 @@ class TestMain:
assert result.exit_code != 0 assert result.exit_code != 0
assert "conventional commit" in result.output.lower() assert "conventional commit" in result.output.lower()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
def test_invalid_pr_number_raises(self, tmp_path, monkeypatch) -> None: def test_invalid_pr_number_raises(self, tmp_path, monkeypatch) -> None:
"""Non-integer PR number should raise.""" """Non-integer PR number should raise."""
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
@@ -351,7 +351,7 @@ class TestMain:
assert result.exit_code != 0 assert result.exit_code != 0
assert "PR number must be an integer" in result.output assert "PR number must be an integer" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
def test_invalid_repo_format_raises(self, tmp_path, monkeypatch) -> None: def test_invalid_repo_format_raises(self, tmp_path, monkeypatch) -> None:
"""Repo without owner/name should raise.""" """Repo without owner/name should raise."""
monkeypatch.chdir(tmp_path) monkeypatch.chdir(tmp_path)
@@ -360,7 +360,7 @@ class TestMain:
assert result.exit_code != 0 assert result.exit_code != 0
assert "owner/name" in result.output assert "owner/name" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja") @patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient") @patch("devx.ci.auto_merge.GiteaClient")
def test_merge_behind_master_does_not_force_push( def test_merge_behind_master_does_not_force_push(
+583
View File
@@ -0,0 +1,583 @@
"""Unit tests for devx.tools.build_image."""
from __future__ import annotations
import json
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from click import ClickException
from click.testing import CliRunner
import devx.tools.build_image as build_image
from devx.tools.build_image import (
ImageSpec,
build_full_tag,
load_manifest,
push_image,
registry_login,
)
from devx.tools.build_image import (
build_image as do_build,
)
from devx.tools.clean_images import select_for_deletion, sort_versions_by_date
class TestImageSpec:
def test_from_dict_minimal(self) -> None:
spec = ImageSpec.from_dict({"name": "ci-base", "dockerfile": "docker/ci-base/Dockerfile"})
assert spec.name == "ci-base"
assert spec.dockerfile == "docker/ci-base/Dockerfile"
assert spec.context == "."
assert spec.tags == ["latest"]
def test_from_dict_full(self) -> None:
spec = ImageSpec.from_dict(
{
"name": "ci-quality",
"dockerfile": "docker/ci-quality/Dockerfile",
"context": ".",
"tags": ["latest", "0.19.3"],
}
)
assert spec.name == "ci-quality"
assert spec.dockerfile == "docker/ci-quality/Dockerfile"
assert spec.context == "."
assert spec.tags == ["latest", "0.19.3"]
def test_from_dict_missing_name(self) -> None:
with pytest.raises(ValueError, match="missing 'name'"):
ImageSpec.from_dict({"dockerfile": "Dockerfile"})
def test_from_dict_missing_dockerfile(self) -> None:
with pytest.raises(ValueError, match="missing 'dockerfile'"):
ImageSpec.from_dict({"name": "ci-base"})
def test_from_dict_tags_not_list(self) -> None:
with pytest.raises(ValueError, match="tags.*must be a list"):
ImageSpec.from_dict(
{
"name": "ci-base",
"dockerfile": "Dockerfile",
"tags": "latest",
}
)
def test_from_dict_empty_tags_defaults_to_latest(self) -> None:
spec = ImageSpec.from_dict(
{
"name": "ci-base",
"dockerfile": "Dockerfile",
"tags": [],
}
)
assert spec.tags == ["latest"]
class TestBuildFullTag:
def test_no_registry(self) -> None:
assert build_full_tag(None, "ci-base", "latest") == "ci-base:latest"
def test_with_registry(self) -> None:
assert build_full_tag("git.example.com", "ci-base", "0.1.0") == "git.example.com/ci-base:0.1.0"
def test_with_registry_and_path(self) -> None:
assert (
build_full_tag("git.example.com", "oblachno/ci-base", "latest") == "git.example.com/oblachno/ci-base:latest"
)
class TestLoadManifest:
def test_load_valid_manifest(self, tmp_path: Path) -> None:
manifest = tmp_path / "images.json"
manifest.write_text(
json.dumps(
[
{"name": "ci-base", "dockerfile": "docker/ci-base/Dockerfile"},
{"name": "ci-quality", "dockerfile": "docker/ci-quality/Dockerfile", "tags": ["latest", "1.0"]},
]
)
)
specs = load_manifest(manifest)
assert len(specs) == 2
assert specs[0].name == "ci-base"
assert specs[1].tags == ["latest", "1.0"]
def test_load_missing_file(self, tmp_path: Path) -> None:
with pytest.raises(ClickException, match="not found"):
load_manifest(tmp_path / "nonexistent.json")
def test_load_not_a_list(self, tmp_path: Path) -> None:
manifest = tmp_path / "images.json"
manifest.write_text(json.dumps({"name": "ci-base"}))
with pytest.raises(ClickException, match="must be a JSON list"):
load_manifest(manifest)
class TestRegistryLogin:
def test_success(self) -> None:
mock_result = MagicMock(returncode=0, stderr="", stdout="")
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run:
assert registry_login("git.example.com", "user", "token") is True
assert mock_run.call_args.args[0] == [
"docker",
"login",
"git.example.com",
"-u",
"user",
"--password-stdin",
]
assert mock_run.call_args.kwargs["input"] == "token"
def test_failure(self) -> None:
mock_result = MagicMock(returncode=1, stderr="auth failed", stdout="")
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
assert registry_login("git.example.com", "user", "bad") is False
def test_dry_run(self) -> None:
with patch("devx.tools.build_image.subprocess.run") as mock_run:
assert registry_login("git.example.com", "user", "token", dry_run=True) is True
mock_run.assert_not_called()
class TestBuildImage:
def test_success(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile), context=".", tags=["latest"])
mock_result = MagicMock(returncode=0)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
assert do_build(spec) is True
def test_dockerfile_not_found(self) -> None:
spec = ImageSpec(name="ci-base", dockerfile="nonexistent/Dockerfile")
assert do_build(spec) is False
def test_build_failure(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile))
mock_result = MagicMock(returncode=1)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
assert do_build(spec) is False
def test_dry_run(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile), tags=["latest", "1.0"])
with patch("devx.tools.build_image.subprocess.run") as mock_run:
assert do_build(spec, dry_run=True) is True
mock_run.assert_not_called()
def test_with_registry(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile), tags=["latest"])
mock_result = MagicMock(returncode=0)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run:
assert do_build(spec, registry="git.example.com") is True
cmd = mock_run.call_args.args[0]
assert "-t" in cmd
idx = cmd.index("-t")
assert cmd[idx + 1] == "git.example.com/ci-base:latest"
def test_pull_flag(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile))
mock_result = MagicMock(returncode=0)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run:
assert do_build(spec, pull=True) is True
cmd = mock_run.call_args.args[0]
assert "--pull" in cmd
class TestPushImage:
def test_success(self) -> None:
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest", "1.0"])
mock_result = MagicMock(returncode=0, stderr="", stdout="")
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run:
assert push_image(spec, "git.example.com") is True
assert mock_run.call_count == 2
def test_partial_failure(self) -> None:
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest", "1.0"])
results = [
MagicMock(returncode=0, stderr="", stdout=""),
MagicMock(returncode=1, stderr="push failed", stdout=""),
]
with patch("devx.tools.build_image.subprocess.run", side_effect=results):
assert push_image(spec, "git.example.com") is False
def test_dry_run(self) -> None:
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
with patch("devx.tools.build_image.subprocess.run") as mock_run:
assert push_image(spec, "git.example.com", dry_run=True) is True
mock_run.assert_not_called()
class TestSortVersions:
def test_sort_by_created_at_desc(self) -> None:
versions = [
{"version": "0.1.0", "created_at": "2025-01-01T00:00:00Z"},
{"version": "0.3.0", "created_at": "2025-03-01T00:00:00Z"},
{"version": "0.2.0", "created_at": "2025-02-01T00:00:00Z"},
]
result = sort_versions_by_date(versions)
assert [v["version"] for v in result] == ["0.3.0", "0.2.0", "0.1.0"]
def test_sort_fallback_to_version(self) -> None:
versions = [
{"version": "0.1.0"},
{"version": "0.3.0"},
{"version": "0.2.0"},
]
result = sort_versions_by_date(versions)
assert [v["version"] for v in result] == ["0.3.0", "0.2.0", "0.1.0"]
class TestSelectForDeletion:
def test_keep_2(self) -> None:
versions = [
{"version": "0.1.0", "created_at": "2025-01-01"},
{"version": "0.2.0", "created_at": "2025-02-01"},
{"version": "0.3.0", "created_at": "2025-03-01"},
{"version": "0.4.0", "created_at": "2025-04-01"},
]
to_delete = select_for_deletion(versions, keep=2)
assert len(to_delete) == 2
assert {v["version"] for v in to_delete} == {"0.1.0", "0.2.0"}
def test_preserve_latest_tag(self) -> None:
versions = [
{"version": "latest", "created_at": "2025-01-01"},
{"version": "0.2.0", "created_at": "2025-02-01"},
{"version": "0.3.0", "created_at": "2025-03-01"},
{"version": "0.4.0", "created_at": "2025-04-01"},
]
to_delete = select_for_deletion(versions, keep=2)
deleted_versions = {v["version"] for v in to_delete}
assert "latest" not in deleted_versions
# latest is oldest by date but still preserved
assert "0.2.0" in deleted_versions
def test_keep_all(self) -> None:
versions = [
{"version": "0.1.0", "created_at": "2025-01-01"},
{"version": "0.2.0", "created_at": "2025-02-01"},
]
to_delete = select_for_deletion(versions, keep=2)
assert len(to_delete) == 0
def test_keep_more_than_available(self) -> None:
versions = [
{"version": "0.1.0", "created_at": "2025-01-01"},
]
to_delete = select_for_deletion(versions, keep=5)
assert len(to_delete) == 0
class TestCleanImagesAPI:
"""Tests for the clean_images module's API functions."""
def test_list_package_versions(self) -> None:
from devx.tools.clean_images import list_package_versions
mock_resp = MagicMock()
mock_resp.json.return_value = [{"version": "0.1.0"}]
mock_resp.raise_for_status = MagicMock()
with patch("devx.tools.clean_images.requests.get", return_value=mock_resp) as mock_get:
versions = list_package_versions(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"token",
)
assert versions == [{"version": "0.1.0"}]
assert "page=1" in mock_get.call_args.args[0]
def test_list_package_versions_pagination(self) -> None:
from devx.tools.clean_images import list_package_versions
# First page: 50 items, second page: 3 items, third page: empty
page1 = [{"version": f"0.{i}.0"} for i in range(50)]
page2 = [{"version": f"1.{i}.0"} for i in range(3)]
responses = [
MagicMock(json=MagicMock(return_value=page1), raise_for_status=MagicMock()),
MagicMock(json=MagicMock(return_value=page2), raise_for_status=MagicMock()),
MagicMock(json=MagicMock(return_value=[]), raise_for_status=MagicMock()),
]
with patch("devx.tools.clean_images.requests.get", side_effect=responses):
versions = list_package_versions(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"token",
)
assert len(versions) == 53
def test_delete_package_version_success(self) -> None:
from devx.tools.clean_images import delete_package_version
mock_resp = MagicMock(status_code=204)
with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp):
assert (
delete_package_version(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"0.1.0",
"token",
)
is True
)
def test_delete_package_version_failure(self) -> None:
from devx.tools.clean_images import delete_package_version
mock_resp = MagicMock(status_code=404)
with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp):
assert (
delete_package_version(
"https://git.example.com/api/v1",
"oblachno-oss",
"ci-base",
"0.1.0",
"token",
)
is False
)
class TestCLIBuildImage:
def test_single_image_build(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
runner = CliRunner()
mock_result = MagicMock(returncode=0)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base", "--tag", "latest"],
)
assert result.exit_code == 0
def test_manifest_build(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
manifest = tmp_path / "images.json"
manifest.write_text(
json.dumps(
[
{"name": "ci-base", "dockerfile": str(dockerfile)},
]
)
)
runner = CliRunner()
mock_result = MagicMock(returncode=0)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
result = runner.invoke(
build_image.main,
["--manifest", str(manifest)],
)
assert result.exit_code == 0
def test_missing_dockerfile_and_manifest(self) -> None:
runner = CliRunner()
result = runner.invoke(build_image.main, [])
assert result.exit_code != 0
assert "manifest" in result.output.lower() or "dockerfile" in result.output.lower()
def test_push_without_registry(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
runner = CliRunner()
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push"],
)
assert result.exit_code != 0
assert "registry" in result.output.lower()
def test_push_without_credentials(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
runner = CliRunner()
with patch.dict("os.environ", {}, clear=True):
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push", "--registry", "git.example.com"],
)
assert result.exit_code != 0
assert "credential" in result.output.lower() or "token" in result.output.lower()
def test_dry_run(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
runner = CliRunner()
with patch("devx.tools.build_image.subprocess.run") as mock_run:
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base", "--dry-run"],
)
assert result.exit_code == 0
mock_run.assert_not_called()
assert "dry-run" in result.output
def test_build_failure_exits_with_error(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
runner = CliRunner()
mock_result = MagicMock(returncode=1)
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base"],
)
assert result.exit_code != 0
def test_push_login_failure(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
runner = CliRunner()
login_result = MagicMock(returncode=1, stderr="auth failed", stdout="")
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake", "CI_GITEA_USERNAME": "user"}):
with patch("devx.tools.build_image.subprocess.run", return_value=login_result):
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push", "--registry", "git.example.com"],
)
assert result.exit_code != 0
assert "login" in result.output.lower()
def test_push_image_failure(self, tmp_path: Path) -> None:
dockerfile = tmp_path / "Dockerfile"
dockerfile.touch()
runner = CliRunner()
build_result = MagicMock(returncode=0)
login_result = MagicMock(returncode=0, stderr="", stdout="")
push_result = MagicMock(returncode=1, stderr="push failed", stdout="")
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake", "CI_GITEA_USERNAME": "user"}):
with patch(
"devx.tools.build_image.subprocess.run",
side_effect=[login_result, build_result, push_result],
):
result = runner.invoke(
build_image.main,
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push", "--registry", "git.example.com"],
)
assert result.exit_code != 0
class TestCLICleanImages:
def test_dry_run(self) -> None:
from devx.tools.clean_images import main as clean_main
runner = CliRunner()
mock_resp = MagicMock()
mock_resp.json.return_value = [
{"version": "0.1.0", "created_at": "2025-01-01"},
{"version": "0.2.0", "created_at": "2025-02-01"},
{"version": "0.3.0", "created_at": "2025-03-01"},
]
mock_resp.raise_for_status = MagicMock()
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
with patch("devx.tools.clean_images.requests.get", return_value=mock_resp):
result = runner.invoke(
clean_main,
["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "1", "--dry-run"],
)
assert result.exit_code == 0
assert "dry-run" in result.output
assert "0.1.0" in result.output
def test_no_token(self) -> None:
from devx.tools.clean_images import main as clean_main
runner = CliRunner()
with patch.dict("os.environ", {}, clear=True):
result = runner.invoke(
clean_main,
["--owner", "oblachno-oss", "--name", "ci-base"],
)
assert result.exit_code != 0
assert "token" in result.output.lower()
def test_no_versions_found(self) -> None:
from devx.tools.clean_images import main as clean_main
runner = CliRunner()
mock_resp = MagicMock()
mock_resp.json.return_value = []
mock_resp.raise_for_status = MagicMock()
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
with patch("devx.tools.clean_images.requests.get", return_value=mock_resp):
result = runner.invoke(
clean_main,
["--owner", "oblachno-oss", "--name", "ci-base", "--dry-run"],
)
assert result.exit_code == 0
assert "No versions" in result.output
def test_actual_delete(self) -> None:
from devx.tools.clean_images import main as clean_main
runner = CliRunner()
list_resp = MagicMock()
list_resp.json.return_value = [
{"version": "0.1.0", "created_at": "2025-01-01"},
{"version": "0.2.0", "created_at": "2025-02-01"},
{"version": "0.3.0", "created_at": "2025-03-01"},
]
list_resp.raise_for_status = MagicMock()
delete_resp = MagicMock(status_code=204)
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
with patch("devx.tools.clean_images.requests.get", return_value=list_resp):
with patch("devx.tools.clean_images.requests.delete", return_value=delete_resp):
result = runner.invoke(
clean_main,
["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "2"],
)
assert result.exit_code == 0
assert "Deleted" in result.output
def test_list_request_exception(self) -> None:
import requests as req
from devx.tools.clean_images import main as clean_main
runner = CliRunner()
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
with patch(
"devx.tools.clean_images.requests.get",
side_effect=req.ConnectionError("network down"),
):
result = runner.invoke(
clean_main,
["--owner", "oblachno-oss", "--name", "ci-base", "--dry-run"],
)
assert result.exit_code == 0
assert "Failed to list" in result.output
def test_delete_failure_in_cli(self) -> None:
from devx.tools.clean_images import main as clean_main
runner = CliRunner()
list_resp = MagicMock()
list_resp.json.return_value = [
{"version": "0.1.0", "created_at": "2025-01-01"},
{"version": "0.2.0", "created_at": "2025-02-01"},
{"version": "0.3.0", "created_at": "2025-03-01"},
]
list_resp.raise_for_status = MagicMock()
delete_resp = MagicMock(status_code=500)
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
with patch("devx.tools.clean_images.requests.get", return_value=list_resp):
with patch("devx.tools.clean_images.requests.delete", return_value=delete_resp):
result = runner.invoke(
clean_main,
["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "2"],
)
assert result.exit_code == 0
assert "FAILED" in result.output
+3 -3
View File
@@ -78,7 +78,7 @@ class TestGetPrTitleFromGitea:
assert get_pr_title_from_gitea("owner/repo", 1) is None assert get_pr_title_from_gitea("owner/repo", 1) is None
def test_returns_none_with_invalid_repo(self) -> None: def test_returns_none_with_invalid_repo(self) -> None:
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True): with patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True):
assert get_pr_title_from_gitea("invalid", 1) is None assert get_pr_title_from_gitea("invalid", 1) is None
@patch("devx.ci.check_auto_merge_ready.GiteaClient") @patch("devx.ci.check_auto_merge_ready.GiteaClient")
@@ -86,7 +86,7 @@ class TestGetPrTitleFromGitea:
mock_client = MagicMock() mock_client = MagicMock()
mock_client.get_pr.return_value = {"title": "DEVX-1: Fix bug"} mock_client.get_pr.return_value = {"title": "DEVX-1: Fix bug"}
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True): with patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True):
result = get_pr_title_from_gitea("owner/repo", 1) result = get_pr_title_from_gitea("owner/repo", 1)
assert result == "DEVX-1: Fix bug" assert result == "DEVX-1: Fix bug"
@@ -95,7 +95,7 @@ class TestGetPrTitleFromGitea:
mock_client = MagicMock() mock_client = MagicMock()
mock_client.get_pr.side_effect = Exception("API error") mock_client.get_pr.side_effect = Exception("API error")
mock_client_cls.return_value = mock_client mock_client_cls.return_value = mock_client
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True): with patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True):
result = get_pr_title_from_gitea("owner/repo", 1) result = get_pr_title_from_gitea("owner/repo", 1)
assert result is None assert result is None
+2 -2
View File
@@ -248,8 +248,8 @@ class TestDevxI18n:
import devx.i18n import devx.i18n
importlib.reload(devx.i18n) importlib.reload(devx.i18n)
# "ERROR: REPO_TOKEN is not set." has a German translation # "ERROR: CI_GITEA_TOKEN is not set." has a German translation
result = devx.i18n._("ERROR: REPO_TOKEN is not set.") result = devx.i18n._("ERROR: CI_GITEA_TOKEN is not set.")
assert "FEHLER" in result assert "FEHLER" in result
# Restore # Restore
+12 -12
View File
@@ -47,7 +47,7 @@ class TestDefaultConfigs:
class TestConfigureRepo: class TestConfigureRepo:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient") @patch("devx.tools.configure_repo.GiteaClient")
def test_configure_repo_success(self, mock_client_cls: MagicMock) -> None: def test_configure_repo_success(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock() mock_client = MagicMock()
@@ -58,7 +58,7 @@ class TestConfigureRepo:
mock_client.ensure_branch_protection.assert_called_once() mock_client.ensure_branch_protection.assert_called_once()
mock_client.update_repo_settings.assert_called_once() mock_client.update_repo_settings.assert_called_once()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient") @patch("devx.tools.configure_repo.GiteaClient")
def test_configure_repo_api_error(self, mock_client_cls: MagicMock) -> None: def test_configure_repo_api_error(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock() mock_client = MagicMock()
@@ -69,10 +69,10 @@ class TestConfigureRepo:
configure_repo(token="tok", owner="owner", repo="repo") configure_repo(token="tok", owner="owner", repo="repo")
def test_configure_repo_no_token(self) -> None: def test_configure_repo_no_token(self) -> None:
with pytest.raises(click.ClickException, match="REPO_TOKEN"): with pytest.raises(click.ClickException, match="CI_GITEA_TOKEN"):
configure_repo(token="", owner="owner", repo="repo") configure_repo(token="", owner="owner", repo="repo")
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient") @patch("devx.tools.configure_repo.GiteaClient")
def test_configure_repo_custom_configs(self, mock_client_cls: MagicMock) -> None: def test_configure_repo_custom_configs(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock() mock_client = MagicMock()
@@ -102,7 +102,7 @@ class TestConfigureRepo:
class TestMain: class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "DEVX_REPO_NAME": "myrepo"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "DEVX_REPO_NAME": "myrepo"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient") @patch("devx.tools.configure_repo.GiteaClient")
def test_main_success_with_env_repo(self, mock_client_cls: MagicMock) -> None: def test_main_success_with_env_repo(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock() mock_client = MagicMock()
@@ -114,7 +114,7 @@ class TestMain:
mock_client.ensure_branch_protection.assert_called_once() mock_client.ensure_branch_protection.assert_called_once()
mock_client.update_repo_settings.assert_called_once() mock_client.update_repo_settings.assert_called_once()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient") @patch("devx.tools.configure_repo.GiteaClient")
def test_main_success_with_cli_repo(self, mock_client_cls: MagicMock) -> None: def test_main_success_with_cli_repo(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock() mock_client = MagicMock()
@@ -125,7 +125,7 @@ class TestMain:
assert result.exit_code == 0 assert result.exit_code == 0
mock_client.ensure_branch_protection.assert_called_once() mock_client.ensure_branch_protection.assert_called_once()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient") @patch("devx.tools.configure_repo.GiteaClient")
def test_main_api_error(self, mock_client_cls: MagicMock) -> None: def test_main_api_error(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock() mock_client = MagicMock()
@@ -142,16 +142,16 @@ class TestMain:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["--repo", "myrepo"]) result = runner.invoke(main, ["--repo", "myrepo"])
assert result.exit_code != 0 assert result.exit_code != 0
assert "REPO_TOKEN" in result.output assert "CI_GITEA_TOKEN" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
def test_main_no_repo(self) -> None: def test_main_no_repo(self) -> None:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, []) result = runner.invoke(main, [])
assert result.exit_code != 0 assert result.exit_code != 0
assert "Repository name not specified" in result.output assert "Repository name not specified" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient") @patch("devx.tools.configure_repo.GiteaClient")
def test_main_custom_branch(self, mock_client_cls: MagicMock) -> None: def test_main_custom_branch(self, mock_client_cls: MagicMock) -> None:
mock_client = MagicMock() mock_client = MagicMock()
@@ -164,7 +164,7 @@ class TestMain:
args = mock_client.ensure_branch_protection.call_args args = mock_client.ensure_branch_protection.call_args
assert args[0][0] == "develop" assert args[0][0] == "develop"
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "DEVX_REPO_NAME": "oblachno/infra"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "DEVX_REPO_NAME": "oblachno/infra"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient") @patch("devx.tools.configure_repo.GiteaClient")
def test_main_parses_owner_repo_from_env(self, mock_client_cls: MagicMock) -> None: def test_main_parses_owner_repo_from_env(self, mock_client_cls: MagicMock) -> None:
"""DEVX_REPO_NAME with 'owner/repo' format should be split.""" """DEVX_REPO_NAME with 'owner/repo' format should be split."""
@@ -181,7 +181,7 @@ class TestMain:
@patch.dict( @patch.dict(
"os.environ", "os.environ",
{"REPO_TOKEN": "tok", "DEVX_REPO_NAME": "infra", "DEVX_REPO_OWNER": "oblachno"}, {"CI_GITEA_TOKEN": "tok", "DEVX_REPO_NAME": "infra", "DEVX_REPO_OWNER": "oblachno"},
clear=True, clear=True,
) )
@patch("devx.tools.configure_repo.REPO_OWNER", "oblachno") @patch("devx.tools.configure_repo.REPO_OWNER", "oblachno")
+4 -4
View File
@@ -95,7 +95,7 @@ class TestCreatePr:
@patch("devx.tools.create_pr.GiteaClient") @patch("devx.tools.create_pr.GiteaClient")
@patch("devx.tools.create_pr.get_vikunja_task_title", return_value="Add feature") @patch("devx.tools.create_pr.get_vikunja_task_title", return_value="Add feature")
@patch("devx.tools.create_pr.find_existing_pr", return_value=None) @patch("devx.tools.create_pr.find_existing_pr", return_value=None)
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
def test_creates_new_pr(self, mock_find: MagicMock, mock_title: MagicMock, mock_gitea: MagicMock) -> None: def test_creates_new_pr(self, mock_find: MagicMock, mock_title: MagicMock, mock_gitea: MagicMock) -> None:
mock_client = MagicMock() mock_client = MagicMock()
mock_client.create_pr.return_value = {"number": 15, "html_url": "https://git.example.com/pr/15"} mock_client.create_pr.return_value = {"number": 15, "html_url": "https://git.example.com/pr/15"}
@@ -112,7 +112,7 @@ class TestCreatePr:
@patch("devx.tools.create_pr.GiteaClient") @patch("devx.tools.create_pr.GiteaClient")
@patch("devx.tools.create_pr.get_vikunja_task_title", return_value="Add feature") @patch("devx.tools.create_pr.get_vikunja_task_title", return_value="Add feature")
@patch("devx.tools.create_pr.find_existing_pr") @patch("devx.tools.create_pr.find_existing_pr")
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
def test_existing_pr_idempotent(self, mock_find: MagicMock, mock_title: MagicMock, mock_gitea: MagicMock) -> None: def test_existing_pr_idempotent(self, mock_find: MagicMock, mock_title: MagicMock, mock_gitea: MagicMock) -> None:
mock_find.return_value = {"number": 10, "html_url": "https://git.example.com/pr/10"} mock_find.return_value = {"number": 10, "html_url": "https://git.example.com/pr/10"}
mock_client = MagicMock() mock_client = MagicMock()
@@ -123,10 +123,10 @@ class TestCreatePr:
@patch.dict("os.environ", {}, clear=True) @patch.dict("os.environ", {}, clear=True)
def test_no_repo_token(self) -> None: def test_no_repo_token(self) -> None:
with pytest.raises(click.ClickException, match="REPO_TOKEN"): with pytest.raises(click.ClickException, match="CI_GITEA_TOKEN"):
create_pr("DEVX-42-fix", "master", "", "owner", "repo") create_pr("DEVX-42-fix", "master", "", "owner", "repo")
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
def test_no_task_id_in_branch(self) -> None: def test_no_task_id_in_branch(self) -> None:
with pytest.raises(click.ClickException, match="does not contain a task ID"): with pytest.raises(click.ClickException, match="does not contain a task ID"):
create_pr("feature-branch", "master", "", "owner", "repo") create_pr("feature-branch", "master", "", "owner", "repo")
+35 -1
View File
@@ -7,7 +7,7 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from devx.gitea_cli import TeaCLI, TeaCLIError, _extract_issue_number, _extract_pr_number from devx.gitea_cli import TeaCLI, TeaCLIError, _extract_issue_number, _extract_pr_number, configure_tea_login
class TestExtractIssueNumber: class TestExtractIssueNumber:
@@ -359,3 +359,37 @@ class TestWhoami:
mock_result = MagicMock(returncode=0, stdout="testuser", stderr="") mock_result = MagicMock(returncode=0, stdout="testuser", stderr="")
with patch("subprocess.run", return_value=mock_result): with patch("subprocess.run", return_value=mock_result):
assert cli.whoami() == "testuser" assert cli.whoami() == "testuser"
class TestConfigureTeaLogin:
@patch.dict("os.environ", {"CI_GITEA_TOKEN": ""}, clear=True)
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
def test_no_token_skips(self, mock_which: MagicMock) -> None:
"""configure_tea_login with no token prints skip message and returns."""
configure_tea_login()
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.gitea_cli.shutil.which", return_value=None)
def test_no_tea_skips(self, mock_which: MagicMock) -> None:
"""configure_tea_login with no tea binary prints skip message and returns."""
configure_tea_login()
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
@patch("devx.gitea_cli.subprocess.run")
def test_configures_login_when_not_present(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None:
"""configure_tea_login adds login when not already configured."""
mock_list = MagicMock(returncode=0, stdout="")
mock_subprocess.return_value = mock_list
configure_tea_login()
assert mock_subprocess.call_count >= 2 # login list + login add + login default
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
@patch("devx.gitea_cli.subprocess.run")
def test_skips_when_already_configured(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None:
"""configure_tea_login skips if login already exists."""
mock_list = MagicMock(returncode=0, stdout="devx https://git.example.com")
mock_subprocess.return_value = mock_list
configure_tea_login()
assert mock_subprocess.call_count == 1 # only login list, no add
+4 -4
View File
@@ -101,7 +101,7 @@ class TestCli:
os.environ, os.environ,
{ {
"GITEA_URL": "https://gitea.example", "GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token", "CI_GITEA_TOKEN": "token",
"RUN_ID": "123", "RUN_ID": "123",
"JOB_NAME": "integration-tests", "JOB_NAME": "integration-tests",
"MATRIX_INDEX": "0", "MATRIX_INDEX": "0",
@@ -148,7 +148,7 @@ class TestCli:
os.environ, os.environ,
{ {
"GITEA_URL": "https://gitea.example", "GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token", "CI_GITEA_TOKEN": "token",
"RUN_ID": "123", "RUN_ID": "123",
"JOB_NAME": "integration-tests", "JOB_NAME": "integration-tests",
"MATRIX_INDEX": "0", "MATRIX_INDEX": "0",
@@ -193,7 +193,7 @@ class TestCli:
os.environ, os.environ,
{ {
"GITEA_URL": "https://gitea.example", "GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token", "CI_GITEA_TOKEN": "token",
"RUN_ID": "123", "RUN_ID": "123",
"JOB_NAME": "integration-tests", "JOB_NAME": "integration-tests",
"MATRIX_INDEX": "0", "MATRIX_INDEX": "0",
@@ -239,7 +239,7 @@ class TestCli:
assert "without cross-runner cancellation" in result.output assert "without cross-runner cancellation" in result.output
def test_partial_env_vars_runs_without_polling(self) -> None: def test_partial_env_vars_runs_without_polling(self) -> None:
"""Only GITEA_URL set (missing REPO_TOKEN and RUN_ID) — should skip polling.""" """Only GITEA_URL set (missing CI_GITEA_TOKEN and RUN_ID) — should skip polling."""
with ( with (
patch.dict( patch.dict(
os.environ, os.environ,
+5 -5
View File
@@ -220,7 +220,7 @@ class TestCli:
os.environ, os.environ,
{ {
"GITEA_URL": "https://gitea.example", "GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token", "CI_GITEA_TOKEN": "token",
"RUN_ID": "123", "RUN_ID": "123",
"JOB_NAME": "molecule-tests", "JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0", "MATRIX_INDEX": "0",
@@ -288,7 +288,7 @@ class TestCli:
os.environ, os.environ,
{ {
"GITEA_URL": "https://gitea.example", "GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token", "CI_GITEA_TOKEN": "token",
"RUN_ID": "123", "RUN_ID": "123",
"JOB_NAME": "molecule-tests", "JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0", "MATRIX_INDEX": "0",
@@ -325,7 +325,7 @@ class TestCli:
os.environ, os.environ,
{ {
"GITEA_URL": "https://gitea.example", "GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token", "CI_GITEA_TOKEN": "token",
"RUN_ID": "123", "RUN_ID": "123",
"JOB_NAME": "molecule-tests", "JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0", "MATRIX_INDEX": "0",
@@ -371,7 +371,7 @@ class TestCli:
os.environ, os.environ,
{ {
"GITEA_URL": "https://gitea.example", "GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token", "CI_GITEA_TOKEN": "token",
"RUN_ID": "123", "RUN_ID": "123",
"JOB_NAME": "molecule-tests", "JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0", "MATRIX_INDEX": "0",
@@ -418,7 +418,7 @@ class TestCli:
os.environ, os.environ,
{ {
"GITEA_URL": "https://gitea.example", "GITEA_URL": "https://gitea.example",
"REPO_TOKEN": "token", "CI_GITEA_TOKEN": "token",
"RUN_ID": "123", "RUN_ID": "123",
"JOB_NAME": "molecule-tests", "JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0", "MATRIX_INDEX": "0",
+29 -29
View File
@@ -4,12 +4,12 @@ from unittest.mock import MagicMock, patch
from click.testing import CliRunner from click.testing import CliRunner
from devx.ci.notify_failure import _configure_tea_login, main from devx.ci.notify_failure import main
from devx.gitea_cli import TeaCLIError from devx.gitea_cli import TeaCLIError, configure_tea_login
class TestNotifyFailure: class TestNotifyFailure:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.ci.notify_failure.TeaCLI") @patch("devx.ci.notify_failure.TeaCLI")
def test_creates_issue_with_tea(self, mock_tea_cls: MagicMock) -> None: def test_creates_issue_with_tea(self, mock_tea_cls: MagicMock) -> None:
mock_tea = MagicMock() mock_tea = MagicMock()
@@ -36,7 +36,7 @@ class TestNotifyFailure:
mock_tea.create_issue.assert_called_once() mock_tea.create_issue.assert_called_once()
mock_tea.add_label.assert_called_once_with("owner/repo", 42, ["bug"]) mock_tea.add_label.assert_called_once_with("owner/repo", 42, ["bug"])
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.ci.notify_failure.TeaCLI") @patch("devx.ci.notify_failure.TeaCLI")
def test_tea_creates_issue_without_bug_label(self, mock_tea_cls: MagicMock) -> None: def test_tea_creates_issue_without_bug_label(self, mock_tea_cls: MagicMock) -> None:
mock_tea = MagicMock() mock_tea = MagicMock()
@@ -53,7 +53,7 @@ class TestNotifyFailure:
assert "issue #43" in result.output assert "issue #43" in result.output
mock_tea.add_label.assert_not_called() mock_tea.add_label.assert_not_called()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.ci.notify_failure.TeaCLI") @patch("devx.ci.notify_failure.TeaCLI")
def test_tea_error_raises(self, mock_tea_cls: MagicMock) -> None: def test_tea_error_raises(self, mock_tea_cls: MagicMock) -> None:
"""When tea fails, the workflow fails — no fallback.""" """When tea fails, the workflow fails — no fallback."""
@@ -70,7 +70,7 @@ class TestNotifyFailure:
assert result.exit_code != 0 assert result.exit_code != 0
assert "tea" in result.output.lower() assert "tea" in result.output.lower()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.ci.notify_failure.TeaCLI") @patch("devx.ci.notify_failure.TeaCLI")
def test_tea_list_labels_error_continues_without_labels(self, mock_tea_cls: MagicMock) -> None: def test_tea_list_labels_error_continues_without_labels(self, mock_tea_cls: MagicMock) -> None:
"""If listing labels fails via tea, issue is still created without labels.""" """If listing labels fails via tea, issue is still created without labels."""
@@ -87,7 +87,7 @@ class TestNotifyFailure:
assert result.exit_code == 0 assert result.exit_code == 0
assert "issue #50" in result.output assert "issue #50" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.ci.notify_failure.TeaCLI") @patch("devx.ci.notify_failure.TeaCLI")
def test_tea_add_label_error_is_ignored(self, mock_tea_cls: MagicMock) -> None: def test_tea_add_label_error_is_ignored(self, mock_tea_cls: MagicMock) -> None:
"""If adding label fails via tea, issue is still reported as created.""" """If adding label fails via tea, issue is still reported as created."""
@@ -105,7 +105,7 @@ class TestNotifyFailure:
assert result.exit_code == 0 assert result.exit_code == 0
assert "issue #51" in result.output assert "issue #51" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": ""}, clear=True)
def test_missing_token_exits(self) -> None: def test_missing_token_exits(self) -> None:
runner = CliRunner() runner = CliRunner()
result = runner.invoke( result = runner.invoke(
@@ -113,10 +113,10 @@ class TestNotifyFailure:
["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc"], ["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc"],
) )
assert result.exit_code != 0 assert result.exit_code != 0
assert "REPO_TOKEN" in result.output assert "CI_GITEA_TOKEN" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.ci.notify_failure.shutil.which", return_value=None) @patch("devx.gitea_cli.shutil.which", return_value=None)
@patch("devx.ci.notify_failure.TeaCLI") @patch("devx.ci.notify_failure.TeaCLI")
def test_auto_login_no_tea_skips(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None: def test_auto_login_no_tea_skips(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None:
"""--auto-login with tea not installed skips login and still creates issue.""" """--auto-login with tea not installed skips login and still creates issue."""
@@ -133,11 +133,11 @@ class TestNotifyFailure:
assert result.exit_code == 0 assert result.exit_code == 0
assert "issue #60" in result.output assert "issue #60" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": ""}, clear=True)
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea") @patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
@patch("devx.ci.notify_failure.TeaCLI") @patch("devx.ci.notify_failure.TeaCLI")
def test_auto_login_no_token_skips_login(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None: def test_auto_login_no_token_skips_login(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None:
"""--auto-login with no REPO_TOKEN skips login but raises before creating issue.""" """--auto-login with no CI_GITEA_TOKEN skips login but raises before creating issue."""
mock_tea = MagicMock() mock_tea = MagicMock()
mock_tea_cls.return_value = mock_tea mock_tea_cls.return_value = mock_tea
@@ -147,25 +147,25 @@ class TestNotifyFailure:
["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc", "--auto-login"], ["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc", "--auto-login"],
) )
assert result.exit_code != 0 assert result.exit_code != 0
assert "REPO_TOKEN" in result.output assert "CI_GITEA_TOKEN" in result.output
class TestConfigureTeaLogin: class TestConfigureTeaLogin:
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": ""}, clear=True)
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea") @patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
def test_no_token_skips(self, mock_which: MagicMock) -> None: def test_no_token_skips(self, mock_which: MagicMock) -> None:
"""_configure_tea_login with no token prints skip message and returns.""" """configure_tea_login with no token prints skip message and returns."""
_configure_tea_login() configure_tea_login()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.ci.notify_failure.shutil.which", return_value=None) @patch("devx.gitea_cli.shutil.which", return_value=None)
def test_no_tea_skips(self, mock_which: MagicMock) -> None: def test_no_tea_skips(self, mock_which: MagicMock) -> None:
"""_configure_tea_login with no tea binary prints skip message and returns.""" """configure_tea_login with no tea binary prints skip message and returns."""
_configure_tea_login() configure_tea_login()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea") @patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
@patch("devx.ci.notify_failure.subprocess.run") @patch("devx.gitea_cli.subprocess.run")
@patch("devx.ci.notify_failure.TeaCLI") @patch("devx.ci.notify_failure.TeaCLI")
def test_auto_login_configures_tea( def test_auto_login_configures_tea(
self, mock_tea_cls: MagicMock, mock_subprocess: MagicMock, mock_which: MagicMock self, mock_tea_cls: MagicMock, mock_subprocess: MagicMock, mock_which: MagicMock
@@ -191,9 +191,9 @@ class TestConfigureTeaLogin:
# tea login add was called # tea login add was called
assert mock_subprocess.call_count >= 2 assert mock_subprocess.call_count >= 2
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea") @patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
@patch("devx.ci.notify_failure.subprocess.run") @patch("devx.gitea_cli.subprocess.run")
@patch("devx.ci.notify_failure.TeaCLI") @patch("devx.ci.notify_failure.TeaCLI")
def test_auto_login_skips_if_already_configured( def test_auto_login_skips_if_already_configured(
self, mock_tea_cls: MagicMock, mock_subprocess: MagicMock, mock_which: MagicMock self, mock_tea_cls: MagicMock, mock_subprocess: MagicMock, mock_which: MagicMock
+6 -6
View File
@@ -683,7 +683,7 @@ class TestMain:
def test_dry_run_does_not_post(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None: def test_dry_run_does_not_post(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = ReviewResult() mock_run.return_value = ReviewResult()
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm", "--dry-run"], env={"REPO_TOKEN": "fake"}) result = runner.invoke(main, ["42", "oblachno-oss/grm", "--dry-run"], env={"CI_GITEA_TOKEN": "fake"})
assert result.exit_code == 0 assert result.exit_code == 0
assert "[dry-run]" in result.output assert "[dry-run]" in result.output
mock_client_class.return_value.create_review.assert_not_called() mock_client_class.return_value.create_review.assert_not_called()
@@ -694,7 +694,7 @@ class TestMain:
mock_run.return_value = ReviewResult() mock_run.return_value = ReviewResult()
mock_client_class.return_value.create_review.return_value = {"id": 123} mock_client_class.return_value.create_review.return_value = {"id": 123}
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": "fake"}) result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"CI_GITEA_TOKEN": "fake"})
assert result.exit_code == 0 assert result.exit_code == 0
assert "Review #123" in result.output assert "Review #123" in result.output
mock_client_class.return_value.create_review.assert_called_once() mock_client_class.return_value.create_review.assert_called_once()
@@ -710,7 +710,7 @@ class TestMain:
{"id": 124}, {"id": 124},
] ]
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": "fake"}) result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"CI_GITEA_TOKEN": "fake"})
assert result.exit_code == 0 assert result.exit_code == 0
assert "Review #124" in result.output assert "Review #124" in result.output
assert client.create_review.call_count == 2 assert client.create_review.call_count == 2
@@ -723,14 +723,14 @@ class TestMain:
client = mock_client_class.return_value client = mock_client_class.return_value
client.create_review.side_effect = APIError(500, "Internal server error") client.create_review.side_effect = APIError(500, "Internal server error")
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": "fake"}) result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"CI_GITEA_TOKEN": "fake"})
assert result.exit_code != 0 assert result.exit_code != 0
def test_no_token_raises(self) -> None: def test_no_token_raises(self) -> None:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": ""}) result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"CI_GITEA_TOKEN": ""})
assert result.exit_code != 0 assert result.exit_code != 0
assert "REPO_TOKEN" in result.output assert "CI_GITEA_TOKEN" in result.output
def test_main_module_block() -> None: def test_main_module_block() -> None:
+51 -17
View File
@@ -165,7 +165,7 @@ class TestDefaultGiteaRegistryUrl:
class TestMain: class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.publish_to_pypi")
@@ -190,7 +190,7 @@ class TestMain:
"owner/repo", tag="v1.0.0", title="v1.0.0", body="Release notes" "owner/repo", tag="v1.0.0", title="v1.0.0", body="Release notes"
) )
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok"}, clear=True)
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_gitea_registry") @patch("devx.ci.publish.publish_to_gitea_registry")
@@ -213,7 +213,7 @@ class TestMain:
mock_gitea_publish.assert_called_once() mock_gitea_publish.assert_called_once()
mock_tea.create_release.assert_called_once() mock_tea.create_release.assert_called_once()
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok"}, clear=True)
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_gitea_registry") @patch("devx.ci.publish.publish_to_gitea_registry")
@@ -239,7 +239,7 @@ class TestMain:
@patch.dict( @patch.dict(
"os.environ", "os.environ",
{"REPO_TOKEN": "gitea-tok", "DEVX_PYPI_REGISTRY_URL": "https://env.registry.com/pypi"}, {"CI_GITEA_TOKEN": "gitea-tok", "DEVX_PYPI_REGISTRY_URL": "https://env.registry.com/pypi"},
clear=True, clear=True,
) )
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@@ -262,7 +262,7 @@ class TestMain:
assert result.exit_code == 0 assert result.exit_code == 0
mock_gitea_publish.assert_called_once_with("https://env.registry.com/pypi", "gitea-tok") mock_gitea_publish.assert_called_once_with("https://env.registry.com/pypi", "gitea-tok")
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok"}, clear=True)
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.build_package") @patch("devx.ci.publish.build_package")
@@ -284,14 +284,14 @@ class TestMain:
assert "PYPI_TOKEN not set" in result.output assert "PYPI_TOKEN not set" in result.output
mock_tea.create_release.assert_called_once() mock_tea.create_release.assert_called_once()
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": ""}, clear=True)
def test_missing_repo_token_exits(self) -> None: def test_missing_repo_token_exits(self) -> None:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo"]) result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 1 assert result.exit_code == 1
assert "REPO_TOKEN" in result.output assert "CI_GITEA_TOKEN" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.publish_to_pypi")
@@ -305,7 +305,7 @@ class TestMain:
assert result.exit_code == 1 assert result.exit_code == 1
assert "build" in result.output assert "build" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.publish_to_pypi")
@@ -326,7 +326,7 @@ class TestMain:
"owner/repo", tag="v1.0.0", title="v1.0.0", body="Release notes" "owner/repo", tag="v1.0.0", title="v1.0.0", body="Release notes"
) )
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.publish_to_pypi")
@@ -343,7 +343,7 @@ class TestMain:
assert result.exit_code == 1 assert result.exit_code == 1
assert "Release creation failed" in result.output assert "Release creation failed" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.build_package") @patch("devx.ci.publish.build_package")
@@ -361,7 +361,7 @@ class TestMain:
mock_build.assert_not_called() mock_build.assert_not_called()
mock_tea.create_release.assert_called_once() mock_tea.create_release.assert_called_once()
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.publish_to_pypi")
@@ -379,7 +379,7 @@ class TestMain:
assert "already exists" in result.output assert "already exists" in result.output
mock_tea.create_release.assert_not_called() mock_tea.create_release.assert_not_called()
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok", "PYPI_TOKEN": "pypi-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_pypi") @patch("devx.ci.publish.publish_to_pypi")
@@ -395,7 +395,7 @@ class TestMain:
result = runner.invoke(main, ["v1.0.0", "owner/repo"]) result = runner.invoke(main, ["v1.0.0", "owner/repo"])
assert result.exit_code == 0 assert result.exit_code == 0
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_gitea_registry") @patch("devx.ci.publish.publish_to_gitea_registry")
@@ -419,7 +419,7 @@ class TestMain:
assert result.exit_code == 0 assert result.exit_code == 0
assert "already exists" in result.output assert "already exists" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "gitea-tok"})
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes") @patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
@patch("devx.ci.publish.TeaCLI") @patch("devx.ci.publish.TeaCLI")
@patch("devx.ci.publish.publish_to_gitea_registry") @patch("devx.ci.publish.publish_to_gitea_registry")
@@ -521,7 +521,7 @@ class TestFromTag:
@patch("devx.ci.publish.is_release_commit", return_value=True) @patch("devx.ci.publish.is_release_commit", return_value=True)
@patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0") @patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0")
def test_from_tag_publishes(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None: def test_from_tag_publishes(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None:
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}): with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
with patch("devx.ci.publish.TeaCLI") as mock_tea_cls: with patch("devx.ci.publish.TeaCLI") as mock_tea_cls:
mock_tea = MagicMock() mock_tea = MagicMock()
mock_tea.list_releases.return_value = [] mock_tea.list_releases.return_value = []
@@ -535,7 +535,7 @@ class TestFromTag:
@patch("devx.ci.publish.is_release_commit", return_value=True) @patch("devx.ci.publish.is_release_commit", return_value=True)
@patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0") @patch("devx.ci.publish.get_latest_tag", return_value="v1.0.0")
def test_from_tag_publishes_no_repo_arg(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None: def test_from_tag_publishes_no_repo_arg(self, _mock_tag: MagicMock, _mock_rel: MagicMock) -> None:
with patch.dict("os.environ", {"REPO_TOKEN": "fake", "GITHUB_REPOSITORY": "owner/repo"}): with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake", "GITHUB_REPOSITORY": "owner/repo"}):
with patch("devx.ci.publish.TeaCLI") as mock_tea_cls: with patch("devx.ci.publish.TeaCLI") as mock_tea_cls:
mock_tea = MagicMock() mock_tea = MagicMock()
mock_tea.list_releases.return_value = [] mock_tea.list_releases.return_value = []
@@ -551,3 +551,37 @@ class TestFromTag:
result = runner.invoke(main, ["", "owner/repo", "--skip-build"]) result = runner.invoke(main, ["", "owner/repo", "--skip-build"])
assert result.exit_code != 0 assert result.exit_code != 0
assert "Tag is required" in result.output assert "Tag is required" in result.output
class TestPublishAutoLogin:
"""Tests for --auto-login flag in publish."""
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.ci.publish.configure_tea_login")
@patch("devx.ci.publish.TeaCLI")
def test_auto_login_calls_configure(self, mock_tea_cls: MagicMock, mock_login: MagicMock) -> None:
"""--auto-login calls configure_tea_login before creating release."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea.create_release.return_value = {"tag_name": "v1.0.0"}
mock_tea_cls.return_value = mock_tea
with patch("devx.ci.publish.generate_release_notes", return_value="notes"):
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build", "--auto-login"])
assert result.exit_code == 0
mock_login.assert_called_once()
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.ci.publish.configure_tea_login")
@patch("devx.ci.publish.TeaCLI")
def test_no_auto_login_skips_configure(self, mock_tea_cls: MagicMock, mock_login: MagicMock) -> None:
"""Without --auto-login, configure_tea_login is not called."""
mock_tea = MagicMock()
mock_tea.list_releases.return_value = []
mock_tea.create_release.return_value = {"tag_name": "v1.0.0"}
mock_tea_cls.return_value = mock_tea
with patch("devx.ci.publish.generate_release_notes", return_value="notes"):
runner = CliRunner()
result = runner.invoke(main, ["v1.0.0", "owner/repo", "--skip-build"])
assert result.exit_code == 0
mock_login.assert_not_called()
+28 -2
View File
@@ -1207,7 +1207,7 @@ class TestMain:
@patch("devx.ci.release.update_init_version") @patch("devx.ci.release.update_init_version")
@patch("devx.ci.release.get_changelog", return_value="changelog") @patch("devx.ci.release.get_changelog", return_value="changelog")
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0") @patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.1.0") @patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True) @patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.ci.release.run_cmd") @patch("devx.ci.release.run_cmd")
def test_full_flow_tag_exists( def test_full_flow_tag_exists(
@@ -1232,7 +1232,33 @@ class TestMain:
result = runner.invoke(main, []) result = runner.invoke(main, [])
assert result.exit_code == 0 assert result.exit_code == 0
assert "already existed" in result.output assert "already existed" in result.output
mock_tag.assert_called_once_with("0.1.0", "changelog", False) mock_tag.assert_called_once_with("0.2.0", "changelog", False)
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.1.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.ci.release.run_cmd")
def test_skips_when_version_doesnt_bump(
self,
mock_run_cmd: MagicMock,
mock_has: MagicMock,
mock_bumped: MagicMock,
mock_latest: MagicMock,
mock_user: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None:
"""Release is skipped when git-cliff doesn't bump the version."""
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 version bump" in result.output
assert "Skipping" in result.output
@patch.dict("os.environ", {}) @patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[]) @patch("devx.ci.release.verify_tag_consistency", return_value=[])
+4 -4
View File
@@ -120,7 +120,7 @@ class TestConfigureTeaLogin:
@patch("devx.tools.setup.subprocess.run") @patch("devx.tools.setup.subprocess.run")
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/tea") @patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/tea")
@patch.dict("os.environ", {"REPO_TOKEN": "tok123"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok123"}, clear=True)
def test_login_already_exists(self, mock_which: MagicMock, mock_run: MagicMock) -> None: def test_login_already_exists(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="devx\ngrm\n", stderr="") mock_run.return_value = MagicMock(returncode=0, stdout="devx\ngrm\n", stderr="")
_configure_tea_login() _configure_tea_login()
@@ -130,7 +130,7 @@ class TestConfigureTeaLogin:
@patch("devx.tools.setup.subprocess.run") @patch("devx.tools.setup.subprocess.run")
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/tea") @patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/tea")
@patch.dict("os.environ", {"REPO_TOKEN": "tok123"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok123"}, clear=True)
def test_login_add_success(self, mock_which: MagicMock, mock_run: MagicMock) -> None: def test_login_add_success(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
list_result = MagicMock(returncode=0, stdout="", stderr="") list_result = MagicMock(returncode=0, stdout="", stderr="")
add_result = MagicMock(returncode=0, stdout="", stderr="") add_result = MagicMock(returncode=0, stdout="", stderr="")
@@ -143,7 +143,7 @@ class TestConfigureTeaLogin:
@patch("devx.tools.setup.subprocess.run") @patch("devx.tools.setup.subprocess.run")
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/tea") @patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/tea")
@patch.dict("os.environ", {"REPO_TOKEN": "tok123"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok123"}, clear=True)
def test_login_add_failure(self, mock_which: MagicMock, mock_run: MagicMock) -> None: def test_login_add_failure(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
list_result = MagicMock(returncode=0, stdout="", stderr="") list_result = MagicMock(returncode=0, stdout="", stderr="")
add_result = MagicMock(returncode=1, stdout="", stderr="auth failed") add_result = MagicMock(returncode=1, stdout="", stderr="auth failed")
@@ -155,7 +155,7 @@ class TestConfigureTeaLogin:
@patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/tea") @patch("devx.tools.setup.shutil.which", return_value="/usr/local/bin/tea")
@patch.dict( @patch.dict(
"os.environ", "os.environ",
{"REPO_TOKEN": "tok123", "DEVX_GITEA_API_URL": "https://custom.example.com/api/v1"}, {"CI_GITEA_TOKEN": "tok123", "DEVX_GITEA_API_URL": "https://custom.example.com/api/v1"},
clear=True, clear=True,
) )
def test_custom_gitea_url(self, mock_which: MagicMock, mock_run: MagicMock) -> None: def test_custom_gitea_url(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
+17 -15
View File
@@ -286,7 +286,7 @@ class TestVerifyWikiIntegrity:
class TestMain: class TestMain:
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@patch("devx.ci.sync_wiki.MAPPING_FILE") @patch("devx.ci.sync_wiki.MAPPING_FILE")
@patch("devx.ci.sync_wiki.DOCS_DIR") @patch("devx.ci.sync_wiki.DOCS_DIR")
@patch("devx.ci.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")
@@ -301,14 +301,16 @@ class TestMain:
assert result.exit_code == 0 assert result.exit_code == 0
assert "dry-run" in result.output assert "dry-run" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": ""}, clear=True)
def test_missing_token_exits(self) -> None: def test_missing_token_exits(self) -> None:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo"]) result = runner.invoke(main, ["--repo", "owner/repo"])
assert result.exit_code == 1 assert result.exit_code == 1
assert "REPO_TOKEN" in result.output assert "CI_GITEA_TOKEN" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "DEVX_REPO_OWNER": "me", "DEVX_REPO_NAME": "myrepo"}, clear=True) @patch.dict(
"os.environ", {"CI_GITEA_TOKEN": "tok", "DEVX_REPO_OWNER": "me", "DEVX_REPO_NAME": "myrepo"}, clear=True
)
@patch("devx.ci.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")
def test_auto_detect_repo(self, mock_client_cls: MagicMock) -> None: def test_auto_detect_repo(self, mock_client_cls: MagicMock) -> None:
"""Test that repo is auto-detected from env vars when --repo is not passed.""" """Test that repo is auto-detected from env vars when --repo is not passed."""
@@ -322,7 +324,7 @@ class TestMain:
assert result.exit_code == 0 assert result.exit_code == 0
mock_client_cls.assert_called_once() mock_client_cls.assert_called_once()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")
def test_missing_mapping_file(self, mock_client_cls: MagicMock) -> None: def test_missing_mapping_file(self, mock_client_cls: MagicMock) -> None:
"""Test that missing mapping.json exits with error.""" """Test that missing mapping.json exits with error."""
@@ -333,7 +335,7 @@ class TestMain:
assert result.exit_code == 1 assert result.exit_code == 1
assert "mapping.json" in result.output assert "mapping.json" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")
def test_existing_pages_message(self, mock_client_cls: MagicMock) -> None: def test_existing_pages_message(self, mock_client_cls: MagicMock) -> None:
"""Test that existing wiki pages are reported.""" """Test that existing wiki pages are reported."""
@@ -347,7 +349,7 @@ class TestMain:
assert result.exit_code == 0 assert result.exit_code == 0
assert "existing wiki pages" in result.output assert "existing wiki pages" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")
def test_file_not_found_fails(self, mock_client_cls: MagicMock) -> None: def test_file_not_found_fails(self, mock_client_cls: MagicMock) -> None:
"""Test that missing doc files cause an error, not a warning.""" """Test that missing doc files cause an error, not a warning."""
@@ -361,7 +363,7 @@ class TestMain:
assert result.exit_code != 0 assert result.exit_code != 0
assert "not found" in result.output assert "not found" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")
def test_empty_doc_file_fails(self, mock_client_cls: MagicMock) -> None: def test_empty_doc_file_fails(self, mock_client_cls: MagicMock) -> None:
"""Test that empty doc files cause an error, not a warning.""" """Test that empty doc files cause an error, not a warning."""
@@ -375,7 +377,7 @@ class TestMain:
assert result.exit_code != 0 assert result.exit_code != 0
assert "empty" in result.output.lower() assert "empty" in result.output.lower()
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")
def test_create_and_update(self, mock_client_cls: MagicMock) -> None: def test_create_and_update(self, mock_client_cls: MagicMock) -> None:
"""Test that pages are created and updated correctly (non-dry-run).""" """Test that pages are created and updated correctly (non-dry-run)."""
@@ -393,7 +395,7 @@ class TestMain:
assert "Created: 1" in result.output assert "Created: 1" in result.output
assert "Updated: 1" in result.output assert "Updated: 1" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")
def test_verify_passes(self, mock_client_cls: MagicMock) -> None: def test_verify_passes(self, mock_client_cls: MagicMock) -> None:
"""Test that --verify passes when content matches.""" """Test that --verify passes when content matches."""
@@ -413,7 +415,7 @@ class TestMain:
assert result.exit_code == 0 assert result.exit_code == 0
assert "Verification passed" in result.output assert "Verification passed" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")
def test_verify_fails_on_empty_content(self, mock_client_cls: MagicMock) -> None: def test_verify_fails_on_empty_content(self, mock_client_cls: MagicMock) -> None:
"""Test that --verify fails when wiki pages have empty content.""" """Test that --verify fails when wiki pages have empty content."""
@@ -430,7 +432,7 @@ class TestMain:
assert result.exit_code == 1 assert result.exit_code == 1
assert "FAIL" in result.output assert "FAIL" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")
def test_verify_skipped_in_dry_run(self, mock_client_cls: MagicMock) -> None: def test_verify_skipped_in_dry_run(self, mock_client_cls: MagicMock) -> None:
"""Test that --verify is skipped during dry-run.""" """Test that --verify is skipped during dry-run."""
@@ -444,7 +446,7 @@ class TestMain:
assert result.exit_code == 0 assert result.exit_code == 0
assert "Verification" not in result.output assert "Verification" not in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")
def test_strict_passes(self, mock_client_cls: MagicMock) -> None: def test_strict_passes(self, mock_client_cls: MagicMock) -> None:
"""Test that --strict passes when integrity check succeeds.""" """Test that --strict passes when integrity check succeeds."""
@@ -461,7 +463,7 @@ class TestMain:
assert result.exit_code == 0 assert result.exit_code == 0
assert "Integrity check passed" in result.output assert "Integrity check passed" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")
def test_strict_fails_on_integrity_issues(self, mock_client_cls: MagicMock) -> None: def test_strict_fails_on_integrity_issues(self, mock_client_cls: MagicMock) -> None:
"""Test that --strict fails when integrity check finds issues.""" """Test that --strict fails when integrity check finds issues."""
@@ -483,7 +485,7 @@ class TestMain:
assert "Missing page: FAQ" in result.output assert "Missing page: FAQ" in result.output
assert "Stale page: Old-Page" in result.output assert "Stale page: Old-Page" in result.output
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True) @patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient") @patch("devx.ci.sync_wiki.GiteaClient")
def test_strict_skipped_in_dry_run(self, mock_client_cls: MagicMock) -> None: def test_strict_skipped_in_dry_run(self, mock_client_cls: MagicMock) -> None:
"""Test that --strict verification is skipped during dry-run.""" """Test that --strict verification is skipped during dry-run."""