Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e604ea2c7 | ||
|
|
4bb50bed58 | ||
|
|
5bb9dce530 | ||
|
|
73662a3bf0 | ||
|
|
a1e87b1905 | ||
|
|
d8d0ad04a2 | ||
|
|
e836c09088 | ||
|
|
507436b134 | ||
|
|
32cec2c5ad | ||
|
|
55c530eb00 | ||
|
|
3928de4507 | ||
|
|
8bb1813715 | ||
|
|
f7f53941a1 | ||
|
|
5edfdaa7aa | ||
|
|
893da8ba34 | ||
|
|
64a58874b6 | ||
|
|
c1c2041ca4 | ||
|
|
49ff8870b1 | ||
|
|
4c1ecbf4fa | ||
|
|
93a1cb9945 | ||
|
|
507bc86b92 | ||
|
|
81dc30ecff | ||
|
|
3c421dd1ad | ||
|
|
11ce99756c | ||
|
|
6149167ba2 | ||
|
|
014ab0b63f | ||
|
|
2a3ee1ec96 | ||
|
|
5d4968eb21 | ||
|
|
33cfbb0f41 | ||
|
|
598238e4d6 | ||
|
|
925b99b7db | ||
|
|
16ed48bd26 | ||
|
|
3b0500164b | ||
|
|
00a44ec5dc | ||
|
|
b385c57621 | ||
|
|
3181b24f5e | ||
|
|
bc8478220c | ||
|
|
a568c0899f | ||
|
|
4216698ca8 | ||
|
|
f3685b9029 | ||
|
|
2e5470236e | ||
|
|
d2dcf8f7c4 | ||
|
|
357e07a9a6 | ||
|
|
ffb3976224 | ||
|
|
ad75b22f2a | ||
|
|
37f867f6d8 | ||
|
|
233a0bc055 | ||
|
|
1a28f5dcc5 | ||
|
|
3af60af439 | ||
|
|
e181104e1c | ||
|
|
ee1826fb34 | ||
|
|
9170546a31 | ||
|
|
91f59076a1 | ||
|
|
ca310fe442 | ||
|
|
decba5ec77 | ||
|
|
082df1d893 | ||
|
|
08f58e551b | ||
|
|
66bace57d9 | ||
|
|
5c8d74015e | ||
|
|
48eec986f6 | ||
|
|
1fc1cfb23f | ||
|
|
f24b6914f6 | ||
|
|
84d02ca221 | ||
|
|
18ac8f4ba9 | ||
|
|
9fb9be9c35 | ||
|
|
9a46723391 | ||
|
|
5828d3f07b | ||
|
|
4232f4baee | ||
|
|
a9fd1a47af | ||
|
|
ecd10241fb | ||
|
|
5fb497d108 | ||
|
|
7d4c32c761 | ||
|
|
cb8af53c26 | ||
|
|
954ede87a7 | ||
|
|
eb30faf027 | ||
|
|
6c4157b5c6 | ||
|
|
ea7ddb2036 | ||
|
|
ef63ada2f0 | ||
|
|
cdd5f5a8da | ||
|
|
63ae375b4b | ||
|
|
8862ea4639 | ||
|
|
8ca0a1b208 | ||
|
|
670f5a099a | ||
|
|
a5277a0790 |
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Gitea API token (required for CI scripts that interact with Gitea)
|
||||
# Create at: https://git.oblachno.oblachno.fyi/user/settings/applications
|
||||
REPO_TOKEN=
|
||||
CI_GITEA_TOKEN=
|
||||
|
||||
# Vikunja API token (required for post-merge task updates)
|
||||
# Create at: https://work.oblachno.oblachno.fyi/settings/tokens
|
||||
|
||||
@@ -23,6 +23,10 @@ on:
|
||||
- src/devx/**
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: build-images
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
detect-type:
|
||||
runs-on: docker
|
||||
@@ -54,19 +58,19 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
run: make setup-release
|
||||
- name: Docker registry login
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
REGISTRY_USERNAME: ${{ vars.REGISTRY_USERNAME }}
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
echo "$REPO_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$REGISTRY_USERNAME" --password-stdin
|
||||
echo "$CI_GITEA_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$CI_GITEA_USERNAME" --password-stdin
|
||||
- name: Build and push tier images
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
REGISTRY_USERNAME: ${{ vars.REGISTRY_USERNAME }}
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
@@ -95,7 +99,7 @@ jobs:
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
@@ -104,7 +108,8 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "build-images/build-and-push" \
|
||||
--commit "${{ github.sha }}"
|
||||
--commit "${{ github.sha }}" \
|
||||
--auto-login
|
||||
|
||||
cleanup:
|
||||
needs: [build-and-push]
|
||||
@@ -119,7 +124,7 @@ jobs:
|
||||
run: make setup-ci
|
||||
- name: Clean up old image versions
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
|
||||
+49
-11
@@ -8,11 +8,15 @@ on:
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
run: make setup-quality
|
||||
run: make setup-image
|
||||
- name: Lint all
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
@@ -60,7 +64,11 @@ jobs:
|
||||
|
||||
detect-changes:
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
outputs:
|
||||
user-facing-changed: ${{ steps.detect.outputs.user-facing-changed }}
|
||||
steps:
|
||||
@@ -68,7 +76,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
run: make setup-image
|
||||
- name: Detect changed paths
|
||||
id: detect
|
||||
env:
|
||||
@@ -84,32 +92,42 @@ jobs:
|
||||
needs: [quality, detect-changes]
|
||||
if: needs.detect-changes.outputs.user-facing-changed == 'true'
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- 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
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.release --dry-run || true
|
||||
python3 -m devx.ci.release --dry-run
|
||||
|
||||
pr-review:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
run: make setup-image
|
||||
- name: Run automated PR review
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
set -euo pipefail
|
||||
@@ -122,24 +140,44 @@ jobs:
|
||||
# Auto-merge runs after all CI checks pass. It reads the task ID
|
||||
# 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).
|
||||
needs: [quality, detect-changes, pr-review]
|
||||
needs: [quality, detect-changes, pr-review, release-dry-run]
|
||||
if: >-
|
||||
always() &&
|
||||
github.event_name == 'pull_request' &&
|
||||
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
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.REPO_TOKEN }}
|
||||
token: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
run: make setup-image
|
||||
- name: Post approval review
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 -m devx.ci.pr_review \
|
||||
"$PR_NUMBER" \
|
||||
"$REPOSITORY" \
|
||||
--event APPROVE \
|
||||
--checklist-confirmed \
|
||||
--checklist-categories 1,2,3,4,5,6,7,8,9,10,11,12,13 \
|
||||
--body "Auto-approved: all CI checks passed (quality, pr-review, release-dry-run)."
|
||||
- name: Squash merge with task ID
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
||||
DEVX_VIKUNJA_PROJECT_ID: "8"
|
||||
PYTHONPATH: src
|
||||
|
||||
@@ -33,7 +33,11 @@ on:
|
||||
jobs:
|
||||
detect-type:
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
outputs:
|
||||
is-release: ${{ steps.check.outputs.is-release }}
|
||||
steps:
|
||||
@@ -41,7 +45,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
run: make setup-image
|
||||
- name: Check if this is a release commit
|
||||
id: check
|
||||
env:
|
||||
@@ -54,13 +58,17 @@ jobs:
|
||||
needs: [detect-type]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
timeout-minutes: 5
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
run: make setup-image
|
||||
- name: Validate latest commit message
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
@@ -74,18 +82,22 @@ jobs:
|
||||
needs: [detect-type]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
outputs:
|
||||
tag: ${{ steps.release-tag.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.REPO_TOKEN }}
|
||||
token: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
- name: Set up environment
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
run: make setup-release
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
run: make setup-image
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.name "devx-ci-bot"
|
||||
@@ -98,17 +110,10 @@ jobs:
|
||||
. .venv/bin/activate
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
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
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
@@ -117,33 +122,37 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/release" \
|
||||
--commit "${{ github.sha }}"
|
||||
--commit "${{ github.sha }}" \
|
||||
--auto-login
|
||||
|
||||
publish:
|
||||
needs: [release]
|
||||
if: needs.release.outputs.tag != ''
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ needs.release.outputs.tag }}
|
||||
- name: Set up environment
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
run: make setup-release
|
||||
run: make setup-image EXTRAS=release
|
||||
- name: Build and publish release
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
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
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
@@ -152,22 +161,27 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/publish" \
|
||||
--commit "${{ github.sha }}"
|
||||
--commit "${{ github.sha }}" \
|
||||
--auto-login
|
||||
|
||||
sync-wiki:
|
||||
needs: [detect-type]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
run: make setup-image
|
||||
- name: Sync documentation to wiki
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
@@ -175,7 +189,7 @@ jobs:
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
@@ -183,25 +197,30 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/sync-wiki" \
|
||||
--commit "${{ github.sha }}"
|
||||
--commit "${{ github.sha }}" \
|
||||
--auto-login
|
||||
|
||||
badges:
|
||||
needs: [detect-type]
|
||||
if: always()
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: master
|
||||
token: ${{ secrets.REPO_TOKEN }}
|
||||
token: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
- name: Fetch latest master
|
||||
run: |
|
||||
git fetch origin master
|
||||
git reset --hard origin/master
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
run: make setup-image
|
||||
- name: Generate and push badges
|
||||
env:
|
||||
PRE_COMMIT_ALLOW_NO_CONFIG: "1"
|
||||
@@ -211,7 +230,7 @@ jobs:
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
@@ -219,19 +238,24 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/badges" \
|
||||
--commit "${{ github.sha }}"
|
||||
--commit "${{ github.sha }}" \
|
||||
--auto-login
|
||||
|
||||
vikunja:
|
||||
needs: [detect-type]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
run: make setup-image
|
||||
- name: Update Vikunja task
|
||||
env:
|
||||
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
||||
@@ -243,7 +267,7 @@ jobs:
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
@@ -251,20 +275,25 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/vikunja" \
|
||||
--commit "${{ github.sha }}"
|
||||
--commit "${{ github.sha }}" \
|
||||
--auto-login
|
||||
|
||||
configure-repo:
|
||||
needs: [detect-type]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
run: make setup-image
|
||||
- name: Ensure branch protection and labels
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
@@ -272,7 +301,7 @@ jobs:
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
@@ -280,4 +309,5 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/configure-repo" \
|
||||
--commit "${{ github.sha }}"
|
||||
--commit "${{ github.sha }}" \
|
||||
--auto-login
|
||||
|
||||
@@ -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
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
```bash
|
||||
make setup # Create venv, install deps, set up hooks, install CI tools
|
||||
make install-tools # Install actionlint, git-cliff, act_runner to ~/.local/bin
|
||||
make lint-all # ruff + pyright + bandit + actionlint
|
||||
make install-tools # Install actionlint, git-cliff, act_runner, tea, hadolint to ~/.local/bin
|
||||
make lint-all # ruff + pyright + bandit + actionlint + lint-dockerfiles
|
||||
make pytest-cov # Unit tests with 100% coverage enforcement
|
||||
make test-unit # Unit tests without coverage
|
||||
make workflow-lint # Static lint of .gitea/workflows/*.yml (actionlint)
|
||||
@@ -16,8 +16,8 @@ make clean # Remove caches, build artifacts, coverage data
|
||||
|
||||
`make setup` automatically installs all development tools:
|
||||
- **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)
|
||||
- **tea CLI login** via `python -m devx.tools.setup` (configures `tea login` from `.env` `REPO_TOKEN`)
|
||||
- **actionlint, git-cliff, act_runner, tea, hadolint** 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` `CI_GITEA_TOKEN`)
|
||||
|
||||
## Workflow Verification (Before Push)
|
||||
|
||||
@@ -62,18 +62,19 @@ src/devx/
|
||||
│ ├── classify_changes.py # User-facing vs workflow-only change detection
|
||||
│ ├── detect_release_commit.py # Detect release commits on master
|
||||
│ ├── validate_commit_msg.py # Conventional commit validation
|
||||
│ ├── pr_review.py # Automated PR review
|
||||
│ ├── pr_review.py # Automated PR review + manual reviews (--event, --body, --checklist-confirmed)
|
||||
│ ├── post_merge.py # Vikunja task updates after merge
|
||||
│ ├── sync_wiki.py # Sync documentation to Gitea wiki
|
||||
│ ├── push_badges.py # Generate and push quality badges (--retries for retry on git push failures)
|
||||
│ ├── notify_failure.py # Create Gitea issues on CI failures (--auto-login)
|
||||
│ ├── distribute_files.py # Distribute files across parallel runners (LPT scheduling)
|
||||
│ ├── distribute_items.py # Distribute generic items (VMs, hosts) across parallel runners (LPT)
|
||||
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
|
||||
│ ├── check_translations.py # Translation completeness check
|
||||
│ └── doc_coverage.py # Documentation coverage check
|
||||
├── tools/ # Developer tooling modules (run locally or by CI)
|
||||
│ ├── 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, hadolint
|
||||
│ ├── 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
|
||||
@@ -83,7 +84,12 @@ src/devx/
|
||||
│ ├── check_test_coverage.py # Ensure changed files have corresponding tests (configurable rules)
|
||||
│ ├── check_agent_docs.py # Validate docs for stale file references (configurable patterns)
|
||||
│ ├── configure_repo.py # Branch protection and label setup
|
||||
│ └── generate_badges.py # Badge SVG generation
|
||||
│ ├── generate_badges.py # Badge SVG generation
|
||||
│ ├── create_task.py # Create Vikunja tasks
|
||||
│ ├── create_pr.py # Create PRs with auto-derived title from Vikunja
|
||||
│ ├── pr_status.py # Check CI status for a PR/commit (--wait polls)
|
||||
│ ├── pr_logs.py # Fetch logs for failed CI jobs
|
||||
│ └── pr_label.py # Add labels to PRs (idempotent)
|
||||
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
|
||||
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
||||
├── discover_runners.py # Dynamic Gitea runner discovery
|
||||
@@ -263,7 +269,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
|
||||
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:
|
||||
- `create_issue()` — Create issues with labels
|
||||
@@ -358,7 +364,7 @@ devx uses environment variables with `.env` file fallback for configuration.
|
||||
| `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) |
|
||||
| `DEVX_VIKUNJA_PROJECT_ID` | `6` | Vikunja project ID |
|
||||
| `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 |
|
||||
|
||||
### Per-Project Overrides
|
||||
@@ -398,13 +404,17 @@ projects.
|
||||
| `devx-create-pr` | Create a PR with auto-derived title |
|
||||
| `devx-push` | Push current branch to origin |
|
||||
| `devx-push-with-pr` | Push and create PR in one step |
|
||||
| `devx-pr-status` | Check CI status for a PR (`PR=`, `WAIT=`, `TIMEOUT=`) |
|
||||
| `devx-pr-logs` | Fetch logs for failed CI jobs (`PR=`, `JOB=`, `TAIL=`) |
|
||||
| `devx-pr-label` | Add a label to a PR (`PR=`, `LABEL=ready-to-merge`) |
|
||||
| `devx-pr-review` | Post a review on a PR (`PR=`, `EVENT=`, `BODY=`, `CHECKLIST=`) |
|
||||
| `devx-check-config` | Validate devx configuration |
|
||||
| `devx-configure-gitea-pypi` | Configure Gitea private PyPI registry |
|
||||
| `devx-env` | Create .env from .env.example |
|
||||
| `devx-venv` | Create Python venv with version check |
|
||||
| `devx-activate-scripts` | Create shell/fish/zsh activate scripts |
|
||||
| `devx-install-hooks` | Set git hooks path to hooks/ |
|
||||
| `devx-install-tools` | Install actionlint, git-cliff, act_runner, tea |
|
||||
| `devx-install-tools` | Install actionlint, git-cliff, act_runner, tea, hadolint |
|
||||
| `devx-install-checkmake` | Install checkmake (Makefile linter) |
|
||||
| `devx-checkmake` | Lint Makefiles with checkmake |
|
||||
| `devx-workflow-lint` | Static lint of Gitea Actions YAML (actionlint) |
|
||||
@@ -428,6 +438,7 @@ projects.
|
||||
| `devx-pre-push` | Run lint + tests before push |
|
||||
| `devx-clean` | Remove caches, build artifacts, coverage data |
|
||||
| `devx-setup-image` | Link /opt/venv + install project (for pre-built image CI jobs) |
|
||||
| `devx-lint-dockerfiles` | Lint Dockerfiles with hadolint (fail-fast, parameterized by `DEVX_DOCKERFILE_PATHS`) |
|
||||
| `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 |
|
||||
@@ -441,6 +452,7 @@ projects.
|
||||
- `DEVX_COV_PKG` — coverage package (default: `src/devx`)
|
||||
- `DEVX_TEST_PATHS` — pytest paths (default: `tests/`)
|
||||
- `DEVX_PR_BASE` — PR base branch (default: `master`)
|
||||
- `DEVX_DOCKERFILE_PATHS` — directory to search for Dockerfiles (default: `docker`)
|
||||
- `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`)
|
||||
@@ -453,7 +465,7 @@ 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-quality-latest` | ci-base + devx[lint] + actionlint + checkmake + hadolint | 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):
|
||||
|
||||
+121
@@ -2,6 +2,127 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.26.3] - 2026-06-28
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Pin all dependencies to exact versions for reproducibility
|
||||
|
||||
## [0.26.2] - 2026-06-28
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Block admin merge override and auto-approve with review token
|
||||
|
||||
## [0.26.1] - 2026-06-28
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Force pip upgrade in setup-image to install new dependencies
|
||||
|
||||
## [0.26.0] - 2026-06-28
|
||||
|
||||
### Features
|
||||
|
||||
- Add distribute_items CI tool for parallel VM deployment
|
||||
|
||||
## [0.25.0] - 2026-06-28
|
||||
|
||||
### Features
|
||||
|
||||
- Add manual review support to pr_review (--event, --body, --checklist-confirmed)
|
||||
|
||||
## [0.24.1] - 2026-06-28
|
||||
|
||||
### Refactor
|
||||
|
||||
- Add find_task_by_identifier, config fallbacks for tools
|
||||
|
||||
## [0.24.0] - 2026-06-27
|
||||
|
||||
### Features
|
||||
|
||||
- Add pr_status, pr_logs, pr_label tools
|
||||
|
||||
## [0.23.4] - 2026-06-27
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add --auto-login to all notify_failure calls in workflows
|
||||
- Classify .gitea/** as user-facing for devx, support glob in user_facing_overrides
|
||||
|
||||
## [0.23.3] - 2026-06-27
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Correct clean_images delete URL and add retry with error handling
|
||||
|
||||
## [0.23.2] - 2026-06-27
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add skip-ci flag to release commits and concurrency to build-images
|
||||
|
||||
## [0.23.1] - 2026-06-27
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add rsync to ci-full image for molecule_docker
|
||||
|
||||
## [0.23.0] - 2026-06-27
|
||||
|
||||
### Features
|
||||
|
||||
- Add devx-lint-dockerfiles to devx.mak, alias setup-image
|
||||
|
||||
### Refactor
|
||||
|
||||
- Remove hadolint on-the-fly install from setup-image
|
||||
|
||||
## [0.22.1] - 2026-06-27
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Checkout release tag in publish job
|
||||
- Fail lint-dockerfiles when hadolint is missing
|
||||
|
||||
## [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
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: all setup setup-ci setup-quality setup-release setup-image 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 build-images push-images build-images-dry-run clean-images
|
||||
.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
|
||||
VENV := .venv
|
||||
@@ -25,14 +25,17 @@ setup-quality: $(VENV)/bin/activate .env install-tools
|
||||
|
||||
# Setup for release jobs (needs git-cliff, tea, lint tools)
|
||||
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; \
|
||||
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)
|
||||
# Note: Not aliased to devx-setup-image because devx's own CI images may have
|
||||
# an older devx.mak that doesn't yet define devx-setup-image. Consumer repos
|
||||
# (grm, infra) can safely alias to devx-setup-image since they install devx from PyPI.
|
||||
setup-image:
|
||||
@if [ -d /opt/venv ]; then ln -sf /opt/venv .venv; . .venv/bin/activate && pip install -e . --no-deps 2>/dev/null; \
|
||||
@if [ -d /opt/venv ]; then ln -sf /opt/venv .venv; . .venv/bin/activate && pip install --no-cache-dir -e . 2>/dev/null; \
|
||||
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
|
||||
|
||||
.env:
|
||||
@@ -96,9 +99,20 @@ create-pr: devx-create-pr
|
||||
push-with-pr: devx-push-with-pr
|
||||
git-push: devx-push
|
||||
|
||||
lint-all: lint workflow-lint
|
||||
lint-all: lint workflow-lint lint-dockerfiles
|
||||
@echo "[lint-all] All linting checks passed."
|
||||
|
||||
# Note: Not aliased to devx-lint-dockerfiles for the same reason as setup-image —
|
||||
# devx's own CI images may have an older devx.mak. Consumer repos can safely alias.
|
||||
lint-dockerfiles:
|
||||
@echo "[lint-dockerfiles] Linting Dockerfiles with hadolint..."
|
||||
@if ! command -v hadolint >/dev/null 2>&1; then \
|
||||
echo "[lint-dockerfiles] ERROR: hadolint not found. Install from https://github.com/hadolint/hadolint/releases" >&2; \
|
||||
exit 1; \
|
||||
fi
|
||||
@find docker -name 'Dockerfile*' -exec hadolint {} +
|
||||
@echo "[lint-dockerfiles] All Dockerfiles passed."
|
||||
|
||||
test-unit: devx-test-unit
|
||||
|
||||
pytest-cov: devx-pytest-cov
|
||||
|
||||
@@ -16,12 +16,12 @@ quality badges.
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## 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_STATUS_CHECKS` | `CI / quality (pull_request)` | Comma-separated status check contexts |
|
||||
| `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 |
|
||||
| `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
|
||||
|
||||
Projects using devx can override the default API URLs and language by setting
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
|
||||
FROM git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
|
||||
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
|
||||
# Install rsync (required by molecule_docker for file sync between host and test containers)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends rsync \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install devx[release,molecule,deploy] from local source
|
||||
COPY . /tmp/devx
|
||||
RUN pip install --no-cache-dir /tmp/devx[release,molecule,deploy] \
|
||||
|
||||
@@ -15,3 +15,8 @@ RUN pip install --no-cache-dir /tmp/devx[lint] \
|
||||
# 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
|
||||
|
||||
+7
-7
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -149,7 +149,7 @@ fallback. Key variables:
|
||||
| `DEVX_REPO_NAME` | **(must be set)** | Repository name |
|
||||
| `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) |
|
||||
| `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 |
|
||||
|
||||
See [AGENTS.md](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/AGENTS.md)
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
### `post_merge.py`
|
||||
@@ -569,7 +569,7 @@ push_badges.py:
|
||||
The `tea` Gitea CLI tool is used for Gitea API interactions where tea provides
|
||||
reliable, official support. It is installed 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
|
||||
tea does not support (wiki management, commit status, runner discovery,
|
||||
|
||||
@@ -305,7 +305,7 @@ post-merge workflow when it creates and pushes a new version tag.
|
||||
and the project itself
|
||||
2. **Install CI tools** — git-cliff and tea via
|
||||
`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>`:
|
||||
- Build the package with `python -m build`
|
||||
- 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
|
||||
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
|
||||
python -m devx.ci.notify_failure --repo <owner/repo> --run-id <id> \
|
||||
|
||||
@@ -148,7 +148,7 @@ devx ci integration-guard -- -x -v --tb=short test_a.py
|
||||
|
||||
Environment variables:
|
||||
- `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`)
|
||||
- `JOB_NAME` — base job name (`GITHUB_JOB`)
|
||||
- `MATRIX_INDEX` — current matrix index (runner-index)
|
||||
@@ -171,7 +171,7 @@ Options:
|
||||
- `--run-id <id>` — CI run ID (required)
|
||||
- `--workflow <name>` — workflow name (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
|
||||
|
||||
### `devx ci post-merge`
|
||||
@@ -462,7 +462,7 @@ Options:
|
||||
|
||||
Environment variables:
|
||||
- `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`)
|
||||
- `JOB_NAME` — base job name (`GITHUB_JOB`)
|
||||
- `MATRIX_INDEX` — current matrix index (runner-index)
|
||||
|
||||
+36
-27
@@ -13,10 +13,13 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
||||
]
|
||||
# All dependencies are pinned to exact versions for full reproducibility.
|
||||
# Update pinned versions in a dedicated PR with verification.
|
||||
dependencies = [
|
||||
"requests>=2.34.2",
|
||||
"python-dotenv>=1.2.2",
|
||||
"click>=8.4.1",
|
||||
"requests==2.34.2",
|
||||
"python-dotenv==1.2.2",
|
||||
"click==8.4.2",
|
||||
"tenacity==9.1.4", # retry logic for GiteaClient/VikunjaClient
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -28,44 +31,44 @@ version = {attr = "devx.__version__"}
|
||||
[project.optional-dependencies]
|
||||
# Test runners (pytest + coverage + parallel execution)
|
||||
ci = [
|
||||
"pytest>=9.1.0",
|
||||
"pytest-cov>=7.1.0",
|
||||
"pytest-xdist>=3.8",
|
||||
"pytest==9.1.1",
|
||||
"pytest-cov==7.1.0",
|
||||
"pytest-xdist==3.8.0",
|
||||
]
|
||||
# Lint and type-checking tools (quality job, badge generation)
|
||||
lint = [
|
||||
"ruff>=0.15.17",
|
||||
"pyright>=1.1.410",
|
||||
"bandit>=1.8.2",
|
||||
"pip-audit>=2.10",
|
||||
"pre-commit>=4.6.0",
|
||||
"ruff==0.15.20",
|
||||
"pyright==1.1.411",
|
||||
"bandit==1.9.4",
|
||||
"pip-audit==2.10.1",
|
||||
"pre-commit==4.6.0",
|
||||
]
|
||||
# Release tools (build + publish to PyPI/Gitea registry)
|
||||
release = [
|
||||
"build>=1.5.0",
|
||||
"twine>=6.2.0",
|
||||
"build==1.5.0",
|
||||
"twine==6.2.0",
|
||||
]
|
||||
# Molecule testing (for projects with Ansible roles)
|
||||
molecule = [
|
||||
"molecule>=26.4.0",
|
||||
"molecule-docker>=2.1.0",
|
||||
"ansible-lint>=26.4.0",
|
||||
"ansible-core>=2.15,<2.17",
|
||||
"molecule==26.4.0",
|
||||
"molecule-docker==2.1.0",
|
||||
"ansible-lint==26.4.0",
|
||||
"ansible-core==2.21.1",
|
||||
]
|
||||
# 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",
|
||||
"ansible-core==2.21.1",
|
||||
"boto3==1.43.36",
|
||||
"docker==7.1.0",
|
||||
"jinja2==3.1.6",
|
||||
"pyyaml==6.0.3",
|
||||
"cryptography==49.0.0",
|
||||
]
|
||||
# Full dev environment (local development)
|
||||
dev = [
|
||||
"devx[ci,lint,release,molecule]",
|
||||
"build>=1.3.0",
|
||||
"twine>=6.2.0",
|
||||
"build==1.5.0",
|
||||
"twine==6.2.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
@@ -97,6 +100,8 @@ indent-style = "space"
|
||||
[tool.pyright]
|
||||
include = ["src"]
|
||||
pythonVersion = "3.12"
|
||||
venvPath = "."
|
||||
venv = ".venv"
|
||||
strict = ["src/devx/config.py", "src/devx/exceptions.py", "src/devx/i18n.py", "src/devx/api_clients.py", "src/devx/gitea_cli.py"]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -139,8 +144,12 @@ infrastructure_overrides = [
|
||||
]
|
||||
|
||||
# User-facing overrides — safety override for broad infrastructure patterns
|
||||
# (empty — add when an infrastructure pattern is too broad)
|
||||
user_facing_overrides = []
|
||||
# devx workflow files (.gitea/**) are reference implementations that
|
||||
# downstream repos (grm, infra) copy from. Changes to them affect how
|
||||
# consumer projects run their CI, so they must trigger a release.
|
||||
user_facing_overrides = [
|
||||
".gitea/**",
|
||||
]
|
||||
|
||||
# Tag patterns — additional categories for CI conditional execution
|
||||
# Orthogonal to release impact (user-facing vs infrastructure)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||
|
||||
__version__ = "0.20.2"
|
||||
__version__ = "0.26.3"
|
||||
|
||||
+130
-93
@@ -4,10 +4,16 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from tenacity import (
|
||||
before_sleep_log,
|
||||
retry,
|
||||
retry_if_exception_type,
|
||||
stop_after_attempt,
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from devx.config import DEFAULT_TIMEOUT, MAX_RETRIES, RETRY_BACKOFF_BASE, RETRY_STATUS_CODES
|
||||
from devx.exceptions import APIError
|
||||
@@ -27,14 +33,69 @@ def _parse_error(e: requests.HTTPError) -> tuple[int, str]:
|
||||
return status, message
|
||||
|
||||
|
||||
def _is_retryable(e: Exception) -> bool:
|
||||
"""Check if an exception is a transient error worth retrying."""
|
||||
if isinstance(e, requests.ConnectionError):
|
||||
return True
|
||||
if isinstance(e, requests.HTTPError):
|
||||
status, _ = _parse_error(e)
|
||||
return status in RETRY_STATUS_CODES
|
||||
return isinstance(e, requests.Timeout)
|
||||
class _TransientHTTPError(requests.HTTPError):
|
||||
"""HTTP error with a retryable status code (wrapped for tenacity)."""
|
||||
|
||||
|
||||
class _RetryableRequestError(Exception):
|
||||
"""Connection/timeout error wrapped for tenacity retry."""
|
||||
|
||||
|
||||
def _execute_request(
|
||||
session: requests.Session,
|
||||
method: str,
|
||||
url: str,
|
||||
**kwargs: Any,
|
||||
) -> requests.Response:
|
||||
"""Execute a single HTTP request, wrapping transient errors for tenacity.
|
||||
|
||||
Non-retryable HTTP errors (4xx except 429) raise :class:`APIError` directly.
|
||||
Retryable errors (429, 5xx, connection, timeout) raise exceptions that
|
||||
tenacity will retry.
|
||||
"""
|
||||
try:
|
||||
response = session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except requests.HTTPError as e:
|
||||
status, message = _parse_error(e)
|
||||
if status in RETRY_STATUS_CODES:
|
||||
# Wrap in _TransientHTTPError so tenacity retries it
|
||||
raise _TransientHTTPError(message, response=e.response) from e
|
||||
raise APIError(status, message) from e
|
||||
except (requests.ConnectionError, requests.Timeout) as e:
|
||||
raise _RetryableRequestError(str(e)) from e
|
||||
|
||||
|
||||
# Tenacity retry decorator shared by both clients.
|
||||
# Retries on transient HTTP errors (429, 5xx) and connection/timeout errors.
|
||||
_retry_decorator = retry(
|
||||
stop=stop_after_attempt(MAX_RETRIES),
|
||||
wait=wait_exponential(multiplier=RETRY_BACKOFF_BASE, min=RETRY_BACKOFF_BASE, max=RETRY_BACKOFF_BASE**MAX_RETRIES),
|
||||
retry=retry_if_exception_type((_TransientHTTPError, _RetryableRequestError)),
|
||||
before_sleep=before_sleep_log(logger, logging.WARNING),
|
||||
reraise=True,
|
||||
)
|
||||
|
||||
|
||||
def _request_with_retry(
|
||||
session: requests.Session,
|
||||
url: str,
|
||||
method: str,
|
||||
**kwargs: Any,
|
||||
) -> requests.Response:
|
||||
"""Execute an HTTP request with tenacity-managed retry logic.
|
||||
|
||||
On exhaustion, the last exception is translated to :class:`APIError`.
|
||||
"""
|
||||
try:
|
||||
return _retry_decorator(_execute_request)(session, method, url, **kwargs)
|
||||
except _TransientHTTPError as e:
|
||||
response = getattr(e, "response", None)
|
||||
status = response.status_code if response is not None else 0
|
||||
raise APIError(status, str(e)) from e
|
||||
except _RetryableRequestError as e:
|
||||
raise APIError(0, str(e)) from e
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
@@ -56,49 +117,7 @@ class GiteaClient:
|
||||
return f"{self._base_url}/repos/{self._owner}/{self._repo}{path}"
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
|
||||
url = self._url(path)
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except requests.HTTPError as e:
|
||||
status, message = _parse_error(e)
|
||||
if _is_retryable(e) and attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
||||
logger.warning(
|
||||
"Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)",
|
||||
status,
|
||||
method,
|
||||
path,
|
||||
wait,
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
last_exc = e
|
||||
continue
|
||||
raise APIError(status, message) from e
|
||||
except (requests.ConnectionError, requests.Timeout) as e:
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
||||
logger.warning(
|
||||
"Connection error on %s %s, retrying in %ds (attempt %d/%d)",
|
||||
method,
|
||||
path,
|
||||
wait,
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
last_exc = e
|
||||
continue
|
||||
raise APIError(0, str(e)) from e
|
||||
# Should not reach here, but just in case
|
||||
if last_exc: # pragma: no cover
|
||||
raise APIError(0, str(last_exc)) from last_exc
|
||||
raise APIError(0, "Max retries exceeded") # pragma: no cover
|
||||
return _request_with_retry(self._session, self._url(path), method, **kwargs)
|
||||
|
||||
# -- repo settings --
|
||||
|
||||
@@ -223,6 +242,20 @@ class GiteaClient:
|
||||
r = self._request("GET", f"/pulls/{pr_number}/files")
|
||||
return r.json()
|
||||
|
||||
def add_pr_label(self, pr_number: str | int, label_names: list[str]) -> None:
|
||||
"""Attach labels to a PR/issue by name.
|
||||
|
||||
Args:
|
||||
pr_number: PR or issue number.
|
||||
label_names: List of label names to attach.
|
||||
"""
|
||||
self._request("POST", f"/issues/{pr_number}/labels", json={"labels": label_names})
|
||||
|
||||
def get_pr_label_names(self, pr_number: str | int) -> list[str]:
|
||||
"""Return label names currently attached to a PR/issue."""
|
||||
r = self._request("GET", f"/issues/{pr_number}/labels")
|
||||
return [label.get("name", "") for label in r.json()]
|
||||
|
||||
def get_pr_commits(self, pr_number: str | int) -> list[dict[str, Any]]:
|
||||
"""Fetch the commits included in a pull request."""
|
||||
r = self._request("GET", f"/pulls/{pr_number}/commits")
|
||||
@@ -301,6 +334,31 @@ class GiteaClient:
|
||||
return existing
|
||||
return self.create_release(tag=tag, name=name, body=body, draft=draft, prerelease=prerelease)
|
||||
|
||||
# -- actions (CI/CD) --
|
||||
|
||||
def list_action_runs(self, **params: Any) -> dict[str, Any]:
|
||||
"""List workflow runs for the repository.
|
||||
|
||||
Returns the raw API response dict (includes ``workflow_runs`` and
|
||||
``total_count`` keys per Gitea API).
|
||||
"""
|
||||
r = self._request("GET", "/actions/runs", params=params)
|
||||
return r.json()
|
||||
|
||||
def get_action_run_jobs(self, run_id: str | int) -> list[dict[str, Any]]:
|
||||
"""List jobs for a specific workflow run."""
|
||||
r = self._request("GET", f"/actions/runs/{run_id}/jobs")
|
||||
data = r.json()
|
||||
return data.get("jobs", [])
|
||||
|
||||
def get_action_job_logs(self, job_id: str | int) -> str:
|
||||
"""Fetch logs for a specific CI job.
|
||||
|
||||
Returns the raw log text. Raises APIError if logs are unavailable.
|
||||
"""
|
||||
r = self._request("GET", f"/actions/jobs/{job_id}/logs")
|
||||
return r.text
|
||||
|
||||
|
||||
class VikunjaClient:
|
||||
"""Low-level Vikunja REST API client with connection pooling."""
|
||||
@@ -312,47 +370,7 @@ class VikunjaClient:
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
|
||||
url = f"{self._base_url}{path}"
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except requests.HTTPError as e:
|
||||
status, message = _parse_error(e)
|
||||
if _is_retryable(e) and attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
||||
logger.warning(
|
||||
"Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)",
|
||||
status,
|
||||
method,
|
||||
path,
|
||||
wait,
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
last_exc = e
|
||||
continue
|
||||
raise APIError(status, message) from e
|
||||
except (requests.ConnectionError, requests.Timeout) as e:
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
||||
logger.warning(
|
||||
"Connection error on %s %s, retrying in %ds (attempt %d/%d)",
|
||||
method,
|
||||
path,
|
||||
wait,
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
last_exc = e
|
||||
continue
|
||||
raise APIError(0, str(e)) from e
|
||||
if last_exc: # pragma: no cover
|
||||
raise APIError(0, str(last_exc)) from last_exc
|
||||
raise APIError(0, "Max retries exceeded") # pragma: no cover
|
||||
return _request_with_retry(self._session, url, method, **kwargs)
|
||||
|
||||
def list_tasks(self, **params: Any) -> list[dict[str, Any]]:
|
||||
r = self._request("GET", "/tasks", params=params)
|
||||
@@ -368,6 +386,25 @@ class VikunjaClient:
|
||||
r = self._request("GET", f"/projects/{project_id}/tasks", params=params)
|
||||
return r.json()
|
||||
|
||||
def find_task_by_identifier(self, project_id: int, identifier: str, per_page: int = 50) -> dict[str, Any] | None:
|
||||
"""Find a task by its identifier (e.g. ``DEVX-42``) in a project.
|
||||
|
||||
Paginates through all tasks in the project. Returns the task dict
|
||||
or None if not found.
|
||||
"""
|
||||
page = 1
|
||||
while True:
|
||||
tasks = self.list_project_tasks(project_id, page=page, per_page=per_page)
|
||||
if not tasks:
|
||||
break
|
||||
for t in tasks:
|
||||
if t.get("identifier") == identifier:
|
||||
return t
|
||||
if len(tasks) < per_page:
|
||||
break
|
||||
page += 1
|
||||
return None
|
||||
|
||||
def create_task(self, project_id: int, title: str, description: str = "") -> dict[str, Any]:
|
||||
"""Create a task in a project and return the created task dict.
|
||||
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
|
||||
from devx.config import TASK_ID_RE
|
||||
from devx.i18n import _
|
||||
|
||||
|
||||
def get_latest_tag() -> str:
|
||||
"""Get the latest git tag, or empty string if none exists."""
|
||||
@@ -16,3 +22,97 @@ def get_latest_tag() -> str:
|
||||
if result.returncode != 0:
|
||||
return ""
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def run_cmd(
|
||||
args: list[str],
|
||||
check: bool = True,
|
||||
capture: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
"""Run a command and return the completed process.
|
||||
|
||||
Args:
|
||||
args: Command and arguments as a list.
|
||||
check: If True, raise :class:`click.ClickException` on non-zero exit.
|
||||
capture: If True, capture stdout/stderr. If False, inherit parent's.
|
||||
"""
|
||||
result = subprocess.run( # nosec B603
|
||||
args,
|
||||
capture_output=capture,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Command failed ({cmd}): {stderr}",
|
||||
cmd=" ".join(args),
|
||||
stderr=result.stderr.strip() if result.stderr else result.stdout.strip(),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def extract_task_id(text: str) -> str:
|
||||
"""Extract the ``{PREFIX}-N`` task identifier from *text*.
|
||||
|
||||
Returns the matched string (e.g. ``DEVX-42``) or an empty string if
|
||||
no task ID is found.
|
||||
"""
|
||||
match = TASK_ID_RE.search(text)
|
||||
return match.group(0) if match else ""
|
||||
|
||||
|
||||
def write_github_env(key: str, value: str) -> None:
|
||||
"""Append a key=value line to the ``$GITHUB_ENV`` file.
|
||||
|
||||
Multi-line values use the heredoc syntax required by Gitea Actions.
|
||||
Raises :class:`click.ClickException` if ``GITHUB_ENV`` is not set.
|
||||
"""
|
||||
gh_env = os.environ.get("GITHUB_ENV")
|
||||
if not gh_env:
|
||||
raise click.ClickException("GITHUB_ENV environment variable is not set")
|
||||
with open(gh_env, "a", encoding="utf-8") as f: # noqa: PTH123
|
||||
if "\n" in value:
|
||||
delimiter = "EOF"
|
||||
f.write(f"{key}<<{delimiter}\n{value}\n{delimiter}\n")
|
||||
else:
|
||||
f.write(f"{key}={value}\n")
|
||||
|
||||
|
||||
def write_github_output(key: str, value: str) -> None:
|
||||
"""Append a key=value line to the ``$GITHUB_OUTPUT`` file.
|
||||
|
||||
Raises :class:`click.ClickException` if ``GITHUB_OUTPUT`` is not set.
|
||||
"""
|
||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
||||
if not gh_output:
|
||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
||||
with open(gh_output, "a", encoding="utf-8") as f: # noqa: PTH123
|
||||
f.write(f"{key}={value}\n")
|
||||
|
||||
|
||||
def lpt_distribute[T](items: list[T], weights: list[int], max_runners: int) -> list[list[T]]:
|
||||
"""Distribute *items* across *max_runners* using LPT scheduling.
|
||||
|
||||
Sorts items by weight (descending), then assigns each to the runner
|
||||
with the least total weight. This produces a more balanced distribution
|
||||
than naive round-robin when items have varying costs.
|
||||
|
||||
Args:
|
||||
items: Items to distribute.
|
||||
weights: Parallel list of integer weights (higher = heavier).
|
||||
max_runners: Number of runner groups to create.
|
||||
|
||||
Returns:
|
||||
A list of ``max_runners`` lists, each containing the items assigned
|
||||
to that runner.
|
||||
"""
|
||||
groups: list[list[T]] = [[] for _ in range(max_runners)]
|
||||
loads = [0] * max_runners
|
||||
indexed = sorted(enumerate(items), key=lambda x: (-weights[x[0]], x[0]))
|
||||
for orig_idx, item in indexed:
|
||||
min_runner = min(range(max_runners), key=lambda r: loads[r])
|
||||
groups[min_runner].append(item)
|
||||
loads[min_runner] += weights[orig_idx]
|
||||
return groups
|
||||
|
||||
+21
-27
@@ -17,12 +17,11 @@ This allows the PR title to be a human-friendly Vikunja task title
|
||||
while the squashed commit follows conventional commits.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 -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 re
|
||||
import subprocess # nosec B404
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -30,11 +29,11 @@ import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from devx.api_clients import GiteaClient, VikunjaClient
|
||||
from devx.ci._shared import extract_task_id as _extract_task_id
|
||||
from devx.config import (
|
||||
CONVENTIONAL_RE,
|
||||
DEFAULT_PER_PAGE,
|
||||
GITEA_API_URL,
|
||||
TASK_ID_RE,
|
||||
TASK_PREFIX,
|
||||
VIKUNJA_API_URL,
|
||||
VIKUNJA_PROJECT_ID,
|
||||
@@ -48,20 +47,6 @@ PR_TITLE_RE = re.compile(rf"^{TASK_PREFIX}-\d+:\s+.+")
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def run_cmd(args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
"""Run a command and return the completed process."""
|
||||
result = subprocess.run(args, capture_output=True, text=True, check=False) # nosec B603
|
||||
if check and result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Command failed ({cmd}): {stderr}",
|
||||
cmd=" ".join(args),
|
||||
stderr=result.stderr.strip() or result.stdout.strip(),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def read_taskid(branch: str) -> str:
|
||||
"""Read task ID from branch name.
|
||||
|
||||
@@ -92,9 +77,8 @@ def read_taskid(branch: str) -> str:
|
||||
|
||||
|
||||
def extract_task_id(branch: str) -> str:
|
||||
"""Extract DEVX-N task identifier from branch name (legacy fallback)."""
|
||||
match = TASK_ID_RE.search(branch)
|
||||
return match.group(0) if match else ""
|
||||
"""Extract task identifier from branch name (delegates to shared utility)."""
|
||||
return _extract_task_id(branch)
|
||||
|
||||
|
||||
def validate_pr_title(pr_title: str, task_id: str) -> None:
|
||||
@@ -174,15 +158,25 @@ def validate_pr_title_matches_vikunja(pr_title: str, task_id: str) -> None:
|
||||
def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
|
||||
"""Extract the conventional commit message from PR commits.
|
||||
|
||||
Iterates commits in reverse order (newest first) to find the first
|
||||
message matching the conventional commit format. Falls back to the
|
||||
newest commit message if none match.
|
||||
Picks the highest-priority conventional commit message from the PR.
|
||||
Priority: feat > fix > refactor > docs > chore > other.
|
||||
Falls back to the newest commit message if none match.
|
||||
"""
|
||||
priority = {"feat": 5, "fix": 4, "refactor": 3, "docs": 2, "chore": 1, "ci": 1, "style": 1, "test": 1}
|
||||
best_msg = ""
|
||||
best_score = 0
|
||||
for commit in reversed(commits):
|
||||
commit_info = commit.get("commit", {})
|
||||
message = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
|
||||
if CONVENTIONAL_RE.match(message):
|
||||
return message
|
||||
m = CONVENTIONAL_RE.match(message)
|
||||
if m:
|
||||
prefix = m.group(1).split("(")[0].strip() # e.g. "feat" from "feat(scope)"
|
||||
score = priority.get(prefix, 0)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_msg = message
|
||||
if best_msg:
|
||||
return best_msg
|
||||
# Fallback: use the newest commit's first line
|
||||
if commits:
|
||||
commit_info = commits[-1].get("commit", {})
|
||||
@@ -196,9 +190,9 @@ def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
|
||||
@click.argument("repo")
|
||||
@click.argument("pr_number")
|
||||
def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
token = os.environ.get("CI_GITEA_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
|
||||
try:
|
||||
|
||||
@@ -15,7 +15,7 @@ Exit code 1 = NOT ready — fix issues before pushing.
|
||||
|
||||
Usage::
|
||||
|
||||
# CI (with VIKUNJA_TOKEN and REPO_TOKEN):
|
||||
# CI (with VIKUNJA_TOKEN and CI_GITEA_TOKEN):
|
||||
python3 -m devx.ci.check_auto_merge_ready \\
|
||||
--branch "$HEAD_REF" \\
|
||||
--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
|
||||
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).
|
||||
"""
|
||||
|
||||
@@ -53,6 +53,7 @@ from devx.config import (
|
||||
VIKUNJA_API_URL,
|
||||
VIKUNJA_PROJECT_ID,
|
||||
)
|
||||
from devx.exceptions import APIError
|
||||
from devx.i18n import _
|
||||
|
||||
load_dotenv()
|
||||
@@ -98,9 +99,9 @@ def is_branch_behind_master(branch: str) -> bool:
|
||||
def get_pr_title_from_gitea(repo: str, pr_number: int) -> str | None:
|
||||
"""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:
|
||||
return None
|
||||
owner, repo_name = repo.split("/", 1)
|
||||
@@ -108,7 +109,7 @@ def get_pr_title_from_gitea(repo: str, pr_number: int) -> str | None:
|
||||
try:
|
||||
pr = client.get_pr(pr_number)
|
||||
return str(pr.get("title", ""))
|
||||
except Exception:
|
||||
except APIError:
|
||||
return None
|
||||
|
||||
|
||||
@@ -189,7 +190,9 @@ def cli(
|
||||
if pr_title is None:
|
||||
# Local mode without PR — only validate branch name
|
||||
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] Branch name OK. Push to create PR, then CI will validate the title.")
|
||||
return
|
||||
|
||||
@@ -138,7 +138,7 @@ from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
from devx.ci._shared import get_latest_tag
|
||||
from devx.ci._shared import get_latest_tag, write_github_output
|
||||
from devx.i18n import _
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -393,14 +393,15 @@ class ChangeClassifier:
|
||||
tags = self._compute_tags(file_path)
|
||||
|
||||
# 1. User-facing overrides (highest priority — safety)
|
||||
if file_path in self._user_overrides:
|
||||
return FileClassification(
|
||||
path=file_path,
|
||||
is_user_facing=True,
|
||||
reason="User-facing override (safety override)",
|
||||
matched_rule="user_facing_overrides",
|
||||
tags=tags,
|
||||
)
|
||||
for pattern in self._user_overrides:
|
||||
if _matches_glob(file_path, pattern):
|
||||
return FileClassification(
|
||||
path=file_path,
|
||||
is_user_facing=True,
|
||||
reason=f"User-facing override (matches '{pattern}')",
|
||||
matched_rule="user_facing_overrides",
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
# 2. Infrastructure overrides
|
||||
if file_path in self._infra_overrides:
|
||||
@@ -583,15 +584,8 @@ def has_user_facing_changes(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _write_github_output(key: str, value: str) -> None:
|
||||
"""Append a key=value line to the $GITHUB_OUTPUT file."""
|
||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
||||
if not gh_output:
|
||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
||||
with open(gh_output, "a") as f: # noqa: PTH123
|
||||
f.write(f"{key}={value}\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Classification logic
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -632,9 +626,9 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
|
||||
force = True
|
||||
|
||||
if force and github_output:
|
||||
_write_github_output("user-facing-changed", "true")
|
||||
write_github_output("user-facing-changed", "true")
|
||||
for tag in available_tags:
|
||||
_write_github_output(f"{tag}-changed", "true")
|
||||
write_github_output(f"{tag}-changed", "true")
|
||||
click.echo("Forced user-facing-changed=true via --force flag.")
|
||||
return
|
||||
|
||||
@@ -642,9 +636,9 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
|
||||
base = get_latest_tag()
|
||||
if not base:
|
||||
if github_output:
|
||||
_write_github_output("user-facing-changed", "true")
|
||||
write_github_output("user-facing-changed", "true")
|
||||
for tag in available_tags:
|
||||
_write_github_output(f"{tag}-changed", "true")
|
||||
write_github_output(f"{tag}-changed", "true")
|
||||
click.echo("No tags found — treating all changes as user-facing.")
|
||||
return
|
||||
if quiet:
|
||||
@@ -656,9 +650,9 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
|
||||
files = get_changed_files(base, head)
|
||||
if not files:
|
||||
if github_output:
|
||||
_write_github_output("user-facing-changed", "false")
|
||||
write_github_output("user-facing-changed", "false")
|
||||
for tag in available_tags:
|
||||
_write_github_output(f"{tag}-changed", "false")
|
||||
write_github_output(f"{tag}-changed", "false")
|
||||
click.echo(f"No changes between {base} and {head}.")
|
||||
return
|
||||
if quiet:
|
||||
@@ -670,9 +664,9 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
|
||||
result = classifier.classify(files)
|
||||
|
||||
if github_output:
|
||||
_write_github_output("user-facing-changed", "true" if result.has_user_facing else "false")
|
||||
write_github_output("user-facing-changed", "true" if result.has_user_facing else "false")
|
||||
for tag in available_tags:
|
||||
_write_github_output(f"{tag}-changed", "true" if result.has_tag(tag) else "false")
|
||||
write_github_output(f"{tag}-changed", "true" if result.has_tag(tag) else "false")
|
||||
click.echo(f"User-facing files changed: {result.has_user_facing}")
|
||||
for tag in available_tags:
|
||||
click.echo(f"{tag.capitalize()} files changed: {result.has_tag(tag)}")
|
||||
|
||||
@@ -12,12 +12,13 @@ Usage::
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
|
||||
from devx.ci._shared import write_github_output
|
||||
|
||||
RELEASE_RE = re.compile(r"^release: v\d+\.\d+\.\d+")
|
||||
|
||||
|
||||
@@ -39,15 +40,6 @@ def is_release_commit(message: str) -> bool:
|
||||
return bool(RELEASE_RE.match(message))
|
||||
|
||||
|
||||
def write_github_output(key: str, value: str) -> None:
|
||||
"""Append a key=value line to the $GITHUB_OUTPUT file."""
|
||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
||||
if not gh_output:
|
||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
||||
with open(gh_output, "a") as f: # noqa: PTH123
|
||||
f.write(f"{key}={value}\n")
|
||||
|
||||
|
||||
@click.command()
|
||||
def main() -> None:
|
||||
"""Detect if the latest commit is a release commit and set GITHUB_OUTPUT."""
|
||||
|
||||
@@ -29,7 +29,7 @@ import os
|
||||
import click
|
||||
import requests
|
||||
|
||||
from devx.config import GITEA_API_URL
|
||||
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
|
||||
|
||||
DEFAULT_MAX_RUNNERS = 3
|
||||
|
||||
@@ -148,12 +148,12 @@ def main(
|
||||
output_indices: bool,
|
||||
github_output: bool,
|
||||
) -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
|
||||
if owner is None:
|
||||
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss")
|
||||
owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
|
||||
if repo is None:
|
||||
repo = os.environ.get("DEVX_REPO_NAME", "devx")
|
||||
repo = os.environ.get("DEVX_REPO_NAME", "") or REPO_NAME
|
||||
|
||||
count = get_runner_count(GITEA_API_URL, token, owner, repo)
|
||||
indices = generate_indices(count)
|
||||
@@ -162,7 +162,7 @@ def main(
|
||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
||||
if not gh_output:
|
||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
||||
with open(gh_output, "a") as f: # noqa: PTH123
|
||||
with open(gh_output, "a", encoding="utf-8") as f: # noqa: PTH123
|
||||
f.write(f"runner-count={count}\n")
|
||||
f.write(f"runner-indices={json.dumps(indices)}\n")
|
||||
click.echo(f"Runner count: {count}")
|
||||
|
||||
@@ -26,6 +26,7 @@ import os
|
||||
|
||||
import click
|
||||
|
||||
from devx.ci._shared import lpt_distribute, write_github_env
|
||||
from devx.i18n import _
|
||||
|
||||
DEFAULT_MAX_RUNNERS = 3
|
||||
@@ -54,15 +55,7 @@ def distribute(files: list[str], max_runners: int) -> list[list[str]]:
|
||||
the runner with the least total weight.
|
||||
"""
|
||||
weights = [_file_weight(f) for f in files]
|
||||
groups: list[list[str]] = [[] for _ in range(max_runners)]
|
||||
loads = [0] * max_runners
|
||||
# Sort by weight descending, preserving original order for ties
|
||||
indexed = sorted(enumerate(files), key=lambda x: (-weights[x[0]], x[0]))
|
||||
for orig_idx, f in indexed:
|
||||
min_runner = min(range(max_runners), key=lambda r: loads[r])
|
||||
groups[min_runner].append(f)
|
||||
loads[min_runner] += weights[orig_idx]
|
||||
return groups
|
||||
return lpt_distribute(files, weights, max_runners)
|
||||
|
||||
|
||||
def files_for_runner(files: list[str], runner_index: int, max_runners: int) -> list[str]:
|
||||
@@ -75,19 +68,6 @@ def files_for_runner(files: list[str], runner_index: int, max_runners: int) -> l
|
||||
return groups[runner_index]
|
||||
|
||||
|
||||
def _write_github_env(key: str, value: str) -> None:
|
||||
gh_env = os.environ.get("GITHUB_ENV")
|
||||
if not gh_env:
|
||||
raise click.ClickException("GITHUB_ENV environment variable is not set")
|
||||
with open(gh_env, "a") as f: # noqa: PTH123
|
||||
if "\n" in value:
|
||||
# Multi-line values require the heredoc syntax in $GITHUB_ENV.
|
||||
delimiter = "EOF"
|
||||
f.write(f"{key}<<{delimiter}\n{value}\n{delimiter}\n")
|
||||
else:
|
||||
f.write(f"{key}={value}\n")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--pattern", required=True, help="Glob pattern for files to distribute.")
|
||||
@click.option(
|
||||
@@ -127,8 +107,8 @@ def main(pattern: str, runner_index: int | None, max_runners: int, github_env: b
|
||||
|
||||
if skip_if_excess and github_env and runner_index > max_runners:
|
||||
click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}")
|
||||
_write_github_env("ASSIGNED_FILES", "")
|
||||
_write_github_env("SKIP", "true")
|
||||
write_github_env("ASSIGNED_FILES", "")
|
||||
write_github_env("SKIP", "true")
|
||||
return
|
||||
|
||||
if runner_index < 1:
|
||||
@@ -139,8 +119,8 @@ def main(pattern: str, runner_index: int | None, max_runners: int, github_env: b
|
||||
encoded = "\n".join(assigned)
|
||||
|
||||
if github_env:
|
||||
_write_github_env("ASSIGNED_FILES", encoded)
|
||||
_write_github_env("SKIP", "false")
|
||||
write_github_env("ASSIGNED_FILES", encoded)
|
||||
write_github_env("SKIP", "false")
|
||||
click.echo(f"Assigned {len(assigned)} files to runner {runner_index}")
|
||||
return
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Distribute a list of items across N parallel runners using LPT scheduling.
|
||||
|
||||
Generic item distribution for CI matrix jobs. Items are read from a JSON
|
||||
array on stdin (or from a file via --items-file), sorted for deterministic
|
||||
ordering, then assigned to *max_runners* groups using LPT (Longest
|
||||
Processing Time first) scheduling.
|
||||
|
||||
Each item is a string (e.g. an Ansible ``--limit`` pattern like
|
||||
``observability`` or ``infra-314-vm``). Optionally, items can be objects
|
||||
with ``{"id": "...", "weight": N}`` to provide explicit weights.
|
||||
|
||||
The assigned group for *runner_index* is written to ``$GITHUB_ENV`` as
|
||||
``ASSIGNED_ITEMS`` (space-delimited) for use by subsequent steps.
|
||||
|
||||
Usage::
|
||||
|
||||
echo '["observability", "infra-314-vm"]' | \\
|
||||
python3 -m devx.ci.distribute_items \\
|
||||
--runner-index 1 --max-runners 3 \\
|
||||
--github-env --skip-if-excess
|
||||
|
||||
# With weights:
|
||||
echo '[{"id": "observability", "weight": 5}, {"id": "customer-1", "weight": 3}]' | \\
|
||||
python3 -m devx.ci.distribute_items \\
|
||||
--runner-index 1 --max-runners 3 --github-env
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
import click
|
||||
|
||||
from devx.ci._shared import lpt_distribute, write_github_env
|
||||
from devx.i18n import _
|
||||
|
||||
DEFAULT_MAX_RUNNERS = 3
|
||||
DEFAULT_WEIGHT = 1
|
||||
|
||||
|
||||
def parse_items(raw: str) -> list[str]:
|
||||
"""Parse a JSON array into a list of item identifier strings.
|
||||
|
||||
Accepts both plain string arrays (``["a", "b"]``) and object arrays
|
||||
(``[{"id": "a", "weight": 2}]``). Returns just the identifier strings.
|
||||
"""
|
||||
data = json.loads(raw)
|
||||
if not isinstance(data, list):
|
||||
raise click.ClickException(_("Items input must be a JSON array, got {type}", type=type(data).__name__))
|
||||
items: list[str] = []
|
||||
for entry in data:
|
||||
if isinstance(entry, str):
|
||||
items.append(entry)
|
||||
elif isinstance(entry, dict) and "id" in entry:
|
||||
items.append(str(entry["id"]))
|
||||
else:
|
||||
raise click.ClickException(
|
||||
_("Each item must be a string or an object with 'id', got {type}", type=type(entry).__name__)
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def parse_weighted_items(raw: str) -> tuple[list[str], list[int]]:
|
||||
"""Parse a JSON array into (items, weights) lists.
|
||||
|
||||
For plain string arrays, all items get ``DEFAULT_WEIGHT``.
|
||||
For object arrays, the ``weight`` field is used (default: ``DEFAULT_WEIGHT``).
|
||||
"""
|
||||
data = json.loads(raw)
|
||||
if not isinstance(data, list):
|
||||
raise click.ClickException(_("Items input must be a JSON array, got {type}", type=type(data).__name__))
|
||||
items: list[str] = []
|
||||
weights: list[int] = []
|
||||
for entry in data:
|
||||
if isinstance(entry, str):
|
||||
items.append(entry)
|
||||
weights.append(DEFAULT_WEIGHT)
|
||||
elif isinstance(entry, dict) and "id" in entry:
|
||||
items.append(str(entry["id"]))
|
||||
weights.append(int(entry.get("weight", DEFAULT_WEIGHT)))
|
||||
else:
|
||||
raise click.ClickException(
|
||||
_("Each item must be a string or an object with 'id', got {type}", type=type(entry).__name__)
|
||||
)
|
||||
return items, weights
|
||||
|
||||
|
||||
def distribute(items: list[str], weights: list[int], max_runners: int) -> list[list[str]]:
|
||||
"""Split *items* into *max_runners* balanced groups using LPT scheduling.
|
||||
|
||||
Items are sorted by weight (descending), then assigned to the runner
|
||||
with the least total weight.
|
||||
"""
|
||||
return lpt_distribute(items, weights, max_runners)
|
||||
|
||||
|
||||
def items_for_runner(items: list[str], weights: list[int], runner_index: int, max_runners: int) -> list[str]:
|
||||
"""Return the subset of items assigned to *runner_index* (0-based)."""
|
||||
groups = distribute(items, weights, max_runners)
|
||||
if runner_index < 0 or runner_index >= len(groups):
|
||||
raise click.ClickException(
|
||||
_("Runner index {index} out of range (0..{max})", index=runner_index, max=max_runners - 1)
|
||||
)
|
||||
return groups[runner_index]
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--items-file",
|
||||
type=click.Path(exists=True, file_okay=True, path_type=None),
|
||||
default=None,
|
||||
help="Read items from a JSON file instead of stdin.",
|
||||
)
|
||||
@click.option(
|
||||
"--runner-index",
|
||||
type=int,
|
||||
default=None,
|
||||
help="One-based runner index. If omitted, prints all groups.",
|
||||
)
|
||||
@click.option(
|
||||
"--max-runners",
|
||||
type=int,
|
||||
default=DEFAULT_MAX_RUNNERS,
|
||||
show_default=True,
|
||||
help="Total number of parallel runners.",
|
||||
)
|
||||
@click.option(
|
||||
"--github-env",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Write ASSIGNED_ITEMS and SKIP to $GITHUB_ENV.",
|
||||
)
|
||||
@click.option(
|
||||
"--skip-if-excess",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="With --github-env: write SKIP=true when runner-index exceeds max-runners.",
|
||||
)
|
||||
def main(
|
||||
items_file: str | None,
|
||||
runner_index: int | None,
|
||||
max_runners: int,
|
||||
github_env: bool,
|
||||
skip_if_excess: bool,
|
||||
) -> None:
|
||||
# Read items from file or stdin
|
||||
if items_file is not None:
|
||||
with open(items_file, encoding="utf-8") as f: # noqa: PTH123
|
||||
raw = f.read()
|
||||
else:
|
||||
raw = sys.stdin.read()
|
||||
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
raw = "[]"
|
||||
|
||||
items, weights = parse_weighted_items(raw)
|
||||
|
||||
if runner_index is None:
|
||||
groups = distribute(items, weights, max_runners)
|
||||
for i, group in enumerate(groups):
|
||||
labels = " ".join(group) if group else "(none)"
|
||||
click.echo(f"Runner {i}: {labels}")
|
||||
return
|
||||
|
||||
if skip_if_excess and github_env and runner_index > max_runners:
|
||||
click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}")
|
||||
write_github_env("ASSIGNED_ITEMS", "")
|
||||
write_github_env("SKIP", "true")
|
||||
return
|
||||
|
||||
if runner_index < 1:
|
||||
raise click.ClickException(f"Runner index {runner_index} is out of range (must be >= 1)")
|
||||
|
||||
zero_based = runner_index - 1
|
||||
assigned = items_for_runner(items, weights, zero_based, max_runners)
|
||||
encoded = " ".join(assigned)
|
||||
|
||||
if github_env:
|
||||
write_github_env("ASSIGNED_ITEMS", encoded)
|
||||
write_github_env("SKIP", "false")
|
||||
click.echo(f"Assigned {len(assigned)} items to runner {runner_index}: {encoded}")
|
||||
return
|
||||
|
||||
click.echo(encoded)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -17,7 +17,7 @@ Usage::
|
||||
|
||||
Environment variables:
|
||||
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).
|
||||
JOB_NAME Base job name (GITHUB_JOB), e.g. "integration-tests".
|
||||
MATRIX_INDEX Current matrix index (runner-index).
|
||||
@@ -36,6 +36,7 @@ import time
|
||||
|
||||
import click
|
||||
|
||||
from devx.config import REPO_NAME, REPO_OWNER
|
||||
from devx.i18n import _
|
||||
from devx.molecule.molecule_ci_guard import (
|
||||
poll_for_other_failures,
|
||||
@@ -49,17 +50,17 @@ POLL_INTERVAL = 10
|
||||
def cli(pytest_args: tuple[str, ...]) -> None:
|
||||
"""Run pytest with cross-runner failure detection."""
|
||||
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"))
|
||||
job_name = os.environ.get("JOB_NAME", "integration-tests")
|
||||
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
|
||||
repository = os.environ.get("GITEA_REPOSITORY", "oblachno-oss/devx")
|
||||
repository = os.environ.get("GITEA_REPOSITORY", "")
|
||||
owner, _sep, repo = repository.partition("/")
|
||||
if not owner or not repo:
|
||||
owner, repo = "oblachno-oss", "devx"
|
||||
owner, repo = REPO_OWNER, REPO_NAME
|
||||
|
||||
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()
|
||||
failed_event = threading.Event()
|
||||
|
||||
@@ -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.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 -m devx.ci.notify_failure \
|
||||
CI_GITEA_TOKEN=<token> python3 -m devx.ci.notify_failure \
|
||||
--repo <owner/repo> \
|
||||
--run-id <run_id> \
|
||||
--workflow <workflow_name> \
|
||||
@@ -14,7 +14,7 @@ Usage:
|
||||
--auto-login
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
@@ -22,14 +22,12 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
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 _
|
||||
|
||||
load_dotenv()
|
||||
@@ -37,49 +35,6 @@ load_dotenv()
|
||||
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:
|
||||
"""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",
|
||||
is_flag=True,
|
||||
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:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
token = os.environ.get("CI_GITEA_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:
|
||||
_configure_tea_login()
|
||||
configure_tea_login()
|
||||
|
||||
title = f"[CI] {workflow} workflow failed (run #{run_id})"
|
||||
body = (
|
||||
|
||||
@@ -13,7 +13,8 @@ import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from devx.api_clients import VikunjaClient
|
||||
from devx.config import DEFAULT_PER_PAGE, TASK_ID_RE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
|
||||
from devx.ci._shared import extract_task_id as _extract_task_id
|
||||
from devx.config import DEFAULT_PER_PAGE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
|
||||
from devx.exceptions import APIError
|
||||
from devx.i18n import _
|
||||
|
||||
@@ -47,10 +48,9 @@ def _get_git_commit_sha() -> str:
|
||||
|
||||
|
||||
def extract_task_id(commit_msg: str) -> str:
|
||||
"""Extract DEVX-N task identifier from the first line of commit message."""
|
||||
"""Extract task identifier from the first line of commit message (delegates to shared utility)."""
|
||||
first_line = commit_msg.split("\n")[0]
|
||||
match = TASK_ID_RE.search(first_line)
|
||||
return match.group(0) if match else ""
|
||||
return _extract_task_id(first_line)
|
||||
|
||||
|
||||
def extract_conventional_msg(commit_msg: str) -> str:
|
||||
@@ -61,7 +61,7 @@ def extract_conventional_msg(commit_msg: str) -> str:
|
||||
- ``DEVX-N <message>`` (current, space-separated)
|
||||
"""
|
||||
first_line = commit_msg.split("\n")[0]
|
||||
return re.sub(r"^DEVX-\d+[:\s]\s*", "", first_line)
|
||||
return re.sub(rf"^{TASK_PREFIX}-\d+[:\s]\s*", "", first_line)
|
||||
|
||||
|
||||
def resolve_task_id(client: VikunjaClient, task_id: str) -> int:
|
||||
|
||||
@@ -17,7 +17,7 @@ Checks performed:
|
||||
8. Commit conventions — conventional commit format on branch commits
|
||||
|
||||
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
|
||||
@@ -520,19 +520,113 @@ def post_review(client: GiteaClient, pr_number: str, result: ReviewResult) -> di
|
||||
return client.create_review(pr_number, event=event, body=body, comments=comments)
|
||||
|
||||
|
||||
def _post_manual_review(
|
||||
client: GiteaClient,
|
||||
pr_number: str,
|
||||
event: str,
|
||||
body: str | None,
|
||||
checklist_confirmed: bool,
|
||||
checklist_categories: str | None,
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
"""Post a manual review with validation for APPROVE events."""
|
||||
if not body or len(body) < 50:
|
||||
raise click.ClickException(_("Review body must be at least 50 characters."))
|
||||
|
||||
if event == "APPROVE":
|
||||
if not checklist_confirmed:
|
||||
raise click.ClickException(
|
||||
_("--checklist-confirmed is required for APPROVE events."),
|
||||
)
|
||||
cats = [c.strip() for c in (checklist_categories or "").split(",") if c.strip()]
|
||||
cat_nums: list[int] = []
|
||||
for c in cats:
|
||||
try:
|
||||
cat_nums.append(int(c))
|
||||
except ValueError:
|
||||
raise click.ClickException(
|
||||
_("Invalid checklist category: {cat}. Must be numbers.", cat=c),
|
||||
) from None
|
||||
if len(cat_nums) < 8:
|
||||
raise click.ClickException(
|
||||
_("--checklist-categories must list at least 8 of 13 categories. Got {count}.", count=len(cat_nums)),
|
||||
)
|
||||
|
||||
click.echo(f"Manual review event: {event}")
|
||||
click.echo(f"Body: {body[:80]}...")
|
||||
if checklist_confirmed:
|
||||
click.echo(f"Checklist confirmed: {checklist_categories}")
|
||||
|
||||
if dry_run:
|
||||
click.echo("\n[dry-run] Review not posted.")
|
||||
return
|
||||
|
||||
try:
|
||||
review = client.create_review(pr_number, event=event, body=body)
|
||||
except APIError as e:
|
||||
if "approve" in e.message.lower() or "422" in str(e.status):
|
||||
click.echo(_("Note: Self-approval not allowed. Posting COMMENT instead."))
|
||||
review = client.create_review(pr_number, event="COMMENT", body=body)
|
||||
else:
|
||||
raise
|
||||
review_id = review.get("id", "?")
|
||||
click.echo(
|
||||
_(
|
||||
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||
review_id=review_id,
|
||||
pr_number=pr_number,
|
||||
event=event,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("pr_number")
|
||||
@click.argument("repo")
|
||||
@click.option("--dry-run", is_flag=True, default=False, help="Print review without posting.")
|
||||
def main(pr_number: str, repo: str, dry_run: bool) -> None:
|
||||
"""Run automated PR review and post results to Gitea."""
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
@click.option(
|
||||
"--event",
|
||||
type=click.Choice(["APPROVE", "REQUEST_CHANGES", "COMMENT"], case_sensitive=False),
|
||||
default=None,
|
||||
help="Post a manual review with the given event (skips automated checks).",
|
||||
)
|
||||
@click.option("--body", default=None, help="Review body text (required with --event).")
|
||||
@click.option(
|
||||
"--checklist-confirmed",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Attest that REVIEW_CHECKLIST.md categories were checked (required for APPROVE).",
|
||||
)
|
||||
@click.option(
|
||||
"--checklist-categories",
|
||||
default=None,
|
||||
help="Comma-separated checklist category numbers (required for APPROVE, min 8 of 13).",
|
||||
)
|
||||
def main(
|
||||
pr_number: str,
|
||||
repo: str,
|
||||
dry_run: bool,
|
||||
event: str | None,
|
||||
body: str | None,
|
||||
checklist_confirmed: bool,
|
||||
checklist_categories: str | None,
|
||||
) -> None:
|
||||
"""Run automated PR review and post results to Gitea.
|
||||
|
||||
Without --event: runs automated checks and posts COMMENT/REQUEST_CHANGES.
|
||||
With --event: posts a manual review (skips automated checks).
|
||||
"""
|
||||
token = os.environ.get("CI_GITEA_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("/")
|
||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
||||
|
||||
if event is not None:
|
||||
_post_manual_review(client, pr_number, event.upper(), body, checklist_confirmed, checklist_categories, dry_run)
|
||||
return
|
||||
|
||||
result = run_review(client, pr_number)
|
||||
|
||||
body = build_review_body(result)
|
||||
|
||||
+19
-8
@@ -9,14 +9,14 @@ Publishing destinations (checked in order):
|
||||
``DEVX_PYPI_REGISTRY_URL`` env var is set, or ``GITEA_API_URL``
|
||||
is converted to a packages URL). Uses ``twine upload
|
||||
--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
|
||||
``twine upload -u __token__ -p <token>`` flow.
|
||||
3. **Skip** — if neither is configured, only the Gitea release is created.
|
||||
|
||||
Usage:
|
||||
REPO_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> [PYPI_TOKEN=<token>] python3 -m devx.ci.publish <tag> <repo>
|
||||
CI_GITEA_TOKEN=<token> python3 -m devx.ci.publish <tag> <repo> --registry-url https://git.example.com/api/packages/owner/pypi
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -28,8 +28,8 @@ from pathlib import Path
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from devx.config import GITEA_API_URL
|
||||
from devx.gitea_cli import TeaCLI, TeaCLIError
|
||||
from devx.config import GITEA_API_URL, REPO_OWNER
|
||||
from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login
|
||||
from devx.i18n import _
|
||||
|
||||
load_dotenv()
|
||||
@@ -165,7 +165,7 @@ def _default_gitea_registry_url() -> str:
|
||||
base = base[: -len("/api/v1")]
|
||||
elif base.endswith("/api"):
|
||||
base = base[: -len("/api")]
|
||||
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss")
|
||||
owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
|
||||
return f"{base}/api/packages/{owner}/pypi"
|
||||
|
||||
|
||||
@@ -221,12 +221,20 @@ def is_release_commit(tag: str) -> bool:
|
||||
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.",
|
||||
)
|
||||
@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(
|
||||
tag: str | None,
|
||||
repo: str | None,
|
||||
registry_url: str | None,
|
||||
skip_build: bool,
|
||||
from_tag: bool,
|
||||
auto_login: bool,
|
||||
) -> None:
|
||||
if repo is None:
|
||||
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||
@@ -245,9 +253,9 @@ def main(
|
||||
|
||||
if not 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:
|
||||
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", "")
|
||||
|
||||
@@ -287,6 +295,9 @@ def main(
|
||||
|
||||
tea = TeaCLI(repo=repo)
|
||||
|
||||
if auto_login:
|
||||
configure_tea_login()
|
||||
|
||||
# Check if release already exists (idempotent — avoids failure when
|
||||
# called multiple times, e.g. by both post-merge and publish workflows)
|
||||
try:
|
||||
|
||||
+31
-39
@@ -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.
|
||||
|
||||
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
|
||||
"""
|
||||
|
||||
@@ -37,13 +37,12 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from devx.ci._shared import get_latest_tag
|
||||
from devx.ci._shared import get_latest_tag, run_cmd, write_github_output
|
||||
from devx.ci.classify_changes import has_user_facing_changes # cross-CI import, needs PYTHONPATH=.
|
||||
from devx.i18n import _
|
||||
|
||||
@@ -54,25 +53,6 @@ CHANGELOG_FILE = "CHANGELOG.md"
|
||||
CLIFF_CONFIG = "cliff.toml"
|
||||
|
||||
|
||||
def run_cmd(args: list[str], check: bool = True, capture: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
"""Run a command and return the completed process."""
|
||||
result = subprocess.run( # nosec B603
|
||||
args,
|
||||
capture_output=capture,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Command failed ({cmd}): {stderr}",
|
||||
cmd=" ".join(args),
|
||||
stderr=result.stderr.strip() if result.stderr else result.stdout.strip(),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def tag_exists(tag: str) -> bool:
|
||||
"""Check if a git tag already exists."""
|
||||
result = run_cmd(["git", "tag", "-l", tag], check=False)
|
||||
@@ -217,7 +197,7 @@ def has_unreleased_changes(bumped_version: str | None = None) -> bool:
|
||||
|
||||
def update_init_version(new_version: str) -> None:
|
||||
"""Update __version__ in __init__.py."""
|
||||
with open(INIT_FILE) as f:
|
||||
with open(INIT_FILE, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
if not re.search(r'^__version__\s*=\s*"[^"]*"', content, flags=re.MULTILINE):
|
||||
raise click.ClickException(_("Could not find __version__ in {file}", file=INIT_FILE))
|
||||
@@ -228,7 +208,7 @@ def update_init_version(new_version: str) -> None:
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
with open(INIT_FILE, "w") as f:
|
||||
with open(INIT_FILE, "w", encoding="utf-8") as f:
|
||||
f.write(updated)
|
||||
|
||||
|
||||
@@ -245,10 +225,10 @@ def update_changelog(changelog: str) -> None:
|
||||
changelog = changelog[section_match.start() :]
|
||||
|
||||
try:
|
||||
with open(CHANGELOG_FILE) as f:
|
||||
with open(CHANGELOG_FILE, encoding="utf-8") as f:
|
||||
existing = f.read()
|
||||
except FileNotFoundError:
|
||||
with open(CHANGELOG_FILE, "w") as f:
|
||||
with open(CHANGELOG_FILE, "w", encoding="utf-8") as f:
|
||||
f.write(changelog + "\n")
|
||||
return
|
||||
|
||||
@@ -261,7 +241,7 @@ def update_changelog(changelog: str) -> None:
|
||||
else:
|
||||
# No version sections found — append
|
||||
updated = existing.rstrip() + "\n\n" + changelog + "\n"
|
||||
with open(CHANGELOG_FILE, "w") as f:
|
||||
with open(CHANGELOG_FILE, "w", encoding="utf-8") as f:
|
||||
f.write(updated)
|
||||
|
||||
|
||||
@@ -279,7 +259,7 @@ def commit_release_changes(new_version: str) -> bool:
|
||||
if status.returncode == 0:
|
||||
click.echo(_("No staged changes — version and changelog already up to date."))
|
||||
return False
|
||||
run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version}"])
|
||||
run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version} [skip ci]"])
|
||||
return True
|
||||
|
||||
|
||||
@@ -317,18 +297,16 @@ def run_tests() -> None:
|
||||
click.echo(_("Tests passed."))
|
||||
|
||||
|
||||
def _write_github_output(tag: str) -> None:
|
||||
def _write_release_tag(tag: str) -> None:
|
||||
"""Write the release tag to GITHUB_OUTPUT for downstream jobs.
|
||||
|
||||
This allows a publish job (needs: release) to read the tag via
|
||||
``${{ needs.release.outputs.tag }}`` instead of relying on
|
||||
tag-push event triggering a separate workflow.
|
||||
"""
|
||||
github_output = os.environ.get("GITHUB_OUTPUT")
|
||||
if not github_output:
|
||||
if not os.environ.get("GITHUB_OUTPUT"):
|
||||
return
|
||||
with open(github_output, "a") as f: # noqa: PTH123
|
||||
f.write(f"tag={tag}\n")
|
||||
write_github_output("tag", tag)
|
||||
click.echo(_("Wrote tag {tag} to GITHUB_OUTPUT.", tag=tag))
|
||||
|
||||
|
||||
@@ -360,7 +338,7 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
|
||||
if not dry_run:
|
||||
# Ensure the existing tag is pushed
|
||||
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"], check=False)
|
||||
_write_github_output(tag)
|
||||
_write_release_tag(tag)
|
||||
return False
|
||||
tag_msg = f"Release v{new_version}\n\n{changelog}"
|
||||
if dry_run:
|
||||
@@ -368,7 +346,7 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
|
||||
return True
|
||||
run_cmd(["git", "tag", "-a", tag, "-m", tag_msg])
|
||||
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"])
|
||||
_write_github_output(tag)
|
||||
_write_release_tag(tag)
|
||||
return True
|
||||
|
||||
|
||||
@@ -380,7 +358,7 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
|
||||
def get_init_version() -> str | None:
|
||||
"""Read __version__ from the version file."""
|
||||
try:
|
||||
with open(INIT_FILE) as f:
|
||||
with open(INIT_FILE, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
match = re.search(r'^__version__\s*=\s*"([^"]*)"', content, flags=re.MULTILINE)
|
||||
return match.group(1) if match else None
|
||||
@@ -391,7 +369,7 @@ def get_init_version() -> str | None:
|
||||
def get_changelog_versions() -> list[str]:
|
||||
"""Extract version numbers from CHANGELOG.md headers, in order."""
|
||||
try:
|
||||
with open(CHANGELOG_FILE) as f:
|
||||
with open(CHANGELOG_FILE, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
return re.findall(r"^## \[(\d+\.\d+\.\d+)\]", content, flags=re.MULTILINE)
|
||||
except FileNotFoundError:
|
||||
@@ -634,7 +612,7 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
|
||||
tag=release_tag,
|
||||
)
|
||||
)
|
||||
_write_github_output(release_tag)
|
||||
_write_release_tag(release_tag)
|
||||
return
|
||||
# Tag is missing — recover by creating and pushing it
|
||||
click.echo(
|
||||
@@ -669,6 +647,20 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
|
||||
return
|
||||
|
||||
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(
|
||||
_(
|
||||
"Bumping version: {current} -> v{new_version}",
|
||||
@@ -691,7 +683,7 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
|
||||
click.echo(_("\n[dry-run] Changelog:\n{changelog}", changelog=changelog))
|
||||
click.echo(_("[dry-run] Would update {init}", init=INIT_FILE))
|
||||
click.echo(_("[dry-run] Would update {changelog_file}", changelog_file=CHANGELOG_FILE))
|
||||
click.echo(_("[dry-run] Would commit: release: v{version}", version=new_version))
|
||||
click.echo(_("[dry-run] Would commit: release: v{version} [skip ci]", version=new_version))
|
||||
click.echo(_("[dry-run] Would push commit to master"))
|
||||
click.echo(_("[dry-run] Would create tag: v{version}", version=new_version))
|
||||
return
|
||||
|
||||
@@ -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}
|
||||
|
||||
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
|
||||
@@ -28,7 +28,7 @@ import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from devx.api_clients import GiteaClient
|
||||
from devx.config import GITEA_API_URL
|
||||
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
|
||||
from devx.exceptions import APIError
|
||||
from devx.i18n import _
|
||||
|
||||
@@ -49,7 +49,7 @@ def load_mapping() -> dict[str, str]:
|
||||
|
||||
Validates that the mapping is a dict of string-to-string pairs.
|
||||
"""
|
||||
with open(MAPPING_FILE) as f:
|
||||
with open(MAPPING_FILE, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict):
|
||||
raise click.ClickException(
|
||||
@@ -64,7 +64,7 @@ def load_mapping() -> dict[str, str]:
|
||||
def read_doc_content(file_path: str) -> str:
|
||||
"""Read markdown content from a docs file."""
|
||||
full_path = DOCS_DIR / file_path
|
||||
with open(full_path) as f:
|
||||
with open(full_path, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
@@ -225,13 +225,13 @@ def verify_wiki_integrity(
|
||||
help="Full integrity check: verify page count, missing pages, stale pages, and content. Implies --verify.",
|
||||
)
|
||||
def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
token = os.environ.get("CI_GITEA_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:
|
||||
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss")
|
||||
repo_name = os.environ.get("DEVX_REPO_NAME", "devx")
|
||||
owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
|
||||
repo_name = os.environ.get("DEVX_REPO_NAME", "") or REPO_NAME
|
||||
else:
|
||||
owner, repo_name = repo.split("/")
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ def main(commit_msg_file: str | None, branch: str | None, from_git: bool) -> Non
|
||||
if commit_msg_file == "-":
|
||||
msg = sys.stdin.read().strip()
|
||||
else:
|
||||
with open(commit_msg_file) as f:
|
||||
with open(commit_msg_file, encoding="utf-8") as f:
|
||||
msg = f.read().strip()
|
||||
else:
|
||||
raise click.ClickException(_("Provide a commit message file or use --git."))
|
||||
|
||||
@@ -74,6 +74,7 @@ VIKUNJA_API_URL = _get("vikunja_api_url", "DEVX_VIKUNJA_API_URL", "https://work.
|
||||
# Organization defaults — each project MUST set DEVX_REPO_OWNER explicitly.
|
||||
# No default: prevents silent 404s when the wrong owner is used.
|
||||
REPO_OWNER = _get("repo_owner", "DEVX_REPO_OWNER", "")
|
||||
REPO_NAME = _get("repo_name", "DEVX_REPO_NAME", "")
|
||||
|
||||
# Task prefix for Vikunja task IDs — each project sets its own (GRM, DEVX, INFRA, etc.)
|
||||
TASK_PREFIX = _get("task_prefix", "DEVX_TASK_PREFIX", "DEVX")
|
||||
|
||||
@@ -40,15 +40,67 @@ Usage::
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
from devx.config import GITEA_API_URL
|
||||
from devx.i18n import _
|
||||
|
||||
|
||||
class TeaCLIError(Exception):
|
||||
"""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:
|
||||
"""Wrapper around the ``tea`` Gitea CLI tool.
|
||||
|
||||
|
||||
+88
-21
@@ -51,17 +51,19 @@ DEVX_GITEA_PYPI_HOST ?= git.oblachno.oblachno.fyi
|
||||
DEVX_GITEA_PYPI_ORG ?= oblachno-oss
|
||||
DEVX_ACTIONLINT_CFG ?= .gitea/actionlint.yaml
|
||||
DEVX_WORKFLOW_DIR ?= .gitea/workflows
|
||||
DEVX_DOCKERFILE_PATHS ?= docker
|
||||
|
||||
# PIP_INSTALL — helper to run pip with Gitea private PyPI registry configured.
|
||||
# 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.
|
||||
DEVX_PIP_INSTALL := if [ -z "$$REPO_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
|
||||
REPO_TOKEN="$${REPO_TOKEN:-$$GITEA_REGISTRY_TOKEN}"; \
|
||||
_PYPI_USER="$${DEVX_GITEA_PYPI_USER:-$${GITEA_PYPI_USER}}"; \
|
||||
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; \
|
||||
# CI_GITEA_USERNAME can be set in .env, as an env var, or as a Make variable.
|
||||
DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
|
||||
CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \
|
||||
_PYPI_USER="$${CI_GITEA_USERNAME:-emil}"; \
|
||||
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
|
||||
|
||||
.PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr devx-check-config
|
||||
.PHONY: devx-pr-status devx-pr-logs devx-pr-label devx-pr-review
|
||||
.PHONY: devx-configure-gitea-pypi devx-install-tools devx-install-checkmake devx-checkmake
|
||||
.PHONY: devx-workflow-lint devx-workflow-dryrun devx-workflow-dryrun-safe devx-workflow-check
|
||||
.PHONY: devx-notify-failure devx-install-hooks devx-activate-scripts
|
||||
@@ -69,6 +71,7 @@ DEVX_PIP_INSTALL := if [ -z "$$REPO_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
|
||||
.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-test-unit devx-pytest-cov
|
||||
.PHONY: devx-setup-image devx-lint-dockerfiles
|
||||
|
||||
# ── Vikunja task and PR management ────────────────────────────────────────────
|
||||
|
||||
@@ -92,15 +95,54 @@ devx-check-config:
|
||||
# Push and create PR in one step
|
||||
devx-push-with-pr: devx-push devx-create-pr
|
||||
|
||||
# Check CI status for a PR (auto-detects current branch's PR)
|
||||
# Usage: make devx-pr-status
|
||||
# make devx-pr-status PR=42
|
||||
# make devx-pr-status PR=42 WAIT=1 TIMEOUT=600
|
||||
devx-pr-status:
|
||||
@$(DEVX_PYTHON) -m devx.tools.pr_status \
|
||||
$(if $(PR),--pr $(PR)) \
|
||||
$(if $(WAIT),--wait) \
|
||||
$(if $(TIMEOUT),--timeout $(TIMEOUT))
|
||||
|
||||
# Fetch logs for failed CI jobs on a PR
|
||||
# Usage: make devx-pr-logs
|
||||
# make devx-pr-logs PR=42
|
||||
# make devx-pr-logs PR=42 JOB=quality TAIL=50
|
||||
devx-pr-logs:
|
||||
@$(DEVX_PYTHON) -m devx.tools.pr_logs \
|
||||
$(if $(PR),--pr $(PR)) \
|
||||
$(if $(JOB),--job $(JOB)) \
|
||||
$(if $(TAIL),--tail $(TAIL))
|
||||
|
||||
# Add a label to a PR (default: ready-to-merge)
|
||||
# Usage: make devx-pr-label
|
||||
# make devx-pr-label PR=42
|
||||
# make devx-pr-label PR=42 LABEL=ready-to-merge
|
||||
devx-pr-label:
|
||||
@$(DEVX_PYTHON) -m devx.tools.pr_label \
|
||||
$(if $(PR),--pr $(PR)) \
|
||||
--label $(or $(LABEL),ready-to-merge)
|
||||
|
||||
# Usage: make devx-pr-review PR=42 EVENT=APPROVE BODY="..." CHECKLIST=1,2,3,4,5,6,7,8,9,10,11,12,13
|
||||
# make devx-pr-review PR=42 EVENT=REQUEST_CHANGES BODY="..."
|
||||
# make devx-pr-review PR=42 (auto review)
|
||||
devx-pr-review:
|
||||
@$(DEVX_PYTHON) -m devx.ci.pr_review \
|
||||
$(PR) $(DEVX_REPO_OWNER)/$(DEVX_REPO_NAME) \
|
||||
$(if $(EVENT),--event $(EVENT)) \
|
||||
$(if $(BODY),--body "$(BODY)") \
|
||||
$(if $(CHECKLIST),--checklist-confirmed --checklist-categories $(CHECKLIST))
|
||||
|
||||
# ── Environment setup ─────────────────────────────────────────────────────────
|
||||
|
||||
# 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:
|
||||
@if [ -z "$$REPO_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
|
||||
REPO_TOKEN="$${REPO_TOKEN:-$$GITEA_REGISTRY_TOKEN}"; \
|
||||
if [ -z "$$REPO_TOKEN" ]; then echo "[configure-gitea-pypi] REPO_TOKEN not set — skipping (devx must be on public PyPI)"; exit 0; fi; \
|
||||
echo "[configure-gitea-pypi] Gitea PyPI registry configured (REPO_TOKEN present)."
|
||||
@if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
|
||||
CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \
|
||||
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 (CI_GITEA_TOKEN present)."
|
||||
|
||||
# Create .env from .env.example if it doesn't exist
|
||||
devx-env:
|
||||
@@ -173,7 +215,7 @@ devx-workflow-check: devx-workflow-lint devx-workflow-dryrun
|
||||
|
||||
# Notify on CI failure — creates a Gitea issue via devx.ci.notify_failure.
|
||||
# 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_VENV)/bin/activate 2>/dev/null || true; \
|
||||
export PATH="$(HOME)/.local/bin:$$PATH"; \
|
||||
@@ -250,22 +292,47 @@ devx-clean:
|
||||
@find . -type f -name "*.pyc" -delete 2>/dev/null || true
|
||||
@rm -rf .coverage htmlcov/ dist/ build/ *.egg-info/ .molecule/ 2>/dev/null || true
|
||||
|
||||
# ── Dockerfile linting ────────────────────────────────────────────────────────
|
||||
#
|
||||
# Lint Dockerfiles with hadolint. Fails fast if hadolint is not installed
|
||||
# (no silent skip). Set DEVX_DOCKERFILE_PATHS to the directory containing
|
||||
# your Dockerfiles (default: docker).
|
||||
#
|
||||
# Usage:
|
||||
# make devx-lint-dockerfiles (lints docker/ directory)
|
||||
# make devx-lint-dockerfiles DEVX_DOCKERFILE_PATHS=ansible (lints ansible/)
|
||||
|
||||
devx-lint-dockerfiles:
|
||||
@echo "[devx-lint-dockerfiles] Linting Dockerfiles with hadolint..."
|
||||
@if ! command -v hadolint >/dev/null 2>&1; then \
|
||||
echo "[devx-lint-dockerfiles] ERROR: hadolint not found. Install from https://github.com/hadolint/hadolint/releases" >&2; \
|
||||
exit 1; \
|
||||
fi
|
||||
@find $(DEVX_DOCKERFILE_PATHS) -name 'Dockerfile*' -exec hadolint {} +
|
||||
@echo "[devx-lint-dockerfiles] All Dockerfiles passed."
|
||||
|
||||
# ── 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 (no-deps, fast).
|
||||
# Falls back to devx-setup-ci if /opt/venv is not present (local dev).
|
||||
# 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 && pip install -e . --no-deps 2>/dev/null; \
|
||||
echo "[devx-setup-image] Linked /opt/venv and installed project (no-deps)."; \
|
||||
else \
|
||||
echo "[devx-setup-image] /opt/venv not found — falling back to devx-setup-ci"; \
|
||||
$(MAKE) devx-setup-ci; \
|
||||
fi
|
||||
@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 --no-cache-dir -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 ───────────────────────────────────────
|
||||
#
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Molecule testing helpers for Ansible projects."""
|
||||
|
||||
@@ -29,7 +29,7 @@ import os
|
||||
import click
|
||||
import requests
|
||||
|
||||
from devx.config import GITEA_API_URL
|
||||
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
|
||||
|
||||
DEFAULT_MAX_RUNNERS = 3
|
||||
|
||||
@@ -142,12 +142,12 @@ def main(
|
||||
output_indices: bool,
|
||||
github_output: bool,
|
||||
) -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
|
||||
if owner is None:
|
||||
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss")
|
||||
owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
|
||||
if repo is None:
|
||||
repo = os.environ.get("DEVX_REPO_NAME", "devx")
|
||||
repo = os.environ.get("DEVX_REPO_NAME", "") or REPO_NAME
|
||||
|
||||
count = get_runner_count(GITEA_API_URL, token, owner, repo)
|
||||
indices = generate_indices(count)
|
||||
@@ -156,7 +156,7 @@ def main(
|
||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
||||
if not gh_output:
|
||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
||||
with open(gh_output, "a") as f: # noqa: PTH123
|
||||
with open(gh_output, "a", encoding="utf-8") as f: # noqa: PTH123
|
||||
f.write(f"runner-count={count}\n")
|
||||
f.write(f"runner-indices={json.dumps(indices)}\n")
|
||||
click.echo(f"Runner count: {count}")
|
||||
|
||||
@@ -25,6 +25,7 @@ from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.ci._shared import lpt_distribute, write_github_env
|
||||
from devx.i18n import _
|
||||
from devx.molecule.platforms import PLATFORMS, load_platforms
|
||||
|
||||
@@ -216,22 +217,8 @@ def _scenario_weight(scenario: str, role: str | None = None) -> int:
|
||||
|
||||
|
||||
def _lpt_distribute[T](items: list[T], weights: list[int], max_runners: int) -> list[list[T]]:
|
||||
"""Distribute *items* across *max_runners* using LPT (Longest Processing Time first).
|
||||
|
||||
Sorts items by weight (descending), then assigns each to the runner
|
||||
with the least total weight. This produces a more balanced distribution
|
||||
than naive round-robin when items have varying costs.
|
||||
"""
|
||||
groups: list[list[T]] = [[] for _ in range(max_runners)]
|
||||
loads = [0] * max_runners
|
||||
# Sort by weight descending, preserving original order for ties
|
||||
indexed = sorted(enumerate(items), key=lambda x: (-weights[x[0]], x[0]))
|
||||
for orig_idx, item in indexed:
|
||||
# Find the runner with the minimum load
|
||||
min_runner = min(range(max_runners), key=lambda r: loads[r])
|
||||
groups[min_runner].append(item)
|
||||
loads[min_runner] += weights[orig_idx]
|
||||
return groups
|
||||
"""Distribute *items* across *max_runners* using LPT (delegates to shared utility)."""
|
||||
return lpt_distribute(items, weights, max_runners)
|
||||
|
||||
|
||||
def distribute_multi_role(pairs: list[MultiRoleTestPair], max_runners: int) -> list[list[MultiRoleTestPair]]:
|
||||
@@ -283,14 +270,8 @@ def pairs_for_runner(pairs: list[TestPair], runner_index: int, max_runners: int)
|
||||
|
||||
|
||||
def _write_github_env(key: str, value: str) -> None:
|
||||
"""Append a key=value line to the $GITHUB_ENV file."""
|
||||
import os
|
||||
|
||||
gh_env = os.environ.get("GITHUB_ENV")
|
||||
if not gh_env:
|
||||
raise click.ClickException("GITHUB_ENV environment variable is not set")
|
||||
with open(gh_env, "a") as f: # noqa: PTH123
|
||||
f.write(f"{key}={value}\n")
|
||||
"""Append a key=value line to the $GITHUB_ENV file (delegates to shared utility)."""
|
||||
write_github_env(key, value)
|
||||
|
||||
|
||||
@click.command()
|
||||
|
||||
@@ -22,7 +22,7 @@ Usage::
|
||||
|
||||
Environment variables:
|
||||
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).
|
||||
JOB_NAME Base job name (GITHUB_JOB), e.g. "molecule-tests".
|
||||
MATRIX_INDEX Current matrix index (runner-index).
|
||||
@@ -43,6 +43,7 @@ from pathlib import Path
|
||||
import click
|
||||
import requests
|
||||
|
||||
from devx.config import REPO_NAME, REPO_OWNER
|
||||
from devx.i18n import _
|
||||
|
||||
POLL_INTERVAL = 10
|
||||
@@ -163,17 +164,17 @@ def resolve_role_dir(role: str, roles_root: Path | None, repo_root: Path) -> Pat
|
||||
def cli(pairs: tuple[str, ...], roles_root: Path | None) -> None:
|
||||
"""Run molecule pairs sequentially, stop if another CI runner fails."""
|
||||
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"))
|
||||
job_name = os.environ.get("JOB_NAME", "molecule-tests")
|
||||
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
|
||||
repository = os.environ.get("GITEA_REPOSITORY", "oblachno-oss/devx")
|
||||
repository = os.environ.get("GITEA_REPOSITORY", "")
|
||||
owner, _sep, repo = repository.partition("/")
|
||||
if not owner or not repo:
|
||||
owner, repo = "oblachno-oss", "devx"
|
||||
owner, repo = REPO_OWNER, REPO_NAME
|
||||
|
||||
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
|
||||
# site-packages directory, not the repo root. Use GITHUB_WORKSPACE
|
||||
|
||||
@@ -41,7 +41,7 @@ def load_platforms(platforms_file: str | Path | None = None) -> list[dict[str, s
|
||||
path = Path(platforms_file)
|
||||
if not path.is_file():
|
||||
return PLATFORMS
|
||||
with path.open() as f:
|
||||
with path.open(encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, list) or not data:
|
||||
return PLATFORMS
|
||||
|
||||
@@ -168,7 +168,7 @@ def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
|
||||
click.echo(_("Docker daemon failed to start"))
|
||||
click.echo("--- dockerd log ---")
|
||||
try:
|
||||
with open(log_file.name) as f:
|
||||
with open(log_file.name, encoding="utf-8") as f:
|
||||
log_content = f.read()
|
||||
click.echo(log_content[-3000:] if len(log_content) > 3000 else log_content)
|
||||
except OSError as e:
|
||||
@@ -191,7 +191,7 @@ def main(timeout: int) -> None:
|
||||
# Export DOCKER_HOST to GITHUB_ENV for subsequent CI steps
|
||||
github_env = os.environ.get("GITHUB_ENV")
|
||||
if github_env and os.environ.get("DOCKER_HOST"):
|
||||
with open(github_env, "a") as f:
|
||||
with open(github_env, "a", encoding="utf-8") as f:
|
||||
f.write(f"DOCKER_HOST={os.environ['DOCKER_HOST']}\n")
|
||||
click.echo(f"Exported DOCKER_HOST={os.environ['DOCKER_HOST']} to GITHUB_ENV")
|
||||
sys.exit(0)
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Shared utilities for tools modules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
|
||||
import click
|
||||
|
||||
|
||||
def arch_string() -> str:
|
||||
"""Return the architecture string used by release assets.
|
||||
|
||||
Maps ``platform.machine()`` to the common release asset naming:
|
||||
``amd64`` for x86_64, ``arm64`` for aarch64.
|
||||
|
||||
Raises:
|
||||
click.ClickException: If the architecture is not supported.
|
||||
"""
|
||||
machine = platform.machine().lower()
|
||||
if machine in {"x86_64", "amd64"}:
|
||||
return "amd64"
|
||||
if machine in {"aarch64", "arm64"}:
|
||||
return "arm64"
|
||||
raise click.ClickException(f"Unsupported architecture: {machine}")
|
||||
@@ -34,9 +34,8 @@ The manifest file is a JSON list of dicts, each with:
|
||||
- ``context``: build context directory (optional, defaults to repo root)
|
||||
- ``tags``: list of tags (optional, defaults to ``["latest"]``)
|
||||
|
||||
Registry authentication uses ``REPO_TOKEN`` (or ``GITEA_REGISTRY_TOKEN``)
|
||||
and ``REGISTRY_USERNAME`` (or ``GITEA_REGISTRY_USERNAME``) environment
|
||||
variables, matching the existing CI workflow patterns.
|
||||
Registry authentication uses ``CI_GITEA_TOKEN`` and ``CI_GITEA_USERNAME``
|
||||
environment variables, matching the existing CI workflow patterns.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -89,7 +88,7 @@ def load_manifest(path: str | Path) -> list[ImageSpec]:
|
||||
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
|
||||
with p.open(encoding="utf-8") as f: # noqa: PTH123
|
||||
data = json.load(f)
|
||||
if not isinstance(data, list):
|
||||
raise click.ClickException(_("Manifest must be a JSON list"))
|
||||
@@ -221,13 +220,9 @@ def push_image(
|
||||
|
||||
|
||||
def _get_registry_creds() -> tuple[str, str]:
|
||||
"""Get registry credentials from environment variables.
|
||||
|
||||
Supports both REPO_TOKEN/GITEA_REGISTRY_TOKEN and
|
||||
REGISTRY_USERNAME/GITEA_REGISTRY_USERNAME patterns.
|
||||
"""
|
||||
token = os.environ.get("REPO_TOKEN") or os.environ.get("GITEA_REGISTRY_TOKEN", "")
|
||||
username = os.environ.get("REGISTRY_USERNAME") or os.environ.get("GITEA_REGISTRY_USERNAME", "")
|
||||
"""Get registry credentials from environment variables."""
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
username = os.environ.get("CI_GITEA_USERNAME", "")
|
||||
return username, token
|
||||
|
||||
|
||||
@@ -312,7 +307,7 @@ def main(
|
||||
username, token = _get_registry_creds()
|
||||
if not token or not username:
|
||||
raise click.ClickException(
|
||||
_("Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars")
|
||||
_("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"))
|
||||
|
||||
@@ -13,7 +13,6 @@ Usage::
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
@@ -27,8 +26,7 @@ def cli() -> None:
|
||||
"""Validate devx configuration in pyproject.toml."""
|
||||
path = Path("pyproject.toml")
|
||||
if not path.exists():
|
||||
click.echo(_("pyproject.toml not found in current directory."))
|
||||
sys.exit(1)
|
||||
raise click.ClickException(_("pyproject.toml not found in current directory."))
|
||||
|
||||
with open(path, "rb") as f: # noqa: PTH123
|
||||
data = tomllib.load(f)
|
||||
@@ -65,7 +63,7 @@ def cli() -> None:
|
||||
if errors:
|
||||
for err in errors:
|
||||
click.echo(f"ERROR: {err}", err=True)
|
||||
sys.exit(1)
|
||||
raise click.ClickException(_("Configuration validation failed."))
|
||||
|
||||
click.echo(_("Configuration OK: [tool.devx] present, devx versions consistent."))
|
||||
|
||||
|
||||
@@ -23,12 +23,12 @@ Usage::
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fnmatch
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.config import _load_pyproject_devx
|
||||
from devx.i18n import _
|
||||
|
||||
@@ -203,49 +203,37 @@ def _find_missing_tests(
|
||||
return missing
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=_("Check that changed files have corresponding tests"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--staged-only",
|
||||
action="store_true",
|
||||
help=_("Only check staged files (for pre-commit)"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--warn-only",
|
||||
action="store_true",
|
||||
help=_("Print warnings but always exit 0"),
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
@click.command()
|
||||
@click.option("--staged-only", is_flag=True, help=_("Only check staged files (for pre-commit)"))
|
||||
@click.option("--warn-only", is_flag=True, help=_("Print warnings but always exit 0"))
|
||||
def cli(staged_only: bool, warn_only: bool) -> None:
|
||||
"""Check that changed files have corresponding tests."""
|
||||
repo_root = Path.cwd()
|
||||
rules, skip_patterns, test_indicators, skip_extensions = _load_rules()
|
||||
|
||||
files = _changed_files(args.staged_only, repo_root)
|
||||
files = _changed_files(staged_only, repo_root)
|
||||
if not files:
|
||||
print(_("[check_test_coverage] No changed files to check."))
|
||||
return 0
|
||||
click.echo(_("[check_test_coverage] No changed files to check."))
|
||||
return
|
||||
|
||||
missing = _find_missing_tests(files, repo_root, rules, skip_patterns, test_indicators, skip_extensions)
|
||||
if not missing:
|
||||
print(f"[check_test_coverage] All {len(files)} changed file(s) have tests.")
|
||||
return 0
|
||||
click.echo(f"[check_test_coverage] All {len(files)} changed file(s) have tests.")
|
||||
return
|
||||
|
||||
print("[check_test_coverage] FAILED: missing tests for changed files:\n", file=sys.stderr)
|
||||
click.echo("[check_test_coverage] FAILED: missing tests for changed files:\n", err=True)
|
||||
for f, reason in missing.items():
|
||||
print(f" {f}", file=sys.stderr)
|
||||
print(f" -> {reason}", file=sys.stderr)
|
||||
click.echo(f" {f}", err=True)
|
||||
click.echo(f" -> {reason}", err=True)
|
||||
|
||||
print(
|
||||
"\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||
file=sys.stderr,
|
||||
click.echo(
|
||||
_("\n[check_test_coverage] Fix: add the missing test file(s) before committing."),
|
||||
err=True,
|
||||
)
|
||||
|
||||
if args.warn_only:
|
||||
return 0
|
||||
return 1
|
||||
if not warn_only:
|
||||
raise click.ClickException(_("Missing tests for changed files."))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
sys.exit(main())
|
||||
cli() # pragma: no cover
|
||||
|
||||
@@ -28,18 +28,19 @@ Usage::
|
||||
--keep 2 \\
|
||||
--dry-run
|
||||
|
||||
Authentication uses ``REPO_TOKEN`` environment variable.
|
||||
Authentication uses ``CI_GITEA_TOKEN`` environment variable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
import requests
|
||||
|
||||
from devx.config import GITEA_API_URL
|
||||
from devx.config import GITEA_API_URL, REPO_OWNER
|
||||
from devx.i18n import _
|
||||
|
||||
|
||||
@@ -85,15 +86,37 @@ def delete_package_version(
|
||||
token: str,
|
||||
*,
|
||||
timeout: int = 30,
|
||||
package_type: str = "container",
|
||||
max_retries: int = 3,
|
||||
) -> bool:
|
||||
"""Delete a specific version of a container package.
|
||||
|
||||
Uses the Gitea API endpoint ``DELETE /packages/{owner}/{type}/{name}/{version}``.
|
||||
Retries on transient failures (5xx, timeouts) up to ``max_retries`` times.
|
||||
|
||||
Returns True on success, False on failure.
|
||||
"""
|
||||
url = f"{api_url}/packages/{owner}/{name}/{version}"
|
||||
url = f"{api_url}/packages/{owner}/{package_type}/{name}/{version}"
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
resp = requests.delete(url, headers=headers, timeout=timeout)
|
||||
return resp.status_code in (204, 200)
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
resp = requests.delete(url, headers=headers, timeout=timeout)
|
||||
except requests.RequestException:
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(2**attempt)
|
||||
continue
|
||||
return False
|
||||
if resp.status_code in (204, 200):
|
||||
return True
|
||||
# 404 means already deleted — treat as success
|
||||
if resp.status_code == 404:
|
||||
return True
|
||||
# 5xx is transient — retry
|
||||
if 500 <= resp.status_code < 600 and attempt < max_retries - 1:
|
||||
time.sleep(2**attempt)
|
||||
continue
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def sort_versions_by_date(
|
||||
@@ -128,8 +151,8 @@ def select_for_deletion(
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--owner",
|
||||
required=True,
|
||||
help="Package owner (user or org).",
|
||||
default=None,
|
||||
help="Package owner (user or org, default: from [tool.devx] repo_owner).",
|
||||
)
|
||||
@click.option(
|
||||
"--name",
|
||||
@@ -157,20 +180,25 @@ def select_for_deletion(
|
||||
help="Gitea API URL (defaults to DEVX_GITEA_API_URL or built-in default).",
|
||||
)
|
||||
def main(
|
||||
owner: str,
|
||||
owner: str | None,
|
||||
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("REPO_TOKEN", "")
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("REPO_TOKEN environment variable required"))
|
||||
raise click.ClickException(_("CI_GITEA_TOKEN environment variable required"))
|
||||
if not owner:
|
||||
owner = REPO_OWNER
|
||||
if not owner:
|
||||
raise click.ClickException(_("Package owner not specified. Use --owner or set [tool.devx] repo_owner."))
|
||||
base_url = api_url or GITEA_API_URL
|
||||
|
||||
total_deleted = 0
|
||||
total_kept = 0
|
||||
total_failed = 0
|
||||
for name in names:
|
||||
click.echo(f"\n{'=' * 60}")
|
||||
click.echo(f"Package: {owner}/{name}")
|
||||
@@ -182,6 +210,7 @@ def main(
|
||||
_("Failed to list versions for {name}: {error}", name=name, error=exc),
|
||||
err=True,
|
||||
)
|
||||
total_failed += 1
|
||||
continue
|
||||
|
||||
if not versions:
|
||||
@@ -203,6 +232,7 @@ def main(
|
||||
continue
|
||||
|
||||
deleted_count = 0
|
||||
failed_count = 0
|
||||
for v in to_delete:
|
||||
version = str(v.get("version", ""))
|
||||
if delete_package_version(base_url, owner, name, version, token):
|
||||
@@ -210,11 +240,15 @@ def main(
|
||||
deleted_count += 1
|
||||
else:
|
||||
click.echo(f" FAILED to delete: {version}", err=True)
|
||||
failed_count += 1
|
||||
|
||||
total_deleted += deleted_count
|
||||
total_kept += kept_count
|
||||
total_failed += failed_count
|
||||
|
||||
click.echo(f"\nDone. Deleted {total_deleted}, kept {total_kept}.")
|
||||
click.echo(f"\nDone. Deleted {total_deleted}, kept {total_kept}, failed {total_failed}.")
|
||||
if total_failed > 0:
|
||||
raise click.ClickException(_("Failed to delete {count} image version(s)", count=total_failed))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -6,8 +6,8 @@ The ``tea`` CLI is used for label creation if available, with a
|
||||
fallback to ``GiteaClient`` if tea is not installed.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 -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
|
||||
CI_GITEA_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo --owner my-org
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,7 +19,7 @@ from typing import Any, cast
|
||||
import click
|
||||
|
||||
from devx.api_clients import GiteaClient
|
||||
from devx.config import GITEA_API_URL, REPO_OWNER
|
||||
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
|
||||
from devx.exceptions import APIError
|
||||
from devx.i18n import _
|
||||
|
||||
@@ -50,11 +50,17 @@ def _default_branch_protection_config() -> dict[str, Any]:
|
||||
"push_whitelist_usernames": [],
|
||||
"enable_status_check": True,
|
||||
"status_check_contexts": _default_status_checks(),
|
||||
"required_approvals": 0,
|
||||
"required_approvals": 1,
|
||||
"dismiss_stale_approvals": True,
|
||||
"block_on_outdated_branch": True,
|
||||
"block_on_rejected_reviews": True,
|
||||
"block_on_official_review_requests": True,
|
||||
# Prevent admins from force-merging PRs that don't meet branch
|
||||
# protection requirements (e.g. missing approvals). Without this,
|
||||
# an admin token can bypass the approval gate via force_merge=true,
|
||||
# allowing merges that failed the auto-merge CI job to reach master
|
||||
# and trigger the post-merge release pipeline.
|
||||
"block_admin_merge_override": True,
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +108,7 @@ def configure_repo(
|
||||
api_url: Gitea API base URL. If None, uses ``GITEA_API_URL`` from config.
|
||||
"""
|
||||
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
|
||||
client = GiteaClient(url, token, owner, repo)
|
||||
@@ -123,6 +129,7 @@ def configure_repo(
|
||||
click.echo(_(" - Dismiss stale approvals: yes"))
|
||||
click.echo(_(" - Block outdated branches: yes"))
|
||||
click.echo(_(" - Block rejected reviews: yes"))
|
||||
click.echo(_(" - Block admin merge override: yes"))
|
||||
checks = ", ".join(cast(list[str], bp_config["status_check_contexts"]))
|
||||
click.echo(_(" - Required status checks: {checks}", checks=checks))
|
||||
|
||||
@@ -148,10 +155,10 @@ def configure_repo(
|
||||
)
|
||||
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."""
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
|
||||
if repo is None:
|
||||
repo = os.environ.get("DEVX_REPO_NAME", "")
|
||||
repo = os.environ.get("DEVX_REPO_NAME", "") or REPO_NAME
|
||||
if not repo:
|
||||
raise click.ClickException(_("ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME."))
|
||||
|
||||
|
||||
+17
-22
@@ -34,6 +34,7 @@ from devx.api_clients import GiteaClient, VikunjaClient
|
||||
from devx.config import (
|
||||
DEFAULT_PER_PAGE,
|
||||
GITEA_API_URL,
|
||||
REPO_NAME,
|
||||
REPO_OWNER,
|
||||
TASK_ID_RE,
|
||||
TASK_PREFIX,
|
||||
@@ -46,15 +47,17 @@ load_dotenv()
|
||||
|
||||
|
||||
def get_repo_name() -> str:
|
||||
"""Auto-detect repository name from env vars or git remote."""
|
||||
"""Auto-detect repository name from env vars, pyproject.toml, or git remote."""
|
||||
name = os.environ.get("DEVX_REPO_NAME", "")
|
||||
if name:
|
||||
return name
|
||||
github_repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||
if github_repo and "/" in github_repo:
|
||||
return github_repo.split("/", 1)[1]
|
||||
if REPO_NAME:
|
||||
return REPO_NAME
|
||||
raise click.ClickException(
|
||||
_("Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var."),
|
||||
_("Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var."),
|
||||
)
|
||||
|
||||
|
||||
@@ -73,24 +76,16 @@ def get_vikunja_task_title(task_id: str) -> str:
|
||||
if not token:
|
||||
raise click.ClickException(_("VIKUNJA_TOKEN is not set. Required to derive PR title."))
|
||||
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||
page = 1
|
||||
while True:
|
||||
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
|
||||
if not tasks:
|
||||
break
|
||||
matches = [t for t in tasks if t.get("identifier") == task_id]
|
||||
if matches:
|
||||
return str(matches[0].get("title", ""))
|
||||
if len(tasks) < DEFAULT_PER_PAGE:
|
||||
break
|
||||
page += 1
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Could not find Vikunja task {task_id} in project {project_id}.",
|
||||
task_id=task_id,
|
||||
project_id=VIKUNJA_PROJECT_ID,
|
||||
),
|
||||
)
|
||||
task = client.find_task_by_identifier(VIKUNJA_PROJECT_ID, task_id, per_page=DEFAULT_PER_PAGE)
|
||||
if not task:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Could not find Vikunja task {task_id} in project {project_id}.",
|
||||
task_id=task_id,
|
||||
project_id=VIKUNJA_PROJECT_ID,
|
||||
),
|
||||
)
|
||||
return str(task.get("title", ""))
|
||||
|
||||
|
||||
def find_existing_pr(client: GiteaClient, branch: str) -> dict | None:
|
||||
@@ -123,9 +118,9 @@ def create_pr(
|
||||
),
|
||||
)
|
||||
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
token = os.environ.get("CI_GITEA_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)
|
||||
pr_title = f"{task_id}: {vikunja_title}"
|
||||
|
||||
@@ -348,7 +348,7 @@ def collect_quality(repo_root: Path) -> dict[str, str | int]:
|
||||
([sys.executable, "-m", "pyright"], "pyright"),
|
||||
([sys.executable, "-m", "bandit", "-r", "src/"], "bandit"),
|
||||
]:
|
||||
rc, _, stderr = run_command(cmd, cwd=repo_root)
|
||||
rc, _stdout, stderr = run_command(cmd, cwd=repo_root)
|
||||
if rc == 0:
|
||||
results.append(True)
|
||||
tool_names.append(f"{name}: pass")
|
||||
|
||||
@@ -109,7 +109,7 @@ def _generate(prefix: str) -> str:
|
||||
@click.option(
|
||||
"--prefix",
|
||||
default=TASK_PREFIX,
|
||||
help="Task ID prefix for commit preprocessor (default: DEVX_TASK_PREFIX env var or 'DEVX').",
|
||||
help="Task ID prefix for commit preprocessor (default: from [tool.devx] task_prefix in pyproject.toml).",
|
||||
)
|
||||
@click.option(
|
||||
"--output",
|
||||
|
||||
@@ -7,7 +7,6 @@ pre-built Linux binary from the official GitHub releases.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
import urllib.request
|
||||
@@ -15,6 +14,8 @@ from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.tools._shared import arch_string
|
||||
|
||||
CHECKMAKE_VERSION = "0.3.2"
|
||||
RELEASE_URL_TEMPLATE = (
|
||||
"https://github.com/checkmake/checkmake/releases/download/"
|
||||
@@ -23,18 +24,11 @@ RELEASE_URL_TEMPLATE = (
|
||||
TARGET_PATH = Path("/usr/local/bin/checkmake")
|
||||
|
||||
|
||||
def _arch() -> str:
|
||||
"""Return the architecture string used by checkmake releases."""
|
||||
machine = platform.machine().lower()
|
||||
if machine in {"x86_64", "amd64"}:
|
||||
return "amd64"
|
||||
if machine in {"aarch64", "arm64"}:
|
||||
return "arm64"
|
||||
raise click.ClickException(f"Unsupported architecture: {machine}")
|
||||
|
||||
|
||||
def _install_with_go() -> bool:
|
||||
"""Install checkmake using go install if Go is available."""
|
||||
"""Install checkmake using go install if Go is available.
|
||||
|
||||
Returns True if the installation succeeded, False if Go is not installed.
|
||||
"""
|
||||
go_bin = shutil.which("go")
|
||||
if go_bin is None:
|
||||
return False
|
||||
@@ -51,12 +45,13 @@ def _install_with_go() -> bool:
|
||||
|
||||
def _download_binary() -> None:
|
||||
"""Download the prebuilt checkmake binary for the current architecture."""
|
||||
url = RELEASE_URL_TEMPLATE.format(arch=_arch())
|
||||
url = RELEASE_URL_TEMPLATE.format(arch=arch_string())
|
||||
urllib.request.urlretrieve(url, TARGET_PATH) # nosec B310
|
||||
TARGET_PATH.chmod(0o755)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@click.command()
|
||||
def cli() -> None:
|
||||
"""Install checkmake if not already present."""
|
||||
if shutil.which("checkmake") is not None:
|
||||
return
|
||||
@@ -66,4 +61,4 @@ def main() -> None:
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main() # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
|
||||
@@ -6,6 +6,7 @@ Handles installation of:
|
||||
- git-cliff (changelog generator)
|
||||
- act_runner (Gitea Actions local runner, optional)
|
||||
- tea (Gitea CLI — official command-line tool for Gitea API operations)
|
||||
- hadolint (Dockerfile linter)
|
||||
|
||||
Each tool is installed to ``~/.local/bin`` if not already on PATH.
|
||||
Idempotent: skips tools that are already available.
|
||||
@@ -39,15 +40,14 @@ ACT_RUNNER_VERSION = "0.2.11"
|
||||
|
||||
TEA_VERSION = "0.14.1"
|
||||
|
||||
HADOLINT_VERSION = "2.12.0"
|
||||
|
||||
|
||||
def _arch() -> str:
|
||||
"""Return the architecture string used by release assets."""
|
||||
machine = platform.machine().lower()
|
||||
if machine in {"x86_64", "amd64"}:
|
||||
return "amd64"
|
||||
if machine in {"aarch64", "arm64"}:
|
||||
return "arm64"
|
||||
raise click.ClickException(f"Unsupported architecture: {machine}")
|
||||
"""Return the architecture string used by release assets (delegates to shared utility)."""
|
||||
from devx.tools._shared import arch_string
|
||||
|
||||
return arch_string()
|
||||
|
||||
|
||||
def _ensure_target_dir() -> Path:
|
||||
@@ -161,7 +161,20 @@ def install_tea() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea"]
|
||||
def install_hadolint() -> bool:
|
||||
"""Install hadolint if not already present. Returns True if installed/skipped."""
|
||||
if _is_installed("hadolint"):
|
||||
click.echo("hadolint: already installed")
|
||||
return True
|
||||
machine = platform.machine().lower()
|
||||
arch = "x86_64" if machine in {"x86_64", "amd64"} else "arm64"
|
||||
url = f"https://github.com/hadolint/hadolint/releases/download/v{HADOLINT_VERSION}/hadolint-Linux-{arch}"
|
||||
dest = _download_binary(url, "hadolint")
|
||||
click.echo(f"hadolint: installed to {dest}")
|
||||
return True
|
||||
|
||||
|
||||
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea", "hadolint"]
|
||||
|
||||
|
||||
def _install_tool(name: str) -> bool:
|
||||
@@ -174,6 +187,8 @@ def _install_tool(name: str) -> bool:
|
||||
return install_act_runner()
|
||||
if name == "tea":
|
||||
return install_tea()
|
||||
if name == "hadolint":
|
||||
return install_hadolint()
|
||||
raise click.ClickException(f"Unknown tool: {name}")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add a label to a pull request (idempotent).
|
||||
|
||||
Commonly used to add the ``ready-to-merge`` label after CI passes and
|
||||
review is complete. The operation is idempotent — if the label is already
|
||||
attached, it succeeds without error.
|
||||
|
||||
Usage::
|
||||
|
||||
# Add ready-to-merge to PR #42
|
||||
python -m devx.tools.pr_label --pr 42 --label ready-to-merge
|
||||
|
||||
# Add label to current branch's PR
|
||||
python -m devx.tools.pr_label --label ready-to-merge
|
||||
|
||||
# Add multiple labels
|
||||
python -m devx.tools.pr_label --pr 42 --label ready-to-merge --label reviewed
|
||||
|
||||
The repository is auto-detected from ``DEVX_REPO_OWNER`` /
|
||||
``DEVX_REPO_NAME`` or ``GITHUB_REPOSITORY`` environment variables.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from devx.api_clients import GiteaClient
|
||||
from devx.config import GITEA_API_URL, REPO_OWNER
|
||||
from devx.i18n import _
|
||||
from devx.tools.create_pr import get_repo_name
|
||||
from devx.tools.pr_status import _get_current_branch_pr
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--pr", "pr_number", type=int, default=None, help="PR number (default: auto-detect from current branch).")
|
||||
@click.option("--label", "labels", multiple=True, required=True, help="Label name(s) to add (can be repeated).")
|
||||
@click.option("--owner", default=None, help="Repository owner (default: DEVX_REPO_OWNER).")
|
||||
@click.option("--repo", default=None, help="Repository name (default: DEVX_REPO_NAME or GITHUB_REPOSITORY).")
|
||||
def cli(
|
||||
pr_number: int | None,
|
||||
labels: tuple[str, ...],
|
||||
owner: str | None,
|
||||
repo: str | None,
|
||||
) -> None:
|
||||
"""Add one or more labels to a pull request (idempotent)."""
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("CI_GITEA_TOKEN is not set."))
|
||||
|
||||
repo_owner = owner or REPO_OWNER
|
||||
if not repo_owner:
|
||||
raise click.ClickException(_("Repository owner not set. Use --owner or DEVX_REPO_OWNER env var."))
|
||||
repo_name = repo or get_repo_name()
|
||||
|
||||
client = GiteaClient(GITEA_API_URL, token, repo_owner, repo_name)
|
||||
|
||||
if pr_number is None:
|
||||
pr_number = _get_current_branch_pr(client)
|
||||
|
||||
label_list = list(labels)
|
||||
existing = client.get_pr_label_names(pr_number)
|
||||
to_add = [lbl for lbl in label_list if lbl not in existing]
|
||||
already = [lbl for lbl in label_list if lbl in existing]
|
||||
|
||||
if already:
|
||||
for lbl in already:
|
||||
click.echo(_("Label '{label}' already on PR #{pr}.", label=lbl, pr=pr_number))
|
||||
|
||||
if to_add:
|
||||
client.add_pr_label(pr_number, to_add)
|
||||
for lbl in to_add:
|
||||
click.echo(_("Added label '{label}' to PR #{pr}.", label=lbl, pr=pr_number))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch logs for failed CI jobs on a pull request.
|
||||
|
||||
Lists CI jobs for the latest workflow run of a PR's branch, then fetches
|
||||
and prints the logs of any failed jobs. Useful for diagnosing CI failures
|
||||
without navigating the web UI.
|
||||
|
||||
Usage::
|
||||
|
||||
# Show failed job logs for PR #42
|
||||
python -m devx.tools.pr_logs --pr 42
|
||||
|
||||
# Show failed job logs for current branch's PR
|
||||
python -m devx.tools.pr_logs
|
||||
|
||||
# Show logs for a specific job (by name)
|
||||
python -m devx.tools.pr_logs --pr 42 --job quality
|
||||
|
||||
# Show last N lines of each failed job's logs
|
||||
python -m devx.tools.pr_logs --pr 42 --tail 50
|
||||
|
||||
The repository is auto-detected from ``DEVX_REPO_OWNER`` /
|
||||
``DEVX_REPO_NAME`` or ``GITHUB_REPOSITORY`` environment variables.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from devx.api_clients import APIError, GiteaClient
|
||||
from devx.config import GITEA_API_URL, REPO_OWNER
|
||||
from devx.i18n import _
|
||||
from devx.tools.create_pr import get_repo_name
|
||||
from devx.tools.pr_status import _get_current_branch_pr
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def _get_pr_sha(client: GiteaClient, pr_number: int) -> str:
|
||||
"""Fetch the head SHA of a PR."""
|
||||
pr = client.get_pr(pr_number)
|
||||
return pr.get("head", {}).get("sha", "")
|
||||
|
||||
|
||||
def _find_latest_run_by_sha(client: GiteaClient, sha: str) -> dict | None:
|
||||
"""Find the latest workflow run for a commit SHA.
|
||||
|
||||
Gitea Actions API doesn't set head_branch for pull_request events,
|
||||
so we filter by head_sha instead.
|
||||
"""
|
||||
data = client.list_action_runs(limit=50)
|
||||
for run in data.get("workflow_runs", []):
|
||||
if run.get("head_sha", "").startswith(sha):
|
||||
return run
|
||||
return None
|
||||
|
||||
|
||||
def _find_failed_jobs(jobs: list[dict]) -> list[dict]:
|
||||
"""Return jobs with conclusion 'failure'."""
|
||||
return [j for j in jobs if j.get("conclusion") == "failure"]
|
||||
|
||||
|
||||
def _find_job_by_name(jobs: list[dict], name: str) -> dict | None:
|
||||
"""Find a job by name (case-insensitive partial match)."""
|
||||
name_lower = name.lower()
|
||||
for j in jobs:
|
||||
if name_lower in j.get("name", "").lower():
|
||||
return j
|
||||
return None
|
||||
|
||||
|
||||
def _print_job_summary(jobs: list[dict]) -> None:
|
||||
"""Print a summary table of all jobs and their status."""
|
||||
for j in jobs:
|
||||
name = j.get("name", "?")
|
||||
conclusion = j.get("conclusion", "pending")
|
||||
status = j.get("status", "?")
|
||||
symbol = "[FAIL]" if conclusion == "failure" else "[OK]" if conclusion == "success" else f"[{conclusion}]"
|
||||
click.echo(f" {symbol} {name} (status: {status}, conclusion: {conclusion})")
|
||||
|
||||
|
||||
def _print_failed_steps(job: dict) -> list[int]:
|
||||
"""Print failed steps for a job. Returns list of failed step numbers."""
|
||||
failed_steps = []
|
||||
for step in job.get("steps", []):
|
||||
if step.get("conclusion") == "failure":
|
||||
name = step.get("name", "?")
|
||||
num = step.get("number", "?")
|
||||
click.echo(f" FAILED step #{num}: {name}")
|
||||
failed_steps.append(num)
|
||||
return failed_steps
|
||||
|
||||
|
||||
def _print_logs(client: GiteaClient, job_id: int, tail: int = 0) -> None:
|
||||
"""Fetch and print logs for a job. If tail > 0, print only last N lines."""
|
||||
try:
|
||||
logs = client.get_action_job_logs(job_id)
|
||||
except APIError as e:
|
||||
click.echo(_(" Could not fetch logs: {error}", error=str(e)))
|
||||
return
|
||||
|
||||
if tail > 0:
|
||||
lines = logs.strip().split("\n")
|
||||
if len(lines) > tail:
|
||||
click.echo(f" ... (showing last {tail} of {len(lines)} lines)")
|
||||
logs = "\n".join(lines[-tail:])
|
||||
|
||||
for line in logs.split("\n"):
|
||||
click.echo(f" {line}")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--pr", "pr_number", type=int, default=None, help="PR number (default: auto-detect from current branch).")
|
||||
@click.option("--job", default=None, help="Job name to show logs for (partial match, case-insensitive).")
|
||||
@click.option("--tail", type=int, default=80, show_default=True, help="Show last N lines of logs (0 = all).")
|
||||
@click.option("--owner", default=None, help="Repository owner (default: DEVX_REPO_OWNER).")
|
||||
@click.option("--repo", default=None, help="Repository name (default: DEVX_REPO_NAME or GITHUB_REPOSITORY).")
|
||||
def cli(
|
||||
pr_number: int | None,
|
||||
job: str | None,
|
||||
tail: int,
|
||||
owner: str | None,
|
||||
repo: str | None,
|
||||
) -> None:
|
||||
"""Fetch logs for failed CI jobs on a pull request."""
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("CI_GITEA_TOKEN is not set."))
|
||||
|
||||
repo_owner = owner or REPO_OWNER
|
||||
if not repo_owner:
|
||||
raise click.ClickException(_("Repository owner not set. Use --owner or DEVX_REPO_OWNER env var."))
|
||||
repo_name = repo or get_repo_name()
|
||||
|
||||
client = GiteaClient(GITEA_API_URL, token, repo_owner, repo_name)
|
||||
|
||||
if pr_number is None:
|
||||
pr_number = _get_current_branch_pr(client)
|
||||
click.echo(_("Fetching logs for PR #{pr_number}...", pr_number=pr_number))
|
||||
|
||||
sha = _get_pr_sha(client, pr_number)
|
||||
if not sha:
|
||||
raise click.ClickException(_("Could not determine head SHA for PR #{pr_number}.", pr_number=pr_number))
|
||||
|
||||
run = _find_latest_run_by_sha(client, sha)
|
||||
if not run:
|
||||
raise click.ClickException(_("No workflow runs found for SHA {sha}.", sha=sha[:8]))
|
||||
|
||||
run_id = run.get("id", 0)
|
||||
run_status = run.get("status", "?")
|
||||
click.echo(_("Latest run: #{run_id} (status: {status})", run_id=run_id, status=run_status))
|
||||
click.echo("")
|
||||
|
||||
jobs = client.get_action_run_jobs(run_id)
|
||||
if not jobs:
|
||||
click.echo(_("No jobs found for run #{run_id}.", run_id=run_id))
|
||||
return
|
||||
|
||||
_print_job_summary(jobs)
|
||||
click.echo("")
|
||||
|
||||
if job:
|
||||
target = _find_job_by_name(jobs, job)
|
||||
if not target:
|
||||
raise click.ClickException(_("No job matching '{job}' found.", job=job))
|
||||
click.echo(f"Logs for job '{target.get('name', '?')}' (id={target.get('id')}):")
|
||||
_print_failed_steps(target)
|
||||
click.echo("")
|
||||
_print_logs(client, target["id"], tail)
|
||||
else:
|
||||
failed = _find_failed_jobs(jobs)
|
||||
if not failed:
|
||||
click.echo(_("No failed jobs."))
|
||||
return
|
||||
for fj in failed:
|
||||
click.echo(f"Logs for failed job '{fj.get('name', '?')}' (id={fj.get('id')}):")
|
||||
_print_failed_steps(fj)
|
||||
click.echo("")
|
||||
_print_logs(client, fj["id"], tail)
|
||||
click.echo("")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -0,0 +1,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check CI status for a pull request or commit.
|
||||
|
||||
Displays the status of all CI checks for a PR (or a specific commit SHA).
|
||||
Optionally polls until all checks complete (``--wait``).
|
||||
|
||||
Usage::
|
||||
|
||||
# Check status of PR #42
|
||||
python -m devx.tools.pr_status --pr 42
|
||||
|
||||
# Check status of current branch's PR
|
||||
python -m devx.tools.pr_status
|
||||
|
||||
# Wait for all checks to complete (timeout 600s)
|
||||
python -m devx.tools.pr_status --pr 42 --wait --timeout 600
|
||||
|
||||
# Check a specific commit SHA
|
||||
python -m devx.tools.pr_status --sha abc1234
|
||||
|
||||
The repository is auto-detected from ``DEVX_REPO_OWNER`` /
|
||||
``DEVX_REPO_NAME`` or ``GITHUB_REPOSITORY`` environment variables.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess # nosec B404
|
||||
import time
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from devx.api_clients import GiteaClient
|
||||
from devx.config import GITEA_API_URL, REPO_OWNER
|
||||
from devx.i18n import _
|
||||
from devx.tools.create_pr import get_repo_name
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Status symbols for terminal output
|
||||
_STATUS_SYMBOLS = {
|
||||
"success": "[OK]",
|
||||
"failure": "[FAIL]",
|
||||
"error": "[FAIL]",
|
||||
"pending": "[..]",
|
||||
"skipped": "[SKIP]",
|
||||
"none": "[--]",
|
||||
}
|
||||
|
||||
|
||||
def _get_symbol(status: str) -> str:
|
||||
return _STATUS_SYMBOLS.get(status, f"[{status}]")
|
||||
|
||||
|
||||
def _get_pr_sha(client: GiteaClient, pr_number: int) -> str:
|
||||
"""Fetch the head SHA of a PR."""
|
||||
pr = client.get_pr(pr_number)
|
||||
return pr.get("head", {}).get("sha", "")
|
||||
|
||||
|
||||
def _get_current_branch_pr(client: GiteaClient) -> int:
|
||||
"""Find the open PR for the current git branch."""
|
||||
result = subprocess.run( # nosec
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(_("Could not detect current branch: {error}", error=result.stderr.strip()))
|
||||
branch = result.stdout.strip()
|
||||
|
||||
prs = client.list_prs(state="open")
|
||||
for pr in prs:
|
||||
if pr.get("head", {}).get("ref") == branch:
|
||||
return int(pr["number"])
|
||||
raise click.ClickException(_("No open PR found for branch '{branch}'.", branch=branch))
|
||||
|
||||
|
||||
def print_status(client: GiteaClient, sha: str) -> str:
|
||||
"""Print CI check statuses for a commit SHA. Returns the overall state."""
|
||||
statuses = client.get_commit_status(sha)
|
||||
if not statuses:
|
||||
click.echo(_("No CI checks found for commit {sha}.", sha=sha[:8]))
|
||||
return "none"
|
||||
|
||||
overall = "success"
|
||||
for s in statuses:
|
||||
context = s.get("context", "?")
|
||||
status = s.get("status", "pending")
|
||||
symbol = _get_symbol(status)
|
||||
click.echo(f" {symbol} {context}")
|
||||
if status in ("failure", "error"):
|
||||
overall = "failure"
|
||||
elif status == "pending" and overall != "failure":
|
||||
overall = "pending"
|
||||
elif status == "skipped" and overall == "success":
|
||||
overall = "success"
|
||||
|
||||
click.echo(f"\n Overall: {_get_symbol(overall)} {overall}")
|
||||
return overall
|
||||
|
||||
|
||||
def wait_for_completion(
|
||||
client: GiteaClient,
|
||||
sha: str,
|
||||
timeout: int = 600,
|
||||
interval: int = 30,
|
||||
) -> str:
|
||||
"""Poll CI status until all checks complete or timeout. Returns final state."""
|
||||
click.echo(_("Waiting for CI checks to complete (timeout: {timeout}s)...", timeout=timeout))
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
state = print_status(client, sha)
|
||||
if state in ("success", "failure", "error", "none"):
|
||||
return state
|
||||
click.echo(f" ...still pending, retrying in {interval}s\n")
|
||||
time.sleep(interval)
|
||||
click.echo(_("Timeout reached after {timeout}s.", timeout=timeout))
|
||||
return "pending"
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--pr", "pr_number", type=int, default=None, help="PR number (default: auto-detect from current branch).")
|
||||
@click.option("--sha", default=None, help="Commit SHA to check (alternative to --pr).")
|
||||
@click.option("--wait", "do_wait", is_flag=True, help="Poll until all checks complete.")
|
||||
@click.option("--timeout", type=int, default=600, show_default=True, help="Wait timeout in seconds.")
|
||||
@click.option("--interval", type=int, default=30, show_default=True, help="Poll interval in seconds.")
|
||||
@click.option("--owner", default=None, help="Repository owner (default: DEVX_REPO_OWNER).")
|
||||
@click.option("--repo", default=None, help="Repository name (default: DEVX_REPO_NAME or GITHUB_REPOSITORY).")
|
||||
def cli(
|
||||
pr_number: int | None,
|
||||
sha: str | None,
|
||||
do_wait: bool,
|
||||
timeout: int,
|
||||
interval: int,
|
||||
owner: str | None,
|
||||
repo: str | None,
|
||||
) -> None:
|
||||
"""Check CI status for a pull request or commit."""
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("CI_GITEA_TOKEN is not set."))
|
||||
|
||||
repo_owner = owner or REPO_OWNER
|
||||
if not repo_owner:
|
||||
raise click.ClickException(_("Repository owner not set. Use --owner or DEVX_REPO_OWNER env var."))
|
||||
repo_name = repo or get_repo_name()
|
||||
|
||||
client = GiteaClient(GITEA_API_URL, token, repo_owner, repo_name)
|
||||
|
||||
if sha is None:
|
||||
if pr_number is None:
|
||||
pr_number = _get_current_branch_pr(client)
|
||||
click.echo(_("Checking status for PR #{pr_number}...", pr_number=pr_number))
|
||||
pr = client.get_pr(pr_number)
|
||||
sha = pr.get("head", {}).get("sha", "")
|
||||
if not sha:
|
||||
raise click.ClickException(_("Could not determine head SHA for PR #{pr_number}.", pr_number=pr_number))
|
||||
|
||||
click.echo(_("Commit: {sha}", sha=sha[:12]))
|
||||
click.echo("")
|
||||
|
||||
state = wait_for_completion(client, sha, timeout, interval) if do_wait else print_status(client, sha)
|
||||
|
||||
if state in ("failure", "error"):
|
||||
raise click.ClickException(_("CI checks failed."))
|
||||
if state == "pending" and do_wait:
|
||||
raise click.ClickException(_("CI checks did not complete within timeout."))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -59,17 +59,7 @@ def task_exists(task_id: str) -> bool:
|
||||
if not token:
|
||||
return False
|
||||
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||
page = 1
|
||||
while True:
|
||||
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
|
||||
if not tasks:
|
||||
break
|
||||
if any(t.get("identifier") == task_id for t in tasks):
|
||||
return True
|
||||
if len(tasks) < DEFAULT_PER_PAGE:
|
||||
break
|
||||
page += 1
|
||||
return False
|
||||
return client.find_task_by_identifier(VIKUNJA_PROJECT_ID, task_id, per_page=DEFAULT_PER_PAGE) is not None
|
||||
|
||||
|
||||
def validate(branch: str) -> None:
|
||||
|
||||
@@ -64,19 +64,19 @@ def _install_ansible_collections(bin_dir: str) -> 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.
|
||||
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")
|
||||
if tea_bin is None:
|
||||
click.echo("tea: not installed — run 'make install-tools' to install it.")
|
||||
return
|
||||
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
token = os.environ.get("CI_GITEA_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
|
||||
|
||||
api_url = os.environ.get("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
|
||||
|
||||
+353
-65
@@ -463,14 +463,6 @@
|
||||
"ru": "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...": {
|
||||
"bg": "Checking CLI command documentation...",
|
||||
"de": "Checking CLI command documentation...",
|
||||
@@ -535,13 +527,13 @@
|
||||
"ru": "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 (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)."
|
||||
"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}.",
|
||||
@@ -647,6 +639,14 @@
|
||||
"ru": "Dockerfile not found: {path}",
|
||||
"zh": "Dockerfile not found: {path}"
|
||||
},
|
||||
"Each item must be a string or an object with 'id', got {type}": {
|
||||
"bg": "Всеки елемент трябва да е низ или обект с 'id', получено {type}",
|
||||
"de": "Jedes Element muss ein String oder ein Objekt mit 'id' sein, erhalten {type}",
|
||||
"en": "Each item must be a string or an object with 'id', got {type}",
|
||||
"pl": "Każdy element musi być ciągiem lub obiektem z 'id', otrzymano {type}",
|
||||
"ru": "Каждый элемент должен быть строкой или объектом с 'id', получено {type}",
|
||||
"zh": "每个元素必须是字符串或带有 'id' 的对象,得到 {type}"
|
||||
},
|
||||
"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.",
|
||||
@@ -655,13 +655,13 @@
|
||||
"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."
|
||||
},
|
||||
"ERROR: REPO_TOKEN is not set.": {
|
||||
"bg": "ГРЕШКА: REPO_TOKEN не е зададен.",
|
||||
"de": "FEHLER: REPO_TOKEN ist nicht gesetzt.",
|
||||
"en": "ERROR: REPO_TOKEN is not set.",
|
||||
"pl": "BŁĄD: REPO_TOKEN nie jest ustawiony.",
|
||||
"ru": "ОШИБКА: REPO_TOKEN не задан.",
|
||||
"zh": "错误:未设置 REPO_TOKEN。"
|
||||
"ERROR: CI_GITEA_TOKEN is not set.": {
|
||||
"bg": "ГРЕШКА: CI_GITEA_TOKEN не е зададен.",
|
||||
"de": "FEHLER: CI_GITEA_TOKEN ist nicht gesetzt.",
|
||||
"en": "ERROR: CI_GITEA_TOKEN is not set.",
|
||||
"pl": "BŁĄD: CI_GITEA_TOKEN nie jest ustawiony.",
|
||||
"ru": "ОШИБКА: CI_GITEA_TOKEN не задан.",
|
||||
"zh": "错误:未设置 CI_GITEA_TOKEN。"
|
||||
},
|
||||
"ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.": {
|
||||
"bg": "ГРЕШКА: Името на хранилището не е указано. Използвайте --repo или задайте DEVX_REPO_NAME.",
|
||||
@@ -759,13 +759,13 @@
|
||||
"ru": "Found {count} stale documentation reference(s)",
|
||||
"zh": "Found {count} stale documentation reference(s)"
|
||||
},
|
||||
"GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.": {
|
||||
"bg": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
||||
"de": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
||||
"en": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
||||
"pl": "GITEA_URL/REPO_TOKEN/RUN_ID nie ustawione; uruchamianie bez anulowania między runnerami.",
|
||||
"ru": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
||||
"zh": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."
|
||||
"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}'.": {
|
||||
"bg": "Generated {file} with prefix '{prefix}'.",
|
||||
@@ -1383,29 +1383,37 @@
|
||||
"ru": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
||||
"zh": "REPO argument is required (or set GITHUB_REPOSITORY env var)."
|
||||
},
|
||||
"REPO_TOKEN environment variable required": {
|
||||
"bg": "REPO_TOKEN environment variable required",
|
||||
"de": "REPO_TOKEN environment variable required",
|
||||
"en": "REPO_TOKEN environment variable required",
|
||||
"pl": "REPO_TOKEN environment variable required",
|
||||
"ru": "REPO_TOKEN environment variable required",
|
||||
"zh": "REPO_TOKEN environment variable required"
|
||||
"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"
|
||||
},
|
||||
"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 所需。"
|
||||
"Failed to delete {count} image version(s)": {
|
||||
"bg": "Failed to delete {count} image version(s)",
|
||||
"de": "Failed to delete {count} image version(s)",
|
||||
"en": "Failed to delete {count} image version(s)",
|
||||
"pl": "Failed to delete {count} image version(s)",
|
||||
"ru": "Failed to delete {count} image version(s)",
|
||||
"zh": "Failed to delete {count} image version(s)"
|
||||
},
|
||||
"Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars": {
|
||||
"bg": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars",
|
||||
"de": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars",
|
||||
"en": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars",
|
||||
"pl": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars",
|
||||
"ru": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars",
|
||||
"zh": "Registry credentials required: set REPO_TOKEN and REGISTRY_USERNAME env vars"
|
||||
"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",
|
||||
@@ -1463,14 +1471,6 @@
|
||||
"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.",
|
||||
@@ -1799,13 +1799,13 @@
|
||||
"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}": {
|
||||
"bg": "[dry-run] Would commit: release: v{version}",
|
||||
"de": "[dry-run] Would commit: release: v{version}",
|
||||
"en": "[dry-run] Would commit: release: v{version}",
|
||||
"pl": "[dry-run] Utworzono by commit: release: v{version}",
|
||||
"ru": "[dry-run] Would commit: release: v{version}",
|
||||
"zh": "[dry-run] Would commit: release: v{version}"
|
||||
"[dry-run] Would commit: release: v{version} [skip ci]": {
|
||||
"bg": "[dry-run] Would commit: release: v{version} [skip ci]",
|
||||
"de": "[dry-run] Would commit: release: v{version} [skip ci]",
|
||||
"en": "[dry-run] Would commit: release: v{version} [skip ci]",
|
||||
"pl": "[dry-run] Utworzono by commit: release: v{version} [skip ci]",
|
||||
"ru": "[dry-run] Would commit: release: v{version} [skip ci]",
|
||||
"zh": "[dry-run] Would commit: release: v{version} [skip ci]"
|
||||
},
|
||||
"[dry-run] Would create tag: v{version}": {
|
||||
"bg": "[dry-run] Would create tag: v{version}",
|
||||
@@ -1990,5 +1990,293 @@
|
||||
"pl": "{file} już istnieje. Użyj --force, aby nadpisać.",
|
||||
"ru": "{file} already exists. Use --force to overwrite.",
|
||||
"zh": "{file} already exists. Use --force to overwrite."
|
||||
},
|
||||
"Version stays at v{version} — no version bump from git-cliff. Commits since last tag don't warrant a new release. Skipping.": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "Version stays at v{version} — no version bump from git-cliff. Commits since last tag don't warrant a new release. Skipping.",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"tea not installed — skipping login configuration.": {
|
||||
"bg": "tea not installed — skipping login configuration.",
|
||||
"de": "tea not installed — skipping login configuration.",
|
||||
"en": "tea not installed — skipping login configuration.",
|
||||
"pl": "tea not installed — skipping login configuration.",
|
||||
"ru": "tea not installed — skipping login configuration.",
|
||||
"zh": "tea not installed — skipping login configuration."
|
||||
},
|
||||
"CI_GITEA_TOKEN not set — skipping login configuration.": {
|
||||
"bg": "CI_GITEA_TOKEN not set — skipping login configuration.",
|
||||
"de": "CI_GITEA_TOKEN not set — skipping login configuration.",
|
||||
"en": "CI_GITEA_TOKEN not set — skipping login configuration.",
|
||||
"pl": "CI_GITEA_TOKEN not set — skipping login configuration.",
|
||||
"ru": "CI_GITEA_TOKEN not set — skipping login configuration.",
|
||||
"zh": "CI_GITEA_TOKEN not set — skipping login configuration."
|
||||
},
|
||||
"tea login '{name}' already configured.": {
|
||||
"bg": "tea login '{name}' already configured.",
|
||||
"de": "tea login '{name}' already configured.",
|
||||
"en": "tea login '{name}' already configured.",
|
||||
"pl": "tea login '{name}' already configured.",
|
||||
"ru": "tea login '{name}' already configured.",
|
||||
"zh": "tea login '{name}' already configured."
|
||||
},
|
||||
"Configuring tea login '{name}' for {url}...": {
|
||||
"bg": "Configuring tea login '{name}' for {url}...",
|
||||
"de": "Configuring tea login '{name}' for {url}...",
|
||||
"en": "Configuring tea login '{name}' for {url}...",
|
||||
"pl": "Configuring tea login '{name}' for {url}...",
|
||||
"ru": "Configuring tea login '{name}' for {url}...",
|
||||
"zh": "Configuring tea login '{name}' for {url}..."
|
||||
},
|
||||
" Could not fetch logs: {error}": {
|
||||
"en": " Could not fetch logs: {error}",
|
||||
"bg": " Could not fetch logs: {error}",
|
||||
"de": " Could not fetch logs: {error}",
|
||||
"pl": " Could not fetch logs: {error}",
|
||||
"ru": " Could not fetch logs: {error}",
|
||||
"zh": " Could not fetch logs: {error}"
|
||||
},
|
||||
"Added label '{label}' to PR #{pr}.": {
|
||||
"en": "Added label '{label}' to PR #{pr}.",
|
||||
"bg": "Added label '{label}' to PR #{pr}.",
|
||||
"de": "Added label '{label}' to PR #{pr}.",
|
||||
"pl": "Added label '{label}' to PR #{pr}.",
|
||||
"ru": "Added label '{label}' to PR #{pr}.",
|
||||
"zh": "Added label '{label}' to PR #{pr}."
|
||||
},
|
||||
"CI checks did not complete within timeout.": {
|
||||
"en": "CI checks did not complete within timeout.",
|
||||
"bg": "CI checks did not complete within timeout.",
|
||||
"de": "CI checks did not complete within timeout.",
|
||||
"pl": "CI checks did not complete within timeout.",
|
||||
"ru": "CI checks did not complete within timeout.",
|
||||
"zh": "CI checks did not complete within timeout."
|
||||
},
|
||||
"CI checks failed.": {
|
||||
"en": "CI checks failed.",
|
||||
"bg": "CI checks failed.",
|
||||
"de": "CI checks failed.",
|
||||
"pl": "CI checks failed.",
|
||||
"ru": "CI checks failed.",
|
||||
"zh": "CI checks failed."
|
||||
},
|
||||
"CI_GITEA_TOKEN is not set.": {
|
||||
"en": "CI_GITEA_TOKEN is not set.",
|
||||
"bg": "CI_GITEA_TOKEN is not set.",
|
||||
"de": "CI_GITEA_TOKEN is not set.",
|
||||
"pl": "CI_GITEA_TOKEN is not set.",
|
||||
"ru": "CI_GITEA_TOKEN is not set.",
|
||||
"zh": "CI_GITEA_TOKEN is not set."
|
||||
},
|
||||
"Checking status for PR #{pr_number}...": {
|
||||
"en": "Checking status for PR #{pr_number}...",
|
||||
"bg": "Checking status for PR #{pr_number}...",
|
||||
"de": "Checking status for PR #{pr_number}...",
|
||||
"pl": "Checking status for PR #{pr_number}...",
|
||||
"ru": "Checking status for PR #{pr_number}...",
|
||||
"zh": "Checking status for PR #{pr_number}..."
|
||||
},
|
||||
"Commit: {sha}": {
|
||||
"en": "Commit: {sha}",
|
||||
"bg": "Commit: {sha}",
|
||||
"de": "Commit: {sha}",
|
||||
"pl": "Commit: {sha}",
|
||||
"ru": "Commit: {sha}",
|
||||
"zh": "Commit: {sha}"
|
||||
},
|
||||
"Could not determine head SHA for PR #{pr_number}.": {
|
||||
"en": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"bg": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"de": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"pl": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"ru": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"zh": "Could not determine head SHA for PR #{pr_number}."
|
||||
},
|
||||
"Fetching logs for PR #{pr_number}...": {
|
||||
"en": "Fetching logs for PR #{pr_number}...",
|
||||
"bg": "Fetching logs for PR #{pr_number}...",
|
||||
"de": "Fetching logs for PR #{pr_number}...",
|
||||
"pl": "Fetching logs for PR #{pr_number}...",
|
||||
"ru": "Fetching logs for PR #{pr_number}...",
|
||||
"zh": "Fetching logs for PR #{pr_number}..."
|
||||
},
|
||||
"Label '{label}' already on PR #{pr}.": {
|
||||
"en": "Label '{label}' already on PR #{pr}.",
|
||||
"bg": "Label '{label}' already on PR #{pr}.",
|
||||
"de": "Label '{label}' already on PR #{pr}.",
|
||||
"pl": "Label '{label}' already on PR #{pr}.",
|
||||
"ru": "Label '{label}' already on PR #{pr}.",
|
||||
"zh": "Label '{label}' already on PR #{pr}."
|
||||
},
|
||||
"Latest run: #{run_id} (status: {status})": {
|
||||
"en": "Latest run: #{run_id} (status: {status})",
|
||||
"bg": "Latest run: #{run_id} (status: {status})",
|
||||
"de": "Latest run: #{run_id} (status: {status})",
|
||||
"pl": "Latest run: #{run_id} (status: {status})",
|
||||
"ru": "Latest run: #{run_id} (status: {status})",
|
||||
"zh": "Latest run: #{run_id} (status: {status})"
|
||||
},
|
||||
"No CI checks found for commit {sha}.": {
|
||||
"en": "No CI checks found for commit {sha}.",
|
||||
"bg": "No CI checks found for commit {sha}.",
|
||||
"de": "No CI checks found for commit {sha}.",
|
||||
"pl": "No CI checks found for commit {sha}.",
|
||||
"ru": "No CI checks found for commit {sha}.",
|
||||
"zh": "No CI checks found for commit {sha}."
|
||||
},
|
||||
"No failed jobs.": {
|
||||
"en": "No failed jobs.",
|
||||
"bg": "No failed jobs.",
|
||||
"de": "No failed jobs.",
|
||||
"pl": "No failed jobs.",
|
||||
"ru": "No failed jobs.",
|
||||
"zh": "No failed jobs."
|
||||
},
|
||||
"No job matching '{job}' found.": {
|
||||
"en": "No job matching '{job}' found.",
|
||||
"bg": "No job matching '{job}' found.",
|
||||
"de": "No job matching '{job}' found.",
|
||||
"pl": "No job matching '{job}' found.",
|
||||
"ru": "No job matching '{job}' found.",
|
||||
"zh": "No job matching '{job}' found."
|
||||
},
|
||||
"No jobs found for run #{run_id}.": {
|
||||
"en": "No jobs found for run #{run_id}.",
|
||||
"bg": "No jobs found for run #{run_id}.",
|
||||
"de": "No jobs found for run #{run_id}.",
|
||||
"pl": "No jobs found for run #{run_id}.",
|
||||
"ru": "No jobs found for run #{run_id}.",
|
||||
"zh": "No jobs found for run #{run_id}."
|
||||
},
|
||||
"No open PR found for branch '{branch}'.": {
|
||||
"en": "No open PR found for branch '{branch}'.",
|
||||
"bg": "No open PR found for branch '{branch}'.",
|
||||
"de": "No open PR found for branch '{branch}'.",
|
||||
"pl": "No open PR found for branch '{branch}'.",
|
||||
"ru": "No open PR found for branch '{branch}'.",
|
||||
"zh": "No open PR found for branch '{branch}'."
|
||||
},
|
||||
"No workflow runs found for SHA {sha}.": {
|
||||
"en": "No workflow runs found for SHA {sha}.",
|
||||
"bg": "No workflow runs found for SHA {sha}.",
|
||||
"de": "No workflow runs found for SHA {sha}.",
|
||||
"pl": "No workflow runs found for SHA {sha}.",
|
||||
"ru": "No workflow runs found for SHA {sha}.",
|
||||
"zh": "No workflow runs found for SHA {sha}."
|
||||
},
|
||||
"Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.": {
|
||||
"en": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"bg": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"de": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"pl": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"ru": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"zh": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var."
|
||||
},
|
||||
"Timeout reached after {timeout}s.": {
|
||||
"en": "Timeout reached after {timeout}s.",
|
||||
"bg": "Timeout reached after {timeout}s.",
|
||||
"de": "Timeout reached after {timeout}s.",
|
||||
"pl": "Timeout reached after {timeout}s.",
|
||||
"ru": "Timeout reached after {timeout}s.",
|
||||
"zh": "Timeout reached after {timeout}s."
|
||||
},
|
||||
"Waiting for CI checks to complete (timeout: {timeout}s)...": {
|
||||
"en": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"bg": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"de": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"pl": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"ru": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"zh": "Waiting for CI checks to complete (timeout: {timeout}s)..."
|
||||
},
|
||||
"\n[check_test_coverage] Fix: add the missing test file(s) before committing.": {
|
||||
"en": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||
"bg": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||
"de": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||
"pl": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||
"ru": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||
"zh": "\n[check_test_coverage] Fix: add the missing test file(s) before committing."
|
||||
},
|
||||
"Package owner not specified. Use --owner or set [tool.devx] repo_owner.": {
|
||||
"en": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||
"bg": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||
"de": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||
"pl": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||
"ru": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||
"zh": "Package owner not specified. Use --owner or set [tool.devx] repo_owner."
|
||||
},
|
||||
"Configuration validation failed.": {
|
||||
"en": "Configuration validation failed.",
|
||||
"bg": "Configuration validation failed.",
|
||||
"de": "Configuration validation failed.",
|
||||
"pl": "Configuration validation failed.",
|
||||
"ru": "Configuration validation failed.",
|
||||
"zh": "Configuration validation failed."
|
||||
},
|
||||
"Missing tests for changed files.": {
|
||||
"en": "Missing tests for changed files.",
|
||||
"bg": "Missing tests for changed files.",
|
||||
"de": "Missing tests for changed files.",
|
||||
"pl": "Missing tests for changed files.",
|
||||
"ru": "Missing tests for changed files.",
|
||||
"zh": "Missing tests for changed files."
|
||||
},
|
||||
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.": {
|
||||
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||
"bg": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||
"de": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||
"pl": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'."
|
||||
},
|
||||
"--checklist-categories must list at least 8 of 13 categories. Got {count}.": {
|
||||
"en": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||
"bg": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||
"de": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||
"pl": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||
"ru": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||
"zh": "--checklist-categories must list at least 8 of 13 categories. Got {count}."
|
||||
},
|
||||
"--checklist-confirmed is required for APPROVE events.": {
|
||||
"en": "--checklist-confirmed is required for APPROVE events.",
|
||||
"bg": "--checklist-confirmed is required for APPROVE events.",
|
||||
"de": "--checklist-confirmed is required for APPROVE events.",
|
||||
"pl": "--checklist-confirmed is required for APPROVE events.",
|
||||
"ru": "--checklist-confirmed is required for APPROVE events.",
|
||||
"zh": "--checklist-confirmed is required for APPROVE events."
|
||||
},
|
||||
"Invalid checklist category: {cat}. Must be numbers.": {
|
||||
"en": "Invalid checklist category: {cat}. Must be numbers.",
|
||||
"bg": "Invalid checklist category: {cat}. Must be numbers.",
|
||||
"de": "Invalid checklist category: {cat}. Must be numbers.",
|
||||
"pl": "Invalid checklist category: {cat}. Must be numbers.",
|
||||
"ru": "Invalid checklist category: {cat}. Must be numbers.",
|
||||
"zh": "Invalid checklist category: {cat}. Must be numbers."
|
||||
},
|
||||
"Items input must be a JSON array, got {type}": {
|
||||
"bg": "Входните данни трябва да са JSON масив, получено {type}",
|
||||
"de": "Eingabe muss ein JSON-Array sein, erhalten {type}",
|
||||
"en": "Items input must be a JSON array, got {type}",
|
||||
"pl": "Dane wejściowe muszą być tablicą JSON, otrzymano {type}",
|
||||
"ru": "Входные данные должны быть JSON-массивом, получено {type}",
|
||||
"zh": "输入必须是 JSON 数组,得到 {type}"
|
||||
},
|
||||
"Review body must be at least 50 characters.": {
|
||||
"en": "Review body must be at least 50 characters.",
|
||||
"bg": "Review body must be at least 50 characters.",
|
||||
"de": "Review body must be at least 50 characters.",
|
||||
"pl": "Review body must be at least 50 characters.",
|
||||
"ru": "Review body must be at least 50 characters.",
|
||||
"zh": "Review body must be at least 50 characters."
|
||||
},
|
||||
" - Block admin merge override: yes": {
|
||||
"bg": " - Блокиране на admin merge override: да",
|
||||
"de": " - Admin-Merge-Override blockieren: ja",
|
||||
"en": " - Block admin merge override: yes",
|
||||
"pl": " - Blokuj admin merge override: tak",
|
||||
"ru": " - Блокировать admin merge override: да",
|
||||
"zh": " - 阻止管理员合并覆盖:是"
|
||||
}
|
||||
}
|
||||
|
||||
+123
-25
@@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from devx.api_clients import GiteaClient, VikunjaClient, _is_retryable, _parse_error
|
||||
from devx.api_clients import GiteaClient, VikunjaClient, _parse_error
|
||||
from devx.config import (
|
||||
DEFAULT_PER_PAGE,
|
||||
DEFAULT_TIMEOUT,
|
||||
@@ -507,7 +507,7 @@ class TestGiteaClient:
|
||||
assert result["id"] == 1
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
@patch("time.sleep")
|
||||
def test_request_retries_on_429(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should retry on 429 rate limit with exponential backoff."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
@@ -520,7 +520,7 @@ class TestGiteaClient:
|
||||
assert client._session.request.call_count == 3
|
||||
assert mock_sleep.call_count == 2
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
@patch("time.sleep")
|
||||
def test_request_retries_on_503(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should retry on 503 service unavailable."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
@@ -532,7 +532,7 @@ class TestGiteaClient:
|
||||
assert result.json() == {"ok": True}
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
@patch("time.sleep")
|
||||
def test_request_no_retry_on_404(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should NOT retry on 404 — it's not a transient error."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
@@ -545,7 +545,7 @@ class TestGiteaClient:
|
||||
assert client._session.request.call_count == 1
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
@patch("time.sleep")
|
||||
def test_request_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should retry on connection errors."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
@@ -555,7 +555,7 @@ class TestGiteaClient:
|
||||
assert result.json() == {"ok": True}
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
@patch("time.sleep")
|
||||
def test_request_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should raise APIError after max retries on persistent 503."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
@@ -567,7 +567,7 @@ class TestGiteaClient:
|
||||
assert exc_info.value.status == 503
|
||||
assert client._session.request.call_count == 3 # MAX_RETRIES
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
@patch("time.sleep")
|
||||
def test_request_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should raise APIError after max retries on persistent connection errors."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
@@ -691,7 +691,7 @@ class TestVikunjaClient:
|
||||
json={"id": 42, "title": "My task", "done": True},
|
||||
)
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
@patch("time.sleep")
|
||||
def test_http_error_raises_api_error(self, mock_sleep: MagicMock) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
mock_resp = MagicMock()
|
||||
@@ -713,7 +713,7 @@ class TestVikunjaClient:
|
||||
client.list_tasks()
|
||||
assert "connection failed" in str(exc_info.value)
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
@patch("time.sleep")
|
||||
def test_vikunja_retries_on_503(self, mock_sleep: MagicMock) -> None:
|
||||
"""VikunjaClient should also retry on 503."""
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
@@ -725,7 +725,7 @@ class TestVikunjaClient:
|
||||
assert len(result) == 1
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
@patch("time.sleep")
|
||||
def test_vikunja_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
|
||||
"""VikunjaClient should retry on connection errors."""
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
@@ -735,7 +735,7 @@ class TestVikunjaClient:
|
||||
assert len(result) == 1
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
@patch("time.sleep")
|
||||
def test_vikunja_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||
"""VikunjaClient should raise APIError after max retries on persistent 503."""
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
@@ -747,7 +747,7 @@ class TestVikunjaClient:
|
||||
assert exc_info.value.status == 503
|
||||
assert client._session.request.call_count == 3 # MAX_RETRIES
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
@patch("time.sleep")
|
||||
def test_vikunja_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||
"""VikunjaClient should raise APIError after max retries on persistent connection errors."""
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
@@ -781,21 +781,119 @@ class TestVikunjaClient:
|
||||
call_kwargs = client._session.request.call_args.kwargs
|
||||
assert call_kwargs["json"]["description"] == ""
|
||||
|
||||
def test_find_task_by_identifier_found(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response([{"identifier": "DEVX-1"}, {"identifier": "DEVX-42", "title": "Found"}])
|
||||
)
|
||||
result = client.find_task_by_identifier(6, "DEVX-42", per_page=50)
|
||||
assert result is not None
|
||||
assert result["title"] == "Found"
|
||||
|
||||
class TestIsRetryable:
|
||||
def test_connection_error_is_retryable(self) -> None:
|
||||
assert _is_retryable(requests.ConnectionError("refused")) is True
|
||||
def test_find_task_by_identifier_not_found(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response([{"identifier": "DEVX-1"}, {"identifier": "DEVX-2"}])
|
||||
)
|
||||
result = client.find_task_by_identifier(6, "DEVX-99", per_page=50)
|
||||
assert result is None
|
||||
|
||||
def test_timeout_is_retryable(self) -> None:
|
||||
assert _is_retryable(requests.Timeout("timed out")) is True
|
||||
def test_find_task_by_identifier_empty_project(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(return_value=_mock_response([]))
|
||||
result = client.find_task_by_identifier(6, "DEVX-1", per_page=50)
|
||||
assert result is None
|
||||
|
||||
def test_429_is_retryable(self) -> None:
|
||||
err = _mock_http_error(429, "rate limited")
|
||||
assert _is_retryable(err) is True
|
||||
def test_find_task_by_identifier_paginates(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
full_page = [{"identifier": f"DEVX-{i}"} for i in range(50)]
|
||||
client._session.request = MagicMock(
|
||||
side_effect=[
|
||||
_mock_response(full_page),
|
||||
_mock_response([{"identifier": "DEVX-50", "title": "Found on page 2"}]),
|
||||
]
|
||||
)
|
||||
result = client.find_task_by_identifier(6, "DEVX-50", per_page=50)
|
||||
assert result is not None
|
||||
assert result["title"] == "Found on page 2"
|
||||
|
||||
def test_404_is_not_retryable(self) -> None:
|
||||
err = _mock_http_error(404, "not found")
|
||||
assert _is_retryable(err) is False
|
||||
|
||||
def test_generic_exception_is_not_retryable(self) -> None:
|
||||
assert _is_retryable(ValueError("oops")) is False
|
||||
class TestGiteaClientPrLabels:
|
||||
def test_add_pr_label(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({}))
|
||||
client.add_pr_label(42, ["ready-to-merge"])
|
||||
client._session.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://git.example.com/repos/owner/repo/issues/42/labels",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json={"labels": ["ready-to-merge"]},
|
||||
)
|
||||
|
||||
def test_add_pr_label_multiple(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({}))
|
||||
client.add_pr_label(42, ["ready-to-merge", "reviewed"])
|
||||
call_kwargs = client._session.request.call_args.kwargs
|
||||
assert call_kwargs["json"]["labels"] == ["ready-to-merge", "reviewed"]
|
||||
|
||||
def test_get_pr_label_names(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response([{"name": "bug"}, {"name": "ready-to-merge"}]))
|
||||
result = client.get_pr_label_names(42)
|
||||
assert result == ["bug", "ready-to-merge"]
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/issues/42/labels",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
class TestGiteaClientActions:
|
||||
def test_list_action_runs(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response({"workflow_runs": [{"id": 1, "status": "completed"}], "total_count": 1})
|
||||
)
|
||||
result = client.list_action_runs(branch="feature-branch", limit=1)
|
||||
assert result["total_count"] == 1
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/actions/runs",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
params={"branch": "feature-branch", "limit": 1},
|
||||
)
|
||||
|
||||
def test_get_action_run_jobs(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response({"jobs": [{"id": 100, "name": "quality", "conclusion": "failure"}]})
|
||||
)
|
||||
result = client.get_action_run_jobs(1410)
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "quality"
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/actions/runs/1410/jobs",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_get_action_run_jobs_empty(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({}))
|
||||
result = client.get_action_run_jobs(1410)
|
||||
assert result == []
|
||||
|
||||
def test_get_action_job_logs(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.text = "log line 1\nlog line 2"
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
client._session.request = MagicMock(return_value=mock_resp)
|
||||
result = client.get_action_job_logs(10026)
|
||||
assert "log line 1" in result
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/actions/jobs/10026/logs",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
@@ -6,12 +6,12 @@ import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci._shared import run_cmd
|
||||
from devx.ci.auto_merge import (
|
||||
extract_conventional_msg,
|
||||
extract_task_id,
|
||||
main,
|
||||
read_taskid,
|
||||
run_cmd,
|
||||
validate_pr_title,
|
||||
validate_pr_title_matches_vikunja,
|
||||
)
|
||||
@@ -195,6 +195,29 @@ class TestExtractConventionalMsg:
|
||||
]
|
||||
assert extract_conventional_msg(commits) == "feat: add feature"
|
||||
|
||||
def test_prefers_feat_over_refactor(self) -> None:
|
||||
"""When both feat and refactor commits exist, feat wins."""
|
||||
commits = [
|
||||
{"commit": {"message": "refactor: add find_task_by_identifier"}},
|
||||
{"commit": {"message": "fix: remove hardcoded fallbacks"}},
|
||||
{"commit": {"message": "feat: add manual review support"}},
|
||||
]
|
||||
assert extract_conventional_msg(commits) == "feat: add manual review support"
|
||||
|
||||
def test_prefers_fix_over_docs(self) -> None:
|
||||
commits = [
|
||||
{"commit": {"message": "docs: update README"}},
|
||||
{"commit": {"message": "fix: resolve bug"}},
|
||||
]
|
||||
assert extract_conventional_msg(commits) == "fix: resolve bug"
|
||||
|
||||
def test_scope_in_prefix(self) -> None:
|
||||
commits = [
|
||||
{"commit": {"message": "refactor(ci): cleanup code"}},
|
||||
{"commit": {"message": "feat(api): add endpoint"}},
|
||||
]
|
||||
assert extract_conventional_msg(commits) == "feat(api): add endpoint"
|
||||
|
||||
|
||||
# -- run_cmd --
|
||||
|
||||
@@ -217,7 +240,7 @@ class TestRunCmd:
|
||||
|
||||
|
||||
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.GiteaClient")
|
||||
def test_full_merge_flow(
|
||||
@@ -239,14 +262,14 @@ class TestMain:
|
||||
assert result.exit_code == 0, result.output
|
||||
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:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["DEVX-19-fix", "DEVX-19: test", "owner/repo", "7"])
|
||||
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")
|
||||
def test_no_task_id_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
@@ -256,7 +279,7 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
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")
|
||||
def test_invalid_pr_title_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
@@ -266,7 +289,7 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
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.GiteaClient")
|
||||
def test_merge_behind_master_raises_no_rebase(
|
||||
@@ -298,7 +321,7 @@ class TestMain:
|
||||
# Must NOT have called merge_pr twice (no retry after rebase)
|
||||
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.GiteaClient")
|
||||
def test_merge_failure_raises(
|
||||
@@ -321,7 +344,7 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
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.GiteaClient")
|
||||
def test_no_conventional_msg_raises(
|
||||
@@ -342,7 +365,7 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
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:
|
||||
"""Non-integer PR number should raise."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
@@ -351,7 +374,7 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
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:
|
||||
"""Repo without owner/name should raise."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
@@ -360,7 +383,7 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
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.GiteaClient")
|
||||
def test_merge_behind_master_does_not_force_push(
|
||||
@@ -376,7 +399,7 @@ class TestMain:
|
||||
mock_client.merge_pr.side_effect = APIError(405, "HEAD branch is behind master")
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("devx.ci.auto_merge.run_cmd") as mock_run:
|
||||
with patch("devx.ci._shared.run_cmd") as mock_run:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
|
||||
+170
-16
@@ -321,6 +321,25 @@ class TestCleanImagesAPI:
|
||||
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) as mock_del:
|
||||
assert (
|
||||
delete_package_version(
|
||||
"https://git.example.com/api/v1",
|
||||
"oblachno-oss",
|
||||
"ci-base",
|
||||
"0.1.0",
|
||||
"token",
|
||||
)
|
||||
is True
|
||||
)
|
||||
# Verify URL includes container type
|
||||
url = mock_del.call_args.args[0]
|
||||
assert "/container/" in url
|
||||
|
||||
def test_delete_package_version_404_treated_as_success(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(
|
||||
@@ -336,7 +355,7 @@ class TestCleanImagesAPI:
|
||||
def test_delete_package_version_failure(self) -> None:
|
||||
from devx.tools.clean_images import delete_package_version
|
||||
|
||||
mock_resp = MagicMock(status_code=404)
|
||||
mock_resp = MagicMock(status_code=403)
|
||||
with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp):
|
||||
assert (
|
||||
delete_package_version(
|
||||
@@ -349,6 +368,110 @@ class TestCleanImagesAPI:
|
||||
is False
|
||||
)
|
||||
|
||||
def test_delete_package_version_retries_on_5xx(self) -> None:
|
||||
from devx.tools.clean_images import delete_package_version
|
||||
|
||||
responses = [
|
||||
MagicMock(status_code=500),
|
||||
MagicMock(status_code=502),
|
||||
MagicMock(status_code=204),
|
||||
]
|
||||
with patch("devx.tools.clean_images.requests.delete", side_effect=responses):
|
||||
with patch("devx.tools.clean_images.time.sleep"):
|
||||
assert (
|
||||
delete_package_version(
|
||||
"https://git.example.com/api/v1",
|
||||
"oblachno-oss",
|
||||
"ci-base",
|
||||
"0.1.0",
|
||||
"token",
|
||||
max_retries=3,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_delete_package_version_retries_on_exception(self) -> None:
|
||||
import requests as req
|
||||
|
||||
from devx.tools.clean_images import delete_package_version
|
||||
|
||||
responses = [
|
||||
req.ConnectionError("network down"),
|
||||
MagicMock(status_code=204),
|
||||
]
|
||||
with patch("devx.tools.clean_images.requests.delete", side_effect=responses):
|
||||
with patch("devx.tools.clean_images.time.sleep"):
|
||||
assert (
|
||||
delete_package_version(
|
||||
"https://git.example.com/api/v1",
|
||||
"oblachno-oss",
|
||||
"ci-base",
|
||||
"0.1.0",
|
||||
"token",
|
||||
max_retries=3,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_delete_package_version_exhausts_retries_on_exception(self) -> None:
|
||||
import requests as req
|
||||
|
||||
from devx.tools.clean_images import delete_package_version
|
||||
|
||||
with patch(
|
||||
"devx.tools.clean_images.requests.delete",
|
||||
side_effect=req.ConnectionError("network down"),
|
||||
):
|
||||
with patch("devx.tools.clean_images.time.sleep"):
|
||||
assert (
|
||||
delete_package_version(
|
||||
"https://git.example.com/api/v1",
|
||||
"oblachno-oss",
|
||||
"ci-base",
|
||||
"0.1.0",
|
||||
"token",
|
||||
max_retries=2,
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_delete_package_version_exhausts_retries_on_5xx(self) -> None:
|
||||
from devx.tools.clean_images import delete_package_version
|
||||
|
||||
with patch(
|
||||
"devx.tools.clean_images.requests.delete",
|
||||
return_value=MagicMock(status_code=500),
|
||||
):
|
||||
with patch("devx.tools.clean_images.time.sleep"):
|
||||
assert (
|
||||
delete_package_version(
|
||||
"https://git.example.com/api/v1",
|
||||
"oblachno-oss",
|
||||
"ci-base",
|
||||
"0.1.0",
|
||||
"token",
|
||||
max_retries=2,
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_delete_package_version_zero_retries(self) -> None:
|
||||
from devx.tools.clean_images import delete_package_version
|
||||
|
||||
with patch("devx.tools.clean_images.requests.delete") as mock_del:
|
||||
assert (
|
||||
delete_package_version(
|
||||
"https://git.example.com/api/v1",
|
||||
"oblachno-oss",
|
||||
"ci-base",
|
||||
"0.1.0",
|
||||
"token",
|
||||
max_retries=0,
|
||||
)
|
||||
is False
|
||||
)
|
||||
mock_del.assert_not_called()
|
||||
|
||||
|
||||
class TestCLIBuildImage:
|
||||
def test_single_image_build(self, tmp_path: Path) -> None:
|
||||
@@ -442,7 +565,7 @@ class TestCLIBuildImage:
|
||||
dockerfile.touch()
|
||||
runner = CliRunner()
|
||||
login_result = MagicMock(returncode=1, stderr="auth failed", stdout="")
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "fake", "REGISTRY_USERNAME": "user"}):
|
||||
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,
|
||||
@@ -458,7 +581,7 @@ class TestCLIBuildImage:
|
||||
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", {"REPO_TOKEN": "fake", "REGISTRY_USERNAME": "user"}):
|
||||
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],
|
||||
@@ -482,7 +605,7 @@ class TestCLICleanImages:
|
||||
{"version": "0.3.0", "created_at": "2025-03-01"},
|
||||
]
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}):
|
||||
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,
|
||||
@@ -511,7 +634,7 @@ class TestCLICleanImages:
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = []
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}):
|
||||
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,
|
||||
@@ -532,7 +655,7 @@ class TestCLICleanImages:
|
||||
]
|
||||
list_resp.raise_for_status = MagicMock()
|
||||
delete_resp = MagicMock(status_code=204)
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}):
|
||||
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(
|
||||
@@ -548,7 +671,7 @@ class TestCLICleanImages:
|
||||
from devx.tools.clean_images import main as clean_main
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "fake"}):
|
||||
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
|
||||
with patch(
|
||||
"devx.tools.clean_images.requests.get",
|
||||
side_effect=req.ConnectionError("network down"),
|
||||
@@ -557,7 +680,7 @@ class TestCLICleanImages:
|
||||
clean_main,
|
||||
["--owner", "oblachno-oss", "--name", "ci-base", "--dry-run"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert result.exit_code != 0
|
||||
assert "Failed to list" in result.output
|
||||
|
||||
def test_delete_failure_in_cli(self) -> None:
|
||||
@@ -571,13 +694,44 @@ class TestCLICleanImages:
|
||||
{"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", {"REPO_TOKEN": "fake"}):
|
||||
delete_resp = MagicMock(status_code=403)
|
||||
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
|
||||
with patch("devx.tools.clean_images.time.sleep"):
|
||||
result = runner.invoke(
|
||||
clean_main,
|
||||
["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "2"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "FAILED" in result.output
|
||||
|
||||
@patch("devx.tools.clean_images.REPO_OWNER", "oblachno-oss")
|
||||
def test_owner_from_config(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,
|
||||
["--name", "ci-base", "--dry-run"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "oblachno-oss/ci-base" in result.output
|
||||
|
||||
@patch("devx.tools.clean_images.REPO_OWNER", "")
|
||||
def test_no_owner_raises(self) -> None:
|
||||
from devx.tools.clean_images import main as clean_main
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
|
||||
result = runner.invoke(
|
||||
clean_main,
|
||||
["--name", "ci-base"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "owner" in result.output.lower()
|
||||
|
||||
@@ -78,7 +78,7 @@ class TestGetPrTitleFromGitea:
|
||||
assert get_pr_title_from_gitea("owner/repo", 1) is 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
|
||||
|
||||
@patch("devx.ci.check_auto_merge_ready.GiteaClient")
|
||||
@@ -86,16 +86,18 @@ class TestGetPrTitleFromGitea:
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr.return_value = {"title": "DEVX-1: Fix bug"}
|
||||
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)
|
||||
assert result == "DEVX-1: Fix bug"
|
||||
|
||||
@patch("devx.ci.check_auto_merge_ready.GiteaClient")
|
||||
def test_returns_none_on_exception(self, mock_client_cls: MagicMock) -> None:
|
||||
from devx.exceptions import APIError
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr.side_effect = Exception("API error")
|
||||
mock_client.get_pr.side_effect = APIError(500, "API error")
|
||||
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)
|
||||
assert result is None
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.check_test_coverage import (
|
||||
BUILTIN_RULES,
|
||||
DEFAULT_SKIP_EXTENSIONS,
|
||||
@@ -13,7 +15,7 @@ from devx.tools.check_test_coverage import (
|
||||
_load_rules,
|
||||
_resolve_test_path,
|
||||
_should_skip_file,
|
||||
main,
|
||||
cli,
|
||||
)
|
||||
|
||||
|
||||
@@ -201,7 +203,10 @@ class TestMain:
|
||||
),
|
||||
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
assert main([]) == 0
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 0
|
||||
assert "No changed files" in result.output
|
||||
|
||||
def test_all_have_tests(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "scripts" / "tests").mkdir(parents=True)
|
||||
@@ -214,7 +219,10 @@ class TestMain:
|
||||
),
|
||||
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
assert main([]) == 0
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 0
|
||||
assert "have tests" in result.output
|
||||
|
||||
def test_missing_test_returns_1(self, tmp_path: Path) -> None:
|
||||
with (
|
||||
@@ -225,7 +233,9 @@ class TestMain:
|
||||
),
|
||||
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
assert main([]) == 1
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_warn_only_returns_0(self, tmp_path: Path) -> None:
|
||||
with (
|
||||
@@ -236,4 +246,6 @@ class TestMain:
|
||||
),
|
||||
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
assert main(["--warn-only"]) == 0
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--warn-only"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@@ -248,8 +248,8 @@ class TestDevxI18n:
|
||||
import devx.i18n
|
||||
|
||||
importlib.reload(devx.i18n)
|
||||
# "ERROR: REPO_TOKEN is not set." has a German translation
|
||||
result = devx.i18n._("ERROR: REPO_TOKEN is not set.")
|
||||
# "ERROR: CI_GITEA_TOKEN is not set." has a German translation
|
||||
result = devx.i18n._("ERROR: CI_GITEA_TOKEN is not set.")
|
||||
assert "FEHLER" in result
|
||||
|
||||
# Restore
|
||||
|
||||
@@ -264,6 +264,16 @@ class TestChangeClassifier:
|
||||
assert fc.is_user_facing
|
||||
assert fc.matched_rule == "user_facing_overrides"
|
||||
|
||||
def test_user_facing_override_glob_matches_nested(self) -> None:
|
||||
"""User-facing overrides support glob patterns like infrastructure."""
|
||||
classifier = self._make_classifier(
|
||||
infrastructure=[".gitea/**"],
|
||||
user_facing_overrides=[".gitea/**"],
|
||||
)
|
||||
fc = classifier.classify_file(".gitea/workflows/ci.yml")
|
||||
assert fc.is_user_facing
|
||||
assert fc.matched_rule == "user_facing_overrides"
|
||||
|
||||
def test_user_facing_override_beats_infrastructure_override(self) -> None:
|
||||
"""User-facing overrides beat infrastructure overrides (safety first)."""
|
||||
classifier = self._make_classifier(
|
||||
@@ -489,7 +499,7 @@ class TestMain:
|
||||
@patch("devx.ci.classify_changes.get_changed_files")
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_workflow_only_exits_2(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
mock_changes.return_value = [".gitea/workflows/ci.yml", "docs/index.md"]
|
||||
mock_changes.return_value = ["docs/index.md", "README.md"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 2
|
||||
@@ -572,7 +582,7 @@ class TestMain:
|
||||
@patch("devx.ci.classify_changes.get_changed_files")
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_quiet_workflow_only(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
mock_changes.return_value = [".gitea/workflows/ci.yml"]
|
||||
mock_changes.return_value = ["docs/index.md"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--quiet"])
|
||||
assert result.exit_code == 0
|
||||
@@ -631,7 +641,7 @@ class TestMain:
|
||||
@patch("devx.ci.classify_changes.get_changed_files")
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_check_user_facing_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
mock_changes.return_value = [".gitea/workflows/ci.yml", "tests/test_foo.py"]
|
||||
mock_changes.return_value = ["docs/index.md", "tests/test_foo.py"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--check", "user-facing", "--quiet"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@@ -32,9 +32,10 @@ class TestDefaultConfigs:
|
||||
assert config["branch_name"] == "master"
|
||||
assert config["enable_push"] is True
|
||||
assert config["enable_push_whitelist"] is False
|
||||
assert config["required_approvals"] == 0
|
||||
assert config["required_approvals"] == 1
|
||||
assert isinstance(config["status_check_contexts"], list)
|
||||
assert "CI / quality (pull_request)" in config["status_check_contexts"]
|
||||
assert config["block_admin_merge_override"] is True
|
||||
|
||||
def test_default_repo_settings_config(self) -> None:
|
||||
config = _default_repo_settings_config()
|
||||
@@ -47,7 +48,7 @@ class TestDefaultConfigs:
|
||||
|
||||
|
||||
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")
|
||||
def test_configure_repo_success(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
@@ -58,7 +59,7 @@ class TestConfigureRepo:
|
||||
mock_client.ensure_branch_protection.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")
|
||||
def test_configure_repo_api_error(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
@@ -69,10 +70,10 @@ class TestConfigureRepo:
|
||||
configure_repo(token="tok", owner="owner", repo="repo")
|
||||
|
||||
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")
|
||||
|
||||
@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")
|
||||
def test_configure_repo_custom_configs(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
@@ -102,7 +103,7 @@ class TestConfigureRepo:
|
||||
|
||||
|
||||
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")
|
||||
def test_main_success_with_env_repo(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
@@ -114,7 +115,7 @@ class TestMain:
|
||||
mock_client.ensure_branch_protection.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")
|
||||
def test_main_success_with_cli_repo(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
@@ -125,7 +126,7 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
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")
|
||||
def test_main_api_error(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
@@ -142,16 +143,17 @@ class TestMain:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--repo", "myrepo"])
|
||||
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.tools.configure_repo.REPO_NAME", "")
|
||||
def test_main_no_repo(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code != 0
|
||||
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")
|
||||
def test_main_custom_branch(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
@@ -164,7 +166,7 @@ class TestMain:
|
||||
args = mock_client.ensure_branch_protection.call_args
|
||||
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")
|
||||
def test_main_parses_owner_repo_from_env(self, mock_client_cls: MagicMock) -> None:
|
||||
"""DEVX_REPO_NAME with 'owner/repo' format should be split."""
|
||||
@@ -181,7 +183,7 @@ class TestMain:
|
||||
|
||||
@patch.dict(
|
||||
"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,
|
||||
)
|
||||
@patch("devx.tools.configure_repo.REPO_OWNER", "oblachno")
|
||||
@@ -197,3 +199,19 @@ class TestMain:
|
||||
call_args = mock_client_cls.call_args
|
||||
assert call_args[0][2] == "oblachno" # owner
|
||||
assert call_args[0][3] == "infra" # repo
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.tools.configure_repo.REPO_NAME", "devx")
|
||||
@patch("devx.tools.configure_repo.REPO_OWNER", "oblachno-oss")
|
||||
@patch("devx.tools.configure_repo.GiteaClient")
|
||||
def test_main_repo_from_pyproject(self, mock_client_cls: MagicMock) -> None:
|
||||
"""When no env var is set, repo name should come from pyproject.toml."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
call_args = mock_client_cls.call_args
|
||||
assert call_args[0][2] == "oblachno-oss" # owner
|
||||
assert call_args[0][3] == "devx" # repo
|
||||
|
||||
@@ -29,10 +29,22 @@ class TestGetRepoName:
|
||||
def test_from_env(self) -> None:
|
||||
assert get_repo_name() == "infra"
|
||||
|
||||
@patch("devx.tools.create_pr.REPO_NAME", "devx")
|
||||
@patch.dict("os.environ", {"GITHUB_REPOSITORY": "oblachno/infra"}, clear=True)
|
||||
def test_env_overrides_pyproject(self) -> None:
|
||||
assert get_repo_name() == "infra"
|
||||
|
||||
@patch("devx.tools.create_pr.REPO_NAME", "devx")
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_from_pyproject(self) -> None:
|
||||
assert get_repo_name() == "devx"
|
||||
|
||||
@patch("devx.tools.create_pr.REPO_NAME", "")
|
||||
@patch.dict("os.environ", {"GITHUB_REPOSITORY": "oblachno/infra"}, clear=True)
|
||||
def test_from_github(self) -> None:
|
||||
assert get_repo_name() == "infra"
|
||||
|
||||
@patch("devx.tools.create_pr.REPO_NAME", "")
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_missing_raises(self) -> None:
|
||||
with pytest.raises(click.ClickException, match="Repository name"):
|
||||
@@ -44,7 +56,7 @@ class TestGetVikunjaTaskTitle:
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_found(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-42", "title": "Add feature"}]
|
||||
mock_client.find_task_by_identifier.return_value = {"identifier": "DEVX-42", "title": "Add feature"}
|
||||
mock_client_cls.return_value = mock_client
|
||||
assert get_vikunja_task_title("DEVX-42") == "Add feature"
|
||||
|
||||
@@ -57,20 +69,7 @@ class TestGetVikunjaTaskTitle:
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_not_found(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = []
|
||||
mock_client_cls.return_value = mock_client
|
||||
with pytest.raises(click.ClickException, match="Could not find"):
|
||||
get_vikunja_task_title("DEVX-42")
|
||||
|
||||
@patch("devx.tools.create_pr.VikunjaClient")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_pagination_not_found(self, mock_client_cls: MagicMock) -> None:
|
||||
from devx.config import DEFAULT_PER_PAGE
|
||||
|
||||
mock_client = MagicMock()
|
||||
page1 = [{"identifier": f"OTHER-{i}"} for i in range(DEFAULT_PER_PAGE)]
|
||||
page2 = [{"identifier": "OTHER-99"}]
|
||||
mock_client.list_project_tasks.side_effect = [page1, page2]
|
||||
mock_client.find_task_by_identifier.return_value = None
|
||||
mock_client_cls.return_value = mock_client
|
||||
with pytest.raises(click.ClickException, match="Could not find"):
|
||||
get_vikunja_task_title("DEVX-42")
|
||||
@@ -95,7 +94,7 @@ class TestCreatePr:
|
||||
@patch("devx.tools.create_pr.GiteaClient")
|
||||
@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.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:
|
||||
mock_client = MagicMock()
|
||||
mock_client.create_pr.return_value = {"number": 15, "html_url": "https://git.example.com/pr/15"}
|
||||
@@ -112,7 +111,7 @@ class TestCreatePr:
|
||||
@patch("devx.tools.create_pr.GiteaClient")
|
||||
@patch("devx.tools.create_pr.get_vikunja_task_title", return_value="Add feature")
|
||||
@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:
|
||||
mock_find.return_value = {"number": 10, "html_url": "https://git.example.com/pr/10"}
|
||||
mock_client = MagicMock()
|
||||
@@ -123,10 +122,10 @@ class TestCreatePr:
|
||||
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
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")
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
def test_no_task_id_in_branch(self) -> None:
|
||||
with pytest.raises(click.ClickException, match="does not contain a task ID"):
|
||||
create_pr("feature-branch", "master", "", "owner", "repo")
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Unit tests for devx.ci.distribute_items."""
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.distribute_items import (
|
||||
DEFAULT_WEIGHT,
|
||||
distribute,
|
||||
items_for_runner,
|
||||
main,
|
||||
parse_items,
|
||||
parse_weighted_items,
|
||||
)
|
||||
|
||||
|
||||
class TestParseItems:
|
||||
def test_string_array(self) -> None:
|
||||
assert parse_items('["a", "b", "c"]') == ["a", "b", "c"]
|
||||
|
||||
def test_object_array(self) -> None:
|
||||
raw = '[{"id": "a", "weight": 2}, {"id": "b"}]'
|
||||
assert parse_items(raw) == ["a", "b"]
|
||||
|
||||
def test_empty_array(self) -> None:
|
||||
assert parse_items("[]") == []
|
||||
|
||||
def test_not_an_array(self) -> None:
|
||||
with pytest.raises(Exception, match="must be a JSON array"):
|
||||
parse_items('{"key": "value"}')
|
||||
|
||||
def test_invalid_entry_type(self) -> None:
|
||||
with pytest.raises(Exception, match="must be a string or an object"):
|
||||
parse_items("[42]")
|
||||
|
||||
def test_object_without_id(self) -> None:
|
||||
with pytest.raises(Exception, match="must be a string or an object"):
|
||||
parse_items('[{"weight": 2}]')
|
||||
|
||||
|
||||
class TestParseWeightedItems:
|
||||
def test_string_array_default_weights(self) -> None:
|
||||
items, weights = parse_weighted_items('["a", "b"]')
|
||||
assert items == ["a", "b"]
|
||||
assert weights == [DEFAULT_WEIGHT, DEFAULT_WEIGHT]
|
||||
|
||||
def test_object_array_with_weights(self) -> None:
|
||||
items, weights = parse_weighted_items('[{"id": "a", "weight": 5}, {"id": "b", "weight": 1}]')
|
||||
assert items == ["a", "b"]
|
||||
assert weights == [5, 1]
|
||||
|
||||
def test_object_array_missing_weight(self) -> None:
|
||||
items, weights = parse_weighted_items('[{"id": "a"}]')
|
||||
assert items == ["a"]
|
||||
assert weights == [DEFAULT_WEIGHT]
|
||||
|
||||
def test_not_an_array(self) -> None:
|
||||
with pytest.raises(Exception, match="must be a JSON array"):
|
||||
parse_weighted_items('"hello"')
|
||||
|
||||
def test_invalid_entry(self) -> None:
|
||||
with pytest.raises(Exception, match="must be a string or an object"):
|
||||
parse_weighted_items("[true]")
|
||||
|
||||
|
||||
class TestDistribute:
|
||||
def test_even_split(self) -> None:
|
||||
items = [f"vm-{i}" for i in range(6)]
|
||||
weights = [1] * 6
|
||||
groups = distribute(items, weights, 3)
|
||||
assert len(groups) == 3
|
||||
assert all(len(g) == 2 for g in groups)
|
||||
|
||||
def test_uneven_split(self) -> None:
|
||||
items = [f"vm-{i}" for i in range(5)]
|
||||
weights = [1] * 5
|
||||
groups = distribute(items, weights, 3)
|
||||
assert len(groups[0]) == 2
|
||||
assert len(groups[1]) == 2
|
||||
assert len(groups[2]) == 1
|
||||
|
||||
def test_more_runners_than_items(self) -> None:
|
||||
items = ["vm-a"]
|
||||
weights = [1]
|
||||
groups = distribute(items, weights, 5)
|
||||
assert len(groups) == 5
|
||||
assert len(groups[0]) == 1
|
||||
assert all(len(g) == 0 for g in groups[1:])
|
||||
|
||||
def test_lpt_heavy_item_on_least_loaded(self) -> None:
|
||||
items = ["heavy", "light1", "light2", "light3"]
|
||||
weights = [10, 1, 1, 1]
|
||||
groups = distribute(items, weights, 2)
|
||||
# Heavy item goes to runner 0, lights go to runner 1 (least loaded)
|
||||
assert "heavy" in groups[0]
|
||||
# Runner 1 should have more items but less total weight
|
||||
assert len(groups[1]) >= 2
|
||||
|
||||
def test_empty_items(self) -> None:
|
||||
groups = distribute([], [], 3)
|
||||
assert len(groups) == 3
|
||||
assert all(len(g) == 0 for g in groups)
|
||||
|
||||
def test_single_runner(self) -> None:
|
||||
items = ["a", "b", "c"]
|
||||
weights = [1, 2, 3]
|
||||
groups = distribute(items, weights, 1)
|
||||
assert len(groups) == 1
|
||||
assert len(groups[0]) == 3
|
||||
|
||||
|
||||
class TestItemsForRunner:
|
||||
def test_returns_assigned_subset(self) -> None:
|
||||
items = ["a", "b", "c", "d", "e", "f"]
|
||||
weights = [1] * 6
|
||||
result = items_for_runner(items, weights, 0, 3)
|
||||
assert len(result) == 2
|
||||
assert all(item in items for item in result)
|
||||
|
||||
def test_out_of_range(self) -> None:
|
||||
with pytest.raises(Exception, match="out of range"):
|
||||
items_for_runner(["a"], [1], 5, 3)
|
||||
|
||||
def test_negative_index(self) -> None:
|
||||
with pytest.raises(Exception, match="out of range"):
|
||||
items_for_runner(["a"], [1], -1, 3)
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_stdin_string_array(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--runner-index", "1", "--max-runners", "2"], input='["a", "b", "c"]')
|
||||
assert result.exit_code == 0
|
||||
# LPT: heaviest first, so "a" goes to runner 0, "b" to runner 1, "c" to runner 0
|
||||
# All weights equal, so round-robin-ish: runner 0 gets "a","c"; runner 1 gets "b"
|
||||
assert "a" in result.output
|
||||
|
||||
def test_stdin_object_array(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--runner-index", "1", "--max-runners", "2"],
|
||||
input='[{"id": "a", "weight": 5}, {"id": "b", "weight": 1}]',
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "a" in result.output
|
||||
|
||||
def test_items_file(self, tmp_path: object) -> None:
|
||||
import pathlib
|
||||
|
||||
items_file = pathlib.Path(str(tmp_path)) / "items.json"
|
||||
items_file.write_text('["x", "y", "z"]')
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--items-file", str(items_file), "--runner-index", "1", "--max-runners", "3"])
|
||||
assert result.exit_code == 0
|
||||
assert "x" in result.output
|
||||
|
||||
def test_print_all_groups_no_runner_index(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--max-runners", "2"], input='["a", "b"]')
|
||||
assert result.exit_code == 0
|
||||
assert "Runner 0:" in result.output
|
||||
assert "Runner 1:" in result.output
|
||||
|
||||
def test_empty_stdin(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--runner-index", "1", "--max-runners", "3"], input="")
|
||||
assert result.exit_code == 0
|
||||
# Empty input → empty assigned items
|
||||
assert result.output.strip() == ""
|
||||
|
||||
def test_github_env(self, tmp_path: object, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import pathlib
|
||||
|
||||
gh_env = pathlib.Path(str(tmp_path)) / "gh_env"
|
||||
gh_env.write_text("")
|
||||
monkeypatch.setenv("GITHUB_ENV", str(gh_env))
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--runner-index", "1", "--max-runners", "2", "--github-env"],
|
||||
input='["a", "b"]',
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
content = gh_env.read_text()
|
||||
assert "ASSIGNED_ITEMS=" in content
|
||||
assert "SKIP=false" in content
|
||||
|
||||
def test_skip_if_excess(self, tmp_path: object, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import pathlib
|
||||
|
||||
gh_env = pathlib.Path(str(tmp_path)) / "gh_env"
|
||||
gh_env.write_text("")
|
||||
monkeypatch.setenv("GITHUB_ENV", str(gh_env))
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--runner-index", "5", "--max-runners", "3", "--github-env", "--skip-if-excess"],
|
||||
input='["a"]',
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
content = gh_env.read_text()
|
||||
assert "ASSIGNED_ITEMS=" in content
|
||||
assert "SKIP=true" in content
|
||||
|
||||
def test_runner_index_zero(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--runner-index", "0"], input='["a"]')
|
||||
assert result.exit_code != 0
|
||||
assert "out of range" in result.output
|
||||
|
||||
def test_default_max_runners(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--runner-index", "1"], input='["a"]')
|
||||
assert result.exit_code == 0
|
||||
assert "a" in result.output
|
||||
|
||||
def test_github_env_not_set(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("GITHUB_ENV", raising=False)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--runner-index", "1", "--github-env"],
|
||||
input='["a"]',
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "GITHUB_ENV" in result.output
|
||||
|
||||
def test_invalid_json(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--runner-index", "1"], input="not json")
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_multiline_github_env(self, tmp_path: object, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import pathlib
|
||||
|
||||
gh_env = pathlib.Path(str(tmp_path)) / "gh_env"
|
||||
gh_env.write_text("")
|
||||
monkeypatch.setenv("GITHUB_ENV", str(gh_env))
|
||||
runner = CliRunner()
|
||||
# Items with newlines in their IDs would trigger multiline syntax
|
||||
# Normal items don't have newlines, but test the path anyway
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--runner-index", "1", "--max-runners", "1", "--github-env"],
|
||||
input='["a\\nb"]',
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
content = gh_env.read_text()
|
||||
# Item "a\nb" contains a newline → heredoc syntax
|
||||
assert "ASSIGNED_ITEMS<<" in content
|
||||
@@ -7,7 +7,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
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:
|
||||
@@ -359,3 +359,37 @@ class TestWhoami:
|
||||
mock_result = MagicMock(returncode=0, stdout="testuser", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
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
|
||||
|
||||
@@ -8,21 +8,22 @@ import pytest
|
||||
from click import ClickException
|
||||
|
||||
import devx.tools.install_checkmake as install_checkmake
|
||||
from devx.tools._shared import arch_string
|
||||
|
||||
|
||||
class TestArch:
|
||||
class TestArchString:
|
||||
def test_amd64(self) -> None:
|
||||
with patch.object(platform, "machine", return_value="x86_64"):
|
||||
assert install_checkmake._arch() == "amd64"
|
||||
assert arch_string() == "amd64"
|
||||
|
||||
def test_arm64(self) -> None:
|
||||
with patch.object(platform, "machine", return_value="aarch64"):
|
||||
assert install_checkmake._arch() == "arm64"
|
||||
assert arch_string() == "arm64"
|
||||
|
||||
def test_unsupported(self) -> None:
|
||||
with patch.object(platform, "machine", return_value="riscv64"):
|
||||
with pytest.raises(ClickException):
|
||||
install_checkmake._arch()
|
||||
arch_string()
|
||||
|
||||
|
||||
class TestInstallWithGo:
|
||||
@@ -63,13 +64,19 @@ class TestDownloadBinary:
|
||||
|
||||
class TestMain:
|
||||
def test_already_installed(self) -> None:
|
||||
from click.testing import CliRunner
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/checkmake"):
|
||||
install_checkmake.main()
|
||||
runner = CliRunner()
|
||||
runner.invoke(install_checkmake.cli, [])
|
||||
|
||||
def test_install_with_go(self) -> None:
|
||||
from click.testing import CliRunner
|
||||
|
||||
with patch("shutil.which", side_effect=[None, "/usr/bin/go"]):
|
||||
with patch("subprocess.run") as mock_run:
|
||||
install_checkmake.main()
|
||||
runner = CliRunner()
|
||||
runner.invoke(install_checkmake.cli, [])
|
||||
mock_run.assert_called_once_with(
|
||||
[
|
||||
"/usr/bin/go",
|
||||
@@ -80,6 +87,8 @@ class TestMain:
|
||||
)
|
||||
|
||||
def test_download_when_no_go(self, tmp_path: Path) -> None:
|
||||
from click.testing import CliRunner
|
||||
|
||||
target = tmp_path / "checkmake"
|
||||
|
||||
def _write_file(url: str, path: str) -> tuple[str, None]:
|
||||
@@ -90,5 +99,6 @@ class TestMain:
|
||||
with patch("shutil.which", side_effect=[None, None]):
|
||||
with patch.object(platform, "machine", return_value="x86_64"):
|
||||
with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve:
|
||||
install_checkmake.main()
|
||||
runner = CliRunner()
|
||||
runner.invoke(install_checkmake.cli, [])
|
||||
mock_retrieve.assert_called_once()
|
||||
|
||||
@@ -222,6 +222,24 @@ class TestInstallTea:
|
||||
assert (tmp_path / "tea").exists()
|
||||
|
||||
|
||||
class TestInstallHadolint:
|
||||
def test_already_installed(self) -> None:
|
||||
with patch.object(install_tools, "_is_installed", return_value=True):
|
||||
assert install_tools.install_hadolint() is True
|
||||
|
||||
def test_install(self, tmp_path: Path) -> None:
|
||||
def _write_file(url: str, path: Path) -> tuple[str, None]:
|
||||
Path(path).write_bytes(b"binary")
|
||||
return str(path), None
|
||||
|
||||
with patch.object(install_tools, "_is_installed", return_value=False):
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
with patch.object(platform, "machine", return_value="x86_64"):
|
||||
with patch.object(install_tools, "_download", side_effect=_write_file):
|
||||
assert install_tools.install_hadolint() is True
|
||||
assert (tmp_path / "hadolint").exists()
|
||||
|
||||
|
||||
class TestListTools:
|
||||
def test_list(self, tmp_path: Path) -> None:
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
@@ -251,6 +269,11 @@ class TestInstallTool:
|
||||
assert install_tools._install_tool("tea") is True
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_hadolint(self) -> None:
|
||||
with patch.object(install_tools, "install_hadolint", return_value=True) as mock:
|
||||
assert install_tools._install_tool("hadolint") is True
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_unknown_tool(self) -> None:
|
||||
with pytest.raises(ClickException, match="Unknown tool"):
|
||||
install_tools._install_tool("unknown")
|
||||
@@ -269,7 +292,7 @@ class TestMain:
|
||||
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
|
||||
result = runner.invoke(install_tools.main, [])
|
||||
assert result.exit_code == 0
|
||||
assert mock_install.call_count == 4
|
||||
assert mock_install.call_count == 5
|
||||
|
||||
def test_install_specific_tool(self) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -101,7 +101,7 @@ class TestCli:
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_URL": "https://gitea.example",
|
||||
"REPO_TOKEN": "token",
|
||||
"CI_GITEA_TOKEN": "token",
|
||||
"RUN_ID": "123",
|
||||
"JOB_NAME": "integration-tests",
|
||||
"MATRIX_INDEX": "0",
|
||||
@@ -148,7 +148,7 @@ class TestCli:
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_URL": "https://gitea.example",
|
||||
"REPO_TOKEN": "token",
|
||||
"CI_GITEA_TOKEN": "token",
|
||||
"RUN_ID": "123",
|
||||
"JOB_NAME": "integration-tests",
|
||||
"MATRIX_INDEX": "0",
|
||||
@@ -193,7 +193,7 @@ class TestCli:
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_URL": "https://gitea.example",
|
||||
"REPO_TOKEN": "token",
|
||||
"CI_GITEA_TOKEN": "token",
|
||||
"RUN_ID": "123",
|
||||
"JOB_NAME": "integration-tests",
|
||||
"MATRIX_INDEX": "0",
|
||||
@@ -239,7 +239,7 @@ class TestCli:
|
||||
assert "without cross-runner cancellation" in result.output
|
||||
|
||||
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 (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
|
||||
@@ -220,7 +220,7 @@ class TestCli:
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_URL": "https://gitea.example",
|
||||
"REPO_TOKEN": "token",
|
||||
"CI_GITEA_TOKEN": "token",
|
||||
"RUN_ID": "123",
|
||||
"JOB_NAME": "molecule-tests",
|
||||
"MATRIX_INDEX": "0",
|
||||
@@ -288,7 +288,7 @@ class TestCli:
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_URL": "https://gitea.example",
|
||||
"REPO_TOKEN": "token",
|
||||
"CI_GITEA_TOKEN": "token",
|
||||
"RUN_ID": "123",
|
||||
"JOB_NAME": "molecule-tests",
|
||||
"MATRIX_INDEX": "0",
|
||||
@@ -325,7 +325,7 @@ class TestCli:
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_URL": "https://gitea.example",
|
||||
"REPO_TOKEN": "token",
|
||||
"CI_GITEA_TOKEN": "token",
|
||||
"RUN_ID": "123",
|
||||
"JOB_NAME": "molecule-tests",
|
||||
"MATRIX_INDEX": "0",
|
||||
@@ -371,7 +371,7 @@ class TestCli:
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_URL": "https://gitea.example",
|
||||
"REPO_TOKEN": "token",
|
||||
"CI_GITEA_TOKEN": "token",
|
||||
"RUN_ID": "123",
|
||||
"JOB_NAME": "molecule-tests",
|
||||
"MATRIX_INDEX": "0",
|
||||
@@ -418,7 +418,7 @@ class TestCli:
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_URL": "https://gitea.example",
|
||||
"REPO_TOKEN": "token",
|
||||
"CI_GITEA_TOKEN": "token",
|
||||
"RUN_ID": "123",
|
||||
"JOB_NAME": "molecule-tests",
|
||||
"MATRIX_INDEX": "0",
|
||||
|
||||
@@ -4,12 +4,12 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.notify_failure import _configure_tea_login, main
|
||||
from devx.gitea_cli import TeaCLIError
|
||||
from devx.ci.notify_failure import main
|
||||
from devx.gitea_cli import TeaCLIError, configure_tea_login
|
||||
|
||||
|
||||
class TestNotifyFailure:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_creates_issue_with_tea(self, mock_tea_cls: MagicMock) -> None:
|
||||
mock_tea = MagicMock()
|
||||
@@ -36,7 +36,7 @@ class TestNotifyFailure:
|
||||
mock_tea.create_issue.assert_called_once()
|
||||
mock_tea.add_label.assert_called_once_with("owner/repo", 42, ["bug"])
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_tea_creates_issue_without_bug_label(self, mock_tea_cls: MagicMock) -> None:
|
||||
mock_tea = MagicMock()
|
||||
@@ -53,7 +53,7 @@ class TestNotifyFailure:
|
||||
assert "issue #43" in result.output
|
||||
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")
|
||||
def test_tea_error_raises(self, mock_tea_cls: MagicMock) -> None:
|
||||
"""When tea fails, the workflow fails — no fallback."""
|
||||
@@ -70,7 +70,7 @@ class TestNotifyFailure:
|
||||
assert result.exit_code != 0
|
||||
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")
|
||||
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."""
|
||||
@@ -87,7 +87,7 @@ class TestNotifyFailure:
|
||||
assert result.exit_code == 0
|
||||
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")
|
||||
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."""
|
||||
@@ -105,7 +105,7 @@ class TestNotifyFailure:
|
||||
assert result.exit_code == 0
|
||||
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:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
@@ -113,10 +113,10 @@ class TestNotifyFailure:
|
||||
["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc"],
|
||||
)
|
||||
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("devx.ci.notify_failure.shutil.which", return_value=None)
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.gitea_cli.shutil.which", return_value=None)
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
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."""
|
||||
@@ -133,11 +133,11 @@ class TestNotifyFailure:
|
||||
assert result.exit_code == 0
|
||||
assert "issue #60" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
|
||||
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": ""}, clear=True)
|
||||
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
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_cls.return_value = mock_tea
|
||||
|
||||
@@ -147,25 +147,25 @@ class TestNotifyFailure:
|
||||
["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc", "--auto-login"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "REPO_TOKEN" in result.output
|
||||
assert "CI_GITEA_TOKEN" in result.output
|
||||
|
||||
|
||||
class TestConfigureTeaLogin:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
|
||||
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
|
||||
@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()
|
||||
"""configure_tea_login with no token prints skip message and returns."""
|
||||
configure_tea_login()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("devx.ci.notify_failure.shutil.which", return_value=None)
|
||||
@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()
|
||||
"""configure_tea_login with no tea binary prints skip message and returns."""
|
||||
configure_tea_login()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("devx.ci.notify_failure.subprocess.run")
|
||||
@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")
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_auto_login_configures_tea(
|
||||
self, mock_tea_cls: MagicMock, mock_subprocess: MagicMock, mock_which: MagicMock
|
||||
@@ -191,9 +191,9 @@ class TestConfigureTeaLogin:
|
||||
# tea login add was called
|
||||
assert mock_subprocess.call_count >= 2
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("devx.ci.notify_failure.subprocess.run")
|
||||
@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")
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_auto_login_skips_if_already_configured(
|
||||
self, mock_tea_cls: MagicMock, mock_subprocess: MagicMock, mock_which: MagicMock
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Unit tests for devx.tools.pr_label."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.pr_label import cli
|
||||
|
||||
|
||||
class TestCli:
|
||||
def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge"])
|
||||
assert result.exit_code != 0
|
||||
assert "CI_GITEA_TOKEN" in result.output
|
||||
|
||||
@patch("devx.tools.pr_label.REPO_OWNER", "")
|
||||
def test_no_owner_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge"])
|
||||
assert result.exit_code != 0
|
||||
assert "owner" in result.output.lower()
|
||||
|
||||
@patch("devx.tools.pr_label.GiteaClient")
|
||||
def test_adds_new_label(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr_label_names.return_value = []
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge"])
|
||||
assert result.exit_code == 0
|
||||
client.add_pr_label.assert_called_once_with(42, ["ready-to-merge"])
|
||||
assert "Added label" in result.output
|
||||
|
||||
@patch("devx.tools.pr_label.GiteaClient")
|
||||
def test_skips_existing_label(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr_label_names.return_value = ["ready-to-merge"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge"])
|
||||
assert result.exit_code == 0
|
||||
client.add_pr_label.assert_not_called()
|
||||
assert "already" in result.output
|
||||
|
||||
@patch("devx.tools.pr_label.GiteaClient")
|
||||
def test_mixed_new_and_existing(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr_label_names.return_value = ["reviewed"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge", "--label", "reviewed"])
|
||||
assert result.exit_code == 0
|
||||
client.add_pr_label.assert_called_once_with(42, ["ready-to-merge"])
|
||||
assert "Added label" in result.output
|
||||
assert "already" in result.output
|
||||
|
||||
@patch("devx.tools.pr_label.GiteaClient")
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
def test_auto_detect_pr(
|
||||
self, mock_subprocess: MagicMock, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
mock_subprocess.return_value = MagicMock(returncode=0, stdout="feature-branch\n")
|
||||
client = mock_client_cls.return_value
|
||||
client.list_prs.return_value = [{"number": 42, "head": {"ref": "feature-branch"}}]
|
||||
client.get_pr_label_names.return_value = []
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--label", "ready-to-merge"])
|
||||
assert result.exit_code == 0
|
||||
client.add_pr_label.assert_called_once_with(42, ["ready-to-merge"])
|
||||
@@ -0,0 +1,312 @@
|
||||
"""Unit tests for devx.tools.pr_logs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.api_clients import APIError, GiteaClient
|
||||
from devx.tools.pr_logs import (
|
||||
_find_failed_jobs,
|
||||
_find_job_by_name,
|
||||
_find_latest_run_by_sha,
|
||||
_get_pr_sha,
|
||||
_print_failed_steps,
|
||||
_print_job_summary,
|
||||
_print_logs,
|
||||
cli,
|
||||
)
|
||||
|
||||
|
||||
class TestGetPrSha:
|
||||
def test_returns_sha(self) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_pr.return_value = {"head": {"sha": "abc123"}}
|
||||
assert _get_pr_sha(client, 42) == "abc123"
|
||||
|
||||
def test_returns_empty_when_missing(self) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_pr.return_value = {"head": {}}
|
||||
assert _get_pr_sha(client, 42) == ""
|
||||
|
||||
|
||||
class TestFindLatestRunBySha:
|
||||
def test_returns_matching_run(self) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.list_action_runs.return_value = {
|
||||
"workflow_runs": [
|
||||
{"id": 2, "head_sha": "def456"},
|
||||
{"id": 1, "head_sha": "abc123def"},
|
||||
],
|
||||
}
|
||||
result = _find_latest_run_by_sha(client, "abc123")
|
||||
assert result is not None
|
||||
assert result["id"] == 1
|
||||
|
||||
def test_returns_none_when_no_match(self) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.list_action_runs.return_value = {
|
||||
"workflow_runs": [{"id": 1, "head_sha": "def456"}],
|
||||
}
|
||||
result = _find_latest_run_by_sha(client, "abc123")
|
||||
assert result is None
|
||||
|
||||
def test_returns_none_when_empty(self) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.list_action_runs.return_value = {"workflow_runs": []}
|
||||
result = _find_latest_run_by_sha(client, "abc123")
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestFindFailedJobs:
|
||||
def test_returns_failed(self) -> None:
|
||||
jobs = [
|
||||
{"id": 1, "name": "quality", "conclusion": "failure"},
|
||||
{"id": 2, "name": "lint", "conclusion": "success"},
|
||||
]
|
||||
result = _find_failed_jobs(jobs)
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "quality"
|
||||
|
||||
def test_empty_when_none_failed(self) -> None:
|
||||
jobs = [{"id": 1, "name": "quality", "conclusion": "success"}]
|
||||
assert _find_failed_jobs(jobs) == []
|
||||
|
||||
|
||||
class TestFindJobByName:
|
||||
def test_case_insensitive_partial(self) -> None:
|
||||
jobs = [{"id": 1, "name": "CI / quality (pull_request)"}]
|
||||
result = _find_job_by_name(jobs, "QUALITY")
|
||||
assert result is not None
|
||||
assert result["id"] == 1
|
||||
|
||||
def test_returns_none_when_not_found(self) -> None:
|
||||
jobs = [{"id": 1, "name": "quality"}]
|
||||
assert _find_job_by_name(jobs, "molecule") is None
|
||||
|
||||
|
||||
class TestPrintJobSummary:
|
||||
def test_prints_all_jobs(self, capsys: pytest.CaptureFixture) -> None:
|
||||
jobs = [
|
||||
{"id": 1, "name": "quality", "conclusion": "failure", "status": "completed"},
|
||||
{"id": 2, "name": "lint", "conclusion": "success", "status": "completed"},
|
||||
]
|
||||
_print_job_summary(jobs)
|
||||
out = capsys.readouterr().out
|
||||
assert "[FAIL]" in out
|
||||
assert "[OK]" in out
|
||||
assert "quality" in out
|
||||
assert "lint" in out
|
||||
|
||||
|
||||
class TestPrintFailedSteps:
|
||||
def test_prints_failed_steps(self, capsys: pytest.CaptureFixture) -> None:
|
||||
job = {
|
||||
"steps": [
|
||||
{"name": "checkout", "number": 1, "conclusion": "success"},
|
||||
{"name": "Unit tests", "number": 3, "conclusion": "failure"},
|
||||
]
|
||||
}
|
||||
result = _print_failed_steps(job)
|
||||
assert result == [3]
|
||||
out = capsys.readouterr().out
|
||||
assert "FAILED step #3" in out
|
||||
assert "Unit tests" in out
|
||||
|
||||
def test_no_failed_steps(self, capsys: pytest.CaptureFixture) -> None:
|
||||
job = {"steps": [{"name": "checkout", "number": 1, "conclusion": "success"}]}
|
||||
result = _print_failed_steps(job)
|
||||
assert result == []
|
||||
|
||||
def test_no_steps_key(self, capsys: pytest.CaptureFixture) -> None:
|
||||
result = _print_failed_steps({})
|
||||
assert result == []
|
||||
|
||||
|
||||
class TestPrintLogs:
|
||||
def test_prints_all_lines(self, capsys: pytest.CaptureFixture) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_action_job_logs.return_value = "line 1\nline 2\nline 3"
|
||||
_print_logs(client, 100, tail=0)
|
||||
out = capsys.readouterr().out
|
||||
assert "line 1" in out
|
||||
assert "line 3" in out
|
||||
|
||||
def test_tail_truncates(self, capsys: pytest.CaptureFixture) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_action_job_logs.return_value = "\n".join(f"line {i}" for i in range(100))
|
||||
_print_logs(client, 100, tail=10)
|
||||
out = capsys.readouterr().out
|
||||
assert "line 99" in out
|
||||
assert "line 0" not in out
|
||||
assert "showing last 10" in out
|
||||
|
||||
def test_api_error_handled(self, capsys: pytest.CaptureFixture) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_action_job_logs.side_effect = APIError(404, "not found")
|
||||
_print_logs(client, 100, tail=0)
|
||||
out = capsys.readouterr().out
|
||||
assert "Could not fetch logs" in out
|
||||
|
||||
|
||||
class TestCli:
|
||||
def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42"])
|
||||
assert result.exit_code != 0
|
||||
assert "CI_GITEA_TOKEN" in result.output
|
||||
|
||||
@patch("devx.tools.pr_logs.REPO_OWNER", "")
|
||||
def test_no_owner_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42"])
|
||||
assert result.exit_code != 0
|
||||
assert "owner" in result.output.lower()
|
||||
|
||||
@patch("devx.tools.pr_logs.GiteaClient")
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
def test_auto_detect_pr(
|
||||
self, mock_subprocess: MagicMock, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
mock_subprocess.return_value = MagicMock(returncode=0, stdout="feature-branch\n")
|
||||
client = mock_client_cls.return_value
|
||||
client.list_prs.return_value = [{"number": 42, "head": {"ref": "feature-branch"}}]
|
||||
client.get_pr.return_value = {"head": {"sha": "abc123"}}
|
||||
client.list_action_runs.return_value = {"workflow_runs": []}
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code != 0
|
||||
assert "Fetching logs for PR #42" in result.output
|
||||
|
||||
@patch("devx.tools.pr_logs.GiteaClient")
|
||||
def test_no_runs_found(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr.return_value = {"head": {"sha": "abc123"}}
|
||||
client.list_action_runs.return_value = {"workflow_runs": []}
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42"])
|
||||
assert result.exit_code != 0
|
||||
assert "No workflow runs" in result.output
|
||||
|
||||
@patch("devx.tools.pr_logs.GiteaClient")
|
||||
def test_no_jobs(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr.return_value = {"head": {"sha": "abc123"}}
|
||||
client.list_action_runs.return_value = {
|
||||
"workflow_runs": [{"id": 1, "status": "completed", "head_sha": "abc123"}],
|
||||
}
|
||||
client.get_action_run_jobs.return_value = []
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42"])
|
||||
assert result.exit_code == 0
|
||||
assert "No jobs" in result.output
|
||||
|
||||
@patch("devx.tools.pr_logs.GiteaClient")
|
||||
def test_no_failed_jobs(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr.return_value = {"head": {"sha": "abc123"}}
|
||||
client.list_action_runs.return_value = {
|
||||
"workflow_runs": [{"id": 1, "status": "completed", "head_sha": "abc123"}],
|
||||
}
|
||||
client.get_action_run_jobs.return_value = [
|
||||
{"id": 100, "name": "quality", "conclusion": "success", "status": "completed", "steps": []}
|
||||
]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42"])
|
||||
assert result.exit_code == 0
|
||||
assert "No failed jobs" in result.output
|
||||
|
||||
@patch("devx.tools.pr_logs.GiteaClient")
|
||||
def test_failed_job_logs(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr.return_value = {"head": {"sha": "abc123"}}
|
||||
client.list_action_runs.return_value = {
|
||||
"workflow_runs": [{"id": 1, "status": "completed", "head_sha": "abc123"}],
|
||||
}
|
||||
client.get_action_run_jobs.return_value = [
|
||||
{
|
||||
"id": 100,
|
||||
"name": "quality",
|
||||
"conclusion": "failure",
|
||||
"status": "completed",
|
||||
"steps": [
|
||||
{"name": "checkout", "number": 1, "conclusion": "success"},
|
||||
{"name": "Unit tests", "number": 3, "conclusion": "failure"},
|
||||
],
|
||||
}
|
||||
]
|
||||
client.get_action_job_logs.return_value = "error: test failed"
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42", "--tail", "0"])
|
||||
assert result.exit_code == 0
|
||||
assert "FAILED step #3" in result.output
|
||||
assert "error: test failed" in result.output
|
||||
|
||||
@patch("devx.tools.pr_logs.GiteaClient")
|
||||
def test_specific_job(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr.return_value = {"head": {"sha": "abc123"}}
|
||||
client.list_action_runs.return_value = {
|
||||
"workflow_runs": [{"id": 1, "status": "completed", "head_sha": "abc123"}],
|
||||
}
|
||||
client.get_action_run_jobs.return_value = [
|
||||
{"id": 100, "name": "quality", "conclusion": "success", "status": "completed", "steps": []},
|
||||
{"id": 101, "name": "lint", "conclusion": "success", "status": "completed", "steps": []},
|
||||
]
|
||||
client.get_action_job_logs.return_value = "lint output here"
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42", "--job", "lint", "--tail", "0"])
|
||||
assert result.exit_code == 0
|
||||
assert "lint output here" in result.output
|
||||
|
||||
@patch("devx.tools.pr_logs.GiteaClient")
|
||||
def test_job_not_found(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr.return_value = {"head": {"sha": "abc123"}}
|
||||
client.list_action_runs.return_value = {
|
||||
"workflow_runs": [{"id": 1, "status": "completed", "head_sha": "abc123"}],
|
||||
}
|
||||
client.get_action_run_jobs.return_value = [
|
||||
{"id": 100, "name": "quality", "conclusion": "success", "status": "completed", "steps": []}
|
||||
]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42", "--job", "nonexistent"])
|
||||
assert result.exit_code != 0
|
||||
assert "No job matching" in result.output
|
||||
|
||||
@patch("devx.tools.pr_logs.GiteaClient")
|
||||
def test_no_sha_raises(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr.return_value = {"head": {}}
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42"])
|
||||
assert result.exit_code != 0
|
||||
assert "SHA" in result.output
|
||||
@@ -683,7 +683,7 @@ class TestMain:
|
||||
def test_dry_run_does_not_post(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = ReviewResult()
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["42", "oblachno-oss/grm", "--dry-run"], env={"REPO_TOKEN": "fake"})
|
||||
result = runner.invoke(main, ["42", "oblachno-oss/grm", "--dry-run"], env={"CI_GITEA_TOKEN": "fake"})
|
||||
assert result.exit_code == 0
|
||||
assert "[dry-run]" in result.output
|
||||
mock_client_class.return_value.create_review.assert_not_called()
|
||||
@@ -694,7 +694,7 @@ class TestMain:
|
||||
mock_run.return_value = ReviewResult()
|
||||
mock_client_class.return_value.create_review.return_value = {"id": 123}
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": "fake"})
|
||||
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"CI_GITEA_TOKEN": "fake"})
|
||||
assert result.exit_code == 0
|
||||
assert "Review #123" in result.output
|
||||
mock_client_class.return_value.create_review.assert_called_once()
|
||||
@@ -710,7 +710,7 @@ class TestMain:
|
||||
{"id": 124},
|
||||
]
|
||||
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 "Review #124" in result.output
|
||||
assert client.create_review.call_count == 2
|
||||
@@ -723,14 +723,190 @@ class TestMain:
|
||||
client = mock_client_class.return_value
|
||||
client.create_review.side_effect = APIError(500, "Internal server error")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": "fake"})
|
||||
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"CI_GITEA_TOKEN": "fake"})
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_no_token_raises(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"REPO_TOKEN": ""})
|
||||
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"CI_GITEA_TOKEN": ""})
|
||||
assert result.exit_code != 0
|
||||
assert "CI_GITEA_TOKEN" in result.output
|
||||
|
||||
|
||||
class TestManualReview:
|
||||
@patch("devx.ci.pr_review.GiteaClient")
|
||||
def test_approve_success(self, mock_client_class: MagicMock) -> None:
|
||||
mock_client_class.return_value.create_review.return_value = {"id": 200}
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"42",
|
||||
"oblachno-oss/devx",
|
||||
"--event",
|
||||
"APPROVE",
|
||||
"--body",
|
||||
"All 13 REVIEW_CHECKLIST.md categories verified. Architecture: clean. Security: no issues.",
|
||||
"--checklist-confirmed",
|
||||
"--checklist-categories",
|
||||
"1,2,3,4,5,6,7,8,9,10,11,12,13",
|
||||
],
|
||||
env={"CI_GITEA_TOKEN": "fake"},
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Review #200" in result.output
|
||||
|
||||
@patch("devx.ci.pr_review.GiteaClient")
|
||||
def test_approve_without_checklist_confirmed_fails(self, mock_client_class: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"42",
|
||||
"oblachno-oss/devx",
|
||||
"--event",
|
||||
"APPROVE",
|
||||
"--body",
|
||||
"x" * 60,
|
||||
"--checklist-categories",
|
||||
"1,2,3,4,5,6,7,8",
|
||||
],
|
||||
env={"CI_GITEA_TOKEN": "fake"},
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "checklist-confirmed" in result.output
|
||||
|
||||
@patch("devx.ci.pr_review.GiteaClient")
|
||||
def test_approve_with_too_few_categories_fails(self, mock_client_class: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"42",
|
||||
"oblachno-oss/devx",
|
||||
"--event",
|
||||
"APPROVE",
|
||||
"--body",
|
||||
"x" * 60,
|
||||
"--checklist-confirmed",
|
||||
"--checklist-categories",
|
||||
"1,2,3",
|
||||
],
|
||||
env={"CI_GITEA_TOKEN": "fake"},
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "at least 8" in result.output
|
||||
|
||||
@patch("devx.ci.pr_review.GiteaClient")
|
||||
def test_approve_with_short_body_fails(self, mock_client_class: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"42",
|
||||
"oblachno-oss/devx",
|
||||
"--event",
|
||||
"APPROVE",
|
||||
"--body",
|
||||
"LGTM",
|
||||
"--checklist-confirmed",
|
||||
"--checklist-categories",
|
||||
"1,2,3,4,5,6,7,8",
|
||||
],
|
||||
env={"CI_GITEA_TOKEN": "fake"},
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "50 characters" in result.output
|
||||
|
||||
@patch("devx.ci.pr_review.GiteaClient")
|
||||
def test_approve_with_invalid_category_fails(self, mock_client_class: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"42",
|
||||
"oblachno-oss/devx",
|
||||
"--event",
|
||||
"APPROVE",
|
||||
"--body",
|
||||
"x" * 60,
|
||||
"--checklist-confirmed",
|
||||
"--checklist-categories",
|
||||
"1,2,abc,4",
|
||||
],
|
||||
env={"CI_GITEA_TOKEN": "fake"},
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "Invalid" in result.output
|
||||
|
||||
@patch("devx.ci.pr_review.GiteaClient")
|
||||
def test_request_changes_success(self, mock_client_class: MagicMock) -> None:
|
||||
mock_client_class.return_value.create_review.return_value = {"id": 201}
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"42",
|
||||
"oblachno-oss/devx",
|
||||
"--event",
|
||||
"REQUEST_CHANGES",
|
||||
"--body",
|
||||
"Please fix the architecture issues in the CLI module before merging.",
|
||||
],
|
||||
env={"CI_GITEA_TOKEN": "fake"},
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Review #201" in result.output
|
||||
|
||||
@patch("devx.ci.pr_review.GiteaClient")
|
||||
def test_manual_review_dry_run(self, mock_client_class: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["42", "oblachno-oss/devx", "--event", "COMMENT", "--body", "x" * 60, "--dry-run"],
|
||||
env={"CI_GITEA_TOKEN": "fake"},
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "[dry-run]" in result.output
|
||||
mock_client_class.return_value.create_review.assert_not_called()
|
||||
|
||||
@patch("devx.ci.pr_review.GiteaClient")
|
||||
def test_manual_review_self_approval_fallback(self, mock_client_class: MagicMock) -> None:
|
||||
client = mock_client_class.return_value
|
||||
client.create_review.side_effect = [
|
||||
APIError(422, "approve your own pull is not allowed"),
|
||||
{"id": 202},
|
||||
]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"42",
|
||||
"oblachno-oss/devx",
|
||||
"--event",
|
||||
"APPROVE",
|
||||
"--body",
|
||||
"x" * 60,
|
||||
"--checklist-confirmed",
|
||||
"--checklist-categories",
|
||||
"1,2,3,4,5,6,7,8",
|
||||
],
|
||||
env={"CI_GITEA_TOKEN": "fake"},
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Review #202" in result.output
|
||||
|
||||
@patch("devx.ci.pr_review.GiteaClient")
|
||||
def test_manual_review_other_error_re_raises(self, mock_client_class: MagicMock) -> None:
|
||||
client = mock_client_class.return_value
|
||||
client.create_review.side_effect = APIError(500, "Internal server error")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["42", "oblachno-oss/devx", "--event", "COMMENT", "--body", "x" * 60],
|
||||
env={"CI_GITEA_TOKEN": "fake"},
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "REPO_TOKEN" in result.output
|
||||
|
||||
|
||||
def test_main_module_block() -> None:
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
"""Unit tests for devx.tools.pr_status."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.api_clients import GiteaClient
|
||||
from devx.tools.pr_status import (
|
||||
_get_pr_sha,
|
||||
_get_symbol,
|
||||
cli,
|
||||
print_status,
|
||||
wait_for_completion,
|
||||
)
|
||||
|
||||
|
||||
class TestGetSymbol:
|
||||
def test_success(self) -> None:
|
||||
assert _get_symbol("success") == "[OK]"
|
||||
|
||||
def test_failure(self) -> None:
|
||||
assert _get_symbol("failure") == "[FAIL]"
|
||||
|
||||
def test_pending(self) -> None:
|
||||
assert _get_symbol("pending") == "[..]"
|
||||
|
||||
def test_unknown(self) -> None:
|
||||
assert _get_symbol("weird") == "[weird]"
|
||||
|
||||
|
||||
class TestGetPrSha:
|
||||
def test_returns_head_sha(self) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_pr.return_value = {"head": {"sha": "abc123"}}
|
||||
assert _get_pr_sha(client, 42) == "abc123"
|
||||
|
||||
def test_returns_empty_when_missing(self) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_pr.return_value = {"head": {}}
|
||||
assert _get_pr_sha(client, 42) == ""
|
||||
|
||||
|
||||
class TestPrintStatus:
|
||||
def test_no_statuses(self, capsys: pytest.CaptureFixture) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_commit_status.return_value = []
|
||||
result = print_status(client, "abc123")
|
||||
assert result == "none"
|
||||
|
||||
def test_all_success(self, capsys: pytest.CaptureFixture) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_commit_status.return_value = [
|
||||
{"context": "CI / quality", "status": "success"},
|
||||
{"context": "CI / lint", "status": "success"},
|
||||
]
|
||||
result = print_status(client, "abc123")
|
||||
assert result == "success"
|
||||
|
||||
def test_has_failure(self, capsys: pytest.CaptureFixture) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_commit_status.return_value = [
|
||||
{"context": "CI / quality", "status": "success"},
|
||||
{"context": "CI / lint", "status": "failure"},
|
||||
]
|
||||
result = print_status(client, "abc123")
|
||||
assert result == "failure"
|
||||
|
||||
def test_pending(self, capsys: pytest.CaptureFixture) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_commit_status.return_value = [
|
||||
{"context": "CI / quality", "status": "pending"},
|
||||
]
|
||||
result = print_status(client, "abc123")
|
||||
assert result == "pending"
|
||||
|
||||
def test_skipped_still_success(self, capsys: pytest.CaptureFixture) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_commit_status.return_value = [
|
||||
{"context": "CI / quality", "status": "success"},
|
||||
{"context": "CI / molecule", "status": "skipped"},
|
||||
]
|
||||
result = print_status(client, "abc123")
|
||||
assert result == "success"
|
||||
|
||||
|
||||
class TestWaitForCompletion:
|
||||
@patch("devx.tools.pr_status.time.sleep")
|
||||
@patch("devx.tools.pr_status.time.time", side_effect=[0, 0, 100, 200])
|
||||
def test_success_after_pending(
|
||||
self, mock_time: MagicMock, mock_sleep: MagicMock, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_commit_status.side_effect = [
|
||||
[{"context": "CI / quality", "status": "pending"}],
|
||||
[{"context": "CI / quality", "status": "success"}],
|
||||
]
|
||||
result = wait_for_completion(client, "abc", timeout=600, interval=1)
|
||||
assert result == "success"
|
||||
|
||||
@patch("devx.tools.pr_status.time.sleep")
|
||||
@patch("devx.tools.pr_status.time.time", side_effect=[0, 0, 100, 200])
|
||||
def test_failure_after_pending(self, mock_time: MagicMock, mock_sleep: MagicMock) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_commit_status.side_effect = [
|
||||
[{"context": "CI / quality", "status": "pending"}],
|
||||
[{"context": "CI / quality", "status": "failure"}],
|
||||
]
|
||||
result = wait_for_completion(client, "abc", timeout=600, interval=1)
|
||||
assert result == "failure"
|
||||
|
||||
@patch("devx.tools.pr_status.time.sleep")
|
||||
@patch("devx.tools.pr_status.time.time", side_effect=[0, 700])
|
||||
def test_timeout(self, mock_time: MagicMock, mock_sleep: MagicMock) -> None:
|
||||
client = MagicMock(spec=GiteaClient)
|
||||
client.get_commit_status.return_value = [{"context": "CI / quality", "status": "pending"}]
|
||||
result = wait_for_completion(client, "abc", timeout=600, interval=1)
|
||||
assert result == "pending"
|
||||
|
||||
|
||||
class TestCli:
|
||||
def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42"])
|
||||
assert result.exit_code != 0
|
||||
assert "CI_GITEA_TOKEN" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.REPO_OWNER", "")
|
||||
@patch("devx.tools.pr_status.get_repo_name", side_effect=Exception("should not reach"))
|
||||
def test_no_owner_raises(self, mock_repo_name: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42"])
|
||||
assert result.exit_code != 0
|
||||
assert "owner" in result.output.lower()
|
||||
|
||||
@patch("devx.tools.pr_status.GiteaClient")
|
||||
def test_check_pr_status(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr.return_value = {"head": {"sha": "abc123"}}
|
||||
client.get_commit_status.return_value = [
|
||||
{"context": "CI / quality", "status": "success"},
|
||||
]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42"])
|
||||
assert result.exit_code == 0
|
||||
assert "[OK]" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.GiteaClient")
|
||||
def test_check_sha_directly(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_commit_status.return_value = [
|
||||
{"context": "CI / quality", "status": "success"},
|
||||
]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--sha", "abc123"])
|
||||
assert result.exit_code == 0
|
||||
assert "[OK]" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.GiteaClient")
|
||||
def test_failure_raises_exception(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr.return_value = {"head": {"sha": "abc123"}}
|
||||
client.get_commit_status.return_value = [
|
||||
{"context": "CI / quality", "status": "failure"},
|
||||
]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42"])
|
||||
assert result.exit_code != 0
|
||||
assert "failed" in result.output.lower()
|
||||
|
||||
@patch("devx.tools.pr_status.GiteaClient")
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
def test_auto_detect_branch(
|
||||
self, mock_subprocess: MagicMock, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
mock_subprocess.return_value = MagicMock(returncode=0, stdout="feature-branch\n")
|
||||
client = mock_client_cls.return_value
|
||||
client.list_prs.return_value = [{"number": 42, "head": {"ref": "feature-branch"}}]
|
||||
client.get_pr.return_value = {"head": {"sha": "abc123"}}
|
||||
client.get_commit_status.return_value = [{"context": "CI / quality", "status": "success"}]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 0
|
||||
assert "PR #42" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.GiteaClient")
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
def test_auto_detect_no_pr_found(
|
||||
self, mock_subprocess: MagicMock, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
mock_subprocess.return_value = MagicMock(returncode=0, stdout="feature-branch\n")
|
||||
client = mock_client_cls.return_value
|
||||
client.list_prs.return_value = []
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code != 0
|
||||
assert "No open PR" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.GiteaClient")
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
def test_auto_detect_branch_error(
|
||||
self, mock_subprocess: MagicMock, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
mock_subprocess.return_value = MagicMock(returncode=1, stderr="git error\n")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code != 0
|
||||
assert "Could not detect" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.GiteaClient")
|
||||
def test_no_sha_raises(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr.return_value = {"head": {}}
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42"])
|
||||
assert result.exit_code != 0
|
||||
assert "SHA" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.time.sleep")
|
||||
@patch("devx.tools.pr_status.time.time", side_effect=[0, 0, 100, 200])
|
||||
@patch("devx.tools.pr_status.GiteaClient")
|
||||
def test_wait_success(
|
||||
self, mock_client_cls: MagicMock, mock_time: MagicMock, mock_sleep: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr.return_value = {"head": {"sha": "abc123"}}
|
||||
client.get_commit_status.side_effect = [
|
||||
[{"context": "CI / quality", "status": "pending"}],
|
||||
[{"context": "CI / quality", "status": "success"}],
|
||||
]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42", "--wait", "--timeout", "600", "--interval", "1"])
|
||||
assert result.exit_code == 0
|
||||
assert "[OK]" in result.output
|
||||
|
||||
@patch("devx.tools.pr_status.time.sleep")
|
||||
@patch("devx.tools.pr_status.time.time", side_effect=[0, 700])
|
||||
@patch("devx.tools.pr_status.GiteaClient")
|
||||
def test_wait_timeout(
|
||||
self, mock_client_cls: MagicMock, mock_time: MagicMock, mock_sleep: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr.return_value = {"head": {"sha": "abc123"}}
|
||||
client.get_commit_status.return_value = [{"context": "CI / quality", "status": "pending"}]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42", "--wait", "--timeout", "600", "--interval", "1"])
|
||||
assert result.exit_code != 0
|
||||
assert "timeout" in result.output.lower()
|
||||
@@ -43,7 +43,7 @@ class TestTaskExists:
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_found(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-42"}]
|
||||
mock_client.find_task_by_identifier.return_value = {"identifier": "DEVX-42"}
|
||||
mock_client_cls.return_value = mock_client
|
||||
assert task_exists("DEVX-42") is True
|
||||
|
||||
@@ -51,7 +51,7 @@ class TestTaskExists:
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_not_found(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-99"}]
|
||||
mock_client.find_task_by_identifier.return_value = None
|
||||
mock_client_cls.return_value = mock_client
|
||||
assert task_exists("DEVX-42") is False
|
||||
|
||||
@@ -59,37 +59,6 @@ class TestTaskExists:
|
||||
def test_no_token(self) -> None:
|
||||
assert task_exists("DEVX-42") is False
|
||||
|
||||
@patch("devx.tools.pre_push_check.VikunjaClient")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_pagination(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
# First page: full page (50 items, none matching), second page: match
|
||||
page1 = [{"identifier": f"OTHER-{i}"} for i in range(50)]
|
||||
page2 = [{"identifier": "DEVX-42"}]
|
||||
mock_client.list_project_tasks.side_effect = [page1, page2]
|
||||
mock_client_cls.return_value = mock_client
|
||||
assert task_exists("DEVX-42") is True
|
||||
|
||||
@patch("devx.tools.pre_push_check.VikunjaClient")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_empty_project(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = []
|
||||
mock_client_cls.return_value = mock_client
|
||||
assert task_exists("DEVX-42") is False
|
||||
|
||||
@patch("devx.tools.pre_push_check.VikunjaClient")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_pagination_not_found(self, mock_client_cls: MagicMock) -> None:
|
||||
from devx.config import DEFAULT_PER_PAGE
|
||||
|
||||
mock_client = MagicMock()
|
||||
page1 = [{"identifier": f"OTHER-{i}"} for i in range(DEFAULT_PER_PAGE)]
|
||||
page2 = [{"identifier": "OTHER-99"}]
|
||||
mock_client.list_project_tasks.side_effect = [page1, page2]
|
||||
mock_client_cls.return_value = mock_client
|
||||
assert task_exists("DEVX-42") is False
|
||||
|
||||
|
||||
class TestValidate:
|
||||
def test_master_branch_skips(self) -> None:
|
||||
|
||||
+51
-17
@@ -165,7 +165,7 @@ class TestDefaultGiteaRegistryUrl:
|
||||
|
||||
|
||||
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.TeaCLI")
|
||||
@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"
|
||||
)
|
||||
|
||||
@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.TeaCLI")
|
||||
@patch("devx.ci.publish.publish_to_gitea_registry")
|
||||
@@ -213,7 +213,7 @@ class TestMain:
|
||||
mock_gitea_publish.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.TeaCLI")
|
||||
@patch("devx.ci.publish.publish_to_gitea_registry")
|
||||
@@ -239,7 +239,7 @@ class TestMain:
|
||||
|
||||
@patch.dict(
|
||||
"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,
|
||||
)
|
||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||
@@ -262,7 +262,7 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
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.TeaCLI")
|
||||
@patch("devx.ci.publish.build_package")
|
||||
@@ -284,14 +284,14 @@ class TestMain:
|
||||
assert "PYPI_TOKEN not set" in result.output
|
||||
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:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
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.TeaCLI")
|
||||
@patch("devx.ci.publish.publish_to_pypi")
|
||||
@@ -305,7 +305,7 @@ class TestMain:
|
||||
assert result.exit_code == 1
|
||||
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.TeaCLI")
|
||||
@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"
|
||||
)
|
||||
|
||||
@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.TeaCLI")
|
||||
@patch("devx.ci.publish.publish_to_pypi")
|
||||
@@ -343,7 +343,7 @@ class TestMain:
|
||||
assert result.exit_code == 1
|
||||
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.TeaCLI")
|
||||
@patch("devx.ci.publish.build_package")
|
||||
@@ -361,7 +361,7 @@ class TestMain:
|
||||
mock_build.assert_not_called()
|
||||
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.TeaCLI")
|
||||
@patch("devx.ci.publish.publish_to_pypi")
|
||||
@@ -379,7 +379,7 @@ class TestMain:
|
||||
assert "already exists" in result.output
|
||||
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.TeaCLI")
|
||||
@patch("devx.ci.publish.publish_to_pypi")
|
||||
@@ -395,7 +395,7 @@ class TestMain:
|
||||
result = runner.invoke(main, ["v1.0.0", "owner/repo"])
|
||||
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.TeaCLI")
|
||||
@patch("devx.ci.publish.publish_to_gitea_registry")
|
||||
@@ -419,7 +419,7 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
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.TeaCLI")
|
||||
@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.get_latest_tag", return_value="v1.0.0")
|
||||
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:
|
||||
mock_tea = MagicMock()
|
||||
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.get_latest_tag", return_value="v1.0.0")
|
||||
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:
|
||||
mock_tea = MagicMock()
|
||||
mock_tea.list_releases.return_value = []
|
||||
@@ -551,3 +551,37 @@ class TestFromTag:
|
||||
result = runner.invoke(main, ["", "owner/repo", "--skip-build"])
|
||||
assert result.exit_code != 0
|
||||
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()
|
||||
|
||||
@@ -34,20 +34,20 @@ from devx.ci.release import (
|
||||
|
||||
|
||||
class TestRunCmd:
|
||||
@patch("devx.ci.release.subprocess.run")
|
||||
@patch("devx.ci._shared.subprocess.run")
|
||||
def test_success(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=0, stderr="", stdout="")
|
||||
result = run_cmd(["echo", "hi"])
|
||||
assert result.returncode == 0
|
||||
mock_run.assert_called_once()
|
||||
|
||||
@patch("devx.ci.release.subprocess.run")
|
||||
@patch("devx.ci._shared.subprocess.run")
|
||||
def test_failure_raises(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr="err", stdout="")
|
||||
with pytest.raises(click.ClickException):
|
||||
run_cmd(["false"])
|
||||
|
||||
@patch("devx.ci.release.subprocess.run")
|
||||
@patch("devx.ci._shared.subprocess.run")
|
||||
def test_check_false_no_raise(self, mock_run: MagicMock) -> None:
|
||||
mock_run.return_value = MagicMock(returncode=1, stderr="err", stdout="")
|
||||
result = run_cmd(["false"], check=False)
|
||||
@@ -802,7 +802,7 @@ class TestCommitReleaseChanges:
|
||||
assert result is True
|
||||
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
|
||||
assert ["git", "add", "src/devx/__init__.py", "CHANGELOG.md"] in calls
|
||||
assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0"] in calls
|
||||
assert ["git", "commit", "--no-verify", "-m", "release: v0.2.0 [skip ci]"] in calls
|
||||
|
||||
@patch("devx.ci.release.run_cmd")
|
||||
def test_skips_when_no_changes(self, mock_run_cmd: MagicMock) -> None:
|
||||
@@ -811,7 +811,7 @@ class TestCommitReleaseChanges:
|
||||
result = commit_release_changes("0.1.0")
|
||||
assert result is False
|
||||
calls = [c.args[0] for c in mock_run_cmd.call_args_list]
|
||||
assert ["git", "commit", "--no-verify", "-m", "release: v0.1.0"] not in calls
|
||||
assert ["git", "commit", "--no-verify", "-m", "release: v0.1.0 [skip ci]"] not in calls
|
||||
|
||||
|
||||
class TestCreateAndPushTag:
|
||||
@@ -1207,7 +1207,7 @@ class TestMain:
|
||||
@patch("devx.ci.release.update_init_version")
|
||||
@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_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.run_cmd")
|
||||
def test_full_flow_tag_exists(
|
||||
@@ -1232,7 +1232,33 @@ class TestMain:
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "already existed" in result.output
|
||||
mock_tag.assert_called_once_with("0.1.0", "changelog", False)
|
||||
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("devx.ci.release.verify_tag_consistency", return_value=[])
|
||||
|
||||
@@ -120,7 +120,7 @@ class TestConfigureTeaLogin:
|
||||
|
||||
@patch("devx.tools.setup.subprocess.run")
|
||||
@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:
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="devx\ngrm\n", stderr="")
|
||||
_configure_tea_login()
|
||||
@@ -130,7 +130,7 @@ class TestConfigureTeaLogin:
|
||||
|
||||
@patch("devx.tools.setup.subprocess.run")
|
||||
@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:
|
||||
list_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.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:
|
||||
list_result = MagicMock(returncode=0, stdout="", stderr="")
|
||||
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.dict(
|
||||
"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,
|
||||
)
|
||||
def test_custom_gitea_url(self, mock_which: MagicMock, mock_run: MagicMock) -> None:
|
||||
|
||||
@@ -272,18 +272,16 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
mock_start.assert_called_once_with(60)
|
||||
|
||||
@patch("devx.molecule.start_docker.os.environ.get")
|
||||
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
|
||||
def test_exports_github_env(self, mock_start: MagicMock, mock_get: MagicMock) -> None:
|
||||
def test_exports_github_env(self, mock_start: MagicMock) -> None:
|
||||
"""Should write DOCKER_HOST to GITHUB_ENV when available."""
|
||||
mock_get.side_effect = lambda key, default="": (
|
||||
"/tmp/github_env" if key == "GITHUB_ENV" else f"unix://{DOCKER_SOCK}" if key == "DOCKER_HOST" else default
|
||||
)
|
||||
with patch("builtins.open", mock_open()) as mock_file:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
mock_file.assert_called_with("/tmp/github_env", "a")
|
||||
env = {"GITHUB_ENV": "/tmp/github_env", "DOCKER_HOST": f"unix://{DOCKER_SOCK}"}
|
||||
with patch.dict("os.environ", env, clear=True):
|
||||
with patch("builtins.open", mock_open()) as mock_file:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
mock_file.assert_called_with("/tmp/github_env", "a", encoding="utf-8")
|
||||
|
||||
@patch("devx.molecule.start_docker.os.environ.get", return_value="")
|
||||
@patch("devx.molecule.start_docker.start_docker_daemon", return_value=True)
|
||||
|
||||
@@ -286,7 +286,7 @@ class TestVerifyWikiIntegrity:
|
||||
|
||||
|
||||
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.DOCS_DIR")
|
||||
@patch("devx.ci.sync_wiki.GiteaClient")
|
||||
@@ -301,14 +301,16 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
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:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--repo", "owner/repo"])
|
||||
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")
|
||||
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."""
|
||||
@@ -322,7 +324,7 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
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")
|
||||
def test_missing_mapping_file(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that missing mapping.json exits with error."""
|
||||
@@ -333,7 +335,7 @@ class TestMain:
|
||||
assert result.exit_code == 1
|
||||
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")
|
||||
def test_existing_pages_message(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that existing wiki pages are reported."""
|
||||
@@ -347,7 +349,7 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
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")
|
||||
def test_file_not_found_fails(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that missing doc files cause an error, not a warning."""
|
||||
@@ -361,7 +363,7 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
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")
|
||||
def test_empty_doc_file_fails(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that empty doc files cause an error, not a warning."""
|
||||
@@ -375,7 +377,7 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
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")
|
||||
def test_create_and_update(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that pages are created and updated correctly (non-dry-run)."""
|
||||
@@ -393,7 +395,7 @@ class TestMain:
|
||||
assert "Created: 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")
|
||||
def test_verify_passes(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that --verify passes when content matches."""
|
||||
@@ -413,7 +415,7 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
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")
|
||||
def test_verify_fails_on_empty_content(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that --verify fails when wiki pages have empty content."""
|
||||
@@ -430,7 +432,7 @@ class TestMain:
|
||||
assert result.exit_code == 1
|
||||
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")
|
||||
def test_verify_skipped_in_dry_run(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that --verify is skipped during dry-run."""
|
||||
@@ -444,7 +446,7 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
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")
|
||||
def test_strict_passes(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that --strict passes when integrity check succeeds."""
|
||||
@@ -461,7 +463,7 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
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")
|
||||
def test_strict_fails_on_integrity_issues(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that --strict fails when integrity check finds issues."""
|
||||
@@ -483,7 +485,7 @@ class TestMain:
|
||||
assert "Missing page: FAQ" in result.output
|
||||
assert "Stale page: Old-Page" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.sync_wiki.GiteaClient")
|
||||
def test_strict_skipped_in_dry_run(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Test that --strict verification is skipped during dry-run."""
|
||||
|
||||
Reference in New Issue
Block a user