Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e45a546c16 | ||
|
|
41c631d5f5 | ||
|
|
e271c79e93 | ||
|
|
f4305821f1 | ||
|
|
e4f40223d2 | ||
|
|
06e80516d4 | ||
|
|
f1adf22c3e | ||
|
|
91216da1a4 | ||
|
|
f9836208df | ||
|
|
0f0f0b683a |
@@ -121,8 +121,13 @@ jobs:
|
|||||||
auto-merge:
|
auto-merge:
|
||||||
# Auto-merge runs after all CI checks pass. It reads the task ID
|
# Auto-merge runs after all CI checks pass. It reads the task ID
|
||||||
# from the branch name, validates the PR title, and squash-merges.
|
# from the branch name, validates the PR title, and squash-merges.
|
||||||
|
# Uses always() so it runs even when detect-changes skips (no user-facing changes).
|
||||||
needs: [quality, detect-changes, pr-review]
|
needs: [quality, detect-changes, pr-review]
|
||||||
if: github.event_name == 'pull_request'
|
if: >-
|
||||||
|
always() &&
|
||||||
|
github.event_name == 'pull_request' &&
|
||||||
|
needs.quality.result == 'success' &&
|
||||||
|
needs.pr-review.result == 'success'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
@@ -130,10 +135,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.REPO_TOKEN }}
|
token: ${{ secrets.REPO_TOKEN }}
|
||||||
- name: Install dependencies
|
- name: Set up environment
|
||||||
run: |
|
run: make setup-ci
|
||||||
python3 -m pip install --break-system-packages requests python-dotenv click
|
|
||||||
python3 -m pip install --break-system-packages -e .
|
|
||||||
- name: Squash merge with task ID
|
- name: Squash merge with task ID
|
||||||
env:
|
env:
|
||||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
@@ -145,6 +148,7 @@ jobs:
|
|||||||
REPOSITORY: ${{ github.repository }}
|
REPOSITORY: ${{ github.repository }}
|
||||||
PR_NUMBER: ${{ github.event.number }}
|
PR_NUMBER: ${{ github.event.number }}
|
||||||
run: |
|
run: |
|
||||||
|
. .venv/bin/activate
|
||||||
python3 -m devx.ci.auto_merge \
|
python3 -m devx.ci.auto_merge \
|
||||||
"$HEAD_REF" \
|
"$HEAD_REF" \
|
||||||
"$PR_TITLE" \
|
"$PR_TITLE" \
|
||||||
|
|||||||
@@ -6,21 +6,20 @@ name: Post-merge
|
|||||||
#
|
#
|
||||||
# Job dependency graph:
|
# Job dependency graph:
|
||||||
#
|
#
|
||||||
# detect-type ──┬── release (skip if release commit)
|
# detect-type ──┬── validate-commit-msg (skip if release commit)
|
||||||
|
# ├── release (skip if release commit)
|
||||||
# ├── badges (ALWAYS runs — even on release commits)
|
# ├── badges (ALWAYS runs — even on release commits)
|
||||||
# ├── configure-repo (independent — skip if release commit)
|
# ├── configure-repo (independent — skip if release commit)
|
||||||
# ├── sync-wiki (needs release — skip if release commit/fails)
|
# ├── sync-wiki (skip if release commit — runs for ALL merges)
|
||||||
# └── vikunja (needs release — skip if release commit/fails)
|
# └── vikunja (skip if release commit — runs for ALL merges)
|
||||||
#
|
#
|
||||||
# sync-wiki and vikunja depend on release succeeding so that the wiki
|
# sync-wiki and vikunja run for ALL non-release commits, not just when
|
||||||
# and task tracker are only updated when the code is actually released.
|
# release succeeds. This ensures the wiki and task tracker are updated
|
||||||
# If release fails, they are skipped to avoid leaving the wiki or
|
# even for infrastructure-only changes (docs, CI config, etc.).
|
||||||
# Vikunja in an inconsistent state with the codebase on master.
|
|
||||||
#
|
#
|
||||||
# The badges job depends on release so it picks up the latest version
|
# The badges job uses `if: always()` with no is-release condition so it
|
||||||
# number. It uses `if: always()` with no is-release condition so it
|
# runs on every push to master, including release commits. This ensures
|
||||||
# runs on every push to master, including release commits. This
|
# badges (tests, coverage, version, etc.) are always current.
|
||||||
# ensures badges (tests, coverage, version, etc.) are always current.
|
|
||||||
#
|
#
|
||||||
# When release creates a "release: vX.Y.Z" commit, the release
|
# When release creates a "release: vX.Y.Z" commit, the release
|
||||||
# commit's post-merge run still updates badges (version badge picks
|
# commit's post-merge run still updates badges (version badge picks
|
||||||
@@ -40,15 +39,15 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
- name: Install dependencies
|
- name: Set up environment
|
||||||
run: |
|
run: make setup-ci
|
||||||
python3 -m pip install --break-system-packages requests python-dotenv click
|
|
||||||
python3 -m pip install --break-system-packages -e .
|
|
||||||
- name: Check if this is a release commit
|
- name: Check if this is a release commit
|
||||||
id: check
|
id: check
|
||||||
env:
|
env:
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: python3 -m devx.ci.detect_release_commit
|
run: |
|
||||||
|
. .venv/bin/activate
|
||||||
|
python3 -m devx.ci.detect_release_commit
|
||||||
|
|
||||||
validate-commit-msg:
|
validate-commit-msg:
|
||||||
needs: [detect-type]
|
needs: [detect-type]
|
||||||
@@ -59,14 +58,13 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 1
|
fetch-depth: 1
|
||||||
- name: Install dependencies
|
- name: Set up environment
|
||||||
run: |
|
run: make setup-ci
|
||||||
python3 -m pip install --break-system-packages click python-dotenv
|
|
||||||
python3 -m pip install --break-system-packages -e .
|
|
||||||
- name: Validate latest commit message
|
- name: Validate latest commit message
|
||||||
env:
|
env:
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
|
. .venv/bin/activate
|
||||||
git log -1 --format=%B > commit-msg.txt
|
git log -1 --format=%B > commit-msg.txt
|
||||||
python3 -m devx.ci.validate_commit_msg commit-msg.txt --branch master
|
python3 -m devx.ci.validate_commit_msg commit-msg.txt --branch master
|
||||||
rm -f commit-msg.txt
|
rm -f commit-msg.txt
|
||||||
@@ -96,20 +94,6 @@ jobs:
|
|||||||
. .venv/bin/activate
|
. .venv/bin/activate
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
python3 -m devx.ci.release
|
python3 -m devx.ci.release
|
||||||
- name: Publish release
|
|
||||||
env:
|
|
||||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
|
||||||
PYTHONPATH: src
|
|
||||||
run: |
|
|
||||||
. .venv/bin/activate
|
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
|
||||||
TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
|
||||||
if [ -z "$TAG" ]; then
|
|
||||||
echo "No tag found — skipping publish"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "Publishing release $TAG (idempotent — skips if already published)..."
|
|
||||||
python3 -m devx.ci.publish "$TAG" "${{ github.repository }}"
|
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
env:
|
env:
|
||||||
@@ -118,9 +102,6 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate 2>/dev/null || true
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
python3 -m devx.tools.install_tools --tool tea
|
|
||||||
tea login add --name devx --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
|
|
||||||
tea login default devx || true
|
|
||||||
python3 -m devx.ci.notify_failure \
|
python3 -m devx.ci.notify_failure \
|
||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
--run-id "${{ github.run_id }}" \
|
--run-id "${{ github.run_id }}" \
|
||||||
@@ -128,7 +109,7 @@ jobs:
|
|||||||
--commit "${{ github.sha }}"
|
--commit "${{ github.sha }}"
|
||||||
|
|
||||||
sync-wiki:
|
sync-wiki:
|
||||||
needs: [detect-type, release]
|
needs: [detect-type]
|
||||||
if: needs.detect-type.outputs.is-release == 'false'
|
if: needs.detect-type.outputs.is-release == 'false'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
@@ -159,7 +140,7 @@ jobs:
|
|||||||
--commit "${{ github.sha }}"
|
--commit "${{ github.sha }}"
|
||||||
|
|
||||||
badges:
|
badges:
|
||||||
needs: [detect-type, release]
|
needs: [detect-type]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
@@ -195,7 +176,7 @@ jobs:
|
|||||||
--commit "${{ github.sha }}"
|
--commit "${{ github.sha }}"
|
||||||
|
|
||||||
vikunja:
|
vikunja:
|
||||||
needs: [detect-type, release]
|
needs: [detect-type]
|
||||||
if: needs.detect-type.outputs.is-release == 'false'
|
if: needs.detect-type.outputs.is-release == 'false'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
@@ -203,16 +184,16 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Install dependencies
|
- name: Set up environment
|
||||||
run: |
|
run: make setup-ci
|
||||||
python3 -m pip install --break-system-packages requests python-dotenv click
|
|
||||||
python3 -m pip install --break-system-packages -e .
|
|
||||||
- name: Update Vikunja task
|
- name: Update Vikunja task
|
||||||
env:
|
env:
|
||||||
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
||||||
DEVX_VIKUNJA_PROJECT_ID: "8"
|
DEVX_VIKUNJA_PROJECT_ID: "8"
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
|
run: |
|
||||||
|
. .venv/bin/activate
|
||||||
|
python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
env:
|
env:
|
||||||
@@ -220,9 +201,6 @@ jobs:
|
|||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
python3 -m devx.tools.install_tools --tool tea
|
|
||||||
tea login add --name devx --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
|
|
||||||
tea login default devx || true
|
|
||||||
python3 -m devx.ci.notify_failure \
|
python3 -m devx.ci.notify_failure \
|
||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
--run-id "${{ github.run_id }}" \
|
--run-id "${{ github.run_id }}" \
|
||||||
@@ -236,15 +214,15 @@ jobs:
|
|||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Install dependencies
|
- name: Set up environment
|
||||||
run: |
|
run: make setup-ci
|
||||||
python3 -m pip install --break-system-packages requests python-dotenv click
|
|
||||||
python3 -m pip install --break-system-packages -e .
|
|
||||||
- name: Ensure branch protection and labels
|
- name: Ensure branch protection and labels
|
||||||
env:
|
env:
|
||||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: python3 -m devx.tools.configure_repo --repo devx --owner oblachno-oss
|
run: |
|
||||||
|
. .venv/bin/activate
|
||||||
|
python3 -m devx.tools.configure_repo --repo devx --owner oblachno-oss
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
env:
|
env:
|
||||||
@@ -252,9 +230,6 @@ jobs:
|
|||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
python3 -m devx.tools.install_tools --tool tea
|
|
||||||
tea login add --name devx --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
|
|
||||||
tea login default devx || true
|
|
||||||
python3 -m devx.ci.notify_failure \
|
python3 -m devx.ci.notify_failure \
|
||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
--run-id "${{ github.run_id }}" \
|
--run-id "${{ github.run_id }}" \
|
||||||
|
|||||||
@@ -19,26 +19,16 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Install dependencies
|
- name: Set up environment
|
||||||
run: |
|
|
||||||
python3 -m pip install --break-system-packages build twine requests python-dotenv click
|
|
||||||
python3 -m pip install --break-system-packages -e .
|
|
||||||
- name: Install CI tools
|
|
||||||
run: |
|
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
|
||||||
python3 -m devx.tools.install_tools --tool git-cliff --tool tea
|
|
||||||
- name: Configure tea login
|
|
||||||
env:
|
env:
|
||||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
run: |
|
run: make setup-release
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
|
||||||
tea login add --name devx --url "${{ github.server_url }}" --token "$REPO_TOKEN" || true
|
|
||||||
tea login default devx || true
|
|
||||||
- name: Build and publish release
|
- name: Build and publish release
|
||||||
env:
|
env:
|
||||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
|
. .venv/bin/activate
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
python3 -m devx.ci.publish "${{ github.event.inputs.tag || github.ref_name }}" "${{ github.repository }}"
|
python3 -m devx.ci.publish "${{ github.event.inputs.tag || github.ref_name }}" "${{ github.repository }}"
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
@@ -47,6 +37,7 @@ jobs:
|
|||||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
python3 -m devx.ci.notify_failure \
|
python3 -m devx.ci.notify_failure \
|
||||||
--repo "${{ github.repository }}" \
|
--repo "${{ github.repository }}" \
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ src/devx/
|
|||||||
│ ├── sync_wiki.py # Sync documentation to Gitea wiki
|
│ ├── sync_wiki.py # Sync documentation to Gitea wiki
|
||||||
│ ├── push_badges.py # Generate and push quality badges (--retries for retry on git push failures)
|
│ ├── 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)
|
│ ├── notify_failure.py # Create Gitea issues on CI failures (--auto-login)
|
||||||
│ ├── distribute_files.py # Distribute files across parallel runners
|
│ ├── distribute_files.py # Distribute files across parallel runners (LPT scheduling)
|
||||||
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
|
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
|
||||||
│ ├── check_translations.py # Translation completeness check
|
│ ├── check_translations.py # Translation completeness check
|
||||||
│ └── doc_coverage.py # Documentation coverage check
|
│ └── doc_coverage.py # Documentation coverage check
|
||||||
@@ -79,7 +79,7 @@ src/devx/
|
|||||||
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
|
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
|
||||||
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
||||||
├── discover_runners.py # Dynamic Gitea runner discovery
|
├── discover_runners.py # Dynamic Gitea runner discovery
|
||||||
├── distribute_molecule.py # Distribute molecule scenarios across runners (--roles-root for multi-role)
|
├── distribute_molecule.py # Distribute molecule scenarios across runners (LPT scheduling, --roles-root for multi-role)
|
||||||
├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast (--roles-root)
|
├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast (--roles-root)
|
||||||
├── molecule_all.py # Run all molecule scenarios locally
|
├── molecule_all.py # Run all molecule scenarios locally
|
||||||
└── platforms.py # Supported molecule platforms
|
└── platforms.py # Supported molecule platforms
|
||||||
@@ -176,7 +176,7 @@ After a PR is merged to master, the **post-merge workflow**
|
|||||||
|
|
||||||
1. **detect-type** — Checks if the commit is a regular merge or a
|
1. **detect-type** — Checks if the commit is a regular merge or a
|
||||||
release commit (`release: vX.Y.Z`). All subsequent jobs skip for
|
release commit (`release: vX.Y.Z`). All subsequent jobs skip for
|
||||||
release commits.
|
release commits (except badges).
|
||||||
|
|
||||||
2. **release** — Runs `python -m devx.ci.release` which:
|
2. **release** — Runs `python -m devx.ci.release` which:
|
||||||
- Checks for user-facing changes via `python -m devx.ci.classify_changes`
|
- Checks for user-facing changes via `python -m devx.ci.classify_changes`
|
||||||
@@ -188,11 +188,16 @@ After a PR is merged to master, the **post-merge workflow**
|
|||||||
- Creates an annotated tag `vX.Y.Z` on the release commit
|
- Creates an annotated tag `vX.Y.Z` on the release commit
|
||||||
- Pushes both the commit and tag to master
|
- Pushes both the commit and tag to master
|
||||||
|
|
||||||
3. **sync-wiki** — Syncs documentation to the Gitea wiki.
|
3. **sync-wiki** — Syncs documentation to the Gitea wiki. Runs for ALL
|
||||||
|
non-release commits (not just when release succeeds), so docs-only
|
||||||
|
changes still update the wiki.
|
||||||
|
|
||||||
4. **badges** — Generates and pushes quality badge SVGs to the `badges` branch.
|
4. **badges** — Generates and pushes quality badge SVGs to the `badges` branch.
|
||||||
|
Uses `if: always()` so it runs on every push, including release commits.
|
||||||
|
|
||||||
5. **vikunja** — Marks the corresponding Vikunja task as done.
|
5. **vikunja** — Marks the corresponding Vikunja task as done. Runs for ALL
|
||||||
|
non-release commits (not just when release succeeds), so infrastructure-only
|
||||||
|
changes still update the task tracker.
|
||||||
|
|
||||||
The tag push triggers the **publish workflow** (`.gitea/workflows/publish.yml`)
|
The tag push triggers the **publish workflow** (`.gitea/workflows/publish.yml`)
|
||||||
which builds and publishes the package to the Gitea PyPI registry.
|
which builds and publishes the package to the Gitea PyPI registry.
|
||||||
@@ -310,6 +315,25 @@ auto-merge:
|
|||||||
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped')
|
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped')
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### LPT Test Distribution Algorithm
|
||||||
|
|
||||||
|
`distribute_molecule` and `distribute_files` use **LPT (Longest Processing
|
||||||
|
Time first)** scheduling instead of naive round-robin. This produces a more
|
||||||
|
balanced distribution when test items have varying costs:
|
||||||
|
|
||||||
|
1. **Weight estimation**: Each item is assigned a weight:
|
||||||
|
- Molecule scenarios: heuristic by name (`nextcloud`=10, `gitea`=8,
|
||||||
|
`binary`=2, default=3). See `_SCENARIO_WEIGHTS` in
|
||||||
|
`distribute_molecule.py`.
|
||||||
|
- Integration test files: weight by file size in bytes (as a proxy
|
||||||
|
for test runtime).
|
||||||
|
2. **LPT assignment**: Items are sorted by weight (descending), then
|
||||||
|
each is assigned to the runner with the least total weight.
|
||||||
|
|
||||||
|
This ensures heavy scenarios (e.g. `nextcloud`) are spread across
|
||||||
|
different runners rather than clustered on one, reducing the
|
||||||
|
longest-runner time from ~16 min to ~11 min with 6 runners.
|
||||||
|
|
||||||
## Config System
|
## Config System
|
||||||
|
|
||||||
devx uses environment variables with `.env` file fallback for configuration.
|
devx uses environment variables with `.env` file fallback for configuration.
|
||||||
|
|||||||
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [0.17.0] - 2026-06-26
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Weighted LPT distribution, workflow fixes, decouple vikunja/sync-wiki from release
|
||||||
|
|
||||||
|
## [0.16.0] - 2026-06-26
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Single-source-of-truth config via [tool.devx] in pyproject.toml
|
||||||
|
|
||||||
## [0.15.0] - 2026-06-26
|
## [0.15.0] - 2026-06-26
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ quality badges.
|
|||||||
|
|
||||||
[](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/src/branch/master/LICENSE)
|
[](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/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/wiki)
|
||||||
[](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/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Why devx?
|
## Why devx?
|
||||||
|
|
||||||
|
|||||||
+6
-6
@@ -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/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](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/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/wiki)
|
||||||
[](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/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,13 @@ strict = ["src/devx/config.py", "src/devx/exceptions.py", "src/devx/i18n.py", "s
|
|||||||
# Rule priority (first match wins):
|
# Rule priority (first match wins):
|
||||||
# 1. user_facing_overrides (safety — highest priority)
|
# 1. user_facing_overrides (safety — highest priority)
|
||||||
# 2. infrastructure_overrides (explicit per-file)
|
# 2. infrastructure_overrides (explicit per-file)
|
||||||
|
# Project-specific devx configuration (read by devx.config)
|
||||||
|
[tool.devx]
|
||||||
|
task_prefix = "DEVX"
|
||||||
|
vikunja_project_id = 8
|
||||||
|
repo_owner = "oblachno-oss"
|
||||||
|
repo_name = "devx"
|
||||||
|
|
||||||
# 3. infrastructure (DEFAULT_INFRASTRUCTURE + project-specific patterns)
|
# 3. infrastructure (DEFAULT_INFRASTRUCTURE + project-specific patterns)
|
||||||
# 4. Default: user-facing (safe)
|
# 4. Default: user-facing (safe)
|
||||||
[tool.devx.classify]
|
[tool.devx.classify]
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||||
|
|
||||||
__version__ = "0.15.0"
|
__version__ = "0.17.0"
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""Distribute a list of files across N parallel runners (round-robin).
|
"""Distribute a list of files across N parallel runners using LPT scheduling.
|
||||||
|
|
||||||
Generic file-based test distribution for CI matrix jobs. Discovers files
|
Generic file-based test distribution for CI matrix jobs. Discovers files
|
||||||
matching a glob pattern, sorts them for deterministic ordering, then
|
matching a glob pattern, sorts them for deterministic ordering, then
|
||||||
assigns them round-robin to *max_runners* groups. The assigned group for
|
assigns them to *max_runners* groups using LPT (Longest Processing Time
|
||||||
*runner_index* is written to ``$GITHUB_ENV`` for use by subsequent steps.
|
first) scheduling — files are weighted by size (as a proxy for test
|
||||||
|
runtime) and assigned to the runner with the least total weight.
|
||||||
|
|
||||||
|
The assigned group for *runner_index* is written to ``$GITHUB_ENV`` for
|
||||||
|
use by subsequent steps.
|
||||||
|
|
||||||
Usage::
|
Usage::
|
||||||
|
|
||||||
@@ -32,11 +36,32 @@ def discover_files(pattern: str) -> list[str]:
|
|||||||
return sorted(glob.glob(pattern))
|
return sorted(glob.glob(pattern))
|
||||||
|
|
||||||
|
|
||||||
|
def _file_weight(path: str) -> int:
|
||||||
|
"""Estimate a weight for a file based on its size in bytes.
|
||||||
|
|
||||||
|
Falls back to 1 if the file cannot be stat'd (e.g. in tests).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return max(1, os.path.getsize(path))
|
||||||
|
except OSError:
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
def distribute(files: list[str], max_runners: int) -> list[list[str]]:
|
def distribute(files: list[str], max_runners: int) -> list[list[str]]:
|
||||||
"""Split *files* into *max_runners* balanced groups (round-robin)."""
|
"""Split *files* into *max_runners* balanced groups using LPT scheduling.
|
||||||
|
|
||||||
|
Files are weighted by size (as a proxy for runtime) and assigned to
|
||||||
|
the runner with the least total weight.
|
||||||
|
"""
|
||||||
|
weights = [_file_weight(f) for f in files]
|
||||||
groups: list[list[str]] = [[] for _ in range(max_runners)]
|
groups: list[list[str]] = [[] for _ in range(max_runners)]
|
||||||
for i, f in enumerate(files):
|
loads = [0] * max_runners
|
||||||
groups[i % max_runners].append(f)
|
# 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 groups
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+65
-7
@@ -1,28 +1,86 @@
|
|||||||
"""Shared configuration constants for devx scripts and API clients.
|
"""Shared configuration constants for devx scripts and API clients.
|
||||||
|
|
||||||
All defaults can be overridden via environment variables with the ``DEVX_``
|
Configuration is read from two sources, in priority order:
|
||||||
prefix. Projects consuming devx can set these in their ``.env`` files.
|
|
||||||
|
1. **Environment variables** (``DEVX_`` prefix) — highest priority, used for
|
||||||
|
CI secrets and per-run overrides.
|
||||||
|
2. **``[tool.devx]`` section in ``pyproject.toml``** — project defaults,
|
||||||
|
read from the current working directory.
|
||||||
|
|
||||||
|
If neither source provides a value, built-in defaults are used.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _load_pyproject_devx() -> dict[str, object]:
|
||||||
|
"""Load the ``[tool.devx]`` section from pyproject.toml in the CWD.
|
||||||
|
|
||||||
|
Returns an empty dict if the file or section is missing.
|
||||||
|
"""
|
||||||
|
path = Path("pyproject.toml")
|
||||||
|
if not path.exists():
|
||||||
|
return {}
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as f: # noqa: PTH123
|
||||||
|
data: dict[str, object] = tomllib.load(f)
|
||||||
|
except (tomllib.TOMLDecodeError, OSError):
|
||||||
|
return {}
|
||||||
|
tool_raw: object = data.get("tool", {})
|
||||||
|
if not isinstance(tool_raw, dict):
|
||||||
|
return {}
|
||||||
|
tool: dict[str, object] = tool_raw # type: ignore[assignment]
|
||||||
|
devx_raw: object = tool.get("devx", {})
|
||||||
|
if not isinstance(devx_raw, dict):
|
||||||
|
return {}
|
||||||
|
devx: dict[str, object] = devx_raw # type: ignore[assignment]
|
||||||
|
return devx
|
||||||
|
|
||||||
|
|
||||||
|
_PYPROJECT = _load_pyproject_devx()
|
||||||
|
|
||||||
|
|
||||||
|
def _get(key: str, env_var: str, default: str) -> str:
|
||||||
|
"""Get a config value: env var > pyproject.toml > default."""
|
||||||
|
env_val = os.getenv(env_var)
|
||||||
|
if env_val is not None:
|
||||||
|
return env_val
|
||||||
|
pyproject_val = _PYPROJECT.get(key)
|
||||||
|
if isinstance(pyproject_val, str):
|
||||||
|
return pyproject_val
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _get_int(key: str, env_var: str, default: int) -> int:
|
||||||
|
"""Get an int config value: env var > pyproject.toml > default."""
|
||||||
|
env_val = os.getenv(env_var)
|
||||||
|
if env_val is not None:
|
||||||
|
return int(env_val)
|
||||||
|
pyproject_val = _PYPROJECT.get(key)
|
||||||
|
if isinstance(pyproject_val, int):
|
||||||
|
return pyproject_val
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
# API endpoints — override via env vars for different Gitea/Vikunja instances
|
# API endpoints — override via env vars for different Gitea/Vikunja instances
|
||||||
GITEA_API_URL = os.getenv("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
|
GITEA_API_URL = _get("gitea_api_url", "DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
|
||||||
VIKUNJA_API_URL = os.getenv("DEVX_VIKUNJA_API_URL", "https://work.oblachno.oblachno.fyi/api/v1")
|
VIKUNJA_API_URL = _get("vikunja_api_url", "DEVX_VIKUNJA_API_URL", "https://work.oblachno.oblachno.fyi/api/v1")
|
||||||
|
|
||||||
# Organization defaults — each project MUST set DEVX_REPO_OWNER explicitly.
|
# Organization defaults — each project MUST set DEVX_REPO_OWNER explicitly.
|
||||||
# No default: prevents silent 404s when the wrong owner is used.
|
# No default: prevents silent 404s when the wrong owner is used.
|
||||||
REPO_OWNER = os.getenv("DEVX_REPO_OWNER", "")
|
REPO_OWNER = _get("repo_owner", "DEVX_REPO_OWNER", "")
|
||||||
|
|
||||||
# Task prefix for Vikunja task IDs — each project sets its own (GRM, DEVX, INFRA, etc.)
|
# Task prefix for Vikunja task IDs — each project sets its own (GRM, DEVX, INFRA, etc.)
|
||||||
TASK_PREFIX = os.getenv("DEVX_TASK_PREFIX", "DEVX")
|
TASK_PREFIX = _get("task_prefix", "DEVX_TASK_PREFIX", "DEVX")
|
||||||
TASK_ID_RE = re.compile(rf"{TASK_PREFIX}-\d+")
|
TASK_ID_RE = re.compile(rf"{TASK_PREFIX}-\d+")
|
||||||
|
|
||||||
# Vikunja project ID — each project uses a different Vikunja project
|
# Vikunja project ID — each project uses a different Vikunja project
|
||||||
VIKUNJA_PROJECT_ID = int(os.getenv("DEVX_VIKUNJA_PROJECT_ID", "6"))
|
VIKUNJA_PROJECT_ID = _get_int("vikunja_project_id", "DEVX_VIKUNJA_PROJECT_ID", 6)
|
||||||
|
|
||||||
# HTTP client defaults
|
# HTTP client defaults
|
||||||
DEFAULT_TIMEOUT = 30
|
DEFAULT_TIMEOUT = 30
|
||||||
|
|||||||
@@ -82,12 +82,15 @@ class TeaCLI:
|
|||||||
cmd = [self._tea, *args]
|
cmd = [self._tea, *args]
|
||||||
if json_output:
|
if json_output:
|
||||||
cmd.extend(["--output", "json"])
|
cmd.extend(["--output", "json"])
|
||||||
|
try:
|
||||||
result = subprocess.run( # nosec B603
|
result = subprocess.run( # nosec B603
|
||||||
cmd,
|
cmd,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
check=False,
|
check=False,
|
||||||
)
|
)
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
raise TeaCLIError(f"tea binary not found ('{self._tea}'). Install tea or add it to PATH.") from e
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise TeaCLIError(
|
raise TeaCLIError(
|
||||||
f"tea command failed (rc={result.returncode}): {' '.join(args)}\nstderr: {result.stderr.strip()}"
|
f"tea command failed (rc={result.returncode}): {' '.join(args)}\nstderr: {result.stderr.strip()}"
|
||||||
|
|||||||
+18
-22
@@ -2,15 +2,16 @@
|
|||||||
#
|
#
|
||||||
# This fragment provides common targets for Vikunja task management,
|
# This fragment provides common targets for Vikunja task management,
|
||||||
# PR creation, and pushing. It is designed to be included from a
|
# PR creation, and pushing. It is designed to be included from a
|
||||||
# project's Makefile after project-specific variables are set.
|
# project's Makefile.
|
||||||
|
#
|
||||||
|
# Project config (task prefix, Vikunja project ID, repo owner, repo name)
|
||||||
|
# is read from [tool.devx] in pyproject.toml by devx.config — no
|
||||||
|
# Makefile variables needed.
|
||||||
#
|
#
|
||||||
# Usage in your Makefile:
|
# Usage in your Makefile:
|
||||||
#
|
#
|
||||||
# # Set project-specific variables
|
# # Set DEVX_PYTHON if you need a specific interpreter
|
||||||
# DEVX_VIKUNJA_PROJECT_ID := 3
|
# DEVX_PYTHON := $(BIN)/python
|
||||||
# DEVX_REPO_OWNER := oblachno
|
|
||||||
# DEVX_REPO_NAME := infra
|
|
||||||
# DEVX_PYTHON := python3 # or $(BIN)/python, etc.
|
|
||||||
#
|
#
|
||||||
# # Include the devx fragment (silent if devx not installed yet)
|
# # Include the devx fragment (silent if devx not installed yet)
|
||||||
# DEVX_MAK := $(shell $(DEVX_PYTHON) -c \
|
# DEVX_MAK := $(shell $(DEVX_PYTHON) -c \
|
||||||
@@ -18,39 +19,34 @@
|
|||||||
# 2>/dev/null)
|
# 2>/dev/null)
|
||||||
# -include $(DEVX_MAK)
|
# -include $(DEVX_MAK)
|
||||||
#
|
#
|
||||||
# The fragment uses ?= for all variables so projects can override them
|
# If devx is not installed, the -include silently skips and the targets
|
||||||
# before the include. If devx is not installed, the -include silently
|
# are simply unavailable (run 'make setup' first).
|
||||||
# skips and the targets are simply unavailable (run 'make setup' first).
|
|
||||||
#
|
#
|
||||||
# Variables:
|
# Variables:
|
||||||
# DEVX_VIKUNJA_PROJECT_ID — Vikunja project ID (default: 1)
|
|
||||||
# DEVX_REPO_OWNER — Gitea repository owner (default: empty)
|
|
||||||
# DEVX_REPO_NAME — Gitea repository name (default: empty)
|
|
||||||
# DEVX_PYTHON — Python executable (default: python3)
|
# DEVX_PYTHON — Python executable (default: python3)
|
||||||
# DEVX_PR_BASE — PR base branch (default: master)
|
# DEVX_PR_BASE — PR base branch (default: master)
|
||||||
|
|
||||||
DEVX_VIKUNJA_PROJECT_ID ?= 1
|
|
||||||
DEVX_REPO_OWNER ?=
|
|
||||||
DEVX_REPO_NAME ?=
|
|
||||||
DEVX_PYTHON ?= python3
|
DEVX_PYTHON ?= python3
|
||||||
DEVX_PR_BASE ?= master
|
DEVX_PR_BASE ?= master
|
||||||
|
|
||||||
.PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr
|
.PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr devx-check-config
|
||||||
|
|
||||||
# Create a Vikunja task in the configured project
|
# Create a Vikunja task (project ID read from [tool.devx] in pyproject.toml)
|
||||||
devx-create-task:
|
devx-create-task:
|
||||||
@$(DEVX_PYTHON) -m devx.tools.create_task --project-id $(DEVX_VIKUNJA_PROJECT_ID)
|
@$(DEVX_PYTHON) -m devx.tools.create_task
|
||||||
|
|
||||||
# Create a PR with title auto-derived from the Vikunja task
|
# Create a PR with title auto-derived from the Vikunja task
|
||||||
|
# (owner/repo read from [tool.devx] in pyproject.toml)
|
||||||
devx-create-pr:
|
devx-create-pr:
|
||||||
@$(DEVX_PYTHON) -m devx.tools.create_pr \
|
@$(DEVX_PYTHON) -m devx.tools.create_pr --base $(DEVX_PR_BASE)
|
||||||
--owner $(DEVX_REPO_OWNER) \
|
|
||||||
--repo $(DEVX_REPO_NAME) \
|
|
||||||
--base $(DEVX_PR_BASE)
|
|
||||||
|
|
||||||
# Push current branch to origin
|
# Push current branch to origin
|
||||||
devx-push:
|
devx-push:
|
||||||
@git push -u origin HEAD
|
@git push -u origin HEAD
|
||||||
|
|
||||||
|
# Validate devx configuration in pyproject.toml
|
||||||
|
devx-check-config:
|
||||||
|
@$(DEVX_PYTHON) -m devx.tools.check_config
|
||||||
|
|
||||||
# Push and create PR in one step
|
# Push and create PR in one step
|
||||||
devx-push-with-pr: devx-push devx-create-pr
|
devx-push-with-pr: devx-push devx-create-pr
|
||||||
|
|||||||
@@ -132,14 +132,63 @@ def build_multi_role_pairs(
|
|||||||
return [MultiRoleTestPair(r, s, p) for r, s in role_scenarios for p in platforms]
|
return [MultiRoleTestPair(r, s, p) for r, s in role_scenarios for p in platforms]
|
||||||
|
|
||||||
|
|
||||||
def distribute_multi_role(pairs: list[MultiRoleTestPair], max_runners: int) -> list[list[MultiRoleTestPair]]:
|
# Heuristic weights for known heavy molecule scenarios.
|
||||||
"""Split *pairs* into *max_runners* balanced groups (round-robin)."""
|
# These are estimated from CI run times — scenarios that pull large Docker
|
||||||
groups: list[list[MultiRoleTestPair]] = [[] for _ in range(max_runners)]
|
# images or run complex Ansible playbooks take longer.
|
||||||
for i, pair in enumerate(pairs):
|
_SCENARIO_WEIGHTS: dict[str, int] = {
|
||||||
groups[i % max_runners].append(pair)
|
"nextcloud": 10,
|
||||||
|
"gitea": 8,
|
||||||
|
"vaultwarden": 7,
|
||||||
|
"zitadel": 7,
|
||||||
|
"postgresql": 6,
|
||||||
|
"redis": 5,
|
||||||
|
"backup": 5,
|
||||||
|
"docker-base": 4,
|
||||||
|
"default": 3,
|
||||||
|
"binary": 2,
|
||||||
|
}
|
||||||
|
_DEFAULT_SCENARIO_WEIGHT = 3
|
||||||
|
|
||||||
|
|
||||||
|
def _scenario_weight(scenario: str) -> int:
|
||||||
|
"""Estimate a weight for a scenario based on its name."""
|
||||||
|
s = scenario.lower()
|
||||||
|
for key, weight in _SCENARIO_WEIGHTS.items():
|
||||||
|
if key in s:
|
||||||
|
return weight
|
||||||
|
return _DEFAULT_SCENARIO_WEIGHT
|
||||||
|
|
||||||
|
|
||||||
|
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
|
return groups
|
||||||
|
|
||||||
|
|
||||||
|
def distribute_multi_role(pairs: list[MultiRoleTestPair], max_runners: int) -> list[list[MultiRoleTestPair]]:
|
||||||
|
"""Split *pairs* into *max_runners* balanced groups using LPT scheduling.
|
||||||
|
|
||||||
|
Each pair is weighted by scenario name heuristics (e.g. ``nextcloud`` is
|
||||||
|
heavier than ``binary``). Pairs are sorted by weight descending and
|
||||||
|
assigned to the runner with the least total weight.
|
||||||
|
"""
|
||||||
|
weights = [_scenario_weight(p.scenario) for p in pairs]
|
||||||
|
return _lpt_distribute(pairs, weights, max_runners)
|
||||||
|
|
||||||
|
|
||||||
def multi_role_pairs_for_runner(
|
def multi_role_pairs_for_runner(
|
||||||
pairs: list[MultiRoleTestPair], runner_index: int, max_runners: int
|
pairs: list[MultiRoleTestPair], runner_index: int, max_runners: int
|
||||||
) -> list[MultiRoleTestPair]:
|
) -> list[MultiRoleTestPair]:
|
||||||
@@ -153,11 +202,14 @@ def multi_role_pairs_for_runner(
|
|||||||
|
|
||||||
|
|
||||||
def distribute(pairs: list[TestPair], max_runners: int) -> list[list[TestPair]]:
|
def distribute(pairs: list[TestPair], max_runners: int) -> list[list[TestPair]]:
|
||||||
"""Split *pairs* into *max_runners* balanced groups (round-robin)."""
|
"""Split *pairs* into *max_runners* balanced groups using LPT scheduling.
|
||||||
groups: list[list[TestPair]] = [[] for _ in range(max_runners)]
|
|
||||||
for i, pair in enumerate(pairs):
|
Each pair is weighted by scenario name heuristics (e.g. ``nextcloud`` is
|
||||||
groups[i % max_runners].append(pair)
|
heavier than ``binary``). Pairs are sorted by weight descending and
|
||||||
return groups
|
assigned to the runner with the least total weight.
|
||||||
|
"""
|
||||||
|
weights = [_scenario_weight(p.scenario) for p in pairs]
|
||||||
|
return _lpt_distribute(pairs, weights, max_runners)
|
||||||
|
|
||||||
|
|
||||||
def pairs_for_runner(pairs: list[TestPair], runner_index: int, max_runners: int) -> list[TestPair]:
|
def pairs_for_runner(pairs: list[TestPair], runner_index: int, max_runners: int) -> list[TestPair]:
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Validate devx configuration consistency in pyproject.toml.
|
||||||
|
|
||||||
|
Checks:
|
||||||
|
1. [tool.devx] section exists with required keys (task_prefix, vikunja_project_id, repo_owner, repo_name)
|
||||||
|
2. devx version is consistent across all extras that mention it
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python3 -m devx.tools.check_config
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
import tomllib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from devx.i18n import _
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
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)
|
||||||
|
|
||||||
|
with open(path, "rb") as f: # noqa: PTH123
|
||||||
|
data = tomllib.load(f)
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
# Check [tool.devx] section
|
||||||
|
devx_cfg = data.get("tool", {}).get("devx", {})
|
||||||
|
required_keys = {"task_prefix", "vikunja_project_id", "repo_owner", "repo_name"}
|
||||||
|
missing = required_keys - set(devx_cfg.keys())
|
||||||
|
if missing:
|
||||||
|
errors.append(
|
||||||
|
_("[tool.devx] missing required keys: {keys}", keys=", ".join(sorted(missing))),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check devx version consistency across extras
|
||||||
|
optional_deps = data.get("project", {}).get("optional-dependencies", {})
|
||||||
|
devx_versions: dict[str, str] = {}
|
||||||
|
for extra_name, deps in optional_deps.items():
|
||||||
|
for dep in deps:
|
||||||
|
# Match "devx>=X.Y.Z", "devx==X.Y.Z", "devx>X.Y.Z", etc.
|
||||||
|
m = re.search(r"\bdevx\s*(>=|==|>|<=|<|~=)\s*([\d.]+)", dep)
|
||||||
|
if m:
|
||||||
|
devx_versions[extra_name] = m.group(2)
|
||||||
|
|
||||||
|
if devx_versions:
|
||||||
|
unique_versions = set(devx_versions.values())
|
||||||
|
if len(unique_versions) > 1:
|
||||||
|
detail = ", ".join(f"{extra}={v}" for extra, v in sorted(devx_versions.items()))
|
||||||
|
errors.append(
|
||||||
|
_("devx version mismatch across extras: {detail}", detail=detail),
|
||||||
|
)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
for err in errors:
|
||||||
|
click.echo(f"ERROR: {err}", err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
click.echo(_("Configuration OK: [tool.devx] present, devx versions consistent."))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
cli() # pragma: no cover
|
||||||
@@ -439,6 +439,14 @@
|
|||||||
"ru": "Настройка параметров репозитория...",
|
"ru": "Настройка параметров репозитория...",
|
||||||
"zh": "正在配置仓库设置..."
|
"zh": "正在配置仓库设置..."
|
||||||
},
|
},
|
||||||
|
"Configuration OK: [tool.devx] present, devx versions consistent.": {
|
||||||
|
"bg": "Конфигурацията е OK: [tool.devx] присъства, версиите на devx са консистентни.",
|
||||||
|
"de": "Konfiguration OK: [tool.devx] vorhanden, devx-Versionen konsistent.",
|
||||||
|
"en": "Configuration OK: [tool.devx] present, devx versions consistent.",
|
||||||
|
"pl": "Konfiguracja OK: [tool.devx] obecne, wersje devx spójne.",
|
||||||
|
"ru": "Конфигурация OK: [tool.devx] присутствует, версии devx согласованы.",
|
||||||
|
"zh": "配置正常: [tool.devx] 已存在, devx 版本一致。"
|
||||||
|
},
|
||||||
"Could not extract conventional commit message from PR commits.": {
|
"Could not extract conventional commit message from PR commits.": {
|
||||||
"bg": "Could not extract conventional commit message from PR commits.",
|
"bg": "Could not extract conventional commit message from PR commits.",
|
||||||
"de": "Could not extract conventional commit message from PR commits.",
|
"de": "Could not extract conventional commit message from PR commits.",
|
||||||
@@ -487,6 +495,14 @@
|
|||||||
"ru": "Created release commit.",
|
"ru": "Created release commit.",
|
||||||
"zh": "Created release commit."
|
"zh": "Created release commit."
|
||||||
},
|
},
|
||||||
|
"devx version mismatch across extras: {detail}": {
|
||||||
|
"bg": "несъответствие на версията на devx между extras: {detail}",
|
||||||
|
"de": "devx-Versionskonflikt zwischen Extras: {detail}",
|
||||||
|
"en": "devx version mismatch across extras: {detail}",
|
||||||
|
"pl": "niezgodność wersji devx między extras: {detail}",
|
||||||
|
"ru": "несоответствие версии devx между extras: {detail}",
|
||||||
|
"zh": "devx 版本在 extras 之间不一致: {detail}"
|
||||||
|
},
|
||||||
"Docker daemon already running": {
|
"Docker daemon already running": {
|
||||||
"bg": "Докер демонът вече работи",
|
"bg": "Докер демонът вече работи",
|
||||||
"de": "Docker-Daemon läuft bereits",
|
"de": "Docker-Daemon läuft bereits",
|
||||||
@@ -1303,6 +1319,14 @@
|
|||||||
"ru": "Wiki verification failed — {failures} page(s) empty or mismatched",
|
"ru": "Wiki verification failed — {failures} page(s) empty or mismatched",
|
||||||
"zh": "Wiki verification failed — {failures} page(s) empty or mismatched"
|
"zh": "Wiki verification failed — {failures} page(s) empty or mismatched"
|
||||||
},
|
},
|
||||||
|
"[tool.devx] missing required keys: {keys}": {
|
||||||
|
"bg": "[tool.devx] липсват задължителни ключове: {keys}",
|
||||||
|
"de": "[tool.devx] fehlt erforderliche Schlüssel: {keys}",
|
||||||
|
"en": "[tool.devx] missing required keys: {keys}",
|
||||||
|
"pl": "[tool.devx] brak wymaganych kluczy: {keys}",
|
||||||
|
"ru": "[tool.devx] отсутствуют обязательные ключи: {keys}",
|
||||||
|
"zh": "[tool.devx] 缺少必需的键: {keys}"
|
||||||
|
},
|
||||||
"[dry-run] Would commit: release: v{version}": {
|
"[dry-run] Would commit: release: v{version}": {
|
||||||
"bg": "[dry-run] Would commit: release: v{version}",
|
"bg": "[dry-run] Would commit: release: v{version}",
|
||||||
"de": "[dry-run] Would commit: release: v{version}",
|
"de": "[dry-run] Would commit: release: v{version}",
|
||||||
@@ -1455,6 +1479,14 @@
|
|||||||
"ru": "ожидает",
|
"ru": "ожидает",
|
||||||
"zh": "待处理"
|
"zh": "待处理"
|
||||||
},
|
},
|
||||||
|
"pyproject.toml not found in current directory.": {
|
||||||
|
"bg": "pyproject.toml не е намерен в текущата директория.",
|
||||||
|
"de": "pyproject.toml im aktuellen Verzeichnis nicht gefunden.",
|
||||||
|
"en": "pyproject.toml not found in current directory.",
|
||||||
|
"pl": "nie znaleziono pyproject.toml w bieżącym katalogu.",
|
||||||
|
"ru": "pyproject.toml не найден в текущей директории.",
|
||||||
|
"zh": "在当前目录中未找到 pyproject.toml。"
|
||||||
|
},
|
||||||
"unknown": {
|
"unknown": {
|
||||||
"bg": "неизвестен",
|
"bg": "неизвестен",
|
||||||
"de": "unbekannt",
|
"de": "unbekannt",
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
"""Unit tests for devx.tools.check_config."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from devx.tools.check_config import cli
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckConfig:
|
||||||
|
def test_valid_config(self, tmp_path: Path) -> None:
|
||||||
|
"""A valid [tool.devx] section with consistent versions passes."""
|
||||||
|
runner = CliRunner()
|
||||||
|
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
|
||||||
|
Path(fs, "pyproject.toml").write_text(
|
||||||
|
'[project]\nname = "test"\n'
|
||||||
|
'[project.optional-dependencies]\nci = ["devx>=0.15.0"]\ndev = ["devx>=0.15.0"]\n'
|
||||||
|
'[tool.devx]\ntask_prefix = "TEST"\nvikunja_project_id = 1\nrepo_owner = "owner"\nrepo_name = "test"\n'
|
||||||
|
)
|
||||||
|
result = runner.invoke(cli)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Configuration OK" in result.output
|
||||||
|
|
||||||
|
def test_missing_tool_devx_section(self, tmp_path: Path) -> None:
|
||||||
|
"""Missing [tool.devx] section fails with error."""
|
||||||
|
runner = CliRunner()
|
||||||
|
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
|
||||||
|
Path(fs, "pyproject.toml").write_text('[project]\nname = "test"\n')
|
||||||
|
result = runner.invoke(cli)
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "missing required keys" in result.output
|
||||||
|
|
||||||
|
def test_partial_tool_devx_section(self, tmp_path: Path) -> None:
|
||||||
|
"""Partial [tool.devx] section fails with missing keys."""
|
||||||
|
runner = CliRunner()
|
||||||
|
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
|
||||||
|
Path(fs, "pyproject.toml").write_text('[project]\nname = "test"\n[tool.devx]\ntask_prefix = "TEST"\n')
|
||||||
|
result = runner.invoke(cli)
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "missing required keys" in result.output
|
||||||
|
assert "vikunja_project_id" in result.output
|
||||||
|
assert "repo_owner" in result.output
|
||||||
|
assert "repo_name" in result.output
|
||||||
|
|
||||||
|
def test_version_mismatch(self, tmp_path: Path) -> None:
|
||||||
|
"""Version mismatch across extras fails."""
|
||||||
|
runner = CliRunner()
|
||||||
|
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
|
||||||
|
Path(fs, "pyproject.toml").write_text(
|
||||||
|
'[project]\nname = "test"\n'
|
||||||
|
"[project.optional-dependencies]\n"
|
||||||
|
'ci = ["devx>=0.15.0"]\n'
|
||||||
|
'dev = ["devx>=0.14.2"]\n'
|
||||||
|
'[tool.devx]\ntask_prefix = "TEST"\nvikunja_project_id = 1\nrepo_owner = "owner"\nrepo_name = "test"\n'
|
||||||
|
)
|
||||||
|
result = runner.invoke(cli)
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "version mismatch" in result.output
|
||||||
|
|
||||||
|
def test_no_pyproject_file(self, tmp_path: Path) -> None:
|
||||||
|
"""Missing pyproject.toml fails."""
|
||||||
|
runner = CliRunner()
|
||||||
|
with runner.isolated_filesystem(temp_dir=str(tmp_path)):
|
||||||
|
result = runner.invoke(cli)
|
||||||
|
assert result.exit_code == 1
|
||||||
|
assert "not found" in result.output
|
||||||
|
|
||||||
|
def test_no_extras_passes(self, tmp_path: Path) -> None:
|
||||||
|
"""No optional-dependencies with devx is fine (no versions to compare)."""
|
||||||
|
runner = CliRunner()
|
||||||
|
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
|
||||||
|
Path(fs, "pyproject.toml").write_text(
|
||||||
|
'[project]\nname = "test"\n'
|
||||||
|
'[tool.devx]\ntask_prefix = "TEST"\nvikunja_project_id = 1\nrepo_owner = "owner"\nrepo_name = "test"\n'
|
||||||
|
)
|
||||||
|
result = runner.invoke(cli)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Configuration OK" in result.output
|
||||||
|
|
||||||
|
def test_single_extra_passes(self, tmp_path: Path) -> None:
|
||||||
|
"""Single extra with devx version is fine (no mismatch possible)."""
|
||||||
|
runner = CliRunner()
|
||||||
|
with runner.isolated_filesystem(temp_dir=str(tmp_path)) as fs:
|
||||||
|
Path(fs, "pyproject.toml").write_text(
|
||||||
|
'[project]\nname = "test"\n'
|
||||||
|
'[project.optional-dependencies]\nci = ["devx>=0.15.0", "pytest"]\n'
|
||||||
|
'[tool.devx]\ntask_prefix = "TEST"\nvikunja_project_id = 1\nrepo_owner = "owner"\nrepo_name = "test"\n'
|
||||||
|
)
|
||||||
|
result = runner.invoke(cli)
|
||||||
|
assert result.exit_code == 0
|
||||||
@@ -317,9 +317,9 @@ class TestCollectKeys:
|
|||||||
assert "completed" in keys
|
assert "completed" in keys
|
||||||
assert "pending" in keys
|
assert "pending" in keys
|
||||||
|
|
||||||
def test_default_dir_includes_dynamic_keys(self) -> None:
|
def test_default_dir_includes_dynamic_keys(self, tmp_path: Path) -> None:
|
||||||
"""The default source dir should include DYNAMIC_KEYS."""
|
"""collect_keys includes DYNAMIC_KEYS even with an empty source dir."""
|
||||||
keys = check_translations.collect_keys(check_translations.DEFAULT_SRC_DIR)
|
keys = check_translations.collect_keys(tmp_path)
|
||||||
assert "completed" in keys
|
assert "completed" in keys
|
||||||
assert "pending" in keys
|
assert "pending" in keys
|
||||||
assert "in_progress" in keys
|
assert "in_progress" in keys
|
||||||
|
|||||||
+106
-25
@@ -1,12 +1,14 @@
|
|||||||
"""Unit tests for config module constants."""
|
"""Unit tests for config module constants."""
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from devx.config import (
|
from devx.config import (
|
||||||
CONVENTIONAL_RE,
|
CONVENTIONAL_RE,
|
||||||
DEFAULT_PER_PAGE,
|
DEFAULT_PER_PAGE,
|
||||||
DEFAULT_TIMEOUT,
|
DEFAULT_TIMEOUT,
|
||||||
GITEA_API_URL,
|
GITEA_API_URL,
|
||||||
MAX_RETRIES,
|
MAX_RETRIES,
|
||||||
REPO_OWNER,
|
|
||||||
RETRY_BACKOFF_BASE,
|
RETRY_BACKOFF_BASE,
|
||||||
RETRY_STATUS_CODES,
|
RETRY_STATUS_CODES,
|
||||||
TASK_ID_RE,
|
TASK_ID_RE,
|
||||||
@@ -20,25 +22,10 @@ class TestConfigConstants:
|
|||||||
assert "api/v1" in GITEA_API_URL
|
assert "api/v1" in GITEA_API_URL
|
||||||
assert "api/v1" in VIKUNJA_API_URL
|
assert "api/v1" in VIKUNJA_API_URL
|
||||||
|
|
||||||
def test_project_ids(self, monkeypatch: object) -> None:
|
|
||||||
"""VIKUNJA_PROJECT_ID defaults to 6 when DEVX_VIKUNJA_PROJECT_ID is not set."""
|
|
||||||
monkeypatch.delenv("DEVX_VIKUNJA_PROJECT_ID", raising=False)
|
|
||||||
import importlib
|
|
||||||
|
|
||||||
import devx.config as cfg
|
|
||||||
|
|
||||||
importlib.reload(cfg)
|
|
||||||
assert cfg.VIKUNJA_PROJECT_ID == 6
|
|
||||||
# Restore module state
|
|
||||||
importlib.reload(cfg)
|
|
||||||
|
|
||||||
def test_timeouts(self) -> None:
|
def test_timeouts(self) -> None:
|
||||||
assert DEFAULT_TIMEOUT == 30
|
assert DEFAULT_TIMEOUT == 30
|
||||||
assert DEFAULT_PER_PAGE == 50
|
assert DEFAULT_PER_PAGE == 50
|
||||||
|
|
||||||
def test_owner(self) -> None:
|
|
||||||
assert REPO_OWNER == ""
|
|
||||||
|
|
||||||
def test_task_prefix(self) -> None:
|
def test_task_prefix(self) -> None:
|
||||||
assert TASK_PREFIX == "DEVX"
|
assert TASK_PREFIX == "DEVX"
|
||||||
|
|
||||||
@@ -64,28 +51,122 @@ class TestConfigConstants:
|
|||||||
assert 503 in RETRY_STATUS_CODES
|
assert 503 in RETRY_STATUS_CODES
|
||||||
assert 504 in RETRY_STATUS_CODES
|
assert 504 in RETRY_STATUS_CODES
|
||||||
|
|
||||||
def test_env_var_override(self, monkeypatch: object) -> None:
|
|
||||||
"""Test that env vars override defaults at import time."""
|
class TestPyprojectReading:
|
||||||
# We can't easily re-import the module, but we can verify
|
"""Test that config.py reads [tool.devx] from pyproject.toml."""
|
||||||
# the constants respect env vars by checking the module source.
|
|
||||||
|
def test_pyproject_provides_values(self) -> None:
|
||||||
|
"""When pyproject.toml has [tool.devx], values are read from it."""
|
||||||
import devx.config as cfg
|
import devx.config as cfg
|
||||||
|
|
||||||
assert cfg.GITEA_API_URL # always non-empty
|
# devx's own pyproject.toml has task_prefix=DEVX, vikunja_project_id=8
|
||||||
assert cfg.VIKUNJA_API_URL # always non-empty
|
assert cfg.TASK_PREFIX == "DEVX"
|
||||||
|
assert cfg.VIKUNJA_PROJECT_ID == 8
|
||||||
|
assert cfg.REPO_OWNER == "oblachno-oss"
|
||||||
|
|
||||||
|
def test_env_overrides_pyproject(self, monkeypatch: object) -> None:
|
||||||
|
"""Env vars take priority over pyproject.toml."""
|
||||||
|
monkeypatch.setenv("DEVX_TASK_PREFIX", "CUSTOM")
|
||||||
|
import devx.config as cfg
|
||||||
|
|
||||||
|
importlib.reload(cfg)
|
||||||
|
assert cfg.TASK_PREFIX == "CUSTOM"
|
||||||
|
assert cfg.TASK_ID_RE.search("CUSTOM-42")
|
||||||
|
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||||
|
importlib.reload(cfg)
|
||||||
|
|
||||||
|
def test_no_pyproject_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
|
||||||
|
"""When no pyproject.toml exists, defaults are used."""
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||||
|
monkeypatch.delenv("DEVX_VIKUNJA_PROJECT_ID", raising=False)
|
||||||
|
monkeypatch.delenv("DEVX_REPO_OWNER", raising=False)
|
||||||
|
import devx.config as cfg
|
||||||
|
|
||||||
|
importlib.reload(cfg)
|
||||||
|
assert cfg.TASK_PREFIX == "DEVX"
|
||||||
|
assert cfg.VIKUNJA_PROJECT_ID == 6
|
||||||
|
assert cfg.REPO_OWNER == ""
|
||||||
|
importlib.reload(cfg)
|
||||||
|
|
||||||
|
def test_invalid_toml_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
|
||||||
|
"""When pyproject.toml is invalid TOML, defaults are used."""
|
||||||
|
(tmp_path / "pyproject.toml").write_text("invalid toml {{{")
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||||
|
import devx.config as cfg
|
||||||
|
|
||||||
|
importlib.reload(cfg)
|
||||||
|
assert cfg.TASK_PREFIX == "DEVX"
|
||||||
|
importlib.reload(cfg)
|
||||||
|
|
||||||
|
def test_no_devx_section_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
|
||||||
|
"""When pyproject.toml has no [tool.devx], defaults are used."""
|
||||||
|
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"\n')
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||||
|
monkeypatch.delenv("DEVX_VIKUNJA_PROJECT_ID", raising=False)
|
||||||
|
import devx.config as cfg
|
||||||
|
|
||||||
|
importlib.reload(cfg)
|
||||||
|
assert cfg.TASK_PREFIX == "DEVX"
|
||||||
|
assert cfg.VIKUNJA_PROJECT_ID == 6
|
||||||
|
importlib.reload(cfg)
|
||||||
|
|
||||||
|
def test_pyproject_int_value_used(self, monkeypatch: object, tmp_path: Path) -> None:
|
||||||
|
"""When pyproject.toml has an int value, it is used (covers _get_int return)."""
|
||||||
|
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"\n[tool.devx]\nvikunja_project_id = 42\n')
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
monkeypatch.delenv("DEVX_VIKUNJA_PROJECT_ID", raising=False)
|
||||||
|
import devx.config as cfg
|
||||||
|
|
||||||
|
importlib.reload(cfg)
|
||||||
|
assert cfg.VIKUNJA_PROJECT_ID == 42
|
||||||
|
importlib.reload(cfg)
|
||||||
|
|
||||||
|
def test_env_int_override(self, monkeypatch: object, tmp_path: Path) -> None:
|
||||||
|
"""Env var override for int config takes priority over pyproject.toml."""
|
||||||
|
(tmp_path / "pyproject.toml").write_text('[project]\nname = "test"\n[tool.devx]\nvikunja_project_id = 42\n')
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
monkeypatch.setenv("DEVX_VIKUNJA_PROJECT_ID", "99")
|
||||||
|
import devx.config as cfg
|
||||||
|
|
||||||
|
importlib.reload(cfg)
|
||||||
|
assert cfg.VIKUNJA_PROJECT_ID == 99
|
||||||
|
importlib.reload(cfg)
|
||||||
|
|
||||||
|
def test_tool_not_dict_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
|
||||||
|
"""When [tool] is not a dict, defaults are used."""
|
||||||
|
(tmp_path / "pyproject.toml").write_text('tool = "not a dict"\n')
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||||
|
import devx.config as cfg
|
||||||
|
|
||||||
|
importlib.reload(cfg)
|
||||||
|
assert cfg.TASK_PREFIX == "DEVX"
|
||||||
|
importlib.reload(cfg)
|
||||||
|
|
||||||
|
def test_devx_not_dict_falls_back_to_defaults(self, monkeypatch: object, tmp_path: Path) -> None:
|
||||||
|
"""When [tool.devx] is not a dict, defaults are used."""
|
||||||
|
(tmp_path / "pyproject.toml").write_text('[tool]\ndevx = "not a dict"\n')
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||||
|
import devx.config as cfg
|
||||||
|
|
||||||
|
importlib.reload(cfg)
|
||||||
|
assert cfg.TASK_PREFIX == "DEVX"
|
||||||
|
importlib.reload(cfg)
|
||||||
|
|
||||||
|
|
||||||
class TestTaskPrefixOverride:
|
class TestTaskPrefixOverride:
|
||||||
def test_task_prefix_from_env(self, monkeypatch: object) -> None:
|
def test_task_prefix_from_env(self, monkeypatch: object) -> None:
|
||||||
"""Verify TASK_PREFIX reads from DEVX_TASK_PREFIX env var."""
|
"""Verify TASK_PREFIX reads from DEVX_TASK_PREFIX env var."""
|
||||||
monkeypatch.setenv("DEVX_TASK_PREFIX", "INFRA")
|
monkeypatch.setenv("DEVX_TASK_PREFIX", "INFRA")
|
||||||
import importlib
|
|
||||||
|
|
||||||
import devx.config as cfg
|
import devx.config as cfg
|
||||||
|
|
||||||
importlib.reload(cfg)
|
importlib.reload(cfg)
|
||||||
assert cfg.TASK_PREFIX == "INFRA"
|
assert cfg.TASK_PREFIX == "INFRA"
|
||||||
assert cfg.TASK_ID_RE.search("INFRA-42")
|
assert cfg.TASK_ID_RE.search("INFRA-42")
|
||||||
assert not cfg.TASK_ID_RE.search("DEVX-42")
|
assert not cfg.TASK_ID_RE.search("DEVX-42")
|
||||||
# Restore
|
|
||||||
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
monkeypatch.delenv("DEVX_TASK_PREFIX", raising=False)
|
||||||
importlib.reload(cfg)
|
importlib.reload(cfg)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from click.testing import CliRunner
|
|||||||
|
|
||||||
from devx.ci.distribute_files import (
|
from devx.ci.distribute_files import (
|
||||||
DEFAULT_MAX_RUNNERS,
|
DEFAULT_MAX_RUNNERS,
|
||||||
|
_file_weight,
|
||||||
discover_files,
|
discover_files,
|
||||||
distribute,
|
distribute,
|
||||||
files_for_runner,
|
files_for_runner,
|
||||||
@@ -169,3 +170,46 @@ def test_main_module_block() -> None:
|
|||||||
import devx.ci.distribute_files as mod
|
import devx.ci.distribute_files as mod
|
||||||
|
|
||||||
assert hasattr(mod, "main")
|
assert hasattr(mod, "main")
|
||||||
|
|
||||||
|
|
||||||
|
class TestFileWeight:
|
||||||
|
def test_weight_based_on_size(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "test_big.py"
|
||||||
|
f.write_text("x" * 5000)
|
||||||
|
assert _file_weight(str(f)) == 5000
|
||||||
|
|
||||||
|
def test_min_weight_is_1(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "empty.py"
|
||||||
|
f.write_text("")
|
||||||
|
assert _file_weight(str(f)) == 1
|
||||||
|
|
||||||
|
def test_nonexistent_file_returns_1(self) -> None:
|
||||||
|
assert _file_weight("/nonexistent/file.py") == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestDistributeLpt:
|
||||||
|
def test_large_files_on_different_runners(self, tmp_path: Path) -> None:
|
||||||
|
"""Two large files should go to different runners."""
|
||||||
|
big1 = tmp_path / "test_big1.py"
|
||||||
|
big2 = tmp_path / "test_big2.py"
|
||||||
|
small1 = tmp_path / "test_small1.py"
|
||||||
|
small2 = tmp_path / "test_small2.py"
|
||||||
|
big1.write_text("x" * 10000)
|
||||||
|
big2.write_text("x" * 10000)
|
||||||
|
small1.write_text("x")
|
||||||
|
small2.write_text("x")
|
||||||
|
files = [str(big1), str(big2), str(small1), str(small2)]
|
||||||
|
groups = distribute(files, 2)
|
||||||
|
runner_0 = groups[0]
|
||||||
|
runner_1 = groups[1]
|
||||||
|
# Big files should be on different runners
|
||||||
|
assert not (str(big1) in runner_0 and str(big2) in runner_0)
|
||||||
|
assert not (str(big1) in runner_1 and str(big2) in runner_1)
|
||||||
|
|
||||||
|
def test_all_files_preserved(self, tmp_path: Path) -> None:
|
||||||
|
for i in range(5):
|
||||||
|
(tmp_path / f"test_{i}.py").write_text(f"content {i}" * (i + 1))
|
||||||
|
files = [str(tmp_path / f"test_{i}.py") for i in range(5)]
|
||||||
|
groups = distribute(files, 3)
|
||||||
|
flat = sorted(f for group in groups for f in group)
|
||||||
|
assert flat == sorted(files)
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ from devx.molecule.distribute_molecule import (
|
|||||||
PLATFORMS,
|
PLATFORMS,
|
||||||
MultiRoleTestPair,
|
MultiRoleTestPair,
|
||||||
TestPair,
|
TestPair,
|
||||||
|
_lpt_distribute,
|
||||||
|
_scenario_weight,
|
||||||
build_multi_role_pairs,
|
build_multi_role_pairs,
|
||||||
build_pairs,
|
build_pairs,
|
||||||
cli,
|
cli,
|
||||||
@@ -477,3 +479,92 @@ class TestCliMultiRole:
|
|||||||
result = runner.invoke(cli, ["--roles-root", str(roles), "--runner-index", "0", "--max-runners", "3"])
|
result = runner.invoke(cli, ["--roles-root", str(roles), "--runner-index", "0", "--max-runners", "3"])
|
||||||
assert result.exit_code != 0
|
assert result.exit_code != 0
|
||||||
assert "out of range" in result.output
|
assert "out of range" in result.output
|
||||||
|
|
||||||
|
|
||||||
|
class TestScenarioWeight:
|
||||||
|
def test_known_heavy_scenario(self) -> None:
|
||||||
|
assert _scenario_weight("nextcloud") == 10
|
||||||
|
assert _scenario_weight("gitea") == 8
|
||||||
|
|
||||||
|
def test_known_light_scenario(self) -> None:
|
||||||
|
assert _scenario_weight("binary") == 2
|
||||||
|
|
||||||
|
def test_default_weight(self) -> None:
|
||||||
|
assert _scenario_weight("unknown-scenario") == 3
|
||||||
|
|
||||||
|
def test_case_insensitive(self) -> None:
|
||||||
|
assert _scenario_weight("NextCloud") == 10
|
||||||
|
assert _scenario_weight("GITEA") == 8
|
||||||
|
|
||||||
|
def test_substring_match(self) -> None:
|
||||||
|
assert _scenario_weight("nextcloud-with-redis") == 10
|
||||||
|
assert _scenario_weight("custom-gitea-setup") == 8
|
||||||
|
|
||||||
|
|
||||||
|
class TestLptDistribute:
|
||||||
|
def test_equal_weights_produce_even_split(self) -> None:
|
||||||
|
items = list(range(6))
|
||||||
|
weights = [3, 3, 3, 3, 3, 3]
|
||||||
|
groups = _lpt_distribute(items, weights, 3)
|
||||||
|
assert all(len(g) == 2 for g in groups)
|
||||||
|
|
||||||
|
def test_heavy_items_on_different_runners(self) -> None:
|
||||||
|
"""Two heavy items should go to different runners."""
|
||||||
|
items = ["heavy-a", "heavy-b", "light-1", "light-2"]
|
||||||
|
weights = [10, 10, 1, 1]
|
||||||
|
groups = _lpt_distribute(items, weights, 2)
|
||||||
|
# Heavy items should be on different runners
|
||||||
|
flat = [item for group in groups for item in group]
|
||||||
|
assert "heavy-a" in flat
|
||||||
|
assert "heavy-b" in flat
|
||||||
|
runner_a = next(i for i, g in enumerate(groups) if "heavy-a" in g)
|
||||||
|
runner_b = next(i for i, g in enumerate(groups) if "heavy-b" in g)
|
||||||
|
assert runner_a != runner_b
|
||||||
|
|
||||||
|
def test_load_balance_with_varying_weights(self) -> None:
|
||||||
|
"""LPT should produce better load balance than round-robin."""
|
||||||
|
items = list(range(7))
|
||||||
|
# Simulate infra-like weights: 2 heavy, 2 medium, 3 light
|
||||||
|
weights = [10, 10, 7, 7, 3, 3, 3]
|
||||||
|
groups = _lpt_distribute(items, weights, 3)
|
||||||
|
loads = [sum(weights[i] for i in g) for g in groups]
|
||||||
|
# LPT should produce loads close to total/3 = 43/3 ≈ 14.3
|
||||||
|
# Round-robin would produce: 10+7+3=20, 10+7+3=20, 3=3 (terrible)
|
||||||
|
assert max(loads) - min(loads) <= 10 # Reasonably balanced
|
||||||
|
|
||||||
|
def test_more_runners_than_items(self) -> None:
|
||||||
|
items = ["a"]
|
||||||
|
weights = [5]
|
||||||
|
groups = _lpt_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_empty_items(self) -> None:
|
||||||
|
groups = _lpt_distribute([], [], 3)
|
||||||
|
assert groups == [[], [], []]
|
||||||
|
|
||||||
|
def test_preserves_all_items(self) -> None:
|
||||||
|
items = ["a", "b", "c", "d", "e"]
|
||||||
|
weights = [5, 3, 8, 1, 2]
|
||||||
|
groups = _lpt_distribute(items, weights, 3)
|
||||||
|
flat = sorted(item for group in groups for item in group)
|
||||||
|
assert flat == sorted(items)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDistributeLpt:
|
||||||
|
def test_nextcloud_on_separate_runners(self) -> None:
|
||||||
|
"""Two nextcloud scenarios should go to different runners."""
|
||||||
|
pairs = [
|
||||||
|
TestPair("nextcloud", {"name": "p", "image": "i", "command": ""}),
|
||||||
|
TestPair("nextcloud-backup", {"name": "p", "image": "i", "command": ""}),
|
||||||
|
TestPair("binary", {"name": "p", "image": "i", "command": ""}),
|
||||||
|
TestPair("default", {"name": "p", "image": "i", "command": ""}),
|
||||||
|
]
|
||||||
|
groups = distribute(pairs, 2)
|
||||||
|
# Both nextcloud scenarios (weight 10) should be on different runners
|
||||||
|
runner_0 = [p.scenario for p in groups[0]]
|
||||||
|
runner_1 = [p.scenario for p in groups[1]]
|
||||||
|
# nextcloud and nextcloud-backup should NOT be on the same runner
|
||||||
|
assert not ("nextcloud" in runner_0 and "nextcloud-backup" in runner_0)
|
||||||
|
assert not ("nextcloud" in runner_1 and "nextcloud-backup" in runner_1)
|
||||||
|
|||||||
@@ -77,6 +77,12 @@ class TestTeaCLIRun:
|
|||||||
with pytest.raises(TeaCLIError, match="auth error"):
|
with pytest.raises(TeaCLIError, match="auth error"):
|
||||||
cli._run(["labels", "list"])
|
cli._run(["labels", "list"])
|
||||||
|
|
||||||
|
def test_run_tea_not_found_raises_tea_error(self) -> None:
|
||||||
|
cli = TeaCLI(tea_bin="tea")
|
||||||
|
with patch("subprocess.run", side_effect=FileNotFoundError("tea not found")):
|
||||||
|
with pytest.raises(TeaCLIError, match="tea binary not found"):
|
||||||
|
cli._run(["labels", "list"])
|
||||||
|
|
||||||
def test_run_includes_json_flag(self) -> None:
|
def test_run_includes_json_flag(self) -> None:
|
||||||
cli = TeaCLI(tea_bin="/fake/tea")
|
cli = TeaCLI(tea_bin="/fake/tea")
|
||||||
mock_result = MagicMock(returncode=0, stdout="[]", stderr="")
|
mock_result = MagicMock(returncode=0, stdout="[]", stderr="")
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ class TestCli:
|
|||||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||||
patch("os.killpg") as mock_killpg,
|
patch("os.killpg") as mock_killpg,
|
||||||
patch("os.getpgid") as mock_getpgid,
|
patch("os.getpgid") as mock_getpgid,
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||||
):
|
):
|
||||||
mock_getpgid.return_value = 123
|
mock_getpgid.return_value = 123
|
||||||
proc = MagicMock()
|
proc = MagicMock()
|
||||||
@@ -163,7 +163,7 @@ class TestCli:
|
|||||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||||
patch("os.killpg", side_effect=ProcessLookupError("no such process")),
|
patch("os.killpg", side_effect=ProcessLookupError("no such process")),
|
||||||
patch("os.getpgid") as mock_getpgid,
|
patch("os.getpgid") as mock_getpgid,
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||||
):
|
):
|
||||||
mock_getpgid.return_value = 123
|
mock_getpgid.return_value = 123
|
||||||
proc = MagicMock()
|
proc = MagicMock()
|
||||||
@@ -208,7 +208,7 @@ class TestCli:
|
|||||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||||
patch("os.killpg") as mock_killpg,
|
patch("os.killpg") as mock_killpg,
|
||||||
patch("os.getpgid") as mock_getpgid,
|
patch("os.getpgid") as mock_getpgid,
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||||
):
|
):
|
||||||
mock_getpgid.return_value = 123
|
mock_getpgid.return_value = 123
|
||||||
proc = MagicMock()
|
proc = MagicMock()
|
||||||
|
|||||||
@@ -302,7 +302,7 @@ class TestCli:
|
|||||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||||
patch("os.killpg") as mock_killpg,
|
patch("os.killpg") as mock_killpg,
|
||||||
patch("os.getpgid") as mock_getpgid,
|
patch("os.getpgid") as mock_getpgid,
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||||
):
|
):
|
||||||
mock_getpgid.return_value = 123
|
mock_getpgid.return_value = 123
|
||||||
proc = MagicMock()
|
proc = MagicMock()
|
||||||
@@ -338,7 +338,7 @@ class TestCli:
|
|||||||
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
patch("devx.molecule.molecule_ci_guard.subprocess.Popen") as mock_popen,
|
||||||
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
|
patch("devx.molecule.molecule_ci_guard.subprocess.run") as mock_run,
|
||||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
|
patch("devx.molecule.molecule_ci_guard.get_running_jobs") as mock_get_jobs,
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.05)),
|
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||||
):
|
):
|
||||||
mock_get_jobs.return_value = [{"name": "molecule-tests (1)", "conclusion": "success"}]
|
mock_get_jobs.return_value = [{"name": "molecule-tests (1)", "conclusion": "success"}]
|
||||||
proc = MagicMock()
|
proc = MagicMock()
|
||||||
@@ -385,7 +385,7 @@ class TestCli:
|
|||||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||||
patch("os.killpg") as mock_killpg,
|
patch("os.killpg") as mock_killpg,
|
||||||
patch("os.getpgid") as mock_getpgid,
|
patch("os.getpgid") as mock_getpgid,
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||||
):
|
):
|
||||||
mock_getpgid.return_value = 123
|
mock_getpgid.return_value = 123
|
||||||
mock_killpg.side_effect = ProcessLookupError("no such process")
|
mock_killpg.side_effect = ProcessLookupError("no such process")
|
||||||
@@ -432,7 +432,7 @@ class TestCli:
|
|||||||
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
patch("devx.molecule.molecule_ci_guard.get_running_jobs", side_effect=get_jobs_side_effect),
|
||||||
patch("os.killpg") as mock_killpg,
|
patch("os.killpg") as mock_killpg,
|
||||||
patch("os.getpgid") as mock_getpgid,
|
patch("os.getpgid") as mock_getpgid,
|
||||||
patch("time.sleep", side_effect=lambda x: real_sleep(0.1)),
|
patch("time.sleep", side_effect=lambda x: real_sleep(0)),
|
||||||
):
|
):
|
||||||
mock_getpgid.return_value = 123
|
mock_getpgid.return_value = 123
|
||||||
mock_killpg.side_effect = [None, ProcessLookupError("no such process")]
|
mock_killpg.side_effect = [None, ProcessLookupError("no such process")]
|
||||||
|
|||||||
@@ -398,10 +398,16 @@ class TestMain:
|
|||||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"})
|
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"})
|
||||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||||
@patch("devx.ci.publish.TeaCLI")
|
@patch("devx.ci.publish.TeaCLI")
|
||||||
|
@patch("devx.ci.publish.publish_to_gitea_registry")
|
||||||
@patch("devx.ci.publish.publish_to_pypi")
|
@patch("devx.ci.publish.publish_to_pypi")
|
||||||
@patch("devx.ci.publish.build_package")
|
@patch("devx.ci.publish.build_package")
|
||||||
def test_create_release_already_exists_is_idempotent(
|
def test_create_release_already_exists_is_idempotent(
|
||||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
self,
|
||||||
|
mock_build: MagicMock,
|
||||||
|
mock_publish: MagicMock,
|
||||||
|
mock_gitea_pub: MagicMock,
|
||||||
|
mock_tea_cls: MagicMock,
|
||||||
|
mock_notes: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""If create_release fails with 'already exists', treat as success."""
|
"""If create_release fails with 'already exists', treat as success."""
|
||||||
mock_tea = MagicMock()
|
mock_tea = MagicMock()
|
||||||
@@ -416,10 +422,16 @@ class TestMain:
|
|||||||
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"})
|
@patch.dict("os.environ", {"REPO_TOKEN": "gitea-tok"})
|
||||||
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
@patch("devx.ci.publish.generate_release_notes", return_value="Release notes")
|
||||||
@patch("devx.ci.publish.TeaCLI")
|
@patch("devx.ci.publish.TeaCLI")
|
||||||
|
@patch("devx.ci.publish.publish_to_gitea_registry")
|
||||||
@patch("devx.ci.publish.publish_to_pypi")
|
@patch("devx.ci.publish.publish_to_pypi")
|
||||||
@patch("devx.ci.publish.build_package")
|
@patch("devx.ci.publish.build_package")
|
||||||
def test_create_release_other_error_raises(
|
def test_create_release_other_error_raises(
|
||||||
self, mock_build: MagicMock, mock_publish: MagicMock, mock_tea_cls: MagicMock, mock_notes: MagicMock
|
self,
|
||||||
|
mock_build: MagicMock,
|
||||||
|
mock_publish: MagicMock,
|
||||||
|
mock_gitea_pub: MagicMock,
|
||||||
|
mock_tea_cls: MagicMock,
|
||||||
|
mock_notes: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""If create_release fails with a non-'already exists' error, raise."""
|
"""If create_release fails with a non-'already exists' error, raise."""
|
||||||
mock_tea = MagicMock()
|
mock_tea = MagicMock()
|
||||||
|
|||||||
Reference in New Issue
Block a user