Compare commits

..
9 Commits
Author SHA1 Message Date
devx-ci-bot 281193c741 release: v0.38.0 [skip ci] 2026-07-08 19:30:58 +00:00
emil 0228fce5b9 DEVX-123: feat: introduce role-based Gitea API token environment variables
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 17s
Post-merge / vikunja (push) Successful in 18s
Post-merge / release (push) Successful in 36s
Post-merge / publish (push) Successful in 20s
Post-merge / badges (push) Successful in 35s
2026-07-08 19:30:10 +00:00
gitea-actions-bot 981d3e41cc chore: update badge URLs to commit fe187115 [skip ci] 2026-07-07 22:02:05 +00:00
devx-ci-bot 3cd2459eef release: v0.37.0 [skip ci] 2026-07-07 22:01:14 +00:00
emil ef08513bcf DEVX-122: feat: consolidate docs checks into devx-docs-check target
Post-merge / detect-type (push) Successful in 19s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / vikunja (push) Successful in 24s
Post-merge / configure-repo (push) Successful in 25s
Post-merge / sync-wiki (push) Successful in 32s
Post-merge / release (push) Successful in 43s
Post-merge / publish (push) Successful in 21s
Post-merge / badges (push) Successful in 38s
2026-07-07 22:00:07 +00:00
gitea-actions-bot 05922eca2f chore: update badge URLs to commit bff19e05 [skip ci] 2026-07-07 15:53:21 +00:00
devx-ci-bot 05de2b0aa9 release: v0.36.2 [skip ci] 2026-07-07 15:52:35 +00:00
emil 6a463a93d2 DEVX-121: fix: GiteaClient.set_repo_variable uses PUT instead of PATCH
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 22s
Post-merge / configure-repo (push) Successful in 17s
Post-merge / sync-wiki (push) Successful in 31s
Post-merge / release (push) Successful in 38s
Post-merge / publish (push) Successful in 23s
Post-merge / badges (push) Successful in 39s
2026-07-07 15:51:45 +00:00
gitea-actions-bot ad7b52c368 chore: update badge URLs to commit d9423d85 [skip ci] 2026-07-07 12:05:28 +00:00
59 changed files with 787 additions and 228 deletions
+15 -2
View File
@@ -1,6 +1,19 @@
# Gitea API token (required for CI scripts that interact with Gitea) # Role-based Gitea API tokens.
# Each token serves a specific role. For small teams the developer and CI
# tokens may belong to the same user, but the reviewer token MUST belong to a
# different Gitea user than the PR author so Gitea accepts approval reviews.
# Create at: https://git.oblachno.oblachno.fyi/user/settings/applications # Create at: https://git.oblachno.oblachno.fyi/user/settings/applications
CI_GITEA_TOKEN=
# Developer token — used by local tooling: create-task, create-pr, setup, etc.
DEVELOPER_GITEA_API_TOKEN=
# CI token — used by CI workflows and scripts that do not post approvals.
# Legacy CI_GITEA_TOKEN is also accepted.
CI_GITEA_API_TOKEN=
# Reviewer token — used by the auto-merge workflow to post APPROVE reviews.
# This must be a different Gitea user from the developer/CI user.
REVIEWER_GITEA_API_TOKEN=
# Vikunja API token (required for post-merge task updates) # Vikunja API token (required for post-merge task updates)
# Create at: https://work.oblachno.oblachno.fyi/settings/tokens # Create at: https://work.oblachno.oblachno.fyi/settings/tokens
+14 -6
View File
@@ -38,6 +38,8 @@ jobs:
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Set up environment - name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-ci run: make setup-ci
- name: Check if this is a release commit - name: Check if this is a release commit
id: check id: check
@@ -62,18 +64,22 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Set up environment - name: Set up environment
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-release run: make setup-release
- name: Docker registry login - name: Docker registry login
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }} CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
run: | run: |
. .venv/bin/activate . .venv/bin/activate
echo "$CI_GITEA_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$CI_GITEA_USERNAME" --password-stdin _TOKEN="$CI_GITEA_API_TOKEN"
[ -z "$_TOKEN" ] && _TOKEN="$DEVELOPER_GITEA_API_TOKEN"
[ -z "$_TOKEN" ] && _TOKEN="$CI_GITEA_TOKEN"
if [ -z "$_TOKEN" ]; then echo "Gitea API token not set — skipping Docker login"; exit 1; fi
echo "$_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$CI_GITEA_USERNAME" --password-stdin
- name: Build and push tier images - name: Build and push tier images
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }} CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
@@ -103,7 +109,7 @@ jobs:
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
@@ -125,10 +131,12 @@ jobs:
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Set up environment - name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-ci run: make setup-ci
- name: Clean up old image versions - name: Clean up old image versions
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate . .venv/bin/activate
+19 -25
View File
@@ -16,6 +16,8 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up environment - name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image run: make setup-image
- name: Lint all - name: Lint all
run: | run: |
@@ -32,30 +34,15 @@ jobs:
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.check_test_speed --max-seconds 6 --max-single-seconds 0.5 python3 -m devx.tools.check_test_speed --max-seconds 6 --max-single-seconds 0.5
- name: Documentation coverage check - name: Documentation gate (coverage + stale refs + lint + version refs + prose)
env: env:
PYTHONPATH: src PYTHONPATH: src
DEVX_DOC_COVERAGE_STRICT: "1"
DEVX_VALE_LEVEL: warning
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.doc_coverage --fail-on-missing export PATH="$HOME/.local/bin:$PATH"
- name: Documentation lint check make devx-docs-check
env:
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.lint_docs --root .
- name: Documentation version reference check
env:
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.check_doc_versions --root .
- name: Vale prose lint check
env:
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
make devx-vale
- name: Translation completeness check - name: Translation completeness check
env: env:
PYTHONPATH: src PYTHONPATH: src
@@ -94,6 +81,8 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up environment - name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image run: make setup-image
- name: Detect changed paths - name: Detect changed paths
id: detect id: detect
@@ -121,10 +110,11 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Set up environment - name: Set up environment
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image run: make setup-image
- name: Release dry-run validation - name: Release dry-run validation
env: env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
@@ -142,10 +132,12 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up environment - name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image run: make setup-image
- name: Run automated PR review - name: Run automated PR review
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
set -euo pipefail set -euo pipefail
@@ -175,12 +167,14 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.CI_GITEA_TOKEN }} token: ${{ secrets.CI_GITEA_API_TOKEN }}
- name: Set up environment - name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image run: make setup-image
- name: Post approval review - name: Post approval review
env: env:
CI_GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }} REVIEWER_GITEA_API_TOKEN: ${{ secrets.REVIEWER_GITEA_API_TOKEN }}
PR_NUMBER: ${{ github.event.number }} PR_NUMBER: ${{ github.event.number }}
REPOSITORY: ${{ github.repository }} REPOSITORY: ${{ github.repository }}
PYTHONPATH: src PYTHONPATH: src
@@ -195,7 +189,7 @@ jobs:
--body "Auto-approved: all CI checks passed (quality, pr-review, release-dry-run)." --body "Auto-approved: all CI checks passed (quality, pr-review, release-dry-run)."
- name: Squash merge with task ID - name: Squash merge with task ID
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }} VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
DEVX_VIKUNJA_PROJECT_ID: "8" DEVX_VIKUNJA_PROJECT_ID: "8"
PYTHONPATH: src PYTHONPATH: src
+28 -12
View File
@@ -47,6 +47,8 @@ jobs:
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Set up environment - name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image run: make setup-image
- name: Check if this is a release commit - name: Check if this is a release commit
id: check id: check
@@ -70,6 +72,8 @@ jobs:
with: with:
fetch-depth: 1 fetch-depth: 1
- name: Set up environment - name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image run: make setup-image
- name: Validate latest commit message - name: Validate latest commit message
env: env:
@@ -95,10 +99,10 @@ jobs:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
fetch-depth: 0 fetch-depth: 0
token: ${{ secrets.CI_GITEA_TOKEN }} token: ${{ secrets.CI_GITEA_API_TOKEN }}
- name: Set up environment - name: Set up environment
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image run: make setup-image
- name: Configure git - name: Configure git
run: | run: |
@@ -107,6 +111,7 @@ jobs:
- name: Run release - name: Run release
id: release-tag id: release-tag
env: env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
@@ -115,7 +120,7 @@ jobs:
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
@@ -142,10 +147,12 @@ jobs:
fetch-depth: 0 fetch-depth: 0
ref: ${{ needs.release.outputs.tag }} ref: ${{ needs.release.outputs.tag }}
- name: Set up environment - name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image EXTRAS=release run: make setup-image EXTRAS=release
- name: Build and publish release - name: Build and publish release
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
@@ -154,7 +161,7 @@ jobs:
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
@@ -183,10 +190,12 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up environment - name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image run: make setup-image
- name: Sync documentation to wiki - name: Sync documentation to wiki
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
@@ -194,7 +203,7 @@ jobs:
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
@@ -219,15 +228,18 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
ref: master ref: master
token: ${{ secrets.CI_GITEA_TOKEN }} token: ${{ secrets.CI_GITEA_API_TOKEN }}
- name: Fetch latest master - name: Fetch latest master
run: | run: |
git fetch origin master git fetch origin master
git reset --hard origin/master git reset --hard origin/master
- name: Set up environment - name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image run: make setup-image
- name: Generate and push badges - name: Generate and push badges
env: env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PRE_COMMIT_ALLOW_NO_CONFIG: "1" PRE_COMMIT_ALLOW_NO_CONFIG: "1"
run: | run: |
. .venv/bin/activate 2>/dev/null || true . .venv/bin/activate 2>/dev/null || true
@@ -235,7 +247,7 @@ jobs:
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
@@ -260,6 +272,8 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Set up environment - name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image run: make setup-image
- name: Update Vikunja task - name: Update Vikunja task
env: env:
@@ -272,7 +286,7 @@ jobs:
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
@@ -295,10 +309,12 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up environment - name: Set up environment
env:
CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
run: make setup-image run: make setup-image
- name: Ensure branch protection and labels - name: Ensure branch protection and labels
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
DEVX_REPO_NAME: devx DEVX_REPO_NAME: devx
DEVX_REPO_OWNER: oblachno-oss DEVX_REPO_OWNER: oblachno-oss
@@ -308,7 +324,7 @@ jobs:
- name: Notify on failure - name: Notify on failure
if: failure() if: failure()
env: env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }} CI_GITEA_API_TOKEN: ${{ secrets.CI_GITEA_API_TOKEN }}
PYTHONPATH: src PYTHONPATH: src
run: | run: |
export PATH="$HOME/.local/bin:$PATH" export PATH="$HOME/.local/bin:$PATH"
+4 -10
View File
@@ -73,18 +73,12 @@ repos:
pass_filenames: false pass_filenames: false
stages: [pre-commit] stages: [pre-commit]
- id: doc-coverage - id: docs-check
name: documentation coverage check name: documentation gate (coverage + stale refs + lint + version refs + prose)
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.doc_coverage --fail-on-missing entry: bash -c 'PYTHONPATH=src DEVX_DOC_COVERAGE_STRICT=1 DEVX_VALE_LEVEL=warning make devx-docs-check'
language: system
pass_filenames: false
stages: [pre-commit]
- id: lint-docs
name: documentation lint check
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.lint_docs --root .
language: system language: system
pass_filenames: false pass_filenames: false
always_run: true
stages: [pre-commit] stages: [pre-commit]
- id: pytest-cov - id: pytest-cov
+4 -1
View File
@@ -32,14 +32,17 @@ write-good.E-Prime = NO
write-good.So = NO write-good.So = NO
write-good.ThereIs = NO write-good.ThereIs = NO
write-good.TooWordy = NO write-good.TooWordy = NO
write-good.Passive = NO
# Vale defaults — spelling catches too many technical terms # Vale defaults — spelling catches too many technical terms
Vale.Terms = NO Vale.Terms = NO
Vale.Repetition = NO Vale.Repetition = NO
Vale.Spelling = NO Vale.Spelling = NO
# Readability — warnings only, technical docs are naturally complex # Readability — technical docs are naturally complex, downgrade to suggestions
Readability.FleschReadingEase = suggestion Readability.FleschReadingEase = suggestion
Readability.FleschKincaid = suggestion
Readability.AutomatedReadability = suggestion
Readability.ColemanLiau = suggestion Readability.ColemanLiau = suggestion
Readability.LIX = suggestion Readability.LIX = suggestion
Readability.GunningFog = suggestion Readability.GunningFog = suggestion
+1 -1
View File
@@ -3,4 +3,4 @@ message: "Unlabeled code block — add a language tag (```bash, ```yaml, etc.)"
level: warning level: warning
scope: raw scope: raw
raw: raw:
- '(?s)```\n(?!.*```)' - '(?ms)^\n```\n.*?^```\s*$'
+1 -1
View File
@@ -2,7 +2,7 @@ Based on [write-good](https://github.com/btford/write-good).
> Naive linter for English prose for developers who can't write good and wanna learn to do other stuff good too. > Naive linter for English prose for developers who can't write good and wanna learn to do other stuff good too.
```text ```
The MIT License (MIT) The MIT License (MIT)
Copyright (c) 2014 Brian Ford Copyright (c) 2014 Brian Ford
+4 -4
View File
@@ -226,14 +226,14 @@ After a PR is merged to master, the **post-merge workflow**
- 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. Runs for ALL 3. **sync-wiki** — Syncs documentation to the Gitea wiki. Runs for ALL
non-release commits (not just when release succeeds), so docs-only non-release commits (not only when release succeeds), so docs-only
changes still update the wiki. 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. Uses `if: always()` so it runs on every push, including release commits.
5. **vikunja** — Marks the corresponding Vikunja task as done. Runs for ALL 5. **vikunja** — Marks the corresponding Vikunja task as done. Runs for ALL
non-release commits (not just when release succeeds), so infrastructure-only non-release commits (not only when release succeeds), so infrastructure-only
changes still update the task tracker. changes still update the task tracker.
6. **publish** — Runs after release succeeds (needs: release). Builds and 6. **publish** — Runs after release succeeds (needs: release). Builds and
@@ -398,7 +398,7 @@ system loads `.env` automatically via `python-dotenv`.
### pyproject.toml [tool.devx] Configuration ### pyproject.toml [tool.devx] Configuration
In addition to `DEVX_` env vars, several devx tools read configuration from In addition to `DEVX_` env vars, many devx tools read configuration from
the `[tool.devx]` section in `pyproject.toml`. This allows per-project the `[tool.devx]` section in `pyproject.toml`. This allows per-project
customization without environment variables. customization without environment variables.
@@ -610,7 +610,7 @@ the user should not need to specify which profile to use.
2. **Background by default, foreground when blocking.** 2. **Background by default, foreground when blocking.**
3. **Provide full context in the prompt** — subagents don't inherit conversation history. 3. **Provide full context in the prompt** — subagents don't inherit conversation history.
4. **One subagent per concern.** Chain: investigate → fix in main session → review. 4. **One subagent per concern.** Chain: investigate → fix in main session → review.
5. **Don't delegate trivial work** (<30s, <50 lines of context). 5. **Don't delegate minor work** (<30s, <50 lines of context).
6. **Compact after subagent returns.** 6. **Compact after subagent returns.**
7. **Never skip delegation to save time** — it keeps main context small. 7. **Never skip delegation to save time** — it keeps main context small.
+18
View File
@@ -2,6 +2,24 @@
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.38.0] - 2026-07-08
### Features
- Introduce role-based Gitea API token environment variables
## [0.37.0] - 2026-07-07
### Features
- Consolidate docs checks into devx-docs-check target
## [0.36.2] - 2026-07-07
### Bug Fixes
- GiteaClient.set_repo_variable uses PUT instead of PATCH
## [0.36.1] - 2026-07-07 ## [0.36.1] - 2026-07-07
### Bug Fixes ### Bug Fixes
+10 -10
View File
@@ -16,12 +16,12 @@ quality badges.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/40fbd801952eefafe3876bef53a09d217267f810/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/fe1871151080d6188805f2a31916f7007d10849b/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/40fbd801952eefafe3876bef53a09d217267f810/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/fe1871151080d6188805f2a31916f7007d10849b/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/40fbd801952eefafe3876bef53a09d217267f810/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/fe1871151080d6188805f2a31916f7007d10849b/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/40fbd801952eefafe3876bef53a09d217267f810/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/fe1871151080d6188805f2a31916f7007d10849b/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/40fbd801952eefafe3876bef53a09d217267f810/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/fe1871151080d6188805f2a31916f7007d10849b/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/40fbd801952eefafe3876bef53a09d217267f810/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/fe1871151080d6188805f2a31916f7007d10849b/python.svg)](https://www.python.org/downloads/)
## Why devx? ## Why devx?
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
```toml ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.36.1", "devx>=0.38.0",
] ]
[tool.pip] [tool.pip]
@@ -101,8 +101,8 @@ pip install -e .
``` ```
> **Note:** If your project requires a specific devx version, pin it in > **Note:** If your project requires a specific devx version, pin it in
> `dependencies` (for example, `"devx==0.36.1"`) or use a version constraint > `dependencies` (for example, `"devx==0.38.0"`) or use a version constraint
> (for example, `"devx>=0.36.1,<0.37"`). > (for example, `"devx>=0.38.0,<0.39"`).
### Optional extras ### Optional extras
@@ -372,7 +372,7 @@ infrastructure = []
# Files that would default to user-facing but are actually infrastructure # Files that would default to user-facing but are actually infrastructure
infrastructure_overrides = [ infrastructure_overrides = [
"src/myproject/__init__.py", # only contains __version__ "src/myproject/__init__.py", # example only — only contains __version__
] ]
# Safety override for broad infrastructure patterns # Safety override for broad infrastructure patterns
+8 -8
View File
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE) [![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/40fbd801952eefafe3876bef53a09d217267f810/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/fe1871151080d6188805f2a31916f7007d10849b/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/40fbd801952eefafe3876bef53a09d217267f810/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/fe1871151080d6188805f2a31916f7007d10849b/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/40fbd801952eefafe3876bef53a09d217267f810/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki) [![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/fe1871151080d6188805f2a31916f7007d10849b/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/40fbd801952eefafe3876bef53a09d217267f810/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions) [![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/fe1871151080d6188805f2a31916f7007d10849b/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/40fbd801952eefafe3876bef53a09d217267f810/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases) [![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/fe1871151080d6188805f2a31916f7007d10849b/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/40fbd801952eefafe3876bef53a09d217267f810/python.svg)](https://www.python.org/downloads/) [![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/fe1871151080d6188805f2a31916f7007d10849b/python.svg)](https://www.python.org/downloads/)
## Overview ## Overview
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
```toml ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.36.1", "devx>=0.38.0",
] ]
[tool.pip] [tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple" extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
``` ```
Pin a specific version if needed: `"devx==0.36.1"` or `"devx>=0.36.1,<0.37"`. Pin a specific version if needed: `"devx==0.38.0"` or `"devx>=0.38.0,<0.39"`.
### Optional extras ### Optional extras
+1 -1
View File
@@ -311,7 +311,7 @@ from `devx.api_clients`, `devx.config`, and `devx.gitea_cli`.
### `setup.py` ### `setup.py`
Project setup: installs Python dependencies (editable mode with extras), Project setup: installs Python dependencies (editable mode with extras),
Ansible Galaxy collections (if `ansible/requirements.yml` exists), pre-commit Ansible Galaxy collections (if `ansible/requirements.yml` exists in the target repo), pre-commit
hooks (pre-commit, commit-msg, pre-push), and configures the `tea` CLI login hooks (pre-commit, commit-msg, pre-push), and configures the `tea` CLI login
profile from `.env`. Supports `--extras` to specify dependency groups, profile from `.env`. Supports `--extras` to specify dependency groups,
`--no-pre-commit` to skip hook installation, and `--no-tea-login` to skip tea `--no-pre-commit` to skip hook installation, and `--no-tea-login` to skip tea
+1 -1
View File
@@ -251,7 +251,7 @@ badges using `python -m devx.ci.push_badges`:
1. **Fetch latest master** — `git fetch origin master && git reset --hard 1. **Fetch latest master** — `git fetch origin master && git reset --hard
origin/master` (ensures the version badge reflects the current state, origin/master` (ensures the version badge reflects the current state,
even if the release job just pushed a new version) even if the release job recently pushed a new version)
2. **Generate badges** — calls `devx.tools.generate_badges` which runs 2. **Generate badges** — calls `devx.tools.generate_badges` which runs
pytest-cov, doc-coverage, lint checks, and version extraction, then writes pytest-cov, doc-coverage, lint checks, and version extraction, then writes
SVG files: `coverage.svg`, `tests.svg`, `docs.svg`, `quality.svg`, SVG files: `coverage.svg`, `tests.svg`, `docs.svg`, `quality.svg`,
+1 -1
View File
@@ -405,7 +405,7 @@ devx tools install-tools --list # list status
### `devx tools setup` ### `devx tools setup`
Project setup: install Python dependencies (editable mode with extras), Project setup: install Python dependencies (editable mode with extras),
Ansible Galaxy collections (if `ansible/requirements.yml` exists), pre-commit Ansible Galaxy collections (if `ansible/requirements.yml` exists in the target repo), pre-commit
hooks (pre-commit, commit-msg, pre-push), and configure the tea CLI login hooks (pre-commit, commit-msg, pre-push), and configure the tea CLI login
profile from `.env`. profile from `.env`.
+2 -2
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml ```toml
[project] [project]
dependencies = [ dependencies = [
"devx>=0.36.1", "devx>=0.38.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
dev = [ dev = [
"devx>=0.36.1", "devx>=0.38.0",
] ]
``` ```
+6
View File
@@ -121,6 +121,12 @@ vikunja_project_id = 8
repo_owner = "oblachno-oss" repo_owner = "oblachno-oss"
repo_name = "devx" repo_name = "devx"
[tool.devx.check_agent_docs]
skip_ref_prefixes = [
"src/myproject/",
"ansible/requirements.yml",
]
# 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 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects.""" """devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.36.1" __version__ = "0.38.0"
+5 -4
View File
@@ -391,15 +391,16 @@ class GiteaClient:
def set_repo_variable(self, name: str, value: str) -> None: def set_repo_variable(self, name: str, value: str) -> None:
"""Create or update a Gitea Actions repository variable (idempotent). """Create or update a Gitea Actions repository variable (idempotent).
Tries PATCH first; if the variable doesn't exist (404), creates it Tries PUT first (update); if the variable doesn't exist (404),
via POST. creates it via POST. Gitea 1.26.x does not support PATCH for
action variables.
""" """
try: try:
self._request("PATCH", f"/actions/variables/{name}", json={"value": value}) self._request("PUT", f"/actions/variables/{name}", json={"value": value})
except APIError as e: except APIError as e:
if e.status != 404: if e.status != 404:
raise raise
self._request("POST", "/actions/variables", json={"name": name, "value": value}) self._request("POST", f"/actions/variables/{name}", json={"value": value})
class VikunjaClient: class VikunjaClient:
+12 -8
View File
@@ -17,10 +17,9 @@ This allows the PR title to be a human-friendly Vikunja task title
while the squashed commit follows conventional commits. while the squashed commit follows conventional commits.
Usage: Usage:
CI_GITEA_TOKEN=<token> python3 -m devx.ci.auto_merge <branch> <pr_title> <repo> <pr_number> CI_GITEA_API_TOKEN=<token> VIKUNJA_TOKEN=<token> python3 -m devx.ci.auto_merge <branch> <pr_title> <repo> <pr_number>
""" """
import os
import re import re
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -40,6 +39,7 @@ from devx.config import (
) )
from devx.exceptions import APIError from devx.exceptions import APIError
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_ci_token, get_vikunja_token
# Strip leading task ID prefix (e.g. "DEVX-12: " or "OBL-INFRA-364: ") from commit subjects. # Strip leading task ID prefix (e.g. "DEVX-12: " or "OBL-INFRA-364: ") from commit subjects.
_TASK_ID_PREFIX_RE = re.compile(rf"^{TASK_PREFIX}-\d+:\s*") _TASK_ID_PREFIX_RE = re.compile(rf"^{TASK_PREFIX}-\d+:\s*")
@@ -115,9 +115,12 @@ def get_vikunja_task_title(task_id: str) -> str:
Raises ClickException if VIKUNJA_TOKEN is not set or the task is not found. Raises ClickException if VIKUNJA_TOKEN is not set or the task is not found.
""" """
token = os.environ.get("VIKUNJA_TOKEN", "") try:
if not token: token = get_vikunja_token()
raise click.ClickException(_("VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.")) except click.ClickException:
raise click.ClickException(
_("VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.")
) from None
client = VikunjaClient(VIKUNJA_API_URL, token) client = VikunjaClient(VIKUNJA_API_URL, token)
page = 1 page = 1
while True: while True:
@@ -197,9 +200,10 @@ def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
@click.argument("repo") @click.argument("repo")
@click.argument("pr_number") @click.argument("pr_number")
def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None: def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
token = os.environ.get("CI_GITEA_TOKEN", "") try:
if not token: token = get_ci_token()
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) except click.ClickException:
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None
# Validate PR number is an integer # Validate PR number is an integer
try: try:
+17 -9
View File
@@ -15,7 +15,7 @@ Exit code 1 = NOT ready — fix issues before pushing.
Usage:: Usage::
# CI (with VIKUNJA_TOKEN and CI_GITEA_TOKEN): # CI (with VIKUNJA_TOKEN and CI_GITEA_API_TOKEN):
python3 -m devx.ci.check_auto_merge_ready \\ python3 -m devx.ci.check_auto_merge_ready \\
--branch "$HEAD_REF" \\ --branch "$HEAD_REF" \\
--pr-title "$PR_TITLE" \\ --pr-title "$PR_TITLE" \\
@@ -34,13 +34,12 @@ skipped (with a warning) — this allows local pre-push hooks to run
without CI secrets. In CI, the token is always set and the check is without CI secrets. In CI, the token is always set and the check is
mandatory. mandatory.
If ``CI_GITEA_TOKEN`` is not set and ``--pr-number`` is not provided, only If ``CI_GITEA_API_TOKEN`` is not set and ``--pr-number`` is not provided, only
branch-name and PR-title-format checks run (local mode). branch-name and PR-title-format checks run (local mode).
""" """
from __future__ import annotations from __future__ import annotations
import os
import subprocess # nosec B404 import subprocess # nosec B404
import click import click
@@ -55,6 +54,7 @@ from devx.config import (
) )
from devx.exceptions import APIError from devx.exceptions import APIError
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_ci_token, get_vikunja_token
load_dotenv() load_dotenv()
@@ -99,10 +99,13 @@ def is_branch_behind_master(branch: str) -> bool:
def get_pr_title_from_gitea(repo: str, pr_number: int) -> str | None: def get_pr_title_from_gitea(repo: str, pr_number: int) -> str | None:
"""Fetch the PR title from the Gitea API. """Fetch the PR title from the Gitea API.
Returns ``None`` if ``CI_GITEA_TOKEN`` is not set or the PR cannot be fetched. Returns ``None`` if no token is set or the PR cannot be fetched.
""" """
token = os.environ.get("CI_GITEA_TOKEN", "") try:
if not token or "/" not in repo: token = get_ci_token()
except click.ClickException:
return None
if "/" not in repo:
return None return None
owner, repo_name = repo.split("/", 1) owner, repo_name = repo.split("/", 1)
client = GiteaClient(GITEA_API_URL, token, owner, repo_name) client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
@@ -120,8 +123,9 @@ def get_vikunja_title_optional(task_id: str) -> str | None:
raise when ``VIKUNJA_TOKEN`` is missing it returns ``None`` so the raise when ``VIKUNJA_TOKEN`` is missing it returns ``None`` so the
caller can skip the check in local mode. caller can skip the check in local mode.
""" """
token = os.environ.get("VIKUNJA_TOKEN", "") try:
if not token: token = get_vikunja_token()
except click.ClickException:
return None return None
client = VikunjaClient(VIKUNJA_API_URL, token) client = VikunjaClient(VIKUNJA_API_URL, token)
from devx.config import DEFAULT_PER_PAGE from devx.config import DEFAULT_PER_PAGE
@@ -221,7 +225,11 @@ def cli(
if not skip_vikunja: if not skip_vikunja:
vikunja_title = get_vikunja_title_optional(task_id) vikunja_title = get_vikunja_title_optional(task_id)
if vikunja_title is None: if vikunja_title is None:
token_set = bool(os.environ.get("VIKUNJA_TOKEN", "")) try:
get_vikunja_token()
token_set = True
except click.ClickException:
token_set = False
if token_set: if token_set:
errors.append( errors.append(
_( _(
+6 -2
View File
@@ -31,6 +31,7 @@ import requests
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_ci_token
DEFAULT_MAX_RUNNERS = 3 DEFAULT_MAX_RUNNERS = 3
@@ -96,7 +97,7 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
return total return total
def get_runner_count(api_url: str, token: str, owner: str, repo: str) -> int: def get_runner_count(api_url: str, token: str | None, owner: str, repo: str) -> int:
"""Determine the number of available runners. """Determine the number of available runners.
Tries the Gitea API first, then falls back to env vars, then default. Tries the Gitea API first, then falls back to env vars, then default.
@@ -152,7 +153,10 @@ def main(
output_indices: bool, output_indices: bool,
github_output: bool, github_output: bool,
) -> None: ) -> None:
token = os.environ.get("CI_GITEA_TOKEN", "") try:
token = get_ci_token()
except click.ClickException:
token = None
if owner is None: if owner is None:
owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
+63 -8
View File
@@ -21,6 +21,7 @@ from pathlib import Path
import click import click
from devx.config import _load_pyproject_devx
from devx.i18n import _ from devx.i18n import _
# Default to the current working directory (consuming repo's root) # Default to the current working directory (consuming repo's root)
@@ -71,12 +72,30 @@ def extract_cli_commands(source_dir: Path) -> list[str]:
# Matches @cli.command, @ci.command, @tools.command, @molecule.command # Matches @cli.command, @ci.command, @tools.command, @molecule.command
for match in re.finditer(r"@\w+\.command\b", content): for match in re.finditer(r"@\w+\.command\b", content):
# Check for explicit name="..." in the decorator arguments # Check for explicit name="..." in the decorator arguments
decorator_end = content.find(")", match.start()) # Use a balanced paren search to find the end of the decorator
# (handles nested parens like @cli.command(help=_("...")))
depth = 0
decorator_end = match.start()
for i in range(match.start(), len(content)):
if content[i] == "(":
depth += 1
elif content[i] == ")":
depth -= 1
if depth == 0:
decorator_end = i
break
decorator_text = content[match.start() : decorator_end + 1] decorator_text = content[match.start() : decorator_end + 1]
name_match = re.search(r'["\']([^"\']+)["\']', decorator_text) # Look for explicit name="..." parameter (not help=, not other kwargs)
name_match = re.search(r'\bname\s*=\s*["\']([^"\']+)["\']', decorator_text)
if name_match: if name_match:
commands.append(name_match.group(1)) commands.append(name_match.group(1))
continue continue
# Look for a positional string argument (e.g. @cli.command("my-cmd"))
# but skip if the only strings are in help= or other keyword args
positional_match = re.search(r'@\w+\.command\s*\(\s*["\']([^"\']+)["\']', decorator_text)
if positional_match:
commands.append(positional_match.group(1))
continue
# Find the next def statement after this decorator # Find the next def statement after this decorator
after = content[decorator_end:] after = content[decorator_end:]
def_match = re.search(r"def\s+(\w+)\s*\(", after) def_match = re.search(r"def\s+(\w+)\s*\(", after)
@@ -106,16 +125,38 @@ def check_module_documented(module: str, docs_content: str) -> bool:
@click.command() @click.command()
@click.option("--docs-dir", default=None, help="Path to the docs directory (default: ./docs).") @click.option("--docs-dir", default=None, help="Path to the docs directory (default: ./docs).")
@click.option("--source-dir", default=None, help="Path to the source directory (default: auto-detect from src/).") @click.option("--source-dir", default=None, help="Path to the source directory (default: auto-detect from src/).")
@click.option(
"--ci-scripts-dir",
default=None,
help=(
"Path to CI scripts directory (default: auto-detect from src/ci/). "
"Set to empty string to skip CI script checks."
),
)
@click.option( @click.option(
"--fail-on-missing", "--fail-on-missing",
is_flag=True, is_flag=True,
default=False, default=False,
help="Exit with non-zero status if any documentation is missing.", help="Exit with non-zero status if any documentation is missing.",
) )
def main(docs_dir: str | None, source_dir: str | None, fail_on_missing: bool) -> None: def main(docs_dir: str | None, source_dir: str | None, ci_scripts_dir: str | None, fail_on_missing: bool) -> None:
root = Path.cwd() root = Path.cwd()
docs_path = Path(docs_dir) if docs_dir else root / "docs" docs_path = Path(docs_dir) if docs_dir else root / "docs"
# Read [tool.devx.doc_coverage] config from pyproject.toml
devx_cfg = _load_pyproject_devx()
doc_cov_cfg_raw: object = devx_cfg.get("doc_coverage", {}) if isinstance(devx_cfg, dict) else {}
doc_cov_cfg: dict[str, object] = doc_cov_cfg_raw if isinstance(doc_cov_cfg_raw, dict) else {}
# CLI args override config; config overrides defaults
if ci_scripts_dir is None and "ci_scripts_dir" in doc_cov_cfg:
ci_scripts_dir = str(doc_cov_cfg["ci_scripts_dir"])
if docs_dir is None and "docs_dir" in doc_cov_cfg:
docs_dir = str(doc_cov_cfg["docs_dir"])
docs_path = Path(docs_dir)
if source_dir is None and "source_dir" in doc_cov_cfg:
source_dir = str(doc_cov_cfg["source_dir"])
# Auto-detect source directory # Auto-detect source directory
if source_dir: if source_dir:
src_path = Path(source_dir) src_path = Path(source_dir)
@@ -166,13 +207,27 @@ def main(docs_dir: str | None, source_dir: str | None, fail_on_missing: bool) ->
missing.append(f"Module: {module}") missing.append(f"Module: {module}")
# Check CI scripts in ci-cd-workflow.md # Check CI scripts in ci-cd-workflow.md
# Auto-detect CI scripts from ci/ subdirectory # Auto-detect CI scripts from ci/ subdirectory, or use explicit config
click.echo(_("\nChecking CI script documentation in ci-cd-workflow.md...")) click.echo(_("\nChecking CI script documentation in ci-cd-workflow.md..."))
ci_dir = src_path / "ci" if src_path.name != "ci" else src_path if ci_scripts_dir is not None:
if ci_dir.exists(): # Explicit config — empty string means skip CI script checks
detected_scripts = sorted(f.name for f in ci_dir.glob("*.py") if f.name != "__init__.py") if ci_scripts_dir == "":
detected_scripts = []
else:
ci_dir = Path(ci_scripts_dir)
if ci_dir.exists():
detected_scripts = sorted(f.name for f in ci_dir.glob("*.py") if f.name != "__init__.py")
else:
detected_scripts = []
else: else:
detected_scripts = REQUIRED_SCRIPTS # Auto-detect from src_path/ci/
ci_dir = src_path / "ci" if src_path.name != "ci" else src_path
if ci_dir.exists():
detected_scripts = sorted(f.name for f in ci_dir.glob("*.py") if f.name != "__init__.py")
else:
# No ci/ directory found — skip CI script checks rather than falling back
# to REQUIRED_SCRIPTS (which is devx-specific)
detected_scripts = []
total += len(detected_scripts) total += len(detected_scripts)
ci_docs = ci_cd_file.read_text() if ci_cd_file.exists() else "" ci_docs = ci_cd_file.read_text() if ci_cd_file.exists() else ""
for script in detected_scripts: for script in detected_scripts:
+6 -2
View File
@@ -17,7 +17,7 @@ Usage::
Environment variables: Environment variables:
GITEA_URL Base URL of the Gitea instance. GITEA_URL Base URL of the Gitea instance.
CI_GITEA_TOKEN API token with repo access. CI_GITEA_API_TOKEN API token with repo access (CI_GITEA_TOKEN accepted for legacy).
RUN_ID Workflow run ID (GITHUB_RUN_ID). RUN_ID Workflow run ID (GITHUB_RUN_ID).
JOB_NAME Base job name (GITHUB_JOB), e.g. "integration-tests". JOB_NAME Base job name (GITHUB_JOB), e.g. "integration-tests".
MATRIX_INDEX Current matrix index (runner-index). MATRIX_INDEX Current matrix index (runner-index).
@@ -41,6 +41,7 @@ from devx.i18n import _
from devx.molecule.molecule_ci_guard import ( from devx.molecule.molecule_ci_guard import (
poll_for_other_failures, poll_for_other_failures,
) )
from devx.tokens import get_ci_token
POLL_INTERVAL = 10 POLL_INTERVAL = 10
@@ -50,7 +51,10 @@ POLL_INTERVAL = 10
def cli(pytest_args: tuple[str, ...]) -> None: def cli(pytest_args: tuple[str, ...]) -> None:
"""Run pytest with cross-runner failure detection.""" """Run pytest with cross-runner failure detection."""
gitea_url = os.environ.get("GITEA_URL", "") gitea_url = os.environ.get("GITEA_URL", "")
token = os.environ.get("CI_GITEA_TOKEN", "") try:
token = get_ci_token()
except click.ClickException:
token = None
run_id = int(os.environ.get("RUN_ID", "0")) run_id = int(os.environ.get("RUN_ID", "0"))
job_name = os.environ.get("JOB_NAME", "integration-tests") job_name = os.environ.get("JOB_NAME", "integration-tests")
current_index = int(os.environ.get("MATRIX_INDEX", "0")) current_index = int(os.environ.get("MATRIX_INDEX", "0"))
+7 -6
View File
@@ -6,7 +6,7 @@ otherwise go unnoticed in the Actions tab. Uses the ``tea`` Gitea CLI
for issue creation tea must be installed and configured. for issue creation tea must be installed and configured.
Usage: Usage:
CI_GITEA_TOKEN=<token> python3 -m devx.ci.notify_failure \ CI_GITEA_API_TOKEN=<token> python3 -m devx.ci.notify_failure \
--repo <owner/repo> \ --repo <owner/repo> \
--run-id <run_id> \ --run-id <run_id> \
--workflow <workflow_name> \ --workflow <workflow_name> \
@@ -14,14 +14,13 @@ Usage:
--auto-login --auto-login
With ``--auto-login``, the script configures the tea CLI login profile With ``--auto-login``, the script configures the tea CLI login profile
from ``CI_GITEA_TOKEN`` and ``DEVX_GITEA_API_URL`` before creating the issue, from the CI API token and ``DEVX_GITEA_API_URL`` before creating the issue,
eliminating the need for a separate ``tea login add`` step in the workflow. eliminating the need for a separate ``tea login add`` step in the workflow.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
import click import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
@@ -29,6 +28,7 @@ from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnk
from devx.config import GITEA_API_URL from devx.config import GITEA_API_URL
from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_ci_token
load_dotenv() load_dotenv()
@@ -74,9 +74,10 @@ def _create_issue_via_tea(repo: str, title: str, body: str) -> int:
help="Configure tea CLI login from CI_GITEA_TOKEN before creating the issue.", help="Configure tea CLI login from CI_GITEA_TOKEN before creating the issue.",
) )
def main(repo: str, run_id: str, workflow: str, commit: str, auto_login: bool) -> None: def main(repo: str, run_id: str, workflow: str, commit: str, auto_login: bool) -> None:
token = os.environ.get("CI_GITEA_TOKEN", "") try:
if not token: get_ci_token()
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) except click.ClickException:
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None
if auto_login: if auto_login:
configure_tea_login() configure_tea_login()
+5 -4
View File
@@ -5,7 +5,6 @@ Usage:
VIKUNJA_TOKEN=<token> python3 -m devx.ci.post_merge <commit_msg> [--commit-sha <sha>] VIKUNJA_TOKEN=<token> python3 -m devx.ci.post_merge <commit_msg> [--commit-sha <sha>]
""" """
import os
import re import re
import subprocess # nosec B404 import subprocess # nosec B404
@@ -17,6 +16,7 @@ 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.config import DEFAULT_PER_PAGE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
from devx.exceptions import APIError from devx.exceptions import APIError
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_vikunja_token
load_dotenv() load_dotenv()
@@ -127,9 +127,10 @@ def main(commit_msg: str | None, commit_sha: str, from_git: bool, git_sha: str)
commit_sha = _get_git_commit_sha() commit_sha = _get_git_commit_sha()
if not commit_msg: if not commit_msg:
raise click.ClickException("commit_msg argument is required (or use --from-git or --git-sha)") raise click.ClickException("commit_msg argument is required (or use --from-git or --git-sha)")
token = os.environ.get("VIKUNJA_TOKEN", "") try:
if not token: token = get_vikunja_token()
raise click.ClickException(_("ERROR: VIKUNJA_TOKEN is not set.")) except click.ClickException:
raise click.ClickException(_("ERROR: VIKUNJA_TOKEN is not set.")) from None
task_id = extract_task_id(commit_msg) task_id = extract_task_id(commit_msg)
if not task_id: if not task_id:
+6 -5
View File
@@ -17,12 +17,11 @@ Checks performed:
8. Commit conventions conventional commit format on branch commits 8. Commit conventions conventional commit format on branch commits
Usage: Usage:
CI_GITEA_TOKEN=<token> python3 -m devx.ci.pr_review <pr_number> <owner/repo> CI_GITEA_API_TOKEN=<token> [REVIEWER_GITEA_API_TOKEN=<token>] python3 -m devx.ci.pr_review <pr_number> <owner/repo>
""" """
from __future__ import annotations from __future__ import annotations
import os
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
@@ -34,6 +33,7 @@ from devx.api_clients import GiteaClient
from devx.config import GITEA_API_URL from devx.config import GITEA_API_URL
from devx.exceptions import APIError from devx.exceptions import APIError
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_ci_token, get_reviewer_token
load_dotenv() load_dotenv()
@@ -636,9 +636,10 @@ def main(
Without --event: runs automated checks and posts COMMENT/REQUEST_CHANGES. Without --event: runs automated checks and posts COMMENT/REQUEST_CHANGES.
With --event: posts a manual review (skips automated checks). With --event: posts a manual review (skips automated checks).
""" """
token = os.environ.get("CI_GITEA_TOKEN", "") try:
if not token: token = get_reviewer_token() if (event and event.upper() == "APPROVE") else get_ci_token()
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) except click.ClickException:
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None
owner, repo_name = repo.split("/") owner, repo_name = repo.split("/")
client = GiteaClient(GITEA_API_URL, token, owner, repo_name) client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
+8 -6
View File
@@ -9,14 +9,14 @@ Publishing destinations (checked in order):
``DEVX_PYPI_REGISTRY_URL`` env var is set, or ``GITEA_API_URL`` ``DEVX_PYPI_REGISTRY_URL`` env var is set, or ``GITEA_API_URL``
is converted to a packages URL). Uses ``twine upload is converted to a packages URL). Uses ``twine upload
--repository-url <url> -u <token> -p <token>`` with the --repository-url <url> -u <token> -p <token>`` with the
``CI_GITEA_TOKEN`` as both username and password. CI API token as both username and password.
2. **Standard PyPI** if ``PYPI_TOKEN`` is set. Uses the standard 2. **Standard PyPI** if ``PYPI_TOKEN`` is set. Uses the standard
``twine upload -u __token__ -p <token>`` flow. ``twine upload -u __token__ -p <token>`` flow.
3. **Skip** if neither is configured, only the Gitea release is created. 3. **Skip** if neither is configured, only the Gitea release is created.
Usage: Usage:
CI_GITEA_TOKEN=<token> [PYPI_TOKEN=<token>] python3 -m devx.ci.publish <tag> <repo> CI_GITEA_API_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 CI_GITEA_API_TOKEN=<token> python3 -m devx.ci.publish <tag> <repo> --registry-url https://git.example.com/api/packages/owner/pypi
""" """
import os import os
@@ -31,6 +31,7 @@ from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnk
from devx.config import GITEA_API_URL, REPO_OWNER from devx.config import GITEA_API_URL, REPO_OWNER
from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_ci_token
load_dotenv() load_dotenv()
@@ -253,9 +254,10 @@ def main(
if not tag: if not tag:
raise click.ClickException(_("Tag is required (or use --from-tag).")) raise click.ClickException(_("Tag is required (or use --from-tag)."))
gitea_token = os.environ.get("CI_GITEA_TOKEN", "") try:
if not gitea_token: gitea_token = get_ci_token()
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) except click.ClickException:
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None
pypi_token = os.environ.get("PYPI_TOKEN", "") pypi_token = os.environ.get("PYPI_TOKEN", "")
+1 -1
View File
@@ -29,7 +29,7 @@ version. This prevents duplicate release commits (a common issue when CI
checkouts don't fetch tags) and ensures tag/version/commit alignment. checkouts don't fetch tags) and ensures tag/version/commit alignment.
Usage: Usage:
CI_GITEA_TOKEN=<token> python3 -m devx.ci.release [--dry-run] [--skip-tests] CI_GITEA_API_TOKEN=<token> python3 -m devx.ci.release [--dry-run] [--skip-tests]
python3 -m devx.ci.release --verify # Check tag/version/release alignment python3 -m devx.ci.release --verify # Check tag/version/release alignment
""" """
+6 -4
View File
@@ -21,7 +21,7 @@ Link transformations:
- Anchor-only links (``#section``) are preserved - Anchor-only links (``#section``) are preserved
Usage: Usage:
CI_GITEA_TOKEN=<token> python3 -m devx.ci.sync_wiki [--dry-run] [--repo owner/repo] CI_GITEA_API_TOKEN=<token> python3 -m devx.ci.sync_wiki [--dry-run] [--repo owner/repo]
""" """
from __future__ import annotations from __future__ import annotations
@@ -40,6 +40,7 @@ from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnk
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_ci_token
load_dotenv() load_dotenv()
@@ -274,9 +275,10 @@ def commit_and_push(wiki_dir: Path, wiki_url: str, dry_run: bool) -> bool:
) )
def main(dry_run: bool, repo: str | None, verify: bool) -> None: def main(dry_run: bool, repo: str | None, verify: bool) -> None:
"""Sync documentation to the Gitea wiki via Git.""" """Sync documentation to the Gitea wiki via Git."""
token = os.environ.get("CI_GITEA_TOKEN", "") try:
if not token: token = get_ci_token()
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) except click.ClickException:
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None
if repo is None: if repo is None:
owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
+6 -5
View File
@@ -40,7 +40,6 @@ Usage::
from __future__ import annotations from __future__ import annotations
import json import json
import os
import shutil import shutil
import subprocess # nosec B404 import subprocess # nosec B404
from typing import Any from typing import Any
@@ -49,6 +48,7 @@ import click
from devx.config import GITEA_API_URL from devx.config import GITEA_API_URL
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_ci_token
class TeaCLIError(Exception): class TeaCLIError(Exception):
@@ -56,10 +56,10 @@ class TeaCLIError(Exception):
def configure_tea_login(login_name: str = "devx") -> None: def configure_tea_login(login_name: str = "devx") -> None:
"""Configure tea CLI login from CI_GITEA_TOKEN and DEVX_GITEA_API_URL. """Configure tea CLI login from CI_GITEA_API_TOKEN and DEVX_GITEA_API_URL.
Idempotent: if a login with the same name already exists, it is not re-added. Idempotent: if a login with the same name already exists, it is not re-added.
Skips silently if tea is not installed or CI_GITEA_TOKEN is not set. Skips silently if tea is not installed or no token is set.
Used by CI scripts (publish, notify_failure) that need tea login but Used by CI scripts (publish, notify_failure) that need tea login but
run in containerized environments where ``make setup`` was not called. run in containerized environments where ``make setup`` was not called.
@@ -69,8 +69,9 @@ def configure_tea_login(login_name: str = "devx") -> None:
click.echo(_("tea not installed — skipping login configuration.")) click.echo(_("tea not installed — skipping login configuration."))
return return
token = os.environ.get("CI_GITEA_TOKEN", "") try:
if not token: token = get_ci_token()
except click.ClickException:
click.echo(_("CI_GITEA_TOKEN not set — skipping login configuration.")) click.echo(_("CI_GITEA_TOKEN not set — skipping login configuration."))
return return
+49 -9
View File
@@ -39,6 +39,9 @@
# DEVX_GITEA_PYPI_ORG — Gitea PyPI org (default: oblachno-oss) # DEVX_GITEA_PYPI_ORG — Gitea PyPI org (default: oblachno-oss)
# DEVX_ACTIONLINT_CFG — actionlint config file (default: .gitea/actionlint.yaml) # DEVX_ACTIONLINT_CFG — actionlint config file (default: .gitea/actionlint.yaml)
# DEVX_WORKFLOW_DIR — workflow directory (default: .gitea/workflows) # DEVX_WORKFLOW_DIR — workflow directory (default: .gitea/workflows)
# DEVX_DOC_COVERAGE_STRICT — fail on missing docs (default: 0)
# DEVX_DOC_VERSIONS_PKG — package name for version ref checks (default: auto)
# DEVX_VALE_LEVEL — vale alert threshold (default: warning)
DEVX_PYTHON ?= python3 DEVX_PYTHON ?= python3
DEVX_PR_BASE ?= master DEVX_PR_BASE ?= master
@@ -52,15 +55,18 @@ DEVX_GITEA_PYPI_ORG ?= oblachno-oss
DEVX_ACTIONLINT_CFG ?= .gitea/actionlint.yaml DEVX_ACTIONLINT_CFG ?= .gitea/actionlint.yaml
DEVX_WORKFLOW_DIR ?= .gitea/workflows DEVX_WORKFLOW_DIR ?= .gitea/workflows
DEVX_DOCKERFILE_PATHS ?= docker DEVX_DOCKERFILE_PATHS ?= docker
DEVX_VALE_LEVEL ?= warning
# PIP_INSTALL — helper to run pip with Gitea private PyPI registry configured. # PIP_INSTALL — helper to run pip with Gitea private PyPI registry configured.
# Usage: $(DEVX_PIP_INSTALL) install -e '.[ci,lint]' # Usage: $(DEVX_PIP_INSTALL) install -e '.[ci,lint]'
# CI_GITEA_USERNAME can be set in .env, as an env var, or as a Make variable. # CI_GITEA_USERNAME can be set in .env, as an env var, or as a Make variable.
# Projects can alias: PIP_INSTALL = $(DEVX_PIP_INSTALL) # Projects can alias: PIP_INSTALL = $(DEVX_PIP_INSTALL)
DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \ DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_API_TOKEN" ] && [ -z "$$DEVELOPER_GITEA_API_TOKEN" ] && [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \ _TOKEN="$$CI_GITEA_API_TOKEN"; \
[ -z "$$_TOKEN" ] && _TOKEN="$$DEVELOPER_GITEA_API_TOKEN"; \
[ -z "$$_TOKEN" ] && _TOKEN="$$CI_GITEA_TOKEN"; \
_PYPI_USER="$${CI_GITEA_USERNAME:-emil}"; \ _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; \ if [ -n "$$_TOKEN" ] && [ -n "$$_PYPI_USER" ]; then export PIP_EXTRA_INDEX_URL="https://$$_PYPI_USER:$$_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \
$(DEVX_BIN)/pip $(DEVX_BIN)/pip
# ── Virtual environment management ──────────────────────────────────────────── # ── Virtual environment management ────────────────────────────────────────────
@@ -192,12 +198,14 @@ devx-pr-rebase:
# ── Environment setup ───────────────────────────────────────────────────────── # ── Environment setup ─────────────────────────────────────────────────────────
# Configure Gitea private PyPI registry so pip can find devx and other # Configure Gitea private PyPI registry so pip can find devx and other
# private packages. In CI, CI_GITEA_TOKEN is set as a secret. Locally, it's in .env. # private packages. In CI, CI_GITEA_API_TOKEN is set as a secret. Locally, DEVELOPER_GITEA_API_TOKEN or CI_GITEA_TOKEN can be used.
devx-configure-gitea-pypi: devx-configure-gitea-pypi:
@if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \ @if [ -z "$$CI_GITEA_API_TOKEN" ] && [ -z "$$DEVELOPER_GITEA_API_TOKEN" ] && [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \ _TOKEN="$$CI_GITEA_API_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; \ [ -z "$$_TOKEN" ] && _TOKEN="$$DEVELOPER_GITEA_API_TOKEN"; \
echo "[configure-gitea-pypi] Gitea PyPI registry configured (CI_GITEA_TOKEN present)." [ -z "$$_TOKEN" ] && _TOKEN="$$CI_GITEA_TOKEN"; \
if [ -z "$$_TOKEN" ]; then echo "[configure-gitea-pypi] Gitea API token not set — skipping (devx must be on public PyPI)"; exit 0; fi; \
echo "[configure-gitea-pypi] Gitea PyPI registry configured (token present)."
# Create .env from .env.example if it doesn't exist # Create .env from .env.example if it doesn't exist
devx-env: devx-env:
@@ -264,7 +272,7 @@ devx-workflow-check: devx-workflow-lint devx-workflow-dryrun
# Notify on CI failure — creates a Gitea issue via devx.ci.notify_failure. # Notify on CI failure — creates a Gitea issue via devx.ci.notify_failure.
# Usage: make devx-notify-failure WORKFLOW=post-merge/release # Usage: make devx-notify-failure WORKFLOW=post-merge/release
# Requires: CI_GITEA_TOKEN, GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_SHA # Requires: CI_GITEA_API_TOKEN, GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_SHA
devx-notify-failure: devx-notify-failure:
@. $(DEVX_VENV)/bin/activate 2>/dev/null || true; \ @. $(DEVX_VENV)/bin/activate 2>/dev/null || true; \
export PATH="$(HOME)/.local/bin:$$PATH"; \ export PATH="$(HOME)/.local/bin:$$PATH"; \
@@ -328,7 +336,39 @@ devx-check-docs:
devx-check-doc-versions: devx-check-doc-versions:
@$(DEVX_PYTHON) -m devx.tools.check_doc_versions --root . @$(DEVX_PYTHON) -m devx.tools.check_doc_versions --root .
# Documentation coverage — checks that all modules/scripts/CLI commands
# are documented. Fails if any are missing when DEVX_DOC_COVERAGE_STRICT=1.
devx-doc-coverage:
@$(DEVX_PYTHON) -m devx.ci.doc_coverage $(if $(filter 1,$(DEVX_DOC_COVERAGE_STRICT)),--fail-on-missing)
# All-in-one documentation gate: coverage + stale refs + structural lint +
# version refs + prose lint. Use in CI and pre-commit as a single step
# instead of 5+ separate steps.
#
# Configuration via environment variables (set in Makefile before include
# or in CI env):
# DEVX_DOC_COVERAGE_STRICT=1 — fail on missing docs (recommended)
# DEVX_DOC_VERSIONS_PKG=<pkg> — enable version ref checks for a named package
# DEVX_VALE_LEVEL=<level> — vale alert threshold (error, warning, suggestion)
# default: warning (catches weasel words, unlabeled
# code blocks, etc. — not just spelling errors)
devx-docs-check: devx-doc-coverage devx-check-docs
@$(DEVX_PYTHON) -m devx.ci.lint_docs --root .
@if [ -n "$(DEVX_DOC_VERSIONS_PKG)" ]; then \
$(DEVX_PYTHON) -m devx.tools.check_doc_versions --root . --package $(DEVX_DOC_VERSIONS_PKG); \
elif $(DEVX_PYTHON) -c "import importlib.util,sys; sys.exit(0 if any(importlib.util.find_spec(p) for p in ['devx','grm','oblachno_infra']) else 1)" 2>/dev/null; then \
$(DEVX_PYTHON) -m devx.tools.check_doc_versions --root . 2>/dev/null || true; \
fi
@export PATH="$$HOME/.local/bin:$$PATH" && \
if ! command -v vale >/dev/null 2>&1; then \
echo "[devx-docs-check] vale not installed — skipping prose lint (install with 'make install-tools')"; \
else \
vale sync >/dev/null 2>&1 || true; \
vale --minAlertLevel=$(DEVX_VALE_LEVEL) docs/ AGENTS.md README.md; \
fi
# Run Vale prose linter on docs and README (skips if vale not installed) # Run Vale prose linter on docs and README (skips if vale not installed)
# Legacy target — use devx-docs-check for the full documentation gate.
devx-vale: devx-vale:
@export PATH="$$HOME/.local/bin:$$PATH" && \ @export PATH="$$HOME/.local/bin:$$PATH" && \
if ! command -v vale >/dev/null 2>&1; then \ if ! command -v vale >/dev/null 2>&1; then \
+6 -2
View File
@@ -30,6 +30,7 @@ import click
import requests import requests
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
from devx.tokens import get_ci_token
DEFAULT_MAX_RUNNERS = 3 DEFAULT_MAX_RUNNERS = 3
@@ -86,7 +87,7 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
return total return total
def get_runner_count(api_url: str, token: str, owner: str, repo: str) -> int: def get_runner_count(api_url: str, token: str | None, owner: str, repo: str) -> int:
"""Determine the number of available runners. """Determine the number of available runners.
Tries the Gitea API first, then falls back to env vars, then default. Tries the Gitea API first, then falls back to env vars, then default.
@@ -142,7 +143,10 @@ def main(
output_indices: bool, output_indices: bool,
github_output: bool, github_output: bool,
) -> None: ) -> None:
token = os.environ.get("CI_GITEA_TOKEN", "") try:
token = get_ci_token()
except click.ClickException:
token = None
if owner is None: if owner is None:
owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
+6 -2
View File
@@ -22,7 +22,7 @@ Usage::
Environment variables: Environment variables:
GITEA_URL Base URL of the Gitea instance. GITEA_URL Base URL of the Gitea instance.
CI_GITEA_TOKEN API token with repo access. CI_GITEA_API_TOKEN API token with repo access (CI_GITEA_TOKEN accepted for legacy).
RUN_ID Workflow run ID (GITHUB_RUN_ID). RUN_ID Workflow run ID (GITHUB_RUN_ID).
JOB_NAME Base job name (GITHUB_JOB), e.g. "molecule-tests". JOB_NAME Base job name (GITHUB_JOB), e.g. "molecule-tests".
MATRIX_INDEX Current matrix index (runner-index). MATRIX_INDEX Current matrix index (runner-index).
@@ -45,6 +45,7 @@ import requests
from devx.config import REPO_NAME, REPO_OWNER from devx.config import REPO_NAME, REPO_OWNER
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_ci_token
POLL_INTERVAL = 10 POLL_INTERVAL = 10
@@ -164,7 +165,10 @@ def resolve_role_dir(role: str, roles_root: Path | None, repo_root: Path) -> Pat
def cli(pairs: tuple[str, ...], roles_root: Path | None) -> None: def cli(pairs: tuple[str, ...], roles_root: Path | None) -> None:
"""Run molecule pairs sequentially, stop if another CI runner fails.""" """Run molecule pairs sequentially, stop if another CI runner fails."""
gitea_url = os.environ.get("GITEA_URL", "") gitea_url = os.environ.get("GITEA_URL", "")
token = os.environ.get("CI_GITEA_TOKEN", "") try:
token = get_ci_token()
except click.ClickException:
token = None
run_id = int(os.environ.get("RUN_ID", "0")) run_id = int(os.environ.get("RUN_ID", "0"))
job_name = os.environ.get("JOB_NAME", "molecule-tests") job_name = os.environ.get("JOB_NAME", "molecule-tests")
current_index = int(os.environ.get("MATRIX_INDEX", "0")) current_index = int(os.environ.get("MATRIX_INDEX", "0"))
+76
View File
@@ -0,0 +1,76 @@
"""Token resolution helpers for devx tools.
Centralizes Gitea/Vikunja token discovery with role-based environment
variable names and backwards compatibility with the legacy
``CI_GITEA_TOKEN`` / ``REVIEW_GITEA_TOKEN`` naming convention.
Roles:
- ``CI_GITEA_API_TOKEN``: CI workflows (read actions, post status, merge, etc.)
- ``REVIEWER_GITEA_API_TOKEN``: PR approval reviews (must be a different user
from the PR author for Gitea to accept the review as an approval)
- ``DEVELOPER_GITEA_API_TOKEN``: local development tools (create-task,
create-pr, setup, etc.)
Fallbacks:
- New role names are checked first.
- Legacy names (``CI_GITEA_TOKEN``, ``REVIEW_GITEA_TOKEN``) are accepted for
backwards compatibility.
- If no role-specific token is set, the generic CI tokens are tried last.
"""
from __future__ import annotations
import os
import click
from devx.i18n import _
# Token environment variable names, in lookup priority order.
CI_TOKEN_NAMES = ["CI_GITEA_API_TOKEN", "CI_GITEA_TOKEN"]
REVIEWER_TOKEN_NAMES = [
"REVIEWER_GITEA_API_TOKEN",
# Legacy name used before role-based tokens.
"REVIEW_GITEA_TOKEN",
*CI_TOKEN_NAMES,
]
DEVELOPER_TOKEN_NAMES = ["DEVELOPER_GITEA_API_TOKEN", *CI_TOKEN_NAMES]
VIKUNJA_TOKEN_NAMES = ["VIKUNJA_TOKEN"]
def get_token(*names: str) -> str:
"""Return the first non-empty value from the listed environment variables.
Raises a ``click.ClickException`` if none of the listed variables are set.
"""
for name in names:
token = os.environ.get(name, "").strip()
if token:
return token
raise click.ClickException(
_(
"Gitea API token not set. Set one of: {names}",
names=", ".join(names),
)
)
def get_ci_token() -> str:
"""Resolve the CI Gitea API token."""
return get_token(*CI_TOKEN_NAMES)
def get_reviewer_token() -> str:
"""Resolve the reviewer Gitea API token used for PR approvals."""
return get_token(*REVIEWER_TOKEN_NAMES)
def get_developer_token() -> str:
"""Resolve the developer Gitea API token used for local tooling."""
return get_token(*DEVELOPER_TOKEN_NAMES)
def get_vikunja_token() -> str:
"""Resolve the Vikunja API token."""
return get_token(*VIKUNJA_TOKEN_NAMES)
+5 -2
View File
@@ -8,6 +8,8 @@ import subprocess # nosec B404
import click import click
from devx.tokens import get_developer_token
def arch_string() -> str: def arch_string() -> str:
"""Return the architecture string used by release assets. """Return the architecture string used by release assets.
@@ -45,8 +47,9 @@ def detect_pr_number() -> int | None:
if branch == "HEAD": if branch == "HEAD":
return None return None
token = os.environ.get("CI_GITEA_TOKEN", "") try:
if not token: token = get_developer_token()
except click.ClickException:
return None return None
owner = os.environ.get("DEVX_REPO_OWNER", "") owner = os.environ.get("DEVX_REPO_OWNER", "")
+8 -4
View File
@@ -34,8 +34,8 @@ The manifest file is a JSON list of dicts, each with:
- ``context``: build context directory (optional, defaults to repo root) - ``context``: build context directory (optional, defaults to repo root)
- ``tags``: list of tags (optional, defaults to ``["latest"]``) - ``tags``: list of tags (optional, defaults to ``["latest"]``)
Registry authentication uses ``CI_GITEA_TOKEN`` and ``CI_GITEA_USERNAME`` Registry authentication uses ``CI_GITEA_API_TOKEN`` (or legacy ``CI_GITEA_TOKEN``)
environment variables, matching the existing CI workflow patterns. and ``CI_GITEA_USERNAME`` environment variables, matching the existing CI workflow patterns.
""" """
from __future__ import annotations from __future__ import annotations
@@ -49,6 +49,7 @@ from pathlib import Path
import click import click
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_developer_token
@dataclass @dataclass
@@ -221,9 +222,12 @@ def push_image(
def _get_registry_creds() -> tuple[str, str]: def _get_registry_creds() -> tuple[str, str]:
"""Get registry credentials from environment variables.""" """Get registry credentials from environment variables."""
token = os.environ.get("CI_GITEA_TOKEN", "") try:
token = get_developer_token()
except click.ClickException:
token = None
username = os.environ.get("CI_GITEA_USERNAME", "") username = os.environ.get("CI_GITEA_USERNAME", "")
return username, token return username, token or ""
@click.command() @click.command()
+6 -5
View File
@@ -28,12 +28,11 @@ Usage::
--keep 2 \\ --keep 2 \\
--dry-run --dry-run
Authentication uses ``CI_GITEA_TOKEN`` environment variable. Authentication uses ``CI_GITEA_API_TOKEN`` environment variable (or legacy ``CI_GITEA_TOKEN``).
""" """
from __future__ import annotations from __future__ import annotations
import os
import time import time
from typing import Any from typing import Any
@@ -42,6 +41,7 @@ import requests
from devx.config import GITEA_API_URL, REPO_OWNER from devx.config import GITEA_API_URL, REPO_OWNER
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_developer_token
def list_package_versions( def list_package_versions(
@@ -187,9 +187,10 @@ def main(
api_url: str | None, api_url: str | None,
) -> None: ) -> None:
"""Clean up old Docker image versions from a Gitea registry.""" """Clean up old Docker image versions from a Gitea registry."""
token = os.environ.get("CI_GITEA_TOKEN", "") try:
if not token: token = get_developer_token()
raise click.ClickException(_("CI_GITEA_TOKEN environment variable required")) except click.ClickException:
raise click.ClickException(_("CI_GITEA_TOKEN environment variable required")) from None
if not owner: if not owner:
owner = REPO_OWNER owner = REPO_OWNER
if not owner: if not owner:
+7 -3
View File
@@ -7,8 +7,8 @@ ci-improvement, doc-improvement, workflow-improvement) are created
idempotently via ``ensure_label``. idempotently via ``ensure_label``.
Usage: Usage:
CI_GITEA_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo DEVELOPER_GITEA_API_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 DEVELOPER_GITEA_API_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo --owner my-org
""" """
from __future__ import annotations from __future__ import annotations
@@ -23,6 +23,7 @@ from devx.api_clients import GiteaClient
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
from devx.exceptions import APIError from devx.exceptions import APIError
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_developer_token
def _default_status_checks() -> list[str]: def _default_status_checks() -> list[str]:
@@ -175,7 +176,10 @@ def configure_repo(
) )
def main(repo: str | None, owner: str | None, branch: str, api_url: str | None) -> None: def main(repo: str | None, owner: str | None, branch: str, api_url: str | None) -> None:
"""Configure branch protection and repository settings via the Gitea API.""" """Configure branch protection and repository settings via the Gitea API."""
token = os.environ.get("CI_GITEA_TOKEN", "") try:
token = get_developer_token()
except click.ClickException:
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set.")) from None
if repo is None: if repo is None:
repo = os.environ.get("DEVX_REPO_NAME", "") or REPO_NAME repo = os.environ.get("DEVX_REPO_NAME", "") or REPO_NAME
+9 -6
View File
@@ -42,6 +42,7 @@ from devx.config import (
VIKUNJA_PROJECT_ID, VIKUNJA_PROJECT_ID,
) )
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_developer_token, get_vikunja_token
load_dotenv() load_dotenv()
@@ -72,9 +73,10 @@ def get_vikunja_task_title(task_id: str) -> str:
Raises ClickException if VIKUNJA_TOKEN is not set or the task is not found. Raises ClickException if VIKUNJA_TOKEN is not set or the task is not found.
""" """
token = os.environ.get("VIKUNJA_TOKEN", "") try:
if not token: token = get_vikunja_token()
raise click.ClickException(_("VIKUNJA_TOKEN is not set. Required to derive PR title.")) except click.ClickException:
raise click.ClickException(_("VIKUNJA_TOKEN is not set. Required to derive PR title.")) from None
client = VikunjaClient(VIKUNJA_API_URL, token) client = VikunjaClient(VIKUNJA_API_URL, token)
task = client.find_task_by_identifier(VIKUNJA_PROJECT_ID, task_id, per_page=DEFAULT_PER_PAGE) task = client.find_task_by_identifier(VIKUNJA_PROJECT_ID, task_id, per_page=DEFAULT_PER_PAGE)
if not task: if not task:
@@ -118,9 +120,10 @@ def create_pr(
), ),
) )
token = os.environ.get("CI_GITEA_TOKEN", "") try:
if not token: token = get_developer_token()
raise click.ClickException(_("CI_GITEA_TOKEN is not set. Required to create a PR.")) except click.ClickException:
raise click.ClickException(_("CI_GITEA_TOKEN is not set. Required to create a PR.")) from None
vikunja_title = get_vikunja_task_title(task_id) vikunja_title = get_vikunja_task_title(task_id)
pr_title = f"{task_id}: {vikunja_title}" pr_title = f"{task_id}: {vikunja_title}"
+5 -5
View File
@@ -17,14 +17,13 @@ and ``DEVX_TASK_PREFIX`` environment variables (or ``.env``).
from __future__ import annotations from __future__ import annotations
import os
import click import click
from dotenv import load_dotenv from dotenv import load_dotenv
from devx.api_clients import VikunjaClient from devx.api_clients import VikunjaClient
from devx.config import TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID from devx.config import TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_vikunja_token
load_dotenv() load_dotenv()
@@ -39,9 +38,10 @@ load_dotenv()
@click.option("--project-id", type=int, default=None, help="Vikunja project ID (default: DEVX_VIKUNJA_PROJECT_ID).") @click.option("--project-id", type=int, default=None, help="Vikunja project ID (default: DEVX_VIKUNJA_PROJECT_ID).")
def cli(title: str, description: str, project_id: int | None) -> None: def cli(title: str, description: str, project_id: int | None) -> None:
"""Create a Vikunja task and print its identifier.""" """Create a Vikunja task and print its identifier."""
token = os.environ.get("VIKUNJA_TOKEN", "") try:
if not token: token = get_vikunja_token()
raise click.ClickException(_("VIKUNJA_TOKEN is not set. Set it in .env or environment.")) except click.ClickException:
raise click.ClickException(_("VIKUNJA_TOKEN is not set. Set it in .env or environment.")) from None
pid = project_id if project_id is not None else VIKUNJA_PROJECT_ID pid = project_id if project_id is not None else VIKUNJA_PROJECT_ID
+5 -5
View File
@@ -22,14 +22,13 @@ The repository is auto-detected from ``DEVX_REPO_OWNER`` /
from __future__ import annotations from __future__ import annotations
import os
import click import click
from dotenv import load_dotenv from dotenv import load_dotenv
from devx.api_clients import GiteaClient from devx.api_clients import GiteaClient
from devx.config import GITEA_API_URL, REPO_OWNER from devx.config import GITEA_API_URL, REPO_OWNER
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_developer_token
from devx.tools.create_pr import get_repo_name from devx.tools.create_pr import get_repo_name
from devx.tools.pr_status import _get_current_branch_pr from devx.tools.pr_status import _get_current_branch_pr
@@ -48,9 +47,10 @@ def cli(
repo: str | None, repo: str | None,
) -> None: ) -> None:
"""Add one or more labels to a pull request (idempotent).""" """Add one or more labels to a pull request (idempotent)."""
token = os.environ.get("CI_GITEA_TOKEN", "") try:
if not token: token = get_developer_token()
raise click.ClickException(_("CI_GITEA_TOKEN is not set.")) except click.ClickException:
raise click.ClickException(_("CI_GITEA_TOKEN is not set.")) from None
repo_owner = owner or REPO_OWNER repo_owner = owner or REPO_OWNER
if not repo_owner: if not repo_owner:
+5 -5
View File
@@ -25,14 +25,13 @@ The repository is auto-detected from ``DEVX_REPO_OWNER`` /
from __future__ import annotations from __future__ import annotations
import os
import click import click
from dotenv import load_dotenv from dotenv import load_dotenv
from devx.api_clients import APIError, GiteaClient from devx.api_clients import APIError, GiteaClient
from devx.config import GITEA_API_URL, REPO_OWNER from devx.config import GITEA_API_URL, REPO_OWNER
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_developer_token
from devx.tools.create_pr import get_repo_name from devx.tools.create_pr import get_repo_name
from devx.tools.pr_status import _get_current_branch_pr from devx.tools.pr_status import _get_current_branch_pr
@@ -126,9 +125,10 @@ def cli(
repo: str | None, repo: str | None,
) -> None: ) -> None:
"""Fetch logs for failed CI jobs on a pull request.""" """Fetch logs for failed CI jobs on a pull request."""
token = os.environ.get("CI_GITEA_TOKEN", "") try:
if not token: token = get_developer_token()
raise click.ClickException(_("CI_GITEA_TOKEN is not set.")) except click.ClickException:
raise click.ClickException(_("CI_GITEA_TOKEN is not set.")) from None
repo_owner = owner or REPO_OWNER repo_owner = owner or REPO_OWNER
if not repo_owner: if not repo_owner:
+5 -3
View File
@@ -32,6 +32,7 @@ from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnk
from devx.api_clients import APIError, GiteaClient from devx.api_clients import APIError, GiteaClient
from devx.config import GITEA_API_URL from devx.config import GITEA_API_URL
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_developer_token
from devx.tools._shared import detect_pr_number from devx.tools._shared import detect_pr_number
@@ -41,9 +42,10 @@ def main(pr: int | None) -> None:
"""Rebase a pull request's head branch onto master via Gitea API.""" """Rebase a pull request's head branch onto master via Gitea API."""
load_dotenv() load_dotenv()
token = os.environ.get("CI_GITEA_TOKEN", "") try:
if not token: token = get_developer_token()
raise click.ClickException(_("CI_GITEA_TOKEN is not set. Add it to .env or export it.")) except click.ClickException:
raise click.ClickException(_("CI_GITEA_TOKEN is not set. Add it to .env or export it.")) from None
pr_num = pr or detect_pr_number() pr_num = pr or detect_pr_number()
if not pr_num: if not pr_num:
+5 -4
View File
@@ -24,7 +24,6 @@ The repository is auto-detected from ``DEVX_REPO_OWNER`` /
from __future__ import annotations from __future__ import annotations
import os
import subprocess # nosec B404 import subprocess # nosec B404
import time import time
@@ -34,6 +33,7 @@ from dotenv import load_dotenv
from devx.api_clients import GiteaClient from devx.api_clients import GiteaClient
from devx.config import GITEA_API_URL, REPO_OWNER from devx.config import GITEA_API_URL, REPO_OWNER
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_developer_token
from devx.tools.create_pr import get_repo_name from devx.tools.create_pr import get_repo_name
load_dotenv() load_dotenv()
@@ -139,9 +139,10 @@ def cli(
repo: str | None, repo: str | None,
) -> None: ) -> None:
"""Check CI status for a pull request or commit.""" """Check CI status for a pull request or commit."""
token = os.environ.get("CI_GITEA_TOKEN", "") try:
if not token: token = get_developer_token()
raise click.ClickException(_("CI_GITEA_TOKEN is not set.")) except click.ClickException:
raise click.ClickException(_("CI_GITEA_TOKEN is not set.")) from None
repo_owner = owner or REPO_OWNER repo_owner = owner or REPO_OWNER
if not repo_owner: if not repo_owner:
+7 -5
View File
@@ -20,7 +20,6 @@ Exit codes:
from __future__ import annotations from __future__ import annotations
import os
import subprocess # nosec B404 import subprocess # nosec B404
import click import click
@@ -29,6 +28,7 @@ from dotenv import load_dotenv
from devx.api_clients import VikunjaClient 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.config import DEFAULT_PER_PAGE, TASK_ID_RE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
from devx.i18n import _ from devx.i18n import _
from devx.tokens import get_vikunja_token
load_dotenv() load_dotenv()
@@ -55,8 +55,9 @@ def task_exists(task_id: str) -> bool:
Returns ``False`` if VIKUNJA_TOKEN is not set (soft-fail in local mode). Returns ``False`` if VIKUNJA_TOKEN is not set (soft-fail in local mode).
""" """
token = os.environ.get("VIKUNJA_TOKEN", "") try:
if not token: token = get_vikunja_token()
except click.ClickException:
return False return False
client = VikunjaClient(VIKUNJA_API_URL, token) client = VikunjaClient(VIKUNJA_API_URL, token)
return client.find_task_by_identifier(VIKUNJA_PROJECT_ID, task_id, per_page=DEFAULT_PER_PAGE) is not None return client.find_task_by_identifier(VIKUNJA_PROJECT_ID, task_id, per_page=DEFAULT_PER_PAGE) is not None
@@ -84,8 +85,9 @@ def validate(branch: str) -> None:
) )
) )
token = os.environ.get("VIKUNJA_TOKEN", "") try:
if not token: get_vikunja_token()
except click.ClickException:
click.echo( click.echo(
_( _(
"WARNING: VIKUNJA_TOKEN not set — skipping task existence check. " "WARNING: VIKUNJA_TOKEN not set — skipping task existence check. "
+8 -5
View File
@@ -16,6 +16,8 @@ from pathlib import Path
import click import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType] from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.tokens import get_developer_token
load_dotenv() load_dotenv()
@@ -64,19 +66,20 @@ def _install_ansible_collections(bin_dir: str) -> None:
def _configure_tea_login() -> None: def _configure_tea_login() -> None:
"""Configure tea CLI login from .env if CI_GITEA_TOKEN is set. """Configure tea CLI login from .env if a Gitea token is set.
Idempotent: if a login with the same name already exists, it is not re-added. Idempotent: if a login with the same name already exists, it is not re-added.
Skips if tea is not installed or CI_GITEA_TOKEN is not set. Skips if tea is not installed or no Gitea token is set.
""" """
tea_bin = shutil.which("tea") tea_bin = shutil.which("tea")
if tea_bin is None: if tea_bin is None:
click.echo("tea: not installed — run 'make install-tools' to install it.") click.echo("tea: not installed — run 'make install-tools' to install it.")
return return
token = os.environ.get("CI_GITEA_TOKEN", "") try:
if not token: token = get_developer_token()
click.echo("tea: CI_GITEA_TOKEN not set — skipping login configuration.") except click.ClickException:
click.echo("tea: Gitea API token not set — skipping login configuration.")
return return
api_url = os.environ.get("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1") api_url = os.environ.get("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
+6 -1
View File
@@ -24,6 +24,8 @@ from pathlib import Path
import click import click
from devx.tokens import get_developer_token
DEFAULT_VENV = ".venv" DEFAULT_VENV = ".venv"
OPT_VENV = "/opt/venv" OPT_VENV = "/opt/venv"
FALLBACK_TARGET = "setup-ci" FALLBACK_TARGET = "setup-ci"
@@ -67,7 +69,10 @@ def _install_in_image(
cmd = [pip_bin, "install", "--no-cache-dir", "-e", spec] cmd = [pip_bin, "install", "--no-cache-dir", "-e", spec]
env = os.environ.copy() env = os.environ.copy()
token = env.get("CI_GITEA_TOKEN", "") try:
token = get_developer_token()
except click.ClickException:
token = None
if token: if token:
username = env.get("CI_GITEA_USERNAME", "emil") username = env.get("CI_GITEA_USERNAME", "emil")
env["PIP_EXTRA_INDEX_URL"] = _build_pip_extra_index_url( env["PIP_EXTRA_INDEX_URL"] = _build_pip_extra_index_url(
+8
View File
@@ -959,6 +959,14 @@
"ru": "CI checks failed.", "ru": "CI checks failed.",
"zh": "CI checks failed." "zh": "CI checks failed."
}, },
"Gitea API token not set. Set one of: {names}": {
"bg": "Gitea API token not set. Set one of: {names}",
"de": "Gitea API token not set. Set one of: {names}",
"en": "Gitea API token not set. Set one of: {names}",
"pl": "Gitea API token not set. Set one of: {names}",
"ru": "Gitea API token not set. Set one of: {names}",
"zh": "Gitea API token not set. Set one of: {names}"
},
"CI_GITEA_TOKEN environment variable required": { "CI_GITEA_TOKEN environment variable required": {
"bg": "CI_GITEA_TOKEN environment variable required", "bg": "CI_GITEA_TOKEN environment variable required",
"de": "CI_GITEA_TOKEN environment variable required", "de": "CI_GITEA_TOKEN environment variable required",
+3 -3
View File
@@ -944,7 +944,7 @@ class TestGiteaClientActions:
client._session.request = MagicMock(return_value=_mock_response({})) client._session.request = MagicMock(return_value=_mock_response({}))
client.set_repo_variable("PRODUCTION_DEPLOY_TAG", "v0.28.2") client.set_repo_variable("PRODUCTION_DEPLOY_TAG", "v0.28.2")
client._session.request.assert_called_once_with( client._session.request.assert_called_once_with(
"PATCH", "PUT",
"https://git.example.com/repos/owner/repo/actions/variables/PRODUCTION_DEPLOY_TAG", "https://git.example.com/repos/owner/repo/actions/variables/PRODUCTION_DEPLOY_TAG",
timeout=DEFAULT_TIMEOUT, timeout=DEFAULT_TIMEOUT,
json={"value": "v0.28.2"}, json={"value": "v0.28.2"},
@@ -960,8 +960,8 @@ class TestGiteaClientActions:
assert client._session.request.call_count == 2 assert client._session.request.call_count == 2
second_call = client._session.request.call_args_list[1] second_call = client._session.request.call_args_list[1]
assert second_call.args[0] == "POST" assert second_call.args[0] == "POST"
assert second_call.args[1] == "https://git.example.com/repos/owner/repo/actions/variables" assert second_call.args[1] == "https://git.example.com/repos/owner/repo/actions/variables/NEW_VAR"
assert second_call.kwargs["json"] == {"name": "NEW_VAR", "value": "v0.29.0"} assert second_call.kwargs["json"] == {"value": "v0.29.0"}
def test_set_repo_variable_reraises_non_404(self) -> None: def test_set_repo_variable_reraises_non_404(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo") client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
+12
View File
@@ -4,6 +4,7 @@ import json
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import click
import pytest import pytest
from click.testing import CliRunner from click.testing import CliRunner
@@ -249,3 +250,14 @@ class TestMain:
args, kwargs = mock_count.call_args args, kwargs = mock_count.call_args
assert "myorg" in args assert "myorg" in args
assert "myrepo" in args assert "myrepo" in args
@patch("devx.ci.discover_runners.get_ci_token", side_effect=click.ClickException("no token"))
@patch("devx.ci.discover_runners.get_runner_count", return_value=3)
def test_missing_token_runs_without_api(self, mock_count: MagicMock, mock_token: MagicMock) -> None:
"""When no token is available, runner discovery falls back to env/default."""
runner = CliRunner()
result = runner.invoke(main, ["--count"])
assert result.exit_code == 0
assert result.output.strip() == "3"
args, _ = mock_count.call_args
assert args[1] is None # token passed as None when missing
+200
View File
@@ -55,6 +55,38 @@ class TestExtractCliCommands:
assert "real_cmd" in commands assert "real_cmd" in commands
assert "pass" not in commands assert "pass" not in commands
def test_command_with_explicit_name_param(self, tmp_path: Path) -> None:
"""When a command uses name="explicit-name", that name is extracted."""
fake_cli = tmp_path / "cli.py"
fake_cli.write_text(
'@click.group()\ndef cli():\n pass\n@cli.command(name="my-command")\ndef my_command():\n pass\n'
)
commands = extract_cli_commands(tmp_path)
assert "my-command" in commands
assert "my_command" not in commands
def test_command_with_help_kwarg_uses_def_name(self, tmp_path: Path) -> None:
"""When a command uses help= kwarg but no name=, falls back to def name."""
fake_cli = tmp_path / "cli.py"
fake_cli.write_text(
"@click.group()\ndef cli():\n pass\n"
'@cli.command(help="Do something useful")\ndef do_something():\n pass\n'
)
commands = extract_cli_commands(tmp_path)
assert "do_something" in commands
assert "Do something useful" not in commands
def test_command_with_help_translation_uses_def_name(self, tmp_path: Path) -> None:
"""When a command uses help=_() translation, falls back to def name."""
fake_cli = tmp_path / "cli.py"
fake_cli.write_text(
"@click.group()\ndef cli():\n pass\n"
'@cli.command(help=_("Install and configure things"))\ndef install():\n pass\n'
)
commands = extract_cli_commands(tmp_path)
assert "install" in commands
assert "Install and configure things" not in commands
class TestCheckCommandDocumented: class TestCheckCommandDocumented:
def test_finds_command_in_heading(self) -> None: def test_finds_command_in_heading(self) -> None:
@@ -195,3 +227,171 @@ class TestMain:
result = runner.invoke(main, ["--docs-dir", str(docs)]) result = runner.invoke(main, ["--docs-dir", str(docs)])
# No source dir found, so no CLI commands, but modules/scripts from REQUIRED lists # No source dir found, so no CLI commands, but modules/scripts from REQUIRED lists
assert result.exit_code == 0 assert result.exit_code == 0
def test_ci_scripts_dir_empty_skips_ci_checks(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When --ci-scripts-dir is empty string, CI script checks are skipped."""
monkeypatch.chdir(tmp_path)
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
src = tmp_path / "src" / "devx"
src.mkdir(parents=True)
(src / "__init__.py").write_text("")
(src / "cli.py").write_text(
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
)
(src / "config.py").write_text("# config")
(docs / "user" / "cli-commands.md").write_text("## release\n")
(docs / "tech" / "architecture.md").write_text("config.py")
(docs / "tech" / "ci-cd-workflow.md").write_text("")
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src), "--ci-scripts-dir", ""])
assert result.exit_code == 0
assert "100%" in result.output
# Should not mention any CI scripts
assert "MISSING" not in result.output or "CI script" not in result.output
def test_ci_scripts_dir_explicit_path(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When --ci-scripts-dir points to a directory, scripts are detected from there."""
monkeypatch.chdir(tmp_path)
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
src = tmp_path / "src" / "myapp"
src.mkdir(parents=True)
(src / "__init__.py").write_text("")
(src / "cli.py").write_text(
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
)
ci_dir = tmp_path / "ci"
ci_dir.mkdir()
(ci_dir / "my_script.py").write_text("# my script")
(ci_dir / "__init__.py").write_text("")
(docs / "user" / "cli-commands.md").write_text("## release\n")
(docs / "tech" / "architecture.md").write_text("")
(docs / "tech" / "ci-cd-workflow.md").write_text("my_script.py")
runner = CliRunner()
result = runner.invoke(
main, ["--docs-dir", str(docs), "--source-dir", str(src), "--ci-scripts-dir", str(ci_dir)]
)
assert result.exit_code == 0
assert "my_script.py" in result.output
assert "OK: my_script.py" in result.output
def test_ci_scripts_dir_nonexistent_skips(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When --ci-scripts-dir points to a non-existent path, CI checks are skipped."""
monkeypatch.chdir(tmp_path)
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
src = tmp_path / "src" / "myapp"
src.mkdir(parents=True)
(src / "__init__.py").write_text("")
(src / "cli.py").write_text(
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
)
(docs / "user" / "cli-commands.md").write_text("## release\n")
(docs / "tech" / "architecture.md").write_text("")
(docs / "tech" / "ci-cd-workflow.md").write_text("")
runner = CliRunner()
result = runner.invoke(
main, ["--docs-dir", str(docs), "--source-dir", str(src), "--ci-scripts-dir", "/nonexistent"]
)
assert result.exit_code == 0
assert "100%" in result.output
def test_config_from_pyproject_ci_scripts_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When [tool.devx.doc_coverage] ci_scripts_dir is set in pyproject.toml, it's used."""
monkeypatch.chdir(tmp_path)
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
src = tmp_path / "src" / "myapp"
src.mkdir(parents=True)
(src / "__init__.py").write_text("")
(src / "cli.py").write_text(
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
)
(src / "config.py").write_text("# config")
(docs / "user" / "cli-commands.md").write_text("## release\n")
(docs / "tech" / "architecture.md").write_text("config.py")
(docs / "tech" / "ci-cd-workflow.md").write_text("")
# Write pyproject.toml with ci_scripts_dir = ""
(tmp_path / "pyproject.toml").write_text('[tool.devx.doc_coverage]\nci_scripts_dir = ""\n')
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src)])
assert result.exit_code == 0
assert "100%" in result.output
def test_config_from_pyproject_docs_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When [tool.devx.doc_coverage] docs_dir is set in pyproject.toml, it's used."""
monkeypatch.chdir(tmp_path)
custom_docs = tmp_path / "custom-docs"
(custom_docs / "user").mkdir(parents=True)
(custom_docs / "tech").mkdir(parents=True)
src = tmp_path / "src" / "myapp"
src.mkdir(parents=True)
(src / "__init__.py").write_text("")
(src / "cli.py").write_text(
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
)
(src / "config.py").write_text("# config")
(custom_docs / "user" / "cli-commands.md").write_text("## release\n")
(custom_docs / "tech" / "architecture.md").write_text("config.py")
(custom_docs / "tech" / "ci-cd-workflow.md").write_text("")
# Write pyproject.toml with custom docs_dir
(tmp_path / "pyproject.toml").write_text(
f'[tool.devx.doc_coverage]\ndocs_dir = "{custom_docs}"\nci_scripts_dir = ""\n'
)
runner = CliRunner()
result = runner.invoke(main, ["--source-dir", str(src)])
assert result.exit_code == 0
assert "100%" in result.output
def test_config_from_pyproject_source_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When [tool.devx.doc_coverage] source_dir is set in pyproject.toml, it's used."""
monkeypatch.chdir(tmp_path)
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
custom_src = tmp_path / "custom-src" / "myapp"
custom_src.mkdir(parents=True)
(custom_src / "__init__.py").write_text("")
(custom_src / "cli.py").write_text(
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
)
(custom_src / "config.py").write_text("# config")
(docs / "user" / "cli-commands.md").write_text("## release\n")
(docs / "tech" / "architecture.md").write_text("config.py")
(docs / "tech" / "ci-cd-workflow.md").write_text("")
# Write pyproject.toml with custom source_dir
(tmp_path / "pyproject.toml").write_text(
f'[tool.devx.doc_coverage]\nsource_dir = "{custom_src}"\nci_scripts_dir = ""\n'
)
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs)])
assert result.exit_code == 0
assert "100%" in result.output
def test_config_doc_coverage_not_dict(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When [tool.devx.doc_coverage] is not a dict, falls back to defaults."""
monkeypatch.chdir(tmp_path)
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
src = tmp_path / "src" / "myapp"
src.mkdir(parents=True)
(src / "__init__.py").write_text("")
(src / "cli.py").write_text(
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
)
(src / "config.py").write_text("# config")
(docs / "user" / "cli-commands.md").write_text("## release\n")
(docs / "tech" / "architecture.md").write_text("config.py")
(docs / "tech" / "ci-cd-workflow.md").write_text("")
# Write pyproject.toml with doc_coverage as a non-dict value
(tmp_path / "pyproject.toml").write_text('[tool.devx]\ndoc_coverage = "not-a-dict"\n')
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src)])
assert result.exit_code == 0
assert "100%" in result.output
+22
View File
@@ -186,6 +186,28 @@ class TestCli:
timeout=60, timeout=60,
) )
@patch("devx.molecule.molecule_ci_guard.get_ci_token", side_effect=click.ClickException("no token"))
def test_missing_token_runs_without_polling(self, mock_token: MagicMock) -> None:
"""When no token is available, cross-runner polling is skipped."""
from click.testing import CliRunner
with (
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.poll_for_other_failures") as mock_poll,
patch("time.sleep"),
):
proc = MagicMock()
proc.poll.return_value = 0
proc.returncode = 0
mock_popen.return_value = proc
mock_run.return_value = MagicMock(returncode=0)
runner = CliRunner()
result = runner.invoke(cli, ["default|ubuntu-2204|img:latest|"])
assert result.exit_code == 0
mock_poll.assert_not_called()
def test_invalid_pair_format_raises(self) -> None: def test_invalid_pair_format_raises(self) -> None:
"""Pair with fewer than 2 parts should raise.""" """Pair with fewer than 2 parts should raise."""
from click.testing import CliRunner from click.testing import CliRunner
@@ -4,6 +4,7 @@ import json
from pathlib import Path from pathlib import Path
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import click
import pytest import pytest
from click.testing import CliRunner from click.testing import CliRunner
@@ -219,3 +220,14 @@ class TestMain:
args, kwargs = mock_count.call_args args, kwargs = mock_count.call_args
assert "myorg" in args assert "myorg" in args
assert "myrepo" in args assert "myrepo" in args
@patch("devx.molecule.discover_runners.get_ci_token", side_effect=click.ClickException("no token"))
@patch("devx.molecule.discover_runners.get_runner_count", return_value=3)
def test_missing_token_runs_without_api(self, mock_count: MagicMock, mock_token: MagicMock) -> None:
"""When no token is available, runner discovery falls back to env/default."""
runner = CliRunner()
result = runner.invoke(main, ["--count"])
assert result.exit_code == 0
assert result.output.strip() == "3"
args, _ = mock_count.call_args
assert args[1] is None # token passed as None when missing
+7 -2
View File
@@ -12,9 +12,14 @@ from devx.tools.pr_label import cli
class TestCli: class TestCli:
def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False) for name in ("DEVELOPER_GITEA_API_TOKEN", "CI_GITEA_API_TOKEN", "CI_GITEA_TOKEN"):
monkeypatch.delenv(name, raising=False)
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge"]) result = runner.invoke(
cli,
["--pr", "42", "--label", "ready-to-merge"],
env={"DEVELOPER_GITEA_API_TOKEN": "", "CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""},
)
assert result.exit_code != 0 assert result.exit_code != 0
assert "CI_GITEA_TOKEN" in result.output assert "CI_GITEA_TOKEN" in result.output
+7 -2
View File
@@ -153,9 +153,14 @@ class TestPrintLogs:
class TestCli: class TestCli:
def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False) for name in ("DEVELOPER_GITEA_API_TOKEN", "CI_GITEA_API_TOKEN", "CI_GITEA_TOKEN"):
monkeypatch.delenv(name, raising=False)
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42"]) result = runner.invoke(
cli,
["--pr", "42"],
env={"DEVELOPER_GITEA_API_TOKEN": "", "CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""},
)
assert result.exit_code != 0 assert result.exit_code != 0
assert "CI_GITEA_TOKEN" in result.output assert "CI_GITEA_TOKEN" in result.output
+2 -1
View File
@@ -756,9 +756,10 @@ class TestMain:
result = runner.invoke(main, ["42", "my-org/my-repo"], env={"CI_GITEA_TOKEN": "fake"}) result = runner.invoke(main, ["42", "my-org/my-repo"], env={"CI_GITEA_TOKEN": "fake"})
assert result.exit_code != 0 assert result.exit_code != 0
@patch.dict("os.environ", {"CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""})
def test_no_token_raises(self) -> None: def test_no_token_raises(self) -> None:
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, ["42", "my-org/my-repo"], env={"CI_GITEA_TOKEN": ""}) result = runner.invoke(main, ["42", "my-org/my-repo"], env={"CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""})
assert result.exit_code != 0 assert result.exit_code != 0
assert "CI_GITEA_TOKEN" in result.output assert "CI_GITEA_TOKEN" in result.output
+7 -2
View File
@@ -122,9 +122,14 @@ class TestWaitForCompletion:
class TestCli: class TestCli:
def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None: def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False) for name in ("DEVELOPER_GITEA_API_TOKEN", "CI_GITEA_API_TOKEN", "CI_GITEA_TOKEN"):
monkeypatch.delenv(name, raising=False)
runner = CliRunner() runner = CliRunner()
result = runner.invoke(cli, ["--pr", "42"]) result = runner.invoke(
cli,
["--pr", "42"],
env={"DEVELOPER_GITEA_API_TOKEN": "", "CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""},
)
assert result.exit_code != 0 assert result.exit_code != 0
assert "CI_GITEA_TOKEN" in result.output assert "CI_GITEA_TOKEN" in result.output
+3 -2
View File
@@ -256,9 +256,10 @@ class TestCommitAndPush:
class TestMain: class TestMain:
def test_no_token_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: def test_no_token_raises(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False) for name in ("CI_GITEA_API_TOKEN", "CI_GITEA_TOKEN"):
monkeypatch.delenv(name, raising=False)
runner = CliRunner() runner = CliRunner()
result = runner.invoke(main, []) result = runner.invoke(main, [], env={"CI_GITEA_API_TOKEN": "", "CI_GITEA_TOKEN": ""})
assert result.exit_code != 0 assert result.exit_code != 0
assert "CI_GITEA_TOKEN" in result.output assert "CI_GITEA_TOKEN" in result.output