Compare commits

...
51 Commits
Author SHA1 Message Date
devx-ci-bot 3406639f13 release: v0.33.2 [skip ci] 2026-07-05 19:18:03 +00:00
emil 9f02ccb40d DEVX-113: fix: abort sync_wiki when list_wiki_pages fails
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / vikunja (push) Successful in 22s
Post-merge / configure-repo (push) Successful in 18s
Post-merge / release (push) Successful in 47s
Post-merge / badges (push) Successful in 54s
Post-merge / sync-wiki (push) Successful in 55s
Post-merge / publish (push) Successful in 31s
2026-07-05 19:17:10 +00:00
gitea-actions-bot 5206158603 chore: update badge URLs to commit 66fec9ab [skip ci] 2026-07-05 14:47:30 +00:00
devx-ci-bot 489cc8343a release: v0.33.1 [skip ci] 2026-07-05 14:47:16 +00:00
emil 20ea80135c DEVX-112: fix: build images after post-merge publish, not on push
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / sync-wiki (push) Successful in 28s
Post-merge / release (push) Successful in 32s
Post-merge / vikunja (push) Successful in 13s
Post-merge / badges (push) Successful in 36s
Post-merge / configure-repo (push) Successful in 14s
Post-merge / publish (push) Successful in 20s
2026-07-05 14:46:33 +00:00
gitea-actions-bot 53b1d300aa chore: update badge URLs to commit 546910d3 [skip ci] 2026-07-05 14:13:08 +00:00
devx-ci-bot 5b9e92f324 release: v0.33.0 [skip ci] 2026-07-05 14:12:51 +00:00
emil 2c0118111d DEVX-111: feat: add check_api_identity_checks, setup_ssh_key, and api utils
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 9s
Build Images / detect-type (push) Successful in 42s
Post-merge / vikunja (push) Successful in 17s
Post-merge / release (push) Successful in 52s
Post-merge / configure-repo (push) Successful in 23s
Post-merge / badges (push) Successful in 55s
Post-merge / sync-wiki (push) Successful in 58s
Post-merge / publish (push) Successful in 21s
Build Images / build-and-push (push) Successful in 3m15s
Build Images / cleanup (push) Successful in 3m38s
2026-07-05 14:11:58 +00:00
gitea-actions-bot 333641f862 chore: update badge URLs to commit e8088b8e [skip ci] 2026-07-01 23:37:55 +00:00
devx-ci-bot f21b01dce2 release: v0.32.1 [skip ci] 2026-07-01 23:37:11 +00:00
emil ff80745eea DEVX-110: fix: add missing i18n translations for new tools
Post-merge / detect-type (push) Successful in 15s
Post-merge / validate-commit-msg (push) Successful in 14s
Build Images / detect-type (push) Successful in 56s
Post-merge / release (push) Successful in 1m2s
Post-merge / configure-repo (push) Successful in 28s
Post-merge / sync-wiki (push) Successful in 1m11s
Post-merge / badges (push) Successful in 1m19s
Post-merge / vikunja (push) Successful in 1m15s
Post-merge / publish (push) Successful in 33s
Build Images / build-and-push (push) Successful in 4m22s
Build Images / cleanup (push) Successful in 2m50s
2026-07-01 23:35:56 +00:00
gitea-actions-bot 319807f41c chore: update badge URLs to commit ce9bf024 [skip ci] 2026-07-01 23:01:36 +00:00
devx-ci-bot e652d3bb75 release: v0.32.0 [skip ci] 2026-07-01 23:01:19 +00:00
emil d59de06652 DEVX-110: feat: extract docker-login, tofu-ops, check-deps, install-tofu to Python tools
Build Images / cleanup (push) Successful in 3m20s
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 21s
Build Images / detect-type (push) Successful in 41s
Post-merge / configure-repo (push) Successful in 17s
Post-merge / release (push) Successful in 43s
Post-merge / sync-wiki (push) Successful in 47s
Post-merge / badges (push) Successful in 51s
Post-merge / publish (push) Successful in 22s
Build Images / build-and-push (push) Successful in 3m26s
2026-07-01 23:00:28 +00:00
gitea-actions-bot ae37a8e3e4 chore: update badge URLs to commit de35661c [skip ci] 2026-07-01 22:35:46 +00:00
devx-ci-bot c63e85923a release: v0.31.0 [skip ci] 2026-07-01 22:35:34 +00:00
emil 77c1af8ed3 DEVX-110: feat: centralize venv management in devx.mak
Post-merge / detect-type (push) Successful in 9s
Build Images / detect-type (push) Failing after 13s
Build Images / build-and-push (push) Has been skipped
Post-merge / validate-commit-msg (push) Successful in 10s
Build Images / cleanup (push) Has been skipped
Post-merge / vikunja (push) Successful in 15s
Post-merge / configure-repo (push) Successful in 18s
Post-merge / sync-wiki (push) Successful in 29s
Post-merge / release (push) Successful in 32s
Post-merge / badges (push) Successful in 39s
Post-merge / publish (push) Successful in 17s
2026-07-01 22:34:49 +00:00
gitea-actions-bot a48fb46c52 chore: update badge URLs to commit 1755d7a2 [skip ci] 2026-07-01 20:54:47 +00:00
emil 2392a13afc DEVX-109: docs: add container-level fix verification and verified state modification rules
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / release (push) Successful in 21s
Post-merge / publish (push) Has been skipped
Post-merge / vikunja (push) Successful in 17s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / sync-wiki (push) Successful in 45s
Post-merge / badges (push) Successful in 46s
2026-07-01 20:53:32 +00:00
gitea-actions-bot 32315b1d5d chore: update badge URLs to commit 753a5f9e [skip ci] 2026-07-01 14:05:12 +00:00
devx-ci-bot f70f468630 release: v0.30.0 [skip ci] 2026-07-01 14:04:57 +00:00
emil 85b5ec1485 DEVX-108: feat: add standard label creation to configure_repo
Post-merge / detect-type (push) Successful in 16s
Post-merge / validate-commit-msg (push) Successful in 21s
Post-merge / vikunja (push) Successful in 24s
Post-merge / configure-repo (push) Successful in 17s
Post-merge / sync-wiki (push) Successful in 49s
Post-merge / release (push) Successful in 50s
Post-merge / badges (push) Successful in 58s
Build Images / detect-type (push) Successful in 1m28s
Post-merge / publish (push) Successful in 20s
Build Images / build-and-push (push) Successful in 3m2s
Build Images / cleanup (push) Successful in 3m9s
2026-07-01 14:03:48 +00:00
gitea-actions-bot 091b951adc chore: update badge URLs to commit d23c6b86 [skip ci] 2026-07-01 09:36:05 +00:00
emil 19eb57445d DEVX-103: docs: fix outdated version refs, language list, config key, and missing modules
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / release (push) Successful in 17s
Post-merge / publish (push) Has been skipped
Post-merge / vikunja (push) Successful in 19s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / sync-wiki (push) Successful in 47s
Post-merge / badges (push) Successful in 42s
2026-07-01 09:34:53 +00:00
gitea-actions-bot bdd0e05869 chore: update badge URLs to commit d5dfe563 [skip ci] 2026-07-01 09:29:34 +00:00
devx-ci-bot 27fd99a091 release: v0.29.1 [skip ci] 2026-07-01 09:29:17 +00:00
emil e3fa9b7c95 DEVX-107: fix: strip task ID prefix from commit messages in extract_conventional_msg
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 13s
Build Images / detect-type (push) Successful in 43s
Post-merge / vikunja (push) Successful in 16s
Post-merge / sync-wiki (push) Successful in 33s
Post-merge / release (push) Successful in 37s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / badges (push) Successful in 48s
Post-merge / publish (push) Successful in 20s
Build Images / build-and-push (push) Successful in 3m7s
Build Images / cleanup (push) Successful in 3m18s
2026-07-01 09:28:23 +00:00
gitea-actions-bot ce60356542 chore: update badge URLs to commit 82fb419c [skip ci] 2026-07-01 06:20:37 +00:00
devx-ci-bot 35c72ef595 release: v0.29.0 [skip ci] 2026-07-01 06:20:24 +00:00
emil c0fcaef25f DEVX-106: feat: detect badge commits as automated CI commits
Build Images / build-and-push (push) Successful in 3m1s
Build Images / cleanup (push) Successful in 2m47s
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 21s
Post-merge / configure-repo (push) Successful in 13s
Build Images / detect-type (push) Successful in 40s
Post-merge / sync-wiki (push) Successful in 36s
Post-merge / release (push) Successful in 39s
Post-merge / badges (push) Successful in 45s
Post-merge / publish (push) Successful in 18s
2026-07-01 06:19:34 +00:00
gitea-actions-bot 3dd5b452c0 chore: update badge URLs to commit d5cf4c7f [skip ci] 2026-07-01 01:11:39 +00:00
emil b2515bbf37 DEVX-105: docs: add devx-workflow skill for agent guidance
Post-merge / detect-type (push) Successful in 13s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / release (push) Successful in 18s
Post-merge / vikunja (push) Successful in 17s
Post-merge / publish (push) Has been skipped
Post-merge / configure-repo (push) Successful in 15s
Post-merge / sync-wiki (push) Successful in 55s
Post-merge / badges (push) Successful in 54s
2026-07-01 01:10:23 +00:00
gitea-actions-bot ad2e59980f chore: update badge URLs to commit b041147a [skip ci] 2026-07-01 00:51:32 +00:00
devx-ci-bot 68a01d1bda release: v0.28.0 [skip ci] 2026-07-01 00:51:20 +00:00
emil 621b051793 DEVX-104: feat: auto-rebase in auto-merge, new rebase tools, CLI registration
Build Images / build-and-push (push) Successful in 5m35s
Build Images / cleanup (push) Successful in 3m32s
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / vikunja (push) Successful in 24s
Post-merge / configure-repo (push) Successful in 21s
Post-merge / sync-wiki (push) Successful in 38s
Post-merge / release (push) Successful in 44s
Post-merge / badges (push) Successful in 50s
Post-merge / publish (push) Successful in 20s
Build Images / detect-type (push) Successful in 1m26s
2026-07-01 00:50:23 +00:00
gitea-actions-bot 66554657f2 chore: update badge URLs to commit 44123e77 [skip ci] 2026-06-30 05:34:13 +00:00
devx-ci-bot 587d3a6ca4 release: v0.27.3 [skip ci] 2026-06-30 05:33:54 +00:00
emil 9d75e408ae DEVX-14: fix: retry wiki integrity check on transient API timeout
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 8s
Build Images / detect-type (push) Successful in 42s
Post-merge / sync-wiki (push) Successful in 30s
Post-merge / vikunja (push) Successful in 16s
Post-merge / release (push) Successful in 44s
Post-merge / configure-repo (push) Successful in 17s
Post-merge / badges (push) Successful in 43s
Post-merge / publish (push) Successful in 20s
Build Images / build-and-push (push) Successful in 3m12s
Build Images / cleanup (push) Successful in 2m37s
2026-06-30 05:33:00 +00:00
gitea-actions-bot 412bbea01d chore: update badge URLs to commit 8d35f5dd [skip ci] 2026-06-29 11:30:18 +00:00
devx-ci-bot ce5ce33a12 release: v0.27.2 [skip ci] 2026-06-29 11:30:13 +00:00
emil 0fae419584 DEVX-100: fix: retry release push on non-fast-forward with rebase loop
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 12s
Post-merge / configure-repo (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 23s
Post-merge / release (push) Successful in 29s
Post-merge / badges (push) Successful in 30s
Build Images / detect-type (push) Successful in 1m0s
Post-merge / publish (push) Successful in 16s
Build Images / build-and-push (push) Successful in 3m57s
Build Images / cleanup (push) Successful in 2m56s
2026-06-29 11:29:31 +00:00
gitea-actions-bot 70b011d4a6 chore: update badge URLs to commit 53e15be6 [skip ci] 2026-06-28 17:02:18 +00:00
devx-ci-bot a5c16a92df release: v0.27.1 [skip ci] 2026-06-28 17:02:12 +00:00
emil e6f022ae96 DEVX-99: fix: exclude .devin/.terraform dirs from lint_docs, add duplicate heading excludes
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / vikunja (push) Successful in 12s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / sync-wiki (push) Successful in 19s
Build Images / detect-type (push) Successful in 35s
Post-merge / release (push) Successful in 25s
Post-merge / badges (push) Successful in 28s
Post-merge / publish (push) Successful in 15s
Build Images / build-and-push (push) Successful in 2m51s
Build Images / cleanup (push) Successful in 2m18s
2026-06-28 17:01:37 +00:00
gitea-actions-bot 1a27983750 chore: update badge URLs to commit 8f2186a4 [skip ci] 2026-06-28 16:36:47 +00:00
devx-ci-bot 5d7ed62b34 release: v0.27.0 [skip ci] 2026-06-28 16:36:35 +00:00
emil ee80c27631 DEVX-98: feat: add lint_docs tool, fix doc_coverage/check_translations for any repo
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / vikunja (push) Successful in 13s
Post-merge / configure-repo (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 21s
Post-merge / release (push) Successful in 25s
Build Images / detect-type (push) Successful in 38s
Post-merge / badges (push) Successful in 34s
Post-merge / publish (push) Successful in 33s
Build Images / build-and-push (push) Successful in 3m20s
Build Images / cleanup (push) Successful in 1m57s
2026-06-28 16:35:59 +00:00
gitea-actions-bot 98b1659579 chore: update badge URLs to commit f308b9f8 [skip ci] 2026-06-28 15:02:09 +00:00
devx-ci-bot c12d9abc6d release: v0.26.4 [skip ci] 2026-06-28 15:01:52 +00:00
emil 40a65cb0a6 DEVX-97: fix: wrap all user-facing strings with _() for i18n completeness
Post-merge / vikunja (push) Successful in 13s
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 21s
Post-merge / release (push) Successful in 29s
Post-merge / configure-repo (push) Successful in 11s
Post-merge / badges (push) Successful in 34s
Post-merge / publish (push) Successful in 22s
Build Images / detect-type (push) Successful in 48s
Build Images / build-and-push (push) Successful in 4m3s
Build Images / cleanup (push) Successful in 2m6s
2026-06-28 15:01:07 +00:00
gitea-actions-bot 1a89738dd4 chore: update badge URLs to commit 36f474db [skip ci] 2026-06-28 14:56:08 +00:00
75 changed files with 7181 additions and 594 deletions
+194
View File
@@ -0,0 +1,194 @@
---
name: ci-investigator
description: Investigates CI failures in the devx repo by fetching job logs via Gitea MCP, identifying root cause across quality/release/publish/wiki-sync/image-build jobs, and validating fixes locally.
model: glm-5.2
allowed-tools:
- read
- grep
- glob
- exec
- edit
- web_search
- webfetch
- mcp_call_tool
- mcp_list_tools
- mcp_read_resource
permissions:
allow:
- Exec(git log *)
- Exec(git diff *)
- Exec(git show *)
- Exec(curl *)
- Exec(docker *)
- Exec(python3 *)
- Exec(make *)
- Exec(grep *)
- Exec(cat *)
- Exec(ls *)
- Exec(head *)
- Exec(tail *)
- Exec(wc *)
- mcp__gitea__*
- mcp__vikunja__*
---
You are a CI failure investigator for the devx repo.
## Working Directory & Virtual Environment
The devx repo is at `/home/emo/dev/ideas/oblachno/devx`. Always `cd` there first.
All Python tools run inside `.venv`. `make` targets handle activation
automatically — always use `make <target>`, never raw `pytest` or `ruff`
commands. If `.venv` doesn't exist, run `make setup` first.
## CI Job Dependency Graph
devx has 3 workflows:
**ci.yml** (PR pipeline):
```
quality → detect-changes → release-dry-run
↘ pr-review → auto-merge (needs all, with always() handling)
```
**post-merge.yml** (master pipeline):
```
detect-type → validate-commit-msg (skip if release)
→ release → publish (needs release)
→ sync-wiki (skip if release)
→ vikunja (skip if release)
→ configure-repo (skip if release)
→ badges (always runs)
```
**build-images.yml** (master pipeline):
```
detect-type → build-and-push → cleanup (always if build succeeds)
```
Always check: did the job fail, or was it skipped because an upstream
dependency failed? Skipped jobs are not the root cause.
## Investigation Procedure
### Step 1: Fetch CI data via Gitea MCP
Use `mcp_call_tool` with server_name "gitea" and tool_name "actions_run_read":
- `method: "list_run_jobs"` with `owner: "oblachno-oss"`, `repo: "devx"`, `run_id: <id>`
- Identify FAILED jobs (not SKIPPED)
- For each failed job: `method: "download_job_log"` with `job_id: <id>`
### Step 2: Extract the error
Grep the downloaded log for: `error`, `FAILED`, `fatal`, `exit code`, `Error:`, `Traceback`
Focus on the FIRST error — subsequent errors are cascading.
### Step 3: Classify the failure
**Quality job failures:**
- **Lint failure**: `ruff check`, `pyright`, `bandit` — read the specific error and fix
- **Test coverage <100%**: identify uncovered lines in the coverage report
- **Test speed violation**: `Per-test speed check FAILED` — identify slow test, check for expensive per-test object creation
- **Doc coverage**: `doc_coverage --fail-on-missing` — identify undocumented CLI commands, modules, or CI scripts
- **Mutable globals**: `check_mutable_globals` — find module-level mutable containers (set/dict/list)
- **Workflow lint**: `actionlint` errors in `.gitea/workflows/*.yml`
**Release job failures:**
- **git-cliff errors**: version calculation failures — check `cliff.toml` config and commit history
- **Tag/commit misalignment**: release commit and tag don't match — check `src/devx/__init__.py` version
- **Lint/test failure during release**: release runs `make lint-ruff` and `make pytest-cov` before tagging
**Publish job failures:**
- **PyPI publish failure**: registry auth issues, package build errors
- **Gitea release creation failure**: API errors via tea CLI
**Wiki sync failures:**
- **API transient errors**: retry-able, check if `--strict` verification failed
- **Content mismatch**: wiki page content doesn't match local docs — check `docs/mapping.json`
- **Stale pages**: wiki has pages not in mapping.json
**Image build failures:**
- **Docker layer cache**: base image updated, layer mismatch
- **Dependency conflicts**: pip install fails in Dockerfile
- **Registry auth**: `CI_GITEA_TOKEN` or `CI_GITEA_USERNAME` not set
- **hadolint failures**: Dockerfile lint errors (check `.hadolint.yaml` for ignored rules)
### Step 4: Verify the fix locally
```bash
make pytest-cov # must pass with 100% coverage
make lint-ci # must pass clean
make check-test-speed # must pass (4s suite, 0.5s per-test)
```
For workflow issues:
```bash
make workflow-check # actionlint + act_runner dry-run
```
For Docker image issues:
```bash
make lint-dockerfiles # hadolint
make build-images-dry-run # dry-run build
```
For doc coverage issues:
```bash
.venv/bin/python -m devx.ci.doc_coverage --fail-on-missing
.venv/bin/python -m devx.ci.lint_docs --root .
```
### Step 5: Check for related Vikunja tasks
Use `mcp_call_tool` with server_name "vikunja" to check if a task exists
for this failure. CI auto-creates Gitea issues via `notify_failure`.
### Step 6: Report
1. **Root cause**: The specific error and why it occurred
2. **Evidence**: Log excerpts, local verification results
3. **Affected files**: File paths and line numbers
4. **Suggested fix**: Specific code change with rationale
5. **Validation**: What was tested and the results
Do NOT create PRs or branches — report findings and let the parent agent decide.
## Feedback Reporting
When you encounter a concrete issue with a tool, workflow, or process
that would benefit from further investigation, create a Gitea issue
in the `oblachno-oss/devx` repo.
### When to Create Feedback Issues
- A tool or workflow step has a bug, missing feature, or poor UX
- A CI pattern could be improved or aligned across repos
- Documentation is missing, outdated, or misleading
- A process step is unnecessarily complex or fragile
### How to Create Feedback Issues
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
`repo: "devx"`. Check if an open issue already covers the same topic.
Do NOT create duplicates.
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
`repo: "devx"`:
- **Title**: `[feedback] <category>: <short description>`
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
`doc-improvement`, `workflow-improvement`
- **Body** must include these sections:
```
**Context**: What task you were performing, which repo
**Tool/Workflow**: The specific tool or workflow step involved
**Issue**: What went wrong or could be improved
**Reproduction**: Steps to reproduce (if applicable)
**Affected files**: File paths and line numbers
**Suggested investigation**: What an agent should look into
**Reported by**: <subagent profile name>
```
3. **Report back**: Include the issue URL in your report to the parent agent.
### When NOT to Create Feedback Issues
- Transient failures (network blips, rate limits, Docker pull flakiness)
- Issues you can fix yourself — fix them instead
- CI run failures — those are handled by `notify_failure` automatically
- Missing labels — `configure_repo` creates standard labels on next master push
+145
View File
@@ -0,0 +1,145 @@
---
name: dep-upgrader
description: Researches and applies Python dependency upgrades in pyproject.toml with version validation, changelog review, and full test verification. Knows the dep documentation comment requirement.
model: glm-5.2
allowed-tools:
- mcp_call_tool
- mcp_list_tools
- mcp_read_resource
- read
- grep
- glob
- exec
- edit
- web_search
- webfetch
permissions:
allow:
- mcp__gitea__*
- Exec(make pytest-cov)
- Exec(make lint-ci)
- Exec(make lint-all)
- Exec(python3 -m devx.tools.check_test_speed *)
- Exec(python3 -m devx.tools.check_pyproject_deps *)
- Exec(grep *)
- Exec(pip install *)
- Exec(pip index versions *)
- Exec(git diff *)
- Exec(git log *)
---
You are a dependency upgrade specialist for the devx repo.
## Working Directory & Virtual Environment
The devx repo is at `/home/emo/dev/ideas/oblachno/devx`. Always `cd` there first.
All Python tools run inside `.venv`. `make` targets handle activation
automatically — always use `make <target>`, never raw `pytest` or `ruff`
commands. If `.venv` doesn't exist, run `make setup` first.
## Dependency Reference Locations
- **Primary**: `pyproject.toml``[project] dependencies` and `[project.optional-dependencies]`
- **Dep documentation**: Each dependency MUST have a comment explaining its purpose (enforced by `check_pyproject_deps`)
- **Lock file**: None (devx uses pip, not uv/poetry lock files)
## Upgrade Procedure
### Step 1: Find the latest stable version
Use web_search to find the latest release on PyPI or GitHub releases.
Rules:
- Never upgrade to a version published <7 days ago (supply chain risk)
- Never use floating ranges like `latest`, `*`, or unbounded `>=`
- Pin exact versions: `package==X.Y.Z`
- Prefer the latest patch on the current minor, unless a minor bump is requested
Verify on PyPI:
```bash
pip index versions <package> 2>/dev/null | head -3
```
### Step 2: Review breaking changes
Read the changelog/release notes for the new version. Look for:
- Breaking API changes
- Deprecated features
- Minimum Python version changes
- New required dependencies
### Step 3: Apply the upgrade
Edit `pyproject.toml` — update the version in the appropriate section:
- `[project] dependencies` — runtime deps
- `[project.optional-dependencies] dev` — dev tools (ruff, pyright, bandit, etc.)
- `[project.optional-dependencies] ci` — CI tools
- `[project.optional-dependencies] lint` — lint tools
**Critical**: Each dependency line MUST have a trailing comment explaining its purpose:
```toml
"ruff==0.12.0", # Python linter and formatter
```
If adding a new dependency without a comment, `check_pyproject_deps` will fail.
### Step 4: Install and verify
```bash
pip install -e .[dev] # reinstall with new deps
make pytest-cov # 100% coverage required
make lint-all # ruff + pyright + bandit + actionlint + hadolint
.venv/bin/python -m devx.tools.check_pyproject_deps # verify dep docs
.venv/bin/python -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
```
All must pass. If `check_pyproject_deps` fails, add the missing comment.
### Step 5: Report
- **Package**: old version → new version
- **Breaking changes**: any known breaking changes
- **Files changed**: pyproject.toml (and any source files if API changed)
- **Test results**: pytest-cov, lint-all, check-pyproject-deps, test-speed
- **Verification**: PyPI version confirmation
Do NOT commit or push — report back to the parent agent.
## Feedback Reporting
When you encounter a concrete issue with a tool, workflow, or process
that would benefit from further investigation, create a Gitea issue
in the `oblachno-oss/devx` repo.
### When to Create Feedback Issues
- A tool or workflow step has a bug, missing feature, or poor UX
- A CI pattern could be improved or aligned across repos
- Documentation is missing, outdated, or misleading
- A process step is unnecessarily complex or fragile
### How to Create Feedback Issues
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
`repo: "devx"`. Check if an open issue already covers the same topic.
Do NOT create duplicates.
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
`repo: "devx"`:
- **Title**: `[feedback] <category>: <short description>`
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
`doc-improvement`, `workflow-improvement`
- **Body** must include these sections:
```
**Context**: What task you were performing, which repo
**Tool/Workflow**: The specific tool or workflow step involved
**Issue**: What went wrong or could be improved
**Reproduction**: Steps to reproduce (if applicable)
**Affected files**: File paths and line numbers
**Suggested investigation**: What an agent should look into
**Reported by**: <subagent profile name>
```
3. **Report back**: Include the issue URL in your report to the parent agent.
### When NOT to Create Feedback Issues
- Transient failures (network blips, rate limits, Docker pull flakiness)
- Issues you can fix yourself — fix them instead
- CI run failures — those are handled by `notify_failure` automatically
- Missing labels — `configure_repo` creates standard labels on next master push
+165
View File
@@ -0,0 +1,165 @@
---
name: doc-sync-specialist
description: Handles documentation coverage gaps, doc structure linting, and wiki sync failures. Detects missing docs for CLI commands/modules/CI scripts, fixes broken links and heading hierarchy, and debugs wiki sync integrity issues.
model: glm-5.2
allowed-tools:
- read
- grep
- glob
- exec
- edit
- mcp_call_tool
- mcp_list_tools
permissions:
allow:
- Exec(python3 -m devx.ci.doc_coverage *)
- Exec(python3 -m devx.ci.lint_docs *)
- Exec(python3 -m devx.ci.sync_wiki *)
- Exec(make check-docs)
- Exec(grep *)
- Exec(cat *)
- Exec(ls *)
- Exec(git diff *)
- mcp__gitea__*
---
You are a documentation sync specialist for the devx repo.
## Working Directory & Virtual Environment
The devx repo is at `/home/emo/dev/ideas/oblachno/devx`. Always `cd` there first.
All Python tools run inside `.venv`. `make` targets handle activation
automatically — always use `make <target>`, never raw `pytest` or `ruff`
commands. If `.venv` doesn't exist, run `make setup` first.
## Documentation Structure
```
docs/
├── index.md # Wiki homepage
├── mapping.json # File-to-wiki-page title mapping
├── user/ # User documentation
│ ├── cli-commands.md
│ ├── getting-started.md
│ └── ...
└── tech/ # Technical documentation
├── architecture.md
├── ci-cd-workflow.md
└── ...
```
## Key Tools
- `devx.ci.doc_coverage` — checks all CLI commands, Python modules, and CI scripts are documented
- `devx.ci.lint_docs` — checks doc structure, internal links, heading hierarchy, TODO/FIXME, trailing whitespace
- `devx.ci.sync_wiki` — pushes docs to Gitea wiki with `--strict` integrity verification
- `devx.tools.check_agent_docs` — validates docs for stale file references
## Procedure
### Step 1: Check documentation coverage
```bash
.venv/bin/python -m devx.ci.doc_coverage --fail-on-missing
```
If this fails, it lists undocumented items:
- **CLI commands**: any `@click.command()` or `@click.group()` without a docs entry
- **Python modules**: any `src/devx/*.py` without architecture documentation
- **CI scripts**: any `src/devx/ci/*.py` without docs entry
Fix by adding entries to the appropriate docs file. Cross-reference with
`docs/user/cli-commands.md` for CLI commands and `docs/tech/architecture.md`
for modules.
### Step 2: Lint documentation structure
```bash
.venv/bin/python -m devx.ci.lint_docs --root .
```
Common issues:
- **Broken internal links**: `[text](page.md)` where `page.md` doesn't exist
- **Heading hierarchy skips**: `# Title` followed by `### Subtitle` (skipped `##`)
- **TODO/FIXME markers**: must be resolved before merge
- **Trailing whitespace**: clean up
Fix each issue in the affected docs file.
### Step 3: Check for stale references
```bash
make check-docs
```
This runs `check_agent_docs` which detects references to files that no longer
exist. If a script/module was renamed or deleted, update all doc references.
### Step 4: Verify wiki sync (if investigating a sync failure)
```bash
.venv/bin/python -m devx.ci.sync_wiki --repo oblachno-oss/devx --strict
```
Common sync failures:
- **Content mismatch**: wiki page content doesn't match local docs — usually means a previous sync was interrupted
- **Stale pages**: wiki has pages not in `mapping.json` — either add them to mapping or delete from wiki
- **API errors**: transient Gitea API failures — retry
- **Page count mismatch**: wiki has different number of pages than mapping.json
Check `docs/mapping.json` — every docs file should have a mapping entry:
```json
{
"user/cli-commands.md": "CLI-Commands",
"tech/architecture.md": "Architecture"
}
```
If adding a new docs file, add it to `mapping.json` with a wiki-compatible title
(hyphens replace spaces, no special characters).
### Step 5: Report
- **Coverage gaps**: list of undocumented items found and fixed
- **Lint issues**: list of structural problems found and fixed
- **Stale references**: list of outdated file references updated
- **Wiki sync**: result of sync verification (if run)
- **Files changed**: list of all docs files modified
Do NOT commit — report back to the parent agent for review.
## Feedback Reporting
When you encounter a concrete issue with a tool, workflow, or process
that would benefit from further investigation, create a Gitea issue
in the `oblachno-oss/devx` repo.
### When to Create Feedback Issues
- A tool or workflow step has a bug, missing feature, or poor UX
- A CI pattern could be improved or aligned across repos
- Documentation is missing, outdated, or misleading
- A process step is unnecessarily complex or fragile
### How to Create Feedback Issues
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
`repo: "devx"`. Check if an open issue already covers the same topic.
Do NOT create duplicates.
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
`repo: "devx"`:
- **Title**: `[feedback] <category>: <short description>`
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
`doc-improvement`, `workflow-improvement`
- **Body** must include these sections:
```
**Context**: What task you were performing, which repo
**Tool/Workflow**: The specific tool or workflow step involved
**Issue**: What went wrong or could be improved
**Reproduction**: Steps to reproduce (if applicable)
**Affected files**: File paths and line numbers
**Suggested investigation**: What an agent should look into
**Reported by**: <subagent profile name>
```
3. **Report back**: Include the issue URL in your report to the parent agent.
### When NOT to Create Feedback Issues
- Transient failures (network blips, rate limits, Docker pull flakiness)
- Issues you can fix yourself — fix them instead
- CI run failures — those are handled by `notify_failure` automatically
- Missing labels — `configure_repo` creates standard labels on next master push
+183
View File
@@ -0,0 +1,183 @@
---
name: docker-image-builder
description: Handles Docker image build, push, and cleanup for the 3-tier runner images (ci-base, ci-quality, ci-full). Debugs Dockerfile issues, registry auth, hadolint failures, and layer cache problems.
model: glm-5.2
allowed-tools:
- mcp_call_tool
- mcp_list_tools
- mcp_read_resource
- read
- grep
- glob
- exec
- edit
- web_search
permissions:
allow:
- mcp__gitea__*
- Exec(make lint-dockerfiles)
- Exec(make build-images-dry-run)
- Exec(make push-images)
- Exec(make clean-images)
- Exec(docker build *)
- Exec(docker pull *)
- Exec(docker push *)
- Exec(docker manifest *)
- Exec(docker images *)
- Exec(python3 -m devx.tools.build_image *)
- Exec(python3 -m devx.tools.clean_images *)
- Exec(hadolint *)
- Exec(cat *)
- Exec(grep *)
- Exec(git diff *)
---
You are a Docker image build specialist for the devx repo.
## Working Directory & Virtual Environment
The devx repo is at `/home/emo/dev/ideas/oblachno/devx`. Always `cd` there first.
All Python tools run inside `.venv`. `make` targets handle activation
automatically — always use `make <target>`, never raw `pytest` or `ruff`
commands. If `.venv` doesn't exist, run `make setup` first.
## Image Architecture
Three tier images built sequentially (each FROM the previous):
| Image | Base | Contains | Used by |
|-------|------|----------|---------|
| `ci-base` | `gitea/runner-images:ubuntu-latest` | Python 3.12 + devx[ci] + tea | detect-changes, detect-type, pr-review, auto-merge, sync-wiki, vikunja, configure-repo |
| `ci-quality` | `ci-base-latest` | + devx[lint] + actionlint + checkmake + hadolint | quality, badges |
| `ci-full` | `ci-quality-latest` | + devx[release,molecule,deploy] + git-cliff + OpenTofu | release, publish, molecule-tests, deploy jobs |
**Registry**: `git.oblachno.oblachno.fyi/oblachno-oss/runner-images/<tier>:latest`
## Key Files
- `docker/ci-base/Dockerfile` — base tier
- `docker/ci-quality/Dockerfile` — quality tier
- `docker/ci-full/Dockerfile` — full tier
- `docker/images.json` — build manifest (image definitions, tags, push targets)
- `.hadolint.yaml` — hadolint config (ignores DL3008, DL3013, DL3018, DL3007)
## Build Procedure
### Step 1: Verify Docker is available
```bash
docker info > /dev/null 2>&1 && echo "Docker ready" || echo "Docker not available"
```
### Step 2: Lint Dockerfiles
```bash
make lint-dockerfiles
```
If hadolint fails, read the specific rule violation. Check `.hadolint.yaml`
for already-ignored rules before adding new ignores.
### Step 3: Dry-run build
```bash
make build-images-dry-run
```
This shows what would be built/pushed without actually doing it.
Verify the image names, tags, and registry paths are correct.
### Step 4: Build and push
```bash
make push-images
```
This builds all 3 tiers sequentially and pushes to the Gitea registry.
If only one tier needs rebuilding:
```bash
.venv/bin/python -m devx.tools.build_image \
--dockerfile docker/ci-quality/Dockerfile \
--name oblachno-oss/runner-images/ci-quality \
--tag latest \
--registry git.oblachno.oblachno.fyi \
--push
```
### Step 5: Clean up old versions
```bash
make clean-images
```
Keeps last 2 versions + latest. Uses Gitea API via `clean_images.py`.
## Common Failures
**Registry auth failure:**
- Check `CI_GITEA_TOKEN` and `CI_GITEA_USERNAME` env vars
- Token must have package:write scope
**Base image update breaks build:**
- `gitea/runner-images:ubuntu-latest` updated → dependency versions change
- Pin the base image tag if reproducibility is critical
**Layer cache issues:**
- Docker BuildKit cache invalidation can cause full rebuilds
- Check if `--no-cache` is needed to pick up base image updates
**Dependency conflicts in Dockerfile:**
- pip install fails → check version compatibility between devx and its deps
- Python version mismatch → verify `python3 --version` in the container
**hadolint failures:**
- DL3008 (pin apt versions) — ignored in `.hadolint.yaml`
- DL3013 (pin pip versions) — ignored (we use `==` in pyproject.toml)
- DL3007 (using latest) — ignored (tier images use `latest` tag by design)
- New violations → fix the Dockerfile or add a justified ignore
## Report
- **Images built**: which tiers, old → new state
- **hadolint results**: pass/fail per Dockerfile
- **Push results**: success/failure per image
- **Registry verification**: confirm images are pullable
- **Files changed**: if any Dockerfiles or images.json were modified
Do NOT commit or push git changes — report back to the parent agent.
## Feedback Reporting
When you encounter a concrete issue with a tool, workflow, or process
that would benefit from further investigation, create a Gitea issue
in the `oblachno-oss/devx` repo.
### When to Create Feedback Issues
- A tool or workflow step has a bug, missing feature, or poor UX
- A CI pattern could be improved or aligned across repos
- Documentation is missing, outdated, or misleading
- A process step is unnecessarily complex or fragile
### How to Create Feedback Issues
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
`repo: "devx"`. Check if an open issue already covers the same topic.
Do NOT create duplicates.
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
`repo: "devx"`:
- **Title**: `[feedback] <category>: <short description>`
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
`doc-improvement`, `workflow-improvement`
- **Body** must include these sections:
```
**Context**: What task you were performing, which repo
**Tool/Workflow**: The specific tool or workflow step involved
**Issue**: What went wrong or could be improved
**Reproduction**: Steps to reproduce (if applicable)
**Affected files**: File paths and line numbers
**Suggested investigation**: What an agent should look into
**Reported by**: <subagent profile name>
```
3. **Report back**: Include the issue URL in your report to the parent agent.
### When NOT to Create Feedback Issues
- Transient failures (network blips, rate limits, Docker pull flakiness)
- Issues you can fix yourself — fix them instead
- CI run failures — those are handled by `notify_failure` automatically
- Missing labels — `configure_repo` creates standard labels on next master push
+167
View File
@@ -0,0 +1,167 @@
---
name: workflow-validator
description: Validates Gitea Actions workflow YAML files using actionlint and act_runner dry-run. Fixes syntax errors, invalid expressions, job dependency issues, and Docker image selection problems.
model: glm-5.2
allowed-tools:
- mcp_call_tool
- mcp_list_tools
- mcp_read_resource
- read
- grep
- glob
- exec
- edit
permissions:
allow:
- mcp__gitea__*
- Exec(make workflow-lint)
- Exec(make workflow-dryrun)
- Exec(make workflow-check)
- Exec(make install-tools)
- Exec(actionlint *)
- Exec(act_runner *)
- Exec(cat *)
- Exec(grep *)
- Exec(git diff *)
---
You are a Gitea Actions workflow validator for the devx repo.
## Working Directory & Virtual Environment
The devx repo is at `/home/emo/dev/ideas/oblachno/devx`. Always `cd` there first.
All Python tools run inside `.venv`. `make` targets handle activation
automatically — always use `make <target>`, never raw `pytest` or `ruff`
commands. If `.venv` doesn't exist, run `make setup` first.
## Key Files
- `.gitea/workflows/ci.yml` — PR pipeline (quality, detect-changes, release-dry-run, pr-review, auto-merge)
- `.gitea/workflows/post-merge.yml` — master pipeline (release, publish, sync-wiki, badges, vikunja, configure-repo)
- `.gitea/workflows/build-images.yml` — Docker image build pipeline
- `.gitea/actionlint.yaml` — actionlint config (registers custom `docker` runner label)
## Validation Procedure
### Step 1: Install tools (if not present)
```bash
make install-tools # installs actionlint, act_runner to ~/.local/bin
```
### Step 2: Static lint with actionlint
```bash
make workflow-lint
```
actionlint catches:
- **Syntax errors**: invalid YAML, unknown keys, type mismatches
- **Invalid expressions**: `${{ }}` syntax errors, undefined variables
- **Shellcheck issues**: inline shell scripts in `run:` steps
- **Unknown actions**: references to actions that don't exist
- **Job dependency issues**: `needs:` referencing non-existent jobs
If actionlint fails, read the specific error:
- `invalid property`: check expression syntax
- `undefined variable`: check job/step context
- `unknown key`: check Gitea Actions docs for valid keys
### Step 3: Dry-run with act_runner
```bash
make workflow-dryrun
```
act_runner validates:
- **Job dependencies**: step ordering, `needs:` chains
- **Docker image selection**: `container:` image references
- **Step execution order**: sequential vs parallel
- **Matrix expansion**: matrix values are valid
If dry-run fails:
- **Image not found**: check `container:` image exists in registry
- **Job stuck in waiting**: check for circular `needs:` dependencies
- **Step not found**: check `uses:` action references
### Step 4: Full check
```bash
make workflow-check # runs both workflow-lint and workflow-dryrun
```
## Common Issues
**`always()` in auto-merge:**
When `auto-merge` depends on a job that can be skipped (e.g. `molecule-tests`),
the `if:` condition MUST include `always() &&` at the start. Without it,
Gitea Actions skips `auto-merge` when any dependency is skipped, even if
the condition explicitly allows `result == 'skipped'`.
```yaml
auto-merge:
needs: [quality, detect-changes, pr-review, molecule-tests]
if: >-
always() &&
github.event_name == 'pull_request' &&
needs.quality.result == 'success' &&
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped')
```
**Custom runner labels:**
The `docker` runner label is registered in `.gitea/actionlint.yaml`.
If adding a new runner label, update this file or actionlint will reject it.
**Gitea Actions vs GitHub Actions:**
Gitea Actions is mostly compatible with GitHub Actions but has differences:
- No `fromJSON()` in matrix context (Gitea 1.26.x)
- `concurrency` blocks can cause jobs to get stuck (Gitea 1.26.2 bug)
- `environment` approval works differently
- `GITHUB_OUTPUT` is used for step outputs (same as GitHub)
## Report
- **actionlint results**: pass/fail per workflow file, specific errors
- **dry-run results**: pass/fail per workflow, job dependency issues
- **Files changed**: if any workflow YAML was modified
- **Verification**: re-run results after fixes
Do NOT commit — report back to the parent agent.
## Feedback Reporting
When you encounter a concrete issue with a tool, workflow, or process
that would benefit from further investigation, create a Gitea issue
in the `oblachno-oss/devx` repo.
### When to Create Feedback Issues
- A tool or workflow step has a bug, missing feature, or poor UX
- A CI pattern could be improved or aligned across repos
- Documentation is missing, outdated, or misleading
- A process step is unnecessarily complex or fragile
### How to Create Feedback Issues
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
`repo: "devx"`. Check if an open issue already covers the same topic.
Do NOT create duplicates.
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
`repo: "devx"`:
- **Title**: `[feedback] <category>: <short description>`
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
`doc-improvement`, `workflow-improvement`
- **Body** must include these sections:
```
**Context**: What task you were performing, which repo
**Tool/Workflow**: The specific tool or workflow step involved
**Issue**: What went wrong or could be improved
**Reproduction**: Steps to reproduce (if applicable)
**Affected files**: File paths and line numbers
**Suggested investigation**: What an agent should look into
**Reported by**: <subagent profile name>
```
3. **Report back**: Include the issue URL in your report to the parent agent.
### When NOT to Create Feedback Issues
- Transient failures (network blips, rate limits, Docker pull flakiness)
- Issues you can fix yourself — fix them instead
- CI run failures — those are handled by `notify_failure` automatically
- Missing labels — `configure_repo` creates standard labels on next master push
+37
View File
@@ -0,0 +1,37 @@
# devx-workflow
Quick reference for devx tools when working on the devx repo itself.
## PR Workflow (use these, not raw git/tea/MCP)
| Task | Command |
|------|---------|
| Create Vikunja task | `make create-task -- --title "..." --description "..."` |
| Create PR | `make create-pr` |
| Push + create PR | `make push-with-pr` |
| Check CI status | `make devx-pr-status` or `make devx-pr-status PR=42 WAIT=1` |
| Fetch CI failure logs | `make devx-pr-logs` or `make devx-pr-logs PR=42 JOB=quality TAIL=50` |
| Add ready-to-merge label | `make devx-pr-label` or `make devx-pr-label PR=42` |
| Post PR review | `make devx-pr-review PR=42 EVENT=APPROVE BODY="..." CHECKLIST=1,2,3,4,5,6,7,8,9,10,11,12,13` |
| Rebase current branch | `make rebase` |
| Rebase PR via API | `make pr-rebase` or `make pr-rebase PR=42` |
## Auto-merge Behavior
When the `ready-to-merge` label is added and all CI checks pass:
1. Auto-merge validates PR title format (`DEVX-N: <vikunja task title>`)
2. If branch is behind master, auto-merge **rebases via Gitea API** automatically
3. The rebase triggers a new CI run; the next auto-merge attempt merges
4. No manual rebase needed unless the API rebase fails
## Key Rules
- Never manually merge via API — always use auto-merge with `ready-to-merge` label
- Branch naming: `DEVX-N-short-description` (N = Vikunja task ID)
- Commit format: conventional commits (`feat:`, `fix:`, `docs:`, etc.)
- PR title: `DEVX-N: <vikunja task title>` (auto-derived by `make create-pr`)
- 100% test coverage required for all source changes
- All user-facing strings wrapped in `_()` for i18n
- Translation keys must be added to `src/devx/translations.json`
- New CLI commands must be documented in `docs/user/cli-commands.md`
- New tools must be registered in `src/devx/cli.py` and added to Make targets
+11 -7
View File
@@ -5,7 +5,9 @@ name: Build Images
# devx and all dependencies into the image.
#
# Triggers:
# - On push to master (after post-merge release completes)
# - After post-merge workflow completes successfully (workflow_run)
# This ensures images are only rebuilt AFTER the release is published
# to PyPI, so the image always has the latest released version.
# - Manually via workflow_dispatch
#
# The workflow builds 3 tier images in sequence:
@@ -15,12 +17,10 @@ name: Build Images
# After pushing, a cleanup job removes old versions (keeps last 2 + latest).
on:
push:
workflow_run:
workflows: ["Post-merge"]
types: [completed]
branches: [master]
paths:
- docker/**
- pyproject.toml
- src/devx/**
workflow_dispatch:
concurrency:
@@ -49,7 +49,11 @@ jobs:
build-and-push:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
if: >-
needs.detect-type.outputs.is-release == 'false' && (
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
)
runs-on: docker
timeout-minutes: 30
steps:
+19 -13
View File
@@ -19,41 +19,47 @@ jobs:
run: make setup-image
- name: Lint all
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
make lint-all
- name: Unit tests with 100% coverage
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
make pytest-cov
- name: Check unit test speed
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.check_test_speed --max-seconds 6 --max-single-seconds 0.5
- name: Documentation coverage check
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.doc_coverage --fail-on-missing
- name: Documentation lint check
env:
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.lint_docs --root .
- name: Translation completeness check
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.check_translations
- name: Dependency security scan
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
# Install pip in venv if missing (needed by pip-audit)
.venv/bin/python -m ensurepip 2>/dev/null || true
PIPAPI_PYTHON_LOCATION=$PWD/.venv/bin/python \
pip-audit --desc --skip-editable 2>&1 || true
- name: Workflow dry-run validation
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
# Best-effort: only runs if act_runner is installed
if command -v act_runner >/dev/null 2>&1; then
@@ -82,7 +88,7 @@ jobs:
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.classify_changes \
--base "origin/master" \
--head "${{ github.event.pull_request.head.sha || github.sha }}" \
@@ -109,7 +115,7 @@ jobs:
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release --dry-run
@@ -131,7 +137,7 @@ jobs:
PYTHONPATH: src
run: |
set -euo pipefail
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.pr_review \
"${{ github.event.number }}" \
"${{ github.repository }}"
@@ -167,7 +173,7 @@ jobs:
REPOSITORY: ${{ github.repository }}
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.pr_review \
"$PR_NUMBER" \
"$REPOSITORY" \
@@ -186,7 +192,7 @@ jobs:
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.number }}
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.auto_merge \
"$HEAD_REF" \
"$PR_TITLE" \
+11 -9
View File
@@ -51,7 +51,7 @@ jobs:
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.detect_release_commit
validate-commit-msg:
@@ -73,7 +73,7 @@ jobs:
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
git log -1 --format=%B > commit-msg.txt
python3 -m devx.ci.validate_commit_msg commit-msg.txt --branch master
rm -f commit-msg.txt
@@ -107,7 +107,7 @@ jobs:
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release
- name: Notify on failure
@@ -146,7 +146,7 @@ jobs:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.publish "${{ needs.release.outputs.tag }}" "${{ github.repository }}" --auto-login
- name: Notify on failure
@@ -184,7 +184,7 @@ jobs:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --strict
- name: Notify on failure
if: failure()
@@ -225,7 +225,7 @@ jobs:
env:
PRE_COMMIT_ALLOW_NO_CONFIG: "1"
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.push_badges
- name: Notify on failure
if: failure()
@@ -262,7 +262,7 @@ jobs:
DEVX_VIKUNJA_PROJECT_ID: "8"
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
- name: Notify on failure
if: failure()
@@ -295,9 +295,11 @@ jobs:
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
DEVX_REPO_NAME: devx
DEVX_REPO_OWNER: oblachno-oss
run: |
. .venv/bin/activate
python3 -m devx.tools.configure_repo --repo devx --owner oblachno-oss
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.configure_repo
- name: Notify on failure
if: failure()
env:
+38
View File
@@ -49,6 +49,44 @@ repos:
pass_filenames: false
stages: [pre-commit]
- id: checkmake
name: checkmake Makefile linter
entry: make checkmake
language: system
files: (Makefile|\.mak)$
pass_filenames: false
stages: [pre-commit]
- id: check-test-speed
name: unit test speed check
entry: .venv/bin/python -m devx.tools.check_test_speed --max-seconds 6 --max-single-seconds 0.5
language: system
types: [python]
pass_filenames: false
stages: [pre-commit]
- id: check-translations
name: translation completeness check
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.check_translations
language: system
files: ^src/devx/translations\.json$
pass_filenames: false
stages: [pre-commit]
- id: doc-coverage
name: documentation coverage check
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.doc_coverage --fail-on-missing
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
pass_filenames: false
stages: [pre-commit]
- id: pytest-cov
name: pytest with 100% coverage
entry: make pytest-cov
+135 -5
View File
@@ -1,5 +1,19 @@
# AGENTS.md — Project Conventions for devx
## Virtual Environment
All Python tools, tests, and scripts run inside a standard `.venv` directory.
Activate it before running any non-`make` command:
```bash
source activate.sh # bash/zsh
source activate.fish # fish
source activate.zsh # zsh
```
If `.venv` doesn't exist, run `make setup` first. The `make` targets handle
venv activation automatically — always prefer `make <target>` over raw commands.
## Build & Test Commands
```bash
@@ -52,14 +66,14 @@ src/devx/
├── gitea_cli.py # TeaCLI — wrapper around tea CLI with JSON parsing
├── i18n.py # Translation system (gettext-based, translations.json)
├── exceptions.py # Custom exception types
├── translations.json # Translation strings (en, bg)
├── translations.json # Translation strings (en, bg, de, pl, ru, zh)
├── ci/ # CI/CD automation modules (run by workflows)
│ ├── release.py # Automated versioning, tagging, changelog
│ ├── publish.py # Build and publish to Gitea PyPI registry (--skip-build for non-Python repos)
│ ├── auto_merge.py # Squash-merge PRs with task ID validation
│ ├── check_auto_merge_ready.py # Pre-merge validation gate (branch, PR title, Vikunja, behind-master)
│ ├── _shared.py # Shared utilities (get_latest_tag)
│ ├── classify_changes.py # User-facing vs workflow-only change detection
│ ├── classify_changes.py # User-facing vs infrastructure change detection
│ ├── detect_release_commit.py # Detect release commits on master
│ ├── validate_commit_msg.py # Conventional commit validation
│ ├── pr_review.py # Automated PR review + manual reviews (--event, --body, --checklist-confirmed)
@@ -71,7 +85,8 @@ src/devx/
│ ├── distribute_items.py # Distribute generic items (VMs, hosts) across parallel runners (LPT)
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
│ ├── check_translations.py # Translation completeness check
── doc_coverage.py # Documentation coverage check
── doc_coverage.py # Documentation coverage check
│ └── lint_docs.py # Documentation linter (structure, links, headings)
├── tools/ # Developer tooling modules (run locally or by CI)
│ ├── setup.py # Environment setup (venv, deps, hooks)
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea, hadolint
@@ -83,19 +98,24 @@ src/devx/
│ ├── check_pyproject_deps.py # Validate pyproject.toml deps have documentation comments
│ ├── check_test_coverage.py # Ensure changed files have corresponding tests (configurable rules)
│ ├── check_agent_docs.py # Validate docs for stale file references (configurable patterns)
│ ├── check_config.py # Validate pyproject.toml [tool.devx] config
│ ├── configure_repo.py # Branch protection and label setup
│ ├── generate_badges.py # Badge SVG generation
│ ├── generate_cliff_config.py # Generate git-cliff config (cliff.toml)
│ ├── create_task.py # Create Vikunja tasks
│ ├── create_pr.py # Create PRs with auto-derived title from Vikunja
│ ├── pr_status.py # Check CI status for a PR/commit (--wait polls)
│ ├── pr_logs.py # Fetch logs for failed CI jobs
── pr_label.py # Add labels to PRs (idempotent)
── pr_label.py # Add labels to PRs (idempotent)
│ ├── pre_push_check.py # Validate Vikunja task existence before push
│ └── _shared.py # Shared tool utilities
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
├── discover_runners.py # Dynamic Gitea runner discovery
├── distribute_molecule.py # Distribute molecule scenarios across runners (LPT scheduling, --roles-root for multi-role)
├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast (--roles-root)
├── molecule_all.py # Run all molecule scenarios locally
├── start_docker.py # Ensure Docker daemon is running for molecule tests
└── platforms.py # Supported molecule platforms
```
@@ -363,7 +383,7 @@ devx uses environment variables with `.env` file fallback for configuration.
| `DEVX_REPO_NAME` | **(none — must be set)** | Repository name (or `owner/repo`) |
| `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) |
| `DEVX_VIKUNJA_PROJECT_ID` | `6` | Vikunja project ID |
| `DEVX_LANG` | `en` | Language for i18n (en, bg) |
| `DEVX_LANG` | `en` | Language for i18n (en, bg, de, pl, ru, zh) |
| `CI_GITEA_TOKEN` | (from .env) | Gitea API token |
| `VIKUNJA_TOKEN` | (from .env) | Vikunja API token |
@@ -515,3 +535,113 @@ create-task: devx-create-task
- Line length: 120 chars
- Secrets are passed via environment variables, never on the command line
- All user-facing strings wrapped in `_()` for i18n
### Container-Level Fix Verification (Mandatory)
**Rule:** Before pushing any fix that modifies container state (CA certs,
config files, installed packages, daemon restarts), reproduce the exact
sequence locally with the actual Docker image. Do not push to CI as the
first test.
This is a hard rule, not a suggestion. CI cycles take 20+ minutes and
ephemeral staging VMs are destroyed after each run, making interactive
debugging impossible. A local reproduction takes 30 seconds and catches
silent failures immediately.
**Procedure:**
1. `docker pull <actual_image>`
2. `docker run -d --name <test> ...` and wait for it to start
3. Run the exact commands from the Ansible task or script
4. Verify the state change took effect
5. Clean up: `docker rm -f <test>`
### Verified State Modification (Mandatory)
Ansible tasks that modify container state with `changed_when: false`
MUST include a post-task verification step that confirms the state
change took effect. `changed_when: false` suppresses both change
detection AND failure visibility — a task can silently do nothing and
report `ok`.
## Subagent Delegation Policy
Custom subagent profiles are defined in `.devin/agents/` (project-specific)
and `~/.config/devin/agents/` (global, shared across repos). The agent MUST
automatically delegate to the appropriate subagent based on the task —
the user should not need to specify which profile to use.
### Available Profiles
**Global** (shared with infra and grm):
| Profile | Location | Purpose |
|---------|----------|---------|
| `pr-reviewer` | `~/.config/devin/agents/` | 13-category PR checklist + quality gates |
| `release-check` | `~/.config/devin/agents/` | Pre-merge readiness validation |
**devx-specific** (in `.devin/agents/`):
| Profile | Purpose |
|---------|---------|
| `ci-investigator` | Investigate CI failures (quality, release, publish, wiki sync, image build) |
| `dep-upgrader` | Python dependency upgrades in pyproject.toml with dep-doc validation |
| `docker-image-builder` | Build/push/cleanup 3-tier runner images (ci-base, ci-quality, ci-full) |
| `doc-sync-specialist` | Doc coverage, doc linting, wiki sync integrity |
| `workflow-validator` | actionlint + act_runner dry-run validation |
### When to Delegate Automatically
| Trigger | Profile | Mode |
|---------|---------|------|
| CI run failure (quality, release, publish, sync-wiki, build-images) | `ci-investigator` | Background |
| PR ready for review | `pr-reviewer` | Foreground |
| Dependency upgrade requested | `dep-upgrader` | Background |
| Docker image build/push needed | `docker-image-builder` | Background |
| Doc coverage failure or wiki sync issue | `doc-sync-specialist` | Background |
| Workflow YAML modified or validation needed | `workflow-validator` | Background |
| Branch ready for merge | `release-check` | Foreground |
### Delegation Rules
1. **Auto-select the profile.** Do not ask the user which profile to use.
2. **Background by default, foreground when blocking.**
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.
5. **Don't delegate trivial work** (<30s, <50 lines of context).
6. **Compact after subagent returns.**
7. **Never skip delegation to save time** — it keeps main context small.
## Feedback Issue Handling
Subagents create Gitea issues in the current repo when they encounter
tool, workflow, or process issues that warrant follow-up. These issues
use the `feedback` label plus a category label (`tooling`,
`ci-improvement`, `doc-improvement`, `workflow-improvement`).
Standard labels are created automatically by `configure_repo` (runs in
post-merge on every master push). If a label does not exist yet, the
subagent's issue creation will still succeed — labels can be added
afterwards.
### When a Subagent Reports a Feedback Issue URL
1. **Acknowledge it** in your response to the user — mention the issue URL
2. **Do NOT close or modify** the issue — it is for follow-up work
3. **Do NOT create a PR** to address it unless the user explicitly asks
4. If the user asks to address feedback, spawn a subagent to investigate
the issue and implement a fix
### Creating Feedback Issues Manually
As the parent agent, you can also create feedback issues directly using
the Gitea MCP (`issue_write` with `create_issue` method). Follow the
same format as subagents:
- Title: `[feedback] <category>: <short description>`
- Labels: `feedback` + category label
- Body: include context, tool/workflow, issue, reproduction, affected
files, suggested investigation, and "Reported by: parent agent"
Always deduplicate first via `list_issues` with `labels: "feedback"`.
+90
View File
@@ -2,6 +2,96 @@
All notable changes to this project will be documented in this file.
## [0.33.2] - 2026-07-05
### Bug Fixes
- Abort sync_wiki when list_wiki_pages fails
## [0.33.1] - 2026-07-05
### Bug Fixes
- Build images after post-merge publish, not on push
## [0.33.0] - 2026-07-05
### Features
- Add check_api_identity_checks, setup_ssh_key, and api utils
## [0.32.1] - 2026-07-01
### Bug Fixes
- Add missing i18n translations for new tools
## [0.32.0] - 2026-07-01
### Features
- Extract docker-login, tofu-ops, check-deps, install-tofu to Python tools
## [0.31.0] - 2026-07-01
### Features
- Centralize venv management in devx.mak
## [0.30.0] - 2026-07-01
### Features
- Add standard label creation to configure_repo
## [0.29.1] - 2026-07-01
### Bug Fixes
- Strip task ID prefix from commit messages in extract_conventional_msg
## [0.29.0] - 2026-07-01
### Features
- Detect badge commits as automated CI commits
## [0.28.0] - 2026-07-01
### Features
- Auto-rebase in auto-merge, new rebase tools, CLI registration
## [0.27.3] - 2026-06-30
### Bug Fixes
- Retry wiki integrity check on transient API timeout
## [0.27.2] - 2026-06-29
### Bug Fixes
- Retry release push on non-fast-forward with rebase loop
## [0.27.1] - 2026-06-28
### Bug Fixes
- Exclude .devin/.terraform dirs from lint_docs, add duplicate heading excludes
## [0.27.0] - 2026-06-28
### Features
- Add lint_docs tool, fix doc_coverage/check_translations for any repo
## [0.26.4] - 2026-06-28
### Bug Fixes
- Wrap all user-facing strings with _() for i18n completeness
## [0.26.3] - 2026-06-28
### Bug Fixes
+40 -34
View File
@@ -6,6 +6,36 @@ BIN := $(VENV)/bin
all: setup
# --- devx.mak integration ----------------------------------------------------
# Include shared targets from the devx package itself (venv management,
# workflow-lint, notify-failure, checkmake, lint targets, quality checks, etc.)
# Since devx IS the package, we can include its own devx.mak.
DEVX_PYTHON := $(BIN)/python
DEVX_VENV := $(VENV)
DEVX_BIN := $(BIN)
DEVX_LINT_PATHS := src/ tests/
DEVX_COV_PKG := src/devx
DEVX_TEST_PATHS := tests/
DEVX_MAK := $(shell $(BIN)/python -c \
"from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
2>/dev/null)
# Fallback: when the venv doesn't exist yet (chicken-and-egg), use the
# source tree copy directly. devx IS the package, so src/devx/make/devx.mak
# is always available in this repo.
ifeq ($(strip $(DEVX_MAK)),)
DEVX_MAK := $(CURDIR)/src/devx/make/devx.mak
endif
-include $(DEVX_MAK)
# venv, .env, and activate-scripts are provided by devx.mak
# (devx-venv, devx-env, devx-activate-scripts, $(DEVX_VENV)/bin/activate rule)
# Aliases for convenience and backward compatibility:
.PHONY: venv activate-scripts
venv: devx-venv
.env: devx-env
activate-scripts: devx-activate-scripts
# Full setup for local development
setup: $(VENV)/bin/activate .env activate-scripts install-tools
@$(BIN)/pip install -e '.[dev]' 2>/dev/null; \
@@ -35,22 +65,9 @@ setup-release: $(VENV)/bin/activate .env
# an older devx.mak that doesn't yet define devx-setup-image. Consumer repos
# (grm, infra) can safely alias to devx-setup-image since they install devx from PyPI.
setup-image:
@if [ -d /opt/venv ]; then ln -sf /opt/venv .venv; . .venv/bin/activate && pip install --no-cache-dir -e . 2>/dev/null; \
@if [ -d /opt/venv ]; then ln -sf /opt/venv $(VENV); . $(VENV)/bin/activate && pip install --no-cache-dir -e . 2>/dev/null; \
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
.env:
@if [ ! -f .env ]; then cp .env.example .env; echo "Created .env from .env.example — please edit it."; fi
$(VENV)/bin/activate:
@python3 -c "import sys; v=sys.version_info; assert v >= (3, 12), f'Python 3.12+ required, found {v.major}.{v.minor}'; print(f'Python {v.major}.{v.minor}.{v.micro} OK')"
$(PYTHON) -m venv $(VENV)
$(BIN)/pip install --upgrade pip setuptools wheel
activate-scripts: $(VENV)/bin/activate
@test -f activate.sh || (echo '#!/usr/bin/env bash' > activate.sh && echo 'source "$$(cd "$$(dirname "$${BASH_SOURCE[0]}")" && pwd)/.venv/bin/activate"' >> activate.sh && chmod +x activate.sh)
@test -f activate.fish || (echo '#!/usr/bin/env fish' > activate.fish && echo 'set -l script_dir (dirname (status --current-filename))' >> activate.fish && echo 'source "$$script_dir/.venv/bin/activate.fish"' >> activate.fish && chmod +x activate.fish)
@test -f activate.zsh || (echo '#!/usr/bin/env zsh' > activate.zsh && echo '0="$${ZERO:-$${0:#$$ZSH_ARGZERO}}"' >> activate.zsh && echo '0="$${$${(M)0:#/*}:-$$PWD/$$0}"' >> activate.zsh && echo 'source "$${0:A:h}/.venv/bin/activate"' >> activate.zsh && chmod +x activate.zsh)
install-hooks:
@cp hooks/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
@cp hooks/pre-push .git/hooks/pre-push && chmod +x .git/hooks/pre-push
@@ -60,23 +77,13 @@ install-tools: $(VENV)/bin/activate
@$(BIN)/pip install -e '.' 2>/dev/null; \
$(BIN)/python -m devx.tools.install_tools
# --- devx.mak integration ----------------------------------------------------
# Include shared targets from the devx package itself (workflow-lint,
# notify-failure, checkmake, lint targets, quality checks, etc.)
# Since devx IS the package, we can include its own devx.mak.
DEVX_PYTHON := $(BIN)/python
DEVX_VENV := $(VENV)
DEVX_BIN := $(BIN)
DEVX_LINT_PATHS := src/ tests/
DEVX_COV_PKG := src/devx
DEVX_TEST_PATHS := tests/
DEVX_MAK := $(shell $(BIN)/python -c \
"from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
2>/dev/null)
-include $(DEVX_MAK)
# Aliases — project-specific names map to devx.mak targets
.PHONY: lint-ruff lint-format typecheck lint-bandit lint-deps lint
.PHONY: workflow-lint workflow-dryrun workflow-dryrun-safe workflow-check
.PHONY: notify-failure checkmake check-mutable-globals check-dep-docs
.PHONY: check-test-speed check-test-coverage check-docs
.PHONY: create-task create-pr push-with-pr git-push rebase pr-rebase
.PHONY: lint-all lint-dockerfiles
lint-ruff: devx-lint-ruff
lint-format: devx-lint-format
typecheck: devx-typecheck
@@ -98,6 +105,8 @@ create-task: devx-create-task
create-pr: devx-create-pr
push-with-pr: devx-push-with-pr
git-push: devx-push
rebase: devx-rebase
pr-rebase: devx-pr-rebase
lint-all: lint workflow-lint lint-dockerfiles
@echo "[lint-all] All linting checks passed."
@@ -106,10 +115,7 @@ lint-all: lint workflow-lint lint-dockerfiles
# devx's own CI images may have an older devx.mak. Consumer repos can safely alias.
lint-dockerfiles:
@echo "[lint-dockerfiles] Linting Dockerfiles with hadolint..."
@if ! command -v hadolint >/dev/null 2>&1; then \
echo "[lint-dockerfiles] ERROR: hadolint not found. Install from https://github.com/hadolint/hadolint/releases" >&2; \
exit 1; \
fi
@command -v hadolint >/dev/null 2>&1 || { echo "hadolint not found" >&2; exit 1; }
@find docker -name 'Dockerfile*' -exec hadolint {} +
@echo "[lint-dockerfiles] All Dockerfiles passed."
+12 -9
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)
[![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/c04f826e43273dbc92325b851c49fe9b4a4b2a76/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c04f826e43273dbc92325b851c49fe9b4a4b2a76/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c04f826e43273dbc92325b851c49fe9b4a4b2a76/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c04f826e43273dbc92325b851c49fe9b4a4b2a76/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c04f826e43273dbc92325b851c49fe9b4a4b2a76/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c04f826e43273dbc92325b851c49fe9b4a4b2a76/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/66fec9ab17b49e54a8ea382b6e3b338eb2c973ea/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/66fec9ab17b49e54a8ea382b6e3b338eb2c973ea/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/66fec9ab17b49e54a8ea382b6e3b338eb2c973ea/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/66fec9ab17b49e54a8ea382b6e3b338eb2c973ea/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/66fec9ab17b49e54a8ea382b6e3b338eb2c973ea/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/66fec9ab17b49e54a8ea382b6e3b338eb2c973ea/python.svg)](https://www.python.org/downloads/)
## Why devx?
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
```toml
[project]
dependencies = [
"devx>=0.11.1",
"devx>=0.27.0",
]
[tool.pip]
@@ -101,8 +101,8 @@ pip install -e .
```
> **Note:** If your project requires a specific devx version, pin it in
> `dependencies` (e.g., `"devx==0.11.1"`) or use a version constraint
> (e.g., `"devx>=0.11.1,<0.12"`).
> `dependencies` (e.g., `"devx==0.27.0"`) or use a version constraint
> (e.g., `"devx>=0.27.0,<0.28"`).
### Optional extras
@@ -158,6 +158,9 @@ python -m devx.ci.check_translations --translations path/to/translations.json
# Documentation coverage check
python -m devx.ci.doc_coverage --fail-on-missing
# Documentation lint (structure, links, headings, TODOs)
python -m devx.ci.lint_docs --root .
# Validate a commit message
python -m devx.ci.validate_commit_msg commit-msg.txt --branch master
+10 -9
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)
[![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/c04f826e43273dbc92325b851c49fe9b4a4b2a76/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c04f826e43273dbc92325b851c49fe9b4a4b2a76/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c04f826e43273dbc92325b851c49fe9b4a4b2a76/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c04f826e43273dbc92325b851c49fe9b4a4b2a76/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c04f826e43273dbc92325b851c49fe9b4a4b2a76/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/c04f826e43273dbc92325b851c49fe9b4a4b2a76/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/66fec9ab17b49e54a8ea382b6e3b338eb2c973ea/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/66fec9ab17b49e54a8ea382b6e3b338eb2c973ea/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/66fec9ab17b49e54a8ea382b6e3b338eb2c973ea/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/66fec9ab17b49e54a8ea382b6e3b338eb2c973ea/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/66fec9ab17b49e54a8ea382b6e3b338eb2c973ea/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/66fec9ab17b49e54a8ea382b6e3b338eb2c973ea/python.svg)](https://www.python.org/downloads/)
## Overview
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
```toml
[project]
dependencies = [
"devx>=0.11.1",
"devx>=0.27.0",
]
[tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
```
Pin a specific version if needed: `"devx==0.11.1"` or `"devx>=0.11.1,<0.12"`.
Pin a specific version if needed: `"devx==0.27.0"` or `"devx>=0.27.0,<0.28"`.
### Optional extras
@@ -131,7 +131,7 @@ wiki sync details.
devx provides a `devx` CLI with three command groups:
- `devx ci <command>` — CI/CD automation (17 commands)
- `devx tools <command>` — Developer tools (7 commands)
- `devx tools <command>` — Developer tools (9 commands)
- `devx molecule <command>` — Molecule testing (4 commands, optional)
See [CLI Commands](CLI-Commands) for full command documentation with examples.
@@ -158,6 +158,7 @@ for the full configuration reference, PR workflow, and project conventions.
## Wiki pages
- [Home](Home) — This page
- [Getting Started](Getting-Started) — Installation, configuration, and quick start guide
- [CLI Commands](CLI-Commands) — Full CLI command documentation with examples
- [Architecture](Architecture) — Package structure, module descriptions, design principles
- [CI/CD Workflow](CI-CD-Workflow) — Pipeline documentation, workflows, and CI scripts
+1
View File
@@ -1,5 +1,6 @@
{
"index.md": "Home",
"user/getting-started.md": "Getting-Started",
"user/cli-commands.md": "CLI-Commands",
"tech/architecture.md": "Architecture",
"tech/ci-cd-workflow.md": "CI-CD-Workflow"
+2 -2
View File
@@ -103,7 +103,7 @@ Custom exception hierarchy:
### `i18n.py`
Simple i18n system using a JSON translations file (`translations.json`).
Supports five languages: `en`, `bg`, `de`, `ru`, `zh`. The `_()` function
Supports six languages: `en`, `bg`, `de`, `pl`, `ru`, `zh`. The `_()` function
wraps user-facing strings for translation.
Projects can extend translations by setting `DEVX_TRANSLATIONS_PATH` to a
@@ -382,7 +382,7 @@ single-role (4-part) and multi-role (5-part) pair encoding.
Runs all molecule scenarios on all supported OS platforms sequentially.
Intended for local development; CI uses the parallel matrix instead.
### `discover_runners.py`
### `molecule/discover_runners.py`
Discovers available Gitea Actions runners for molecule tests. Same logic as
`devx.ci.discover_runners` but intended for molecule-specific workflows.
+1 -1
View File
@@ -166,7 +166,7 @@ When `release` creates a `release: vX.Y.Z` commit, the release commit's
post-merge run still updates badges (the version badge picks up the new
version). Other jobs skip. The tag push triggers `publish.yml`.
### Jobs
### Post-merge jobs
#### `detect-type`
+53 -1
View File
@@ -127,14 +127,37 @@ Click commands from `cli.py` and checks if each has documentation in
```bash
devx ci doc-coverage
devx ci doc-coverage --docs-dir docs/ --fail-on-missing
devx ci doc-coverage --docs-dir docs/ --source-dir src/ --fail-on-missing
```
Options:
- `--docs-dir <dir>` — path to the docs directory (default: `docs/`)
- `--source-dir <dir>` — path to the source directory (default: auto-detect)
- `--fail-on-missing` — exit with non-zero status if any documentation is
missing
### `devx ci lint-docs`
Lint documentation files for structure, broken links, heading hierarchy,
duplicate headings, TODO/FIXME markers, and trailing whitespace.
```bash
devx ci lint-docs
devx ci lint-docs --root . --fix
devx ci lint-docs --no-check-links --no-check-stale
```
Options:
- `--root <dir>` — repository root directory (default: `.`)
- `--docs-dir <dir>` — docs directory (default: `<root>/docs`)
- `--check-links/--no-check-links` — check internal links (default: yes)
- `--check-headings/--no-check-headings` — check heading hierarchy (default: yes)
- `--check-todo/--no-check-todo` — check for TODO/FIXME markers (default: yes)
- `--check-stale/--no-check-stale` — check for stale docs (default: no)
- `--check-trailing/--no-check-trailing` — check trailing whitespace (default: yes)
- `--check-duplicates/--no-check-duplicates` — check duplicate headings (default: yes)
- `--fix` — auto-fix trailing whitespace
### `devx ci integration-guard`
Run pytest with cross-runner failure detection. If any
@@ -398,6 +421,35 @@ Options:
- `--no-pre-commit` — skip pre-commit hook installation
- `--no-tea-login` — skip tea CLI login configuration
### `devx tools rebase`
Rebase the current branch onto `origin/master` and force-push with
`--force-with-lease`. Checks if the branch is behind master first —
if up-to-date, exits without doing anything.
```bash
devx tools rebase # rebase + force-push
devx tools rebase -- --no-push # rebase locally only
```
Options (pass after `--`):
- `--no-push` — rebase locally without pushing
### `devx tools pr-rebase`
Rebase a pull request's head branch onto master via the Gitea API
(server-side). This triggers a new `pull_request synchronize` event,
which starts a new CI run. Useful when you don't have the branch
checked out locally.
```bash
devx tools pr-rebase -- --pr 42 # rebase PR #42
devx tools pr-rebase # auto-detect PR from current branch
```
Options (pass after `--`):
- `--pr <N>` — PR number (auto-detected from current branch if omitted)
## Molecule Commands
Molecule commands require the `molecule` extra (`pip install devx[molecule]`).
+161
View File
@@ -0,0 +1,161 @@
# Getting Started with devx
This guide walks you through installing devx, configuring it for your project,
and setting up a complete CI/CD pipeline.
## Prerequisites
- **Python 3.12+**
- **A Gitea instance** with Actions enabled
- **A Gitea API token** with repo, workflow, and organization scopes
- **(Optional) Vikunja API token** for task tracking integration
## Installation
devx is published to the Gitea PyPI registry. Configure pip to use it:
```bash
# Configure Gitea PyPI registry
pip config set global.extra-index-url https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
# Install devx
pip install devx
```
Or install from source:
```bash
git clone https://git.oblachno.oblachno.fyi/oblachno-oss/devx.git
cd devx
make setup
```
## Quick Start
### 1. Configure environment variables
Create a `.env` file in your project root:
```bash
CI_GITEA_TOKEN=your_gitea_api_token
VIKUNJA_TOKEN=your_vikunja_api_token # optional
```
### 2. Add devx to your project
Add devx to your `pyproject.toml`:
```toml
[project]
dependencies = [
"devx>=0.27.0",
]
[project.optional-dependencies]
dev = [
"devx[dev]>=0.27.0",
]
```
### 3. Set up the Makefile
devx provides a shared Makefile fragment. Add this to your `Makefile`:
```makefile
include devx.mak
```
Run `devx tools setup` to install all development tools (actionlint, git-cliff,
tea CLI, etc.) and configure pre-commit hooks.
### 4. Create the docs structure
devx expects a `docs/` directory with at minimum:
```
docs/
├── index.md # Documentation home page
├── mapping.json # Wiki page title mappings
├── user/ # User-facing documentation
│ └── cli-commands.md
└── tech/ # Technical documentation
├── architecture.md
└── ci-cd-workflow.md
```
Example `docs/mapping.json`:
```json
{
"index.md": "Home",
"user/cli-commands.md": "CLI-Commands",
"tech/architecture.md": "Architecture",
"tech/ci-cd-workflow.md": "CI-CD-Workflow"
}
```
### 5. Set up CI workflows
Create `.gitea/workflows/ci.yml` and `.gitea/workflows/post-merge.yml` in your
project. See the [CI/CD Workflow guide](../tech/ci-cd-workflow.md) for details.
### 6. Configure release settings
Add a `cliff.toml` for git-cliff-based versioning:
```bash
devx tools generate-cliff-config
```
Add `[tool.devx]` section to `pyproject.toml` for project-specific config:
```toml
[tool.devx]
# Vikunja project ID for task tracking
vikunja_project_id = 6
[tool.devx.classify]
# File patterns that are infrastructure (no release needed)
infrastructure = [
".gitea/**",
"docs/**",
"tests/**",
"AGENTS.md",
"README.md",
"CHANGELOG.md",
]
```
## Available Tools
### CI/CD Automation (`devx.ci.*`)
- `devx.ci.release` — Automated semver versioning and tagging
- `devx.ci.publish` — Package publishing to Gitea PyPI registry
- `devx.ci.auto_merge` — Squash-merge automation with task ID validation
- `devx.ci.pr_review` — Automated PR review with inline comments
- `devx.ci.classify_changes` — User-facing vs workflow-only change detection
- `devx.ci.sync_wiki` — Push docs/ to Gitea wiki
- `devx.ci.doc_coverage` — Documentation coverage checker
- `devx.ci.lint_docs` — Documentation linter (structure, links, headings)
- `devx.ci.check_translations` — i18n translation completeness checker
- `devx.ci.notify_failure` — Create Gitea issues on CI failures
- `devx.ci.distribute_files` — Parallel test file distribution
- `devx.ci.distribute_items` — Parallel item distribution across runners
- `devx.ci.discover_runners` — Dynamic runner discovery via Gitea API
### Development Tools (`devx.tools.*`)
- `devx.tools.setup` — Environment setup (venv, deps, hooks, tools)
- `devx.tools.install_tools` — Install CI/CD tools (actionlint, git-cliff, tea)
- `devx.tools.create_task` — Create Vikunja tasks
- `devx.tools.create_pr` — Create Gitea PRs with task ID in title
- `devx.tools.configure_repo` — Configure branch protection and labels
- `devx.tools.generate_badges` — Generate quality badge SVGs
- `devx.tools.check_test_speed` — Enforce test execution speed limits
## Next Steps
- Read the [CLI Commands reference](cli-commands.md) for all available commands
- Read the [Architecture guide](../tech/architecture.md) to understand internals
- Read the [CI/CD Workflow guide](../tech/ci-cd-workflow.md) for pipeline details
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.26.3"
__version__ = "0.33.2"
+13
View File
@@ -194,6 +194,19 @@ class GiteaClient:
payload = {"Do": "squash", "MergeTitleField": merge_title}
self._request("POST", f"/pulls/{pr_number}/merge", json=payload)
def update_pr_branch(self, pr_number: str | int, style: str = "rebase") -> None:
"""Update PR head branch by merging/rebasing the base branch into it.
Uses the Gitea API ``POST /pulls/{index}/update?style=rebase`` endpoint.
This rebases the PR's head branch onto the latest base branch server-side,
triggering a ``pull_request synchronize`` event that starts a new CI run.
Args:
pr_number: PR number.
style: Update method ``"rebase"`` (default) or ``"merge"``.
"""
self._request("POST", f"/pulls/{pr_number}/update", params={"style": style})
def get_commit_status(self, sha: str) -> list[dict[str, Any]]:
"""Fetch all status check contexts reported for a commit.
+38 -13
View File
@@ -41,6 +41,9 @@ from devx.config import (
from devx.exceptions import APIError
from devx.i18n import _
# 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*")
TASKID_FILE = ".taskid" # Deprecated, kept for backward-compat warnings
PR_TITLE_RE = re.compile(rf"^{TASK_PREFIX}-\d+:\s+.+")
@@ -168,19 +171,23 @@ def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
for commit in reversed(commits):
commit_info = commit.get("commit", {})
message = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
m = CONVENTIONAL_RE.match(message)
# Strip any leading task ID prefix (e.g. "OBL-INFRA-364: fix: ...") so
# conventional commit matching works on the remainder.
stripped = _TASK_ID_PREFIX_RE.sub("", message)
m = CONVENTIONAL_RE.match(stripped)
if m:
prefix = m.group(1).split("(")[0].strip() # e.g. "feat" from "feat(scope)"
score = priority.get(prefix, 0)
if score > best_score:
best_score = score
best_msg = message
best_msg = stripped
if best_msg:
return best_msg
# Fallback: use the newest commit's first line
# Fallback: use the newest commit's first line (strip task ID prefix if present)
if commits:
commit_info = commits[-1].get("commit", {})
return str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
raw = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
return _TASK_ID_PREFIX_RE.sub("", raw)
return ""
@@ -231,17 +238,35 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
client.merge_pr(pr_num, merge_title)
except APIError as e:
if e.status == 405 and "behind" in e.message.lower():
# Head branch is behind master — do NOT auto-rebase.
# Auto-rebasing creates a feedback loop: the force-push triggers
# a new pull_request synchronize event, which starts a new CI run,
# which runs auto-merge again, which rebases again, etc.
raise click.ClickException(
# Head branch is behind master. Auto-rebase via Gitea API.
# This triggers a new pull_request synchronize event → new CI run.
# The next auto-merge attempt will find the branch up-to-date and
# merge successfully. This is NOT an infinite loop: the rebase
# resolves the "behind" condition, so the next run merges.
# If another PR merges in between, the branch may fall behind
# again, but the process converges as PRs stop merging.
click.echo(
_(
"Branch is behind master. Rebase manually:\n"
" git fetch origin master && git rebase origin/master && git push --force-with-lease\n"
"Then re-add the ready-to-merge label.",
"Branch is behind master. Auto-rebasing via Gitea API...\n"
"A new CI run will start automatically after the rebase.\n"
"The next auto-merge attempt will merge this PR.",
)
) from None
)
try:
client.update_pr_branch(pr_num, style="rebase")
except APIError as rebase_err:
raise click.ClickException(
_(
"Auto-rebase failed with HTTP {status}: {message}\n"
"Rebase manually:\n"
" git fetch origin master && git rebase origin/master && git push --force-with-lease\n"
"Then re-add the ready-to-merge label.",
status=rebase_err.status,
message=rebase_err.message,
)
) from None
# Exit cleanly — the rebase triggers a new CI run that will retry.
return
else:
raise click.ClickException(
_(
+33 -9
View File
@@ -31,11 +31,11 @@ from pathlib import Path
import click
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
REPO_ROOT = Path.cwd()
SUPPORTED_LANGS = ("en", "bg", "de", "ru", "zh", "pl")
# Default translation set: devx package itself
# Default translation set: look for translations.json in the current repo
DEFAULT_TRANS_FILE = REPO_ROOT / "src" / "devx" / "translations.json"
DEFAULT_SRC_DIR = REPO_ROOT / "src" / "devx"
@@ -169,20 +169,44 @@ def print_result(result: TranslationCheckResult) -> None:
"translations",
multiple=True,
type=click.Path(exists=False, path_type=Path),
help="Path to a translations JSON file to check (can be repeated). Defaults to src/devx/translations.json.",
help="Path to a translations JSON file to check (can be repeated). Auto-detects by default.",
)
def main(translations: tuple[Path, ...]) -> None:
@click.option(
"--source-dir",
default=None,
help="Source directory to scan for _() calls (default: auto-detect).",
)
def main(translations: tuple[Path, ...], source_dir: str | None) -> None:
"""Check translation files for gaps, dead keys, and missing languages."""
results: list[TranslationCheckResult] = []
if not translations:
# Default: check the devx package's own translations
results = [
check_translation_set("devx", DEFAULT_SRC_DIR, DEFAULT_TRANS_FILE),
# Auto-detect translations file in the current repo
root = Path.cwd()
# Try common locations
candidates = [
root / "src" / "devx" / "translations.json",
root / "src" / "gitea_runner_manager" / "translations.json",
]
# Also search for any translations.json in src/
for match in root.glob("src/*/translations.json"):
candidates.append(match)
found = False
for candidate in candidates:
if candidate.exists():
src_dir = Path(source_dir) if source_dir else candidate.parent
results.append(check_translation_set(candidate.parent.name, src_dir, candidate))
found = True
break
if not found:
# No translations file found — this repo doesn't use i18n
click.echo("PASS: No translations file found — skipping (repo does not use i18n).")
return
else:
results = []
for trans_file in translations:
# Infer source directory as the parent of the translations file
src_dir = trans_file.parent
src_dir = Path(source_dir) if source_dir else trans_file.parent
name = trans_file.parent.name
results.append(check_translation_set(name, src_dir, trans_file))
+24 -5
View File
@@ -1,7 +1,10 @@
#!/usr/bin/env python3
"""Detect whether the latest git commit is a release commit.
"""Detect whether the latest git commit is an automated CI commit.
Release commits have the format ``release: vX.Y.Z``.
Badge commits have the format ``chore: update badge URLs ... [skip ci]``.
Both are generated by CI and should skip post-merge jobs.
This script writes ``is-release=true`` or ``is-release=false`` to
``$GITHUB_OUTPUT`` for use in CI workflow conditionals.
@@ -18,8 +21,10 @@ import subprocess # nosec B404
import click
from devx.ci._shared import write_github_output
from devx.i18n import _
RELEASE_RE = re.compile(r"^release: v\d+\.\d+\.\d+")
BADGE_RE = re.compile(r"^chore: update badge URLs.*\[skip ci\]")
def get_commit_message() -> str:
@@ -40,17 +45,31 @@ def is_release_commit(message: str) -> bool:
return bool(RELEASE_RE.match(message))
def is_badge_commit(message: str) -> bool:
"""Check if a commit message matches the badge commit format."""
return bool(BADGE_RE.match(message))
def is_automated_commit(message: str) -> bool:
"""Check if a commit is an automated CI commit (release or badge)."""
return is_release_commit(message) or is_badge_commit(message)
@click.command()
def main() -> None:
"""Detect if the latest commit is a release commit and set GITHUB_OUTPUT."""
"""Detect if the latest commit is an automated CI commit and set GITHUB_OUTPUT."""
msg = get_commit_message()
click.echo(f"Commit message: {msg}")
click.echo(_("Commit message: {msg}", msg=msg))
is_release = is_release_commit(msg)
is_automated = is_automated_commit(msg)
write_github_output("is-release", "true" if is_release else "false")
write_github_output("is-automated", "true" if is_automated else "false")
if is_release:
click.echo("Release commit — skipping all post-merge jobs.")
click.echo(_("Release commit — skipping all post-merge jobs."))
elif is_automated:
click.echo(_("Automated CI commit (badge) — skipping post-merge jobs."))
else:
click.echo("Regular merge commit — running all post-merge jobs.")
click.echo(_("Regular merge commit — running all post-merge jobs."))
if __name__ == "__main__": # pragma: no cover
+14 -10
View File
@@ -30,6 +30,7 @@ import click
import requests
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
from devx.i18n import _
DEFAULT_MAX_RUNNERS = 3
@@ -55,9 +56,9 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
data = r.json()
total += data.get("total_count", 0)
else:
click.echo(f"Warning: repo-level runners query returned HTTP {r.status_code}", err=True)
click.echo(_("Warning: repo-level runners query returned HTTP {status}", status=r.status_code), err=True)
except (requests.RequestException, ValueError) as e:
click.echo(f"Warning: repo-level runners query failed: {e}", err=True)
click.echo(_("Warning: repo-level runners query failed: {error}", error=e), err=True)
# 2. Organization-level runners
try:
@@ -70,9 +71,9 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
data = r.json()
total += data.get("total_count", 0)
else:
click.echo(f"Warning: org-level runners query returned HTTP {r.status_code}", err=True)
click.echo(_("Warning: org-level runners query returned HTTP {status}", status=r.status_code), err=True)
except (requests.RequestException, ValueError) as e:
click.echo(f"Warning: org-level runners query failed: {e}", err=True)
click.echo(_("Warning: org-level runners query failed: {error}", error=e), err=True)
# 3. Instance-level runners (requires admin scope)
try:
@@ -85,9 +86,12 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
data = r.json()
total += data.get("total_count", 0)
elif r.status_code != 403: # 403 is expected without admin scope
click.echo(f"Warning: instance-level runners query returned HTTP {r.status_code}", err=True)
click.echo(
_("Warning: instance-level runners query returned HTTP {status}", status=r.status_code),
err=True,
)
except (requests.RequestException, ValueError) as e:
click.echo(f"Warning: instance-level runners query failed: {e}", err=True)
click.echo(_("Warning: instance-level runners query failed: {error}", error=e), err=True)
return total
@@ -165,8 +169,8 @@ def main(
with open(gh_output, "a", encoding="utf-8") as f: # noqa: PTH123
f.write(f"runner-count={count}\n")
f.write(f"runner-indices={json.dumps(indices)}\n")
click.echo(f"Runner count: {count}")
click.echo(f"Runner indices: {indices}")
click.echo(_("Runner count: {count}", count=count))
click.echo(_("Runner indices: {indices}", indices=indices))
return
if output_count:
@@ -178,8 +182,8 @@ def main(
return
# Default: output both as key=value pairs for CI consumption
click.echo(f"count={count}")
click.echo(f"indices={json.dumps(indices)}")
click.echo(_("count={count}", count=count))
click.echo(_("indices={indices}", indices=json.dumps(indices)))
if __name__ == "__main__": # pragma: no cover
+12 -4
View File
@@ -102,17 +102,25 @@ def main(pattern: str, runner_index: int | None, max_runners: int, github_env: b
groups = distribute(files, max_runners)
for i, group in enumerate(groups):
labels = " ".join(group) if group else "(none)"
click.echo(f"Runner {i}: {labels}")
click.echo(_("Runner {i}: {labels}", i=i, labels=labels))
return
if skip_if_excess and github_env and runner_index > max_runners:
click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}")
click.echo(
_(
"Skipping — runner index {runner_index} > max runners {max_runners}",
runner_index=runner_index,
max_runners=max_runners,
)
)
write_github_env("ASSIGNED_FILES", "")
write_github_env("SKIP", "true")
return
if runner_index < 1:
raise click.ClickException(f"Runner index {runner_index} is out of range (must be >= 1)")
raise click.ClickException(
_("Runner index {runner_index} is out of range (must be >= 1)", runner_index=runner_index)
)
zero_based = runner_index - 1
assigned = files_for_runner(files, zero_based, max_runners)
@@ -121,7 +129,7 @@ def main(pattern: str, runner_index: int | None, max_runners: int, github_env: b
if github_env:
write_github_env("ASSIGNED_FILES", encoded)
write_github_env("SKIP", "false")
click.echo(f"Assigned {len(assigned)} files to runner {runner_index}")
click.echo(_("Assigned {count} files to runner {runner_index}", count=len(assigned), runner_index=runner_index))
return
click.echo(encoded)
+19 -4
View File
@@ -162,17 +162,25 @@ def main(
groups = distribute(items, weights, max_runners)
for i, group in enumerate(groups):
labels = " ".join(group) if group else "(none)"
click.echo(f"Runner {i}: {labels}")
click.echo(_("Runner {i}: {labels}", i=i, labels=labels))
return
if skip_if_excess and github_env and runner_index > max_runners:
click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}")
click.echo(
_(
"Skipping — runner index {runner_index} > max runners {max_runners}",
runner_index=runner_index,
max_runners=max_runners,
)
)
write_github_env("ASSIGNED_ITEMS", "")
write_github_env("SKIP", "true")
return
if runner_index < 1:
raise click.ClickException(f"Runner index {runner_index} is out of range (must be >= 1)")
raise click.ClickException(
_("Runner index {runner_index} is out of range (must be >= 1)", runner_index=runner_index)
)
zero_based = runner_index - 1
assigned = items_for_runner(items, weights, zero_based, max_runners)
@@ -181,7 +189,14 @@ def main(
if github_env:
write_github_env("ASSIGNED_ITEMS", encoded)
write_github_env("SKIP", "false")
click.echo(f"Assigned {len(assigned)} items to runner {runner_index}: {encoded}")
click.echo(
_(
"Assigned {count} items to runner {runner_index}: {encoded}",
count=len(assigned),
runner_index=runner_index,
encoded=encoded,
)
)
return
click.echo(encoded)
+55 -17
View File
@@ -5,8 +5,12 @@ Parses Click commands from the CLI source code and checks if each command
has corresponding documentation in the wiki/docs. Reports missing
documentation as warnings and exits with non-zero if coverage is below 100%.
By default, checks the current repository's own source and docs directories.
When run from the devx package itself (development mode), it checks devx's
own files. When installed as a package, it checks the consuming repo's files.
Usage:
python3 -m devx.ci.doc_coverage [--docs-dir docs/] [--fail-on-missing]
python3 -m devx.ci.doc_coverage [--docs-dir docs/] [--source-dir src/] [--fail-on-missing]
"""
from __future__ import annotations
@@ -19,11 +23,12 @@ import click
from devx.i18n import _
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
# Default to the current working directory (consuming repo's root)
REPO_ROOT = Path.cwd()
DOCS_DIR = REPO_ROOT / "docs"
CLI_FILE = REPO_ROOT / "src" / "devx" / "cli.py"
# Major modules that should be documented in tech/architecture.md
# These are devx-specific; when checking other repos, use --source-dir
REQUIRED_MODULES = [
"cli.py",
"i18n.py",
@@ -51,11 +56,16 @@ REQUIRED_SCRIPTS = [
]
def extract_cli_commands() -> list[str]:
def extract_cli_commands(source_dir: Path) -> list[str]:
"""Extract command names from the CLI source file."""
if not CLI_FILE.exists():
# Try to find the CLI file in the source directory
cli_file = None
for candidate in source_dir.rglob("cli.py"):
cli_file = candidate
break
if cli_file is None or not cli_file.exists():
return []
content = CLI_FILE.read_text()
content = cli_file.read_text()
commands: list[str] = []
# Find all @<group>.command("name") occurrences in the CLI source
# Matches @cli.command, @ci.command, @tools.command, @molecule.command
@@ -94,15 +104,30 @@ def check_module_documented(module: str, docs_content: str) -> bool:
@click.command()
@click.option("--docs-dir", default=str(DOCS_DIR), help="Path to the docs directory.")
@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(
"--fail-on-missing",
is_flag=True,
default=False,
help="Exit with non-zero status if any documentation is missing.",
)
def main(docs_dir: str, fail_on_missing: bool) -> None:
docs_path = Path(docs_dir)
def main(docs_dir: str | None, source_dir: str | None, fail_on_missing: bool) -> None:
root = Path.cwd()
docs_path = Path(docs_dir) if docs_dir else root / "docs"
# Auto-detect source directory
if source_dir:
src_path = Path(source_dir)
else:
# Try common source directories
for candidate in [root / "src", root / "scripts"]:
if candidate.exists():
src_path = candidate
break
else:
src_path = root / "src"
cli_commands_file = docs_path / "user" / "cli-commands.md"
architecture_file = docs_path / "tech" / "architecture.md"
ci_cd_file = docs_path / "tech" / "ci-cd-workflow.md"
@@ -112,21 +137,28 @@ def main(docs_dir: str, fail_on_missing: bool) -> None:
# Check CLI commands
click.echo(_("Checking CLI command documentation..."))
commands = extract_cli_commands()
commands = extract_cli_commands(src_path)
total += len(commands)
cli_docs = cli_commands_file.read_text() if cli_commands_file.exists() else ""
for cmd in commands:
if check_command_documented(cmd, cli_docs):
click.echo(_(" OK: devx {cmd}", cmd=cmd))
click.echo(_(" OK: {cmd}", cmd=cmd))
else:
click.echo(_(" MISSING: devx {cmd}", cmd=cmd))
missing.append(f"CLI command: devx {cmd}")
click.echo(_(" MISSING: {cmd}", cmd=cmd))
missing.append(f"CLI command: {cmd}")
# Check modules in architecture.md
# Auto-detect modules from source directory (top-level only, exclude subdirs)
click.echo(_("\nChecking module documentation in architecture.md..."))
total += len(REQUIRED_MODULES)
if src_path.exists():
detected_modules = sorted(
f.name for f in src_path.glob("*.py") if f.name != "__init__.py" and f.name != "cli.py"
)
else:
detected_modules = REQUIRED_MODULES
total += len(detected_modules)
arch_docs = architecture_file.read_text() if architecture_file.exists() else ""
for module in REQUIRED_MODULES:
for module in detected_modules:
if check_module_documented(module, arch_docs):
click.echo(_(" OK: {module}", module=module))
else:
@@ -134,10 +166,16 @@ def main(docs_dir: str, fail_on_missing: bool) -> None:
missing.append(f"Module: {module}")
# Check CI scripts in ci-cd-workflow.md
# Auto-detect CI scripts from ci/ subdirectory
click.echo(_("\nChecking CI script documentation in ci-cd-workflow.md..."))
total += len(REQUIRED_SCRIPTS)
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:
detected_scripts = REQUIRED_SCRIPTS
total += len(detected_scripts)
ci_docs = ci_cd_file.read_text() if ci_cd_file.exists() else ""
for script in REQUIRED_SCRIPTS:
for script in detected_scripts:
if check_module_documented(script, ci_docs):
click.echo(_(" OK: {script}", script=script))
else:
+1 -1
View File
@@ -86,7 +86,7 @@ def cli(pytest_args: tuple[str, ...]) -> None:
cmd = [sys.executable, "-m", "pytest"]
cmd.extend(pytest_args)
click.echo(f"Running: {' '.join(cmd)}")
click.echo(_("Running: {cmd}", cmd=" ".join(cmd)))
process = subprocess.Popen( # nosec B603
cmd,
+415
View File
@@ -0,0 +1,415 @@
#!/usr/bin/env python3
"""Lint documentation files for structure, links, and quality.
Checks performed (all configurable via pyproject.toml ``[tool.devx.docs]``):
- **Required files**: README.md, AGENTS.md, CHANGELOG.md must exist.
- **Docs structure**: ``docs/index.md`` and ``docs/mapping.json`` must exist.
- **Broken internal links**: relative paths and anchors in markdown files
must resolve to actual files and headings.
- **Heading hierarchy**: no skipping heading levels (e.g., ``#`` → ``###``).
- **TODO/FIXME**: flags leftover TODO/FIXME markers in documentation.
- **Stale docs**: files not modified in >180 days (warning only).
- **Trailing whitespace**: lines should not end with whitespace.
- **Blank line before headings**: headings should have a blank line before them.
Usage::
python3 -m devx.ci.lint_docs
python3 -m devx.ci.lint_docs --docs-dir docs/ --root .
python3 -m devx.ci.lint_docs --fix # auto-fix trailing whitespace
"""
from __future__ import annotations
import json
import re
import sys
from datetime import datetime, timedelta
from pathlib import Path
import click
from devx.i18n import _
# Heading slug pattern (GitHub-style)
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$", re.MULTILINE)
# Markdown link pattern: [text](url)
_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]+)\)")
# Trailing whitespace
_TRAILING_WS_RE = re.compile(r"[ \t]+$")
# Heading without blank line before
_HEADING_NO_BLANK_RE = re.compile(r"([^\n])\n(#{1,6}\s)")
# Files that must exist in every project
REQUIRED_FILES = ["README.md", "AGENTS.md", "CHANGELOG.md"]
# Files that must exist in docs/
REQUIRED_DOC_FILES = ["index.md"]
# Maximum age for docs before they're considered stale (days)
STALE_THRESHOLD_DAYS = 180
# Files excluded from duplicate heading checks (auto-generated or structured
# with repeated subsections under different parent sections)
DUPLICATE_HEADING_EXCLUDES = {
"CHANGELOG.md",
"incident-response-sso.md",
"role-sync-design.md",
}
# TODO/FIXME pattern — matches "TODO:" or "FIXME:" at start of line/after whitespace
# Does NOT match references to the word "TODO" in rules/documentation
_TODO_RE = re.compile(r"(?m)^\s*(?:>>>?\s*)?(TODO|FIXME|HACK|XXX)\s*:", re.IGNORECASE)
# Directories excluded from markdown file scanning
_EXCLUDE_DIRS = {
".venv",
".git",
"node_modules",
"__pycache__",
".pytest_cache",
".devin",
".terraform",
"site-packages",
"dist-info",
}
def slugify(text: str) -> str:
"""Convert heading text to a GitHub-style slug."""
slug = text.lower().strip()
slug = re.sub(r"[^\w\s-]", "", slug)
slug = re.sub(r"[\s]+", "-", slug)
return slug
def strip_code_blocks(content: str) -> str:
"""Remove fenced code blocks from markdown content.
Replaces ```...``` blocks with empty lines so heading detection
doesn't pick up # comments inside code blocks.
"""
result: list[str] = []
in_code_block = False
for line in content.splitlines():
if line.strip().startswith("```"):
in_code_block = not in_code_block
result.append("")
continue
if in_code_block:
result.append("")
continue
result.append(line)
return "\n".join(result)
def extract_headings(filepath: Path) -> dict[str, int]:
"""Extract all headings from a markdown file.
Returns a dict mapping slug heading level.
"""
content = strip_code_blocks(filepath.read_text(encoding="utf-8"))
headings: dict[str, int] = {}
for match in _HEADING_RE.finditer(content):
level = len(match.group(1))
text = match.group(2)
slug = slugify(text)
headings[slug] = level
return headings
def extract_links(filepath: Path) -> list[tuple[int, str, str]]:
"""Extract all markdown links from a file.
Returns a list of (line_number, link_text, url) tuples.
Includes anchor-only links (#section) for validation.
Skips external links (http/https) and mailto.
"""
content = filepath.read_text(encoding="utf-8")
links: list[tuple[int, str, str]] = []
for match in _LINK_RE.finditer(content):
url = match.group(2).strip()
# Skip external links and mailto
if url.startswith(("http://", "https://", "mailto:")):
continue
line_num = content[: match.start()].count("\n") + 1
links.append((line_num, match.group(1), url))
return links
def check_required_files(root: Path) -> list[str]:
"""Check that required files exist."""
issues: list[str] = []
for filename in REQUIRED_FILES:
if not (root / filename).exists():
issues.append(f"Missing required file: {filename}")
return issues
def check_docs_structure(root: Path, docs_dir: Path) -> list[str]:
"""Check that docs directory has required structure."""
issues: list[str] = []
if not docs_dir.exists():
issues.append(f"Docs directory not found: {docs_dir}")
return issues
for filename in REQUIRED_DOC_FILES:
if not (docs_dir / filename).exists():
issues.append(f"Missing required doc file: docs/{filename}")
mapping_file = docs_dir / "mapping.json"
if mapping_file.exists():
try:
mapping = json.loads(mapping_file.read_text(encoding="utf-8"))
if not isinstance(mapping, dict):
issues.append("docs/mapping.json must be a JSON object")
elif not mapping:
issues.append("docs/mapping.json is empty")
except json.JSONDecodeError as e:
issues.append(f"docs/mapping.json is invalid JSON: {e}")
return issues
def check_internal_links(root: Path, docs_dir: Path) -> list[str]:
"""Check that all internal links in markdown files resolve."""
issues: list[str] = []
md_files = list(root.rglob("*.md"))
# Exclude .venv, .git, node_modules
md_files = [f for f in md_files if not any(part in _EXCLUDE_DIRS for part in f.parts)]
# Load wiki page names from mapping.json — these are valid link targets
wiki_pages: set[str] = set()
mapping_file = docs_dir / "mapping.json"
if mapping_file.exists():
try:
mapping = json.loads(mapping_file.read_text(encoding="utf-8"))
wiki_pages = set(mapping.values())
except (json.JSONDecodeError, AttributeError):
pass
for md_file in md_files:
rel_path = md_file.relative_to(root)
links = extract_links(md_file)
headings = extract_headings(md_file)
for line_num, _link_text, url in links:
# Split into path and anchor
if "#" in url:
path_part, anchor = url.split("#", 1)
else:
path_part, anchor = url, ""
# Skip wiki page references (no file extension, no /, matches mapping.json values)
if path_part and "." not in path_part and "/" not in path_part:
if path_part in wiki_pages:
continue
# Also skip if it looks like a wiki page name (CamelCase or hyphenated)
# without a file extension — can't verify these locally
if not any(c in path_part for c in "/\\"):
continue
# Resolve relative path
if path_part:
target = (md_file.parent / path_part).resolve()
if not target.exists():
issues.append(f"{rel_path}:{line_num}: broken link '{url}' — file not found: {path_part}")
continue
# Check anchor in target file
if anchor:
target_headings = extract_headings(target)
target_slug = slugify(anchor)
if target_slug not in target_headings:
issues.append(f"{rel_path}:{line_num}: broken anchor '#{anchor}' in {path_part}")
elif anchor:
# Anchor-only link — check in current file
anchor_slug = slugify(anchor)
if anchor_slug not in headings:
issues.append(f"{rel_path}:{line_num}: broken anchor '#{anchor}'")
return issues
def check_heading_hierarchy(root: Path) -> list[str]:
"""Check that headings don't skip levels."""
issues: list[str] = []
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
content = strip_code_blocks(md_file.read_text(encoding="utf-8"))
prev_level = 0
for match in _HEADING_RE.finditer(content):
level = len(match.group(1))
if prev_level > 0 and level > prev_level + 1:
issues.append(f"{rel_path}: heading hierarchy skip — H{prev_level} → H{level}: '{match.group(2)}'")
prev_level = level
return issues
def check_todo_fixme(root: Path) -> list[str]:
"""Check for TODO/FIXME/HACK/XXX markers in documentation.
Only flags actual TODO/FIXME markers (e.g., "TODO: fix this"), not
references to the word "TODO" in rules or documentation about TODOs.
"""
issues: list[str] = []
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
content = md_file.read_text(encoding="utf-8")
for match in _TODO_RE.finditer(content):
line_num = content[: match.start()].count("\n") + 1
line = content.splitlines()[line_num - 1] if line_num <= len(content.splitlines()) else ""
issues.append(f"{rel_path}:{line_num}: TODO/FIXME found: {line.strip()}")
return issues
def check_trailing_whitespace(root: Path) -> list[str]:
"""Check for trailing whitespace in markdown files."""
issues: list[str] = []
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
content = md_file.read_text(encoding="utf-8")
for i, line in enumerate(content.splitlines(), 1):
if _TRAILING_WS_RE.search(line):
issues.append(f"{rel_path}:{i}: trailing whitespace")
return issues
def check_stale_docs(root: Path) -> list[str]:
"""Check for stale documentation (not modified in >180 days)."""
issues: list[str] = []
threshold = datetime.now() - timedelta(days=STALE_THRESHOLD_DAYS)
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
mtime = datetime.fromtimestamp(md_file.stat().st_mtime)
if mtime < threshold:
days_old = (datetime.now() - mtime).days
issues.append(f"{rel_path}: stale doc — not modified in {days_old} days")
return issues
def check_duplicate_headings(root: Path) -> list[str]:
"""Check for duplicate headings within the same file."""
issues: list[str] = []
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
# Skip auto-generated files like CHANGELOG.md
if md_file.name in DUPLICATE_HEADING_EXCLUDES:
continue
content = strip_code_blocks(md_file.read_text(encoding="utf-8"))
seen: dict[str, int] = {}
for match in _HEADING_RE.finditer(content):
text = match.group(2)
slug = slugify(text)
if slug in seen:
issues.append(f"{rel_path}: duplicate heading '{text}'")
seen[slug] = 1
return issues
@click.command()
@click.option("--root", default=".", help="Repository root directory.")
@click.option("--docs-dir", default=None, help="Docs directory (default: <root>/docs).")
@click.option("--check-links/--no-check-links", default=True, help="Check internal links.")
@click.option("--check-headings/--no-check-headings", default=True, help="Check heading hierarchy.")
@click.option("--check-todo/--no-check-todo", default=True, help="Check for TODO/FIXME.")
@click.option("--check-stale/--no-check-stale", default=False, help="Check for stale docs.")
@click.option("--check-trailing/--no-check-trailing", default=True, help="Check trailing whitespace.")
@click.option("--check-duplicates/--no-check-duplicates", default=True, help="Check duplicate headings.")
@click.option("--fix", is_flag=True, default=False, help="Auto-fix trailing whitespace.")
def main(
root: str,
docs_dir: str | None,
check_links: bool,
check_headings: bool,
check_todo: bool,
check_stale: bool,
check_trailing: bool,
check_duplicates: bool,
fix: bool,
) -> None:
"""Lint documentation files for structure, links, and quality."""
root_path = Path(root).resolve()
docs_path = Path(docs_dir) if docs_dir else root_path / "docs"
click.echo(_("Linting documentation in {root}...", root=str(root_path)))
all_issues: list[str] = []
# Structure checks
click.echo(_("Checking required files..."))
all_issues.extend(check_required_files(root_path))
click.echo(_("Checking docs structure..."))
all_issues.extend(check_docs_structure(root_path, docs_path))
# Link checks
if check_links:
click.echo(_("Checking internal links..."))
all_issues.extend(check_internal_links(root_path, docs_path))
# Heading hierarchy
if check_headings:
click.echo(_("Checking heading hierarchy..."))
all_issues.extend(check_heading_hierarchy(root_path))
# Duplicate headings
if check_duplicates:
click.echo(_("Checking duplicate headings..."))
all_issues.extend(check_duplicate_headings(root_path))
# TODO/FIXME
if check_todo:
click.echo(_("Checking for TODO/FIXME markers..."))
all_issues.extend(check_todo_fixme(root_path))
# Trailing whitespace
if check_trailing:
click.echo(_("Checking trailing whitespace..."))
ws_issues = check_trailing_whitespace(root_path)
if fix and ws_issues:
fixed = 0
md_files = [f for f in root_path.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
content = md_file.read_text(encoding="utf-8")
fixed_content = _TRAILING_WS_RE.sub("", content)
if content != fixed_content:
md_file.write_text(fixed_content, encoding="utf-8")
fixed += 1
click.echo(_(" Auto-fixed trailing whitespace in {n} files", n=fixed))
else:
all_issues.extend(ws_issues)
# Stale docs
if check_stale:
click.echo(_("Checking for stale docs..."))
stale = check_stale_docs(root_path)
for issue in stale:
click.echo(f" WARN: {issue}")
# Stale docs are warnings, not errors
click.echo(_(" {n} stale docs found (warnings only)", n=len(stale)))
# Report
click.echo(f"\n{'=' * 60}")
if all_issues:
click.echo(_("FAIL: {n} documentation issues found:", n=len(all_issues)))
for issue in all_issues:
click.echo(f" - {issue}")
sys.exit(1)
else:
click.echo(_("PASS: All documentation checks passed!"))
if __name__ == "__main__": # pragma: no cover
main()
+20
View File
@@ -387,14 +387,34 @@ def check_documentation(files: list[dict[str, Any]], result: ReviewResult) -> No
for f in files
)
has_ansible_changes = any(f.get("filename", "").startswith("ansible/") for f in files)
has_tofu_changes = any(f.get("filename", "").startswith("tofu/") for f in files)
has_workflow_changes = any(f.get("filename", "").startswith(".gitea/") for f in files)
# Check for TODO/FIXME in changed docs
todo_issues: list[str] = []
for f in files:
filename = f.get("filename", "")
if filename.endswith(".md") and filename.startswith(("docs/", "README", "AGENTS")):
# Can't check file content from PR API easily, but flag if patch adds TODO
patch = f.get("patch", "")
if patch and re.search(r"^\+.*\b(TODO|FIXME|HACK|XXX)\b", patch, re.IGNORECASE):
todo_issues.append(f"{filename}: new TODO/FIXME added in documentation")
if has_src_changes and not has_doc_changes:
result.add_summary("- Documentation: WARNING — source files changed but no docs updated")
elif has_ansible_changes and not has_doc_changes:
result.add_summary("- Documentation: WARNING — Ansible role changed but no docs updated")
elif has_tofu_changes and not has_doc_changes:
result.add_summary("- Documentation: WARNING — OpenTofu changes but no docs updated")
elif has_workflow_changes and not has_doc_changes:
result.add_summary("- Documentation: INFO — workflow changes (consider updating CI docs if behavior changed)")
else:
result.add_summary("- Documentation: OK")
if todo_issues:
for issue in todo_issues:
result.add_summary(f"- Documentation: WARNING — {issue}")
def check_test_coverage(files: list[dict[str, Any]], result: ReviewResult) -> None:
"""Check that tests are updated for source changes."""
+22 -11
View File
@@ -28,6 +28,8 @@ from typing import Any
import click
from devx.i18n import _
def _repo_root() -> Path:
"""Resolve repo root from GITHUB_WORKSPACE or cwd."""
@@ -68,7 +70,7 @@ def fetch_latest_master(branch: str = "master") -> None:
"""
_run(["git", "fetch", "origin", branch]) # nosec B607
_run(["git", "reset", "--hard", f"origin/{branch}"]) # nosec B607
click.echo(f"Synced to latest origin/{branch}")
click.echo(_("Synced to latest origin/{branch}", branch=branch))
def generate_badges(output_dir: str) -> None:
@@ -76,8 +78,8 @@ def generate_badges(output_dir: str) -> None:
_run([sys.executable, "-m", "devx.tools.generate_badges", "--output-dir", output_dir])
badges = list(Path(output_dir).glob("*.svg"))
if not badges:
raise click.ClickException("No badge SVG files generated")
click.echo(f"Generated {len(badges)} badge files")
raise click.ClickException(_("No badge SVG files generated"))
click.echo(_("Generated {count} badge files", count=len(badges)))
def push_to_badges_branch(badges_dir: str) -> str:
@@ -99,12 +101,12 @@ def push_to_badges_branch(badges_dir: str) -> str:
_run(["git", "add", "./*.svg"]) # nosec B607
_run(["git", "commit", "--no-verify", "-m", "Update badges [skip ci]"]) # nosec B607
_run(["git", "push", "origin", "badges", "--force"]) # nosec B607
click.echo("Badges pushed to badges branch")
click.echo(_("Badges pushed to badges branch"))
# Get the commit SHA of the badges branch
result = _run_capture(["git", "rev-parse", "HEAD"]) # nosec B607
sha = result.stdout.strip()
click.echo(f"Badges commit SHA: {sha}")
click.echo(_("Badges commit SHA: {sha}", sha=sha))
return sha
@@ -142,11 +144,11 @@ def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None)
new_content = update_badge_urls(content, badges_sha)
if new_content != content:
filepath.write_text(new_content)
click.echo(f"Updated badge URLs in {filename}")
click.echo(_("Updated badge URLs in {filename}", filename=filename))
updated_any = True
if not updated_any:
click.echo("No badge URLs found to update — README already up to date")
click.echo(_("No badge URLs found to update — README already up to date"))
return
_run(["git", "add", "README.md", "docs/index.md"]) # nosec B607
@@ -160,7 +162,7 @@ def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None)
]
) # nosec B607
_run(["git", "push", "origin", "master"]) # nosec B607
click.echo(f"Pushed README update with badge SHA {badges_sha[:8]}")
click.echo(_("Pushed README update with badge SHA {sha}", sha=badges_sha[:8]))
@click.command()
@@ -193,13 +195,22 @@ def main(output_dir: str, branch: str, no_readme_update: bool, retries: int) ->
except (subprocess.CalledProcessError, RuntimeError) as exc:
last_error = exc
if attempt < retries:
click.echo(f"Badge push attempt {attempt}/{retries} failed — retrying: {exc}")
click.echo(
_(
"Badge push attempt {attempt}/{retries} failed — retrying: {error}",
attempt=attempt,
retries=retries,
error=exc,
)
)
time.sleep(10)
with contextlib.suppress(subprocess.CalledProcessError):
fetch_latest_master(branch)
else:
click.echo(f"Badge push failed after {retries} attempts: {exc}")
raise click.ClickException(f"Badge push failed after {retries} attempts: {last_error}")
click.echo(_("Badge push failed after {retries} attempts: {error}", retries=retries, error=exc))
raise click.ClickException(
_("Badge push failed after {retries} attempts: {error}", retries=retries, error=last_error)
)
if __name__ == "__main__": # pragma: no cover
+34 -3
View File
@@ -38,6 +38,7 @@ from __future__ import annotations
import os
import re
import sys
import time
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
@@ -709,9 +710,39 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
click.echo(_("Created release commit."))
# Pull --rebase before push to handle the case where master
# advanced between checkout and commit (e.g., another merge).
run_cmd(["git", "pull", "--rebase", "origin", "master"], check=False)
# Use refs/heads/master to avoid ambiguity with a 'master' tag
run_cmd(["git", "push", "origin", "refs/heads/master:refs/heads/master"])
# Retry up to 3 times to handle concurrent pushes.
push_succeeded = False
for attempt in range(3):
rebase = run_cmd(["git", "pull", "--rebase", "origin", "master"], check=False)
if rebase.returncode != 0:
# Rebase failed (likely conflicts). Abort and retry.
click.echo(
_(
"Rebase attempt {n}/3 failed: {err}",
n=attempt + 1,
err=rebase.stderr.strip() if rebase.stderr else rebase.stdout.strip(),
)
)
run_cmd(["git", "rebase", "--abort"], check=False)
# Brief delay before retry to let concurrent pushes settle.
time.sleep(5)
continue
push = run_cmd(["git", "push", "origin", "refs/heads/master:refs/heads/master"], check=False)
if push.returncode == 0:
push_succeeded = True
break
click.echo(
_(
"Push attempt {n}/3 failed: {err}",
n=attempt + 1,
err=push.stderr.strip() if push.stderr else push.stdout.strip(),
)
)
time.sleep(5)
if not push_succeeded:
raise click.ClickException(
_("Failed to push release commit after 3 attempts. Manual intervention required.")
)
click.echo(_("Pushed release commit to master."))
else:
click.echo(_("Skipping commit push — no staged changes."))
+69 -8
View File
@@ -21,11 +21,19 @@ from __future__ import annotations
import base64
import json
import logging
import os
from pathlib import Path
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from tenacity import (
before_sleep_log,
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
from devx.api_clients import GiteaClient
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
@@ -86,11 +94,12 @@ def decode_content(content_b64: str) -> str:
def list_wiki_pages(client: GiteaClient) -> dict[str, str]:
"""List existing wiki pages, returning {title: sub_url}."""
try:
pages = client._request("GET", "/wiki/pages").json()
except APIError:
return {}
"""List existing wiki pages, returning {title: sub_url}.
Raises :class:`APIError` if the wiki API is unavailable the caller
is responsible for retrying or handling the failure.
"""
pages = client._request("GET", "/wiki/pages").json()
return {page.get("title", ""): page.get("sub_url", page.get("title", "")) for page in pages}
@@ -161,6 +170,28 @@ def verify_wiki_page(
return actual.strip() == expected_content.strip()
def _list_wiki_pages_with_retry(client: GiteaClient) -> dict[str, str]:
"""List wiki pages with tenacity retry on APIError.
The Gitea API can be briefly unavailable right after a batch of wiki
page updates. Uses the same tenacity pattern as ``api_clients`` for
exponential backoff.
"""
_logger = logging.getLogger("sync_wiki")
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=2, min=2, max=8),
retry=retry_if_exception_type(APIError),
before_sleep=before_sleep_log(_logger, logging.WARNING),
reraise=True,
)
def _do_list() -> dict[str, str]:
return list_wiki_pages(client)
return _do_list()
def verify_wiki_integrity(
client: GiteaClient,
mapping: dict[str, str],
@@ -176,9 +207,25 @@ def verify_wiki_integrity(
5. Page count matches
Returns a list of failure messages (empty if all checks pass).
If the wiki API is temporarily unavailable (all retry attempts
fail), returns an empty list with a warning the sync itself
already succeeded, so a transient API outage should not fail the job.
"""
failures: list[str] = []
existing_pages = list_wiki_pages(client)
try:
existing_pages = _list_wiki_pages_with_retry(client)
except APIError:
click.echo(
_(
"WARNING: Could not fetch wiki page list after retries. "
"The sync itself succeeded ({count} pages updated), but the "
"integrity check could not verify them due to a transient API issue.",
count=len(synced),
)
)
return []
expected_titles = set(mapping.values())
# Check 1: Page count
@@ -243,7 +290,12 @@ def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
click.echo(_("Syncing {count} documentation pages to wiki...", count=len(mapping)))
existing_pages = list_wiki_pages(client)
try:
existing_pages = list_wiki_pages(client)
except APIError as e:
raise click.ClickException(
_("Failed to list existing wiki pages: {error}. Aborting to avoid creating duplicate pages.", error=e)
) from e
if existing_pages:
click.echo(_("Found {count} existing wiki pages.", count=len(existing_pages)))
@@ -302,7 +354,16 @@ def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
else:
click.echo(_("\nVerifying wiki pages have content..."))
# Re-fetch the page list to get updated sub_urls
existing_pages = list_wiki_pages(client)
try:
existing_pages = _list_wiki_pages_with_retry(client)
except APIError:
click.echo(
_(
"WARNING: Could not re-fetch wiki page list for verification. "
"Skipping content verification due to transient API issue."
)
)
return
failures = 0
for page_title, expected_content in sorted(synced.items()):
ok = verify_wiki_page(client, page_title, expected_content, existing_pages)
+21
View File
@@ -95,6 +95,13 @@ def ci_doc_coverage(args: tuple[str, ...]) -> None:
_run_module("devx.ci.doc_coverage", list(args))
@ci.command("lint-docs")
@click.argument("args", nargs=-1)
def ci_lint_docs(args: tuple[str, ...]) -> None:
"""Lint documentation files for structure, links, and quality."""
_run_module("devx.ci.lint_docs", list(args))
@ci.command("notify-failure")
@click.argument("args", nargs=-1)
def ci_notify_failure(args: tuple[str, ...]) -> None:
@@ -219,6 +226,20 @@ def tools_setup(args: tuple[str, ...]) -> None:
_run_module("devx.tools.setup", list(args))
@tools.command("rebase")
@click.argument("args", nargs=-1)
def tools_rebase(args: tuple[str, ...]) -> None:
"""Rebase current branch onto origin/master and force-push."""
_run_module("devx.tools.rebase", list(args))
@tools.command("pr-rebase")
@click.argument("args", nargs=-1)
def tools_pr_rebase(args: tuple[str, ...]) -> None:
"""Rebase a PR's head branch onto master via Gitea API (server-side)."""
_run_module("devx.tools.pr_rebase", list(args))
@cli.group()
def molecule() -> None:
"""Molecule testing commands (requires devx[molecule])."""
+68 -15
View File
@@ -56,20 +56,61 @@ DEVX_DOCKERFILE_PATHS ?= docker
# PIP_INSTALL — helper to run pip with Gitea private PyPI registry configured.
# 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.
# Projects can alias: PIP_INSTALL = $(DEVX_PIP_INSTALL)
DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \
_PYPI_USER="$${CI_GITEA_USERNAME:-emil}"; \
if [ -n "$$CI_GITEA_TOKEN" ] && [ -n "$$_PYPI_USER" ]; then export PIP_EXTRA_INDEX_URL="https://$$_PYPI_USER:$$CI_GITEA_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \
$(DEVX_BIN)/pip
# ── Virtual environment management ────────────────────────────────────────────
#
# These targets provide a single, consistent venv setup across all
# devx-integrated projects (infra, grm, devx). Each project includes
# devx.mak and aliases its local targets to these.
#
# The venv is a standard .venv directory (no pyenv virtualenv dependency).
# pyenv can still be used to install Python 3.12+ but the venv itself
# is created with `python3 -m venv .venv`.
#
# Projects should set these variables BEFORE including devx.mak:
# DEVX_VENV — venv directory (default: .venv)
# DEVX_BIN — venv bin directory (default: $(DEVX_VENV)/bin)
# DEVX_PYTHON — Python executable (default: python3; should be $(DEVX_BIN)/python after setup)
#
# Common aliases in project Makefiles:
# PIP_INSTALL = $(DEVX_PIP_INSTALL)
# venv: devx-venv
# activate-scripts: devx-activate-scripts
# .env: devx-env
# Create .venv with Python version check (3.12+ required)
$(DEVX_VENV)/bin/activate:
@python3 -c "import sys; v=sys.version_info; assert v >= (3, 12), f'Python 3.12+ required, found {v.major}.{v.minor}'; print(f'Python {v.major}.{v.minor}.{v.micro} OK')"
python3 -m venv $(DEVX_VENV)
$(DEVX_BIN)/pip install --upgrade pip setuptools wheel
# Alias: devx-venv creates the venv (delegates to the activate rule)
devx-venv: $(DEVX_VENV)/bin/activate
# Ensure a venv exists — in CI (no pyenv), creates .venv if missing.
# Locally, uses the existing .venv (created by `make setup` or `make devx-venv`).
devx-ensure-venv:
@if [ ! -f $(DEVX_BIN)/python ]; then \
echo "[ensure-venv] Creating $(DEVX_VENV) (no venv found)..."; \
python3 -m venv $(DEVX_VENV); \
$(DEVX_BIN)/pip install --upgrade pip setuptools wheel; \
fi
.PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr devx-check-config
.PHONY: devx-pr-status devx-pr-logs devx-pr-label devx-pr-review
.PHONY: devx-pr-status devx-pr-logs devx-pr-label devx-pr-review devx-rebase devx-pr-rebase
.PHONY: devx-configure-gitea-pypi devx-install-tools devx-install-checkmake devx-checkmake
.PHONY: devx-workflow-lint devx-workflow-dryrun devx-workflow-dryrun-safe devx-workflow-check
.PHONY: devx-notify-failure devx-install-hooks devx-activate-scripts
.PHONY: devx-notify-failure devx-install-hooks devx-activate-scripts devx-venv devx-ensure-venv
.PHONY: devx-lint-ruff devx-lint-format devx-typecheck devx-lint-bandit devx-lint-deps devx-lint
.PHONY: devx-clean devx-pre-push
.PHONY: devx-check-mutable-globals devx-check-dep-docs devx-check-test-coverage devx-check-docs devx-check-test-speed
.PHONY: devx-check-api-identity-checks devx-setup-ssh-key
.PHONY: devx-test-unit devx-pytest-cov
.PHONY: devx-setup-image devx-lint-dockerfiles
@@ -134,6 +175,20 @@ devx-pr-review:
$(if $(BODY),--body "$(BODY)") \
$(if $(CHECKLIST),--checklist-confirmed --checklist-categories $(CHECKLIST))
# Rebase current branch onto origin/master and force-push
# Usage: make devx-rebase
# make devx-rebase NO_PUSH=1
devx-rebase:
@$(DEVX_PYTHON) -m devx.tools.rebase \
$(if $(NO_PUSH),--no-push)
# Rebase a PR's head branch via Gitea API (server-side, no local git needed)
# Usage: make devx-pr-rebase
# make devx-pr-rebase PR=42
devx-pr-rebase:
@$(DEVX_PYTHON) -m devx.tools.pr_rebase \
$(if $(PR),--pr $(PR))
# ── Environment setup ─────────────────────────────────────────────────────────
# Configure Gitea private PyPI registry so pip can find devx and other
@@ -151,12 +206,6 @@ devx-env:
echo "Created .env from .env.example — please edit it with your credentials."; \
fi
# Create Python venv with version check
devx-venv:
@python3 -c "import sys; v=sys.version_info; assert v >= (3, 12), f'Python 3.12+ required, found {v.major}.{v.minor}'; print(f'Python {v.major}.{v.minor}.{v.micro} OK')"
$(DEVX_PYTHON) -m venv $(DEVX_VENV)
$(DEVX_BIN)/pip install --upgrade pip setuptools wheel
# Create activate scripts for shell/fish/zsh
devx-activate-scripts:
@test -f activate.sh || (echo '#!/usr/bin/env bash' > activate.sh && echo 'source "$$(cd "$$(dirname "$${BASH_SOURCE[0]}")" && pwd)/.venv/bin/activate"' >> activate.sh && chmod +x activate.sh)
@@ -252,7 +301,7 @@ devx-lint: devx-lint-ruff devx-lint-format devx-typecheck devx-lint-bandit
# ── Testing ───────────────────────────────────────────────────────────────────
devx-test-unit:
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -v --no-cov
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -q --no-cov
devx-pytest-cov:
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -v --cov=$(DEVX_COV_PKG) --cov-report=term-missing --cov-fail-under=100
@@ -279,6 +328,14 @@ devx-check-docs:
devx-check-test-speed:
@$(DEVX_PYTHON) -m devx.tools.check_test_speed
# Scan integration tests for unsafe is True/is False identity checks
devx-check-api-identity-checks:
@$(DEVX_PYTHON) -m devx.tools.check_api_identity_checks
# Set up SSH private key from SSH_PRIVATE_KEY env var
devx-setup-ssh-key:
@$(DEVX_PYTHON) -m devx.tools.setup_ssh_key
# ── Pre-push validation ───────────────────────────────────────────────────────
# Run lint + tests before push (projects can override with project-specific targets)
@@ -327,12 +384,8 @@ devx-lint-dockerfiles:
# devx-setup-ci) — each project defines its own setup-ci target.
devx-setup-image:
@if [ -d /opt/venv ]; then ln -sf /opt/venv $(DEVX_VENV); . $(DEVX_BIN)/activate; \
_U="$${CI_GITEA_USERNAME:-emil}"; \
if [ -n "$$CI_GITEA_TOKEN" ]; then export PIP_EXTRA_INDEX_URL="https://$$_U:$$CI_GITEA_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \
pip install --no-cache-dir -e .$(if $(EXTRAS),[$(EXTRAS)],); \
echo "[devx-setup-image] Linked /opt/venv$(if $(EXTRAS), with [$(EXTRAS)],)."; \
else echo "[devx-setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
@/opt/venv/bin/python -m devx.tools.setup_image --venv $(DEVX_VENV) --extras "$(EXTRAS)" \
--gitea-host $(DEVX_GITEA_PYPI_HOST) --gitea-org $(DEVX_GITEA_PYPI_ORG)
# ── Docker image build / push / cleanup ───────────────────────────────────────
#
+51
View File
@@ -2,7 +2,9 @@
from __future__ import annotations
import os
import platform
import subprocess # nosec B404
import click
@@ -22,3 +24,52 @@ def arch_string() -> str:
if machine in {"aarch64", "arm64"}:
return "arm64"
raise click.ClickException(f"Unsupported architecture: {machine}")
def detect_pr_number() -> int | None:
"""Detect the PR number for the current git branch.
Returns the PR number if the current branch has an open PR, or None
if no PR is found. Does NOT raise callers decide how to handle None.
Best-effort: returns None on any failure (no token, API down, etc.).
"""
result = subprocess.run( # nosec B603, B607
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return None
branch = result.stdout.strip()
if branch == "HEAD":
return None
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
return None
owner = os.environ.get("DEVX_REPO_OWNER", "")
repo = os.environ.get("DEVX_REPO_NAME", "")
if not owner or not repo:
github_repo = os.environ.get("GITHUB_REPOSITORY", "")
if "/" in github_repo:
owner, repo = github_repo.split("/", 1)
if not owner or not repo:
return None
# Lazy import to avoid circular dependency
from devx.api_clients import APIError, GiteaClient # noqa: PLC0415
from devx.config import GITEA_API_URL # noqa: PLC0415
client = GiteaClient(GITEA_API_URL, token, owner, repo)
try:
prs = client.list_prs(state="open")
except APIError:
# Best-effort: API down or auth failure → no PR detected
return None
for pr in prs:
if pr.get("head", {}).get("ref") == branch:
return int(pr["number"])
return None
+137
View File
@@ -0,0 +1,137 @@
#!/usr/bin/env python3
"""Scan integration tests for unsafe ``is True``/``is False`` identity checks.
Many APIs (e.g. Mattermost) return boolean values as strings (``"true"``,
``"false"``) rather than native JSON booleans. Using ``is True`` or
``is not False`` on such responses silently fails because ``"true" is True``
evaluates to ``False`` in Python.
This tool scans ``tests/integration/test_*.py`` files for identity checks
on API response values and reports them as errors.
Configuration (``[tool.devx.check_api_identity_checks]`` in pyproject.toml):
``scan_dirs`` list of directories to scan (default: ``["tests/integration"]``)
``skip_patterns`` list of filename patterns to skip (default: ``["test_*_helpers.py"]``)
``noqa_marker`` comment to suppress individual lines (default: ``# noqa``)
Usage::
python3 -m devx.tools.check_api_identity_checks
python3 -m devx.tools.check_api_identity_checks --scan-dir tests/integration
"""
from __future__ import annotations
import re
from pathlib import Path
import click
from devx.config import _load_pyproject_devx
from devx.i18n import _
DEFAULT_SCAN_DIRS = ["tests/integration"]
DEFAULT_SKIP_PATTERNS = ["test_*_helpers.py"]
DEFAULT_NOQA_MARKER = "# noqa"
# Matches: x is True, x is False, x is not True, x is not False
_IDENTITY_CHECK_RE = re.compile(r"\bis\s+(not\s+)?(True|False)\b")
def _load_config() -> tuple[list[str], list[str], str]:
"""Load configuration from pyproject.toml [tool.devx.check_api_identity_checks]."""
devx_cfg = _load_pyproject_devx()
cfg_raw = devx_cfg.get("check_api_identity_checks", {})
if not isinstance(cfg_raw, dict):
return DEFAULT_SCAN_DIRS, DEFAULT_SKIP_PATTERNS, DEFAULT_NOQA_MARKER
cfg: dict[str, object] = cfg_raw # type: ignore[assignment]
scan_dirs_raw = cfg.get("scan_dirs", DEFAULT_SCAN_DIRS)
scan_dirs: list[str] = [str(d) for d in scan_dirs_raw] if isinstance(scan_dirs_raw, list) else DEFAULT_SCAN_DIRS
skip_raw = cfg.get("skip_patterns", DEFAULT_SKIP_PATTERNS)
skip_patterns: list[str] = [str(p) for p in skip_raw] if isinstance(skip_raw, list) else DEFAULT_SKIP_PATTERNS
noqa_marker = str(cfg.get("noqa_marker", DEFAULT_NOQA_MARKER))
return scan_dirs, skip_patterns, noqa_marker
def _matches_skip_pattern(path: Path, skip_patterns: list[str]) -> bool:
"""Check if a file path matches any skip pattern."""
name = path.name
return any(Path(name).match(pattern) for pattern in skip_patterns)
def find_identity_checks(
file_path: Path,
repo_root: Path,
noqa_marker: str,
) -> list[str]:
"""Return a list of issue strings for unsafe identity checks in *file_path*."""
issues: list[str] = []
try:
source = file_path.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError):
return issues
rel = str(file_path.relative_to(repo_root))
for lineno, line in enumerate(source.splitlines(), 1):
if noqa_marker in line:
continue
match = _IDENTITY_CHECK_RE.search(line)
if match:
issues.append(
f"{rel}:{lineno}: unsafe identity check '{match.group()}' "
f"— APIs may return string 'true'/'false'. "
f"Use string comparison or _is_truthy()/_is_falsy() helpers."
)
return issues
@click.command()
@click.option(
"--scan-dir",
multiple=True,
help=_("Directory to scan (default: tests/integration). Can be repeated."),
)
def cli(scan_dir: tuple[str, ...]) -> None:
"""Scan integration tests for unsafe ``is True``/``is False`` identity checks."""
repo_root = Path.cwd()
config_scan_dirs, skip_patterns, noqa_marker = _load_config()
scan_dirs = list(scan_dir) if scan_dir else config_scan_dirs
all_issues: list[str] = []
for scan_dir_name in scan_dirs:
scan_path = repo_root / scan_dir_name
if not scan_path.exists():
continue
for py_file in scan_path.rglob("test_*.py"):
if _matches_skip_pattern(py_file, skip_patterns):
continue
all_issues.extend(find_identity_checks(py_file, repo_root, noqa_marker))
if all_issues:
click.echo(
_("Found {count} unsafe identity check(s) in integration tests.", count=len(all_issues)),
err=True,
)
for issue in all_issues:
click.echo(f" {issue}", err=True)
raise click.ClickException(
_(
"Use string comparison or _is_truthy()/_is_falsy() helpers instead. "
"Add '{marker}' to suppress individual lines.",
marker=noqa_marker,
)
)
click.echo(_("[check-api-identity-checks] Passed: no unsafe identity checks found"))
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Check that required development tools are present.
Verifies the availability of core tools (tofu, docker, checkmake, Python
3.12+ in the venv) and prints warnings or errors for missing ones.
Usage::
python3 -m devx.tools.check_deps
python3 -m devx.tools.check_deps --venv .venv
"""
from __future__ import annotations
import shutil
import subprocess # nosec B404
from pathlib import Path
import click
from devx.i18n import _
REQUIRED_TOOLS = ["tofu", "docker"]
OPTIONAL_TOOLS = ["checkmake"]
PYTHON_MIN_VERSION = (3, 12)
def _check_tool(name: str, *, optional: bool = False) -> bool:
"""Check if a tool is on PATH. Returns True if found."""
found = shutil.which(name) is not None
if found:
return True
level = "WARN" if optional else "ERROR"
click.echo(
_("{level}: {tool} not found.{hint}", level=level, tool=name, hint=""),
err=True,
)
return False
def _check_python_version(venv_bin: Path) -> None:
"""Check that the venv Python is >= 3.12."""
python_bin = venv_bin / "python"
if not python_bin.exists():
click.echo(
_("WARN: .venv not found. Run 'make setup-venv' to create it."),
err=True,
)
return
result = subprocess.run( # nosec B603
[str(python_bin), "--version"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
click.echo(_("WARN: Could not determine Python version in .venv."), err=True)
return
version_str = result.stdout.strip().split()[-1] if result.stdout else ""
try:
major, minor = int(version_str.split(".")[0]), int(version_str.split(".")[1])
except (IndexError, ValueError):
click.echo(_("WARN: Could not parse Python version '{version}'.", version=version_str), err=True)
return
if (major, minor) < PYTHON_MIN_VERSION:
click.echo(
_(
"WARN: .venv has Python {version}, but >={req} is required.",
version=version_str,
req=f"{PYTHON_MIN_VERSION[0]}.{PYTHON_MIN_VERSION[1]}",
),
err=True,
)
return
click.echo(_("[check-deps] Virtualenv .venv ready (Python {version}).", version=version_str))
@click.command()
@click.option("--venv", default=".venv", show_default=True, help="Path to the virtual environment.")
@click.option("--checkmake-bin", default=None, help="Path to checkmake binary (fallback if not on PATH).")
def cli(venv: str, checkmake_bin: str | None) -> None:
"""Verify that required development tools are present."""
click.echo(_("[check-deps] Verifying tools..."))
all_required = True
for tool in REQUIRED_TOOLS:
if not _check_tool(tool):
all_required = False
for tool in OPTIONAL_TOOLS:
if not _check_tool(tool, optional=True):
if checkmake_bin and Path(checkmake_bin).exists():
click.echo(_(" {tool}: found at {path}", tool=tool, path=checkmake_bin))
else:
click.echo(_(" Run 'make install-checkmake' to install the Makefile linter."))
_check_python_version(Path(venv) / "bin")
if not all_required:
raise click.ClickException(_("Required tools missing."))
click.echo(_("[check-deps] All core tools present."))
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+4 -1
View File
@@ -32,7 +32,10 @@ _TIMING_RE = re.compile(r"(\d+) passed.* in ([0-9.]+)s")
# Matches per-test duration lines from --durations=0:
# 0.51s call tests/test_foo.py::test_bar
_DURATION_LINE_RE = re.compile(r"^(\d+\.?\d*)s\s+(?:setup|call|teardown)\s+(.+)$")
# Only "call" duration is counted — "setup" includes import/collection
# overhead (coverage init, module imports) which is environment-dependent
# and not a test quality signal.
_DURATION_LINE_RE = re.compile(r"^(\d+\.?\d*)s\s+call\s+(.+)$")
def run_tests() -> tuple[str, str]:
+19 -10
View File
@@ -200,9 +200,9 @@ def main(
total_kept = 0
total_failed = 0
for name in names:
click.echo(f"\n{'=' * 60}")
click.echo(f"Package: {owner}/{name}")
click.echo(f"{'=' * 60}")
click.echo(_("\n{separator}", separator="=" * 60))
click.echo(_("Package: {owner}/{name}", owner=owner, name=name))
click.echo(_("{separator}", separator="=" * 60))
try:
versions = list_package_versions(base_url, owner, name, token)
except requests.RequestException as exc:
@@ -217,17 +217,19 @@ def main(
click.echo(_("No versions found."))
continue
click.echo(f"Found {len(versions)} version(s):")
click.echo(_("Found {count} version(s):", count=len(versions)))
for v in sort_versions_by_date(versions):
click.echo(f" {v.get('version', '?')} (created: {v.get('created_at', '?')})")
click.echo(
_(" {version} (created: {created})", version=v.get("version", "?"), created=v.get("created_at", "?"))
)
to_delete = select_for_deletion(versions, keep)
kept_count = len(versions) - len(to_delete)
click.echo(f"\nKeeping {kept_count}, would delete {len(to_delete)}")
click.echo(_("\nKeeping {kept}, would delete {count}", kept=kept_count, count=len(to_delete)))
if dry_run:
for v in to_delete:
click.echo(f" [dry-run] Would delete: {v.get('version', '?')}")
click.echo(_(" [dry-run] Would delete: {version}", version=v.get("version", "?")))
total_kept += kept_count
continue
@@ -236,17 +238,24 @@ def main(
for v in to_delete:
version = str(v.get("version", ""))
if delete_package_version(base_url, owner, name, version, token):
click.echo(f" Deleted: {version}")
click.echo(_(" Deleted: {version}", version=version))
deleted_count += 1
else:
click.echo(f" FAILED to delete: {version}", err=True)
click.echo(_(" FAILED to delete: {version}", version=version), err=True)
failed_count += 1
total_deleted += deleted_count
total_kept += kept_count
total_failed += failed_count
click.echo(f"\nDone. Deleted {total_deleted}, kept {total_kept}, failed {total_failed}.")
click.echo(
_(
"\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
deleted=total_deleted,
kept=total_kept,
failed=total_failed,
)
)
if total_failed > 0:
raise click.ClickException(_("Failed to delete {count} image version(s)", count=total_failed))
+24 -4
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env python3
"""Configure repository: branch protection + repo settings via Gitea REST API.
"""Configure repository: branch protection, repo settings, and standard labels.
Uses ``GiteaClient`` for branch protection and repo settings.
The ``tea`` CLI is used for label creation if available, with a
fallback to ``GiteaClient`` if tea is not installed.
Uses ``GiteaClient`` for branch protection, repo settings, and label
creation. Standard labels (bug, ready-to-merge, feedback, tooling,
ci-improvement, doc-improvement, workflow-improvement) are created
idempotently via ``ensure_label``.
Usage:
CI_GITEA_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo
@@ -71,6 +72,19 @@ def _default_repo_settings_config() -> dict[str, Any]:
}
# Standard labels created in every oblachno repo.
# These cover CI failure notifications, subagent feedback, and auto-merge.
_STANDARD_LABELS: list[dict[str, str]] = [
{"name": "bug", "color": "#ee0701", "description": "Something is not working"},
{"name": "ready-to-merge", "color": "#a2eeef", "description": "PR has been reviewed and is ready for auto-merge"},
{"name": "feedback", "color": "#fbca04", "description": "Issues from subagent or agent feedback"},
{"name": "tooling", "color": "#c5def5", "description": "Tool-related feedback or improvements"},
{"name": "ci-improvement", "color": "#84b6eb", "description": "CI workflow improvements"},
{"name": "doc-improvement", "color": "#d4c5f9", "description": "Documentation improvements"},
{"name": "workflow-improvement", "color": "#fef2c0", "description": "Workflow alignment or pattern improvements"},
]
def _handle_http_error(e: APIError) -> None:
"""Raise a user-friendly Click exception for HTTP errors."""
if e.status == http.HTTPStatus.FORBIDDEN:
@@ -138,6 +152,12 @@ def configure_repo(
client.update_repo_settings(cast(dict[str, object], rs_config))
click.echo(_(" - Auto-delete branch after merge: yes"))
click.echo("")
click.echo(_("Ensuring standard labels..."))
for label in _STANDARD_LABELS:
client.ensure_label(label["name"], label["color"], label["description"])
click.echo(_(" - {count} standard labels verified", count=len(_STANDARD_LABELS)))
click.echo("")
click.echo(_("Repository configuration complete."))
except APIError as e:
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""Docker registry login helper.
Handles login to Docker registries (Gitea, Docker Hub) with credential
loading from environment variables. Supports required and optional modes.
Usage::
python3 -m devx.tools.docker_login --registry git.oblachno.oblachno.fyi \\
--token-env CI_GITEA_TOKEN --username-env CI_GITEA_USERNAME \\
--default-username emil
python3 -m devx.tools.docker_login --registry docker.io \\
--token-env DOCKER_HUB_TOKEN --username-env DOCKER_HUB_USERNAME --optional
"""
from __future__ import annotations
import subprocess # nosec B404
import click
from devx.i18n import _
def docker_login(
registry: str,
username: str,
token: str,
*,
suppress_failure: bool = False,
) -> bool:
"""Log in to a Docker registry.
Returns True on success, False on failure.
If ``suppress_failure`` is True, prints a warning instead of raising.
"""
cmd = ["docker", "login", registry, "-u", username, "-p", token]
result = subprocess.run( # nosec B603
cmd,
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
if suppress_failure:
click.echo(
_("[docker-login] Login to {registry} failed (continuing).", registry=registry),
err=True,
)
return False
raise click.ClickException(
_("Login to {registry} failed: {error}", registry=registry, error=result.stderr.strip()),
)
click.echo(_("[docker-login] Logged in to {registry}.", registry=registry))
return True
def _resolve_credentials(
token_env: str,
username_env: str,
default_username: str | None,
) -> tuple[str | None, str | None]:
"""Resolve credentials from environment variables.
Returns (username, token) or (None, None) if token is not set.
"""
import os
token = os.environ.get(token_env, "")
if not token:
return None, None
username = os.environ.get(username_env, "") or (default_username or "")
return username, token
@click.command()
@click.option("--registry", required=True, help="Docker registry URL (e.g. docker.io, git.example.com).")
@click.option("--token-env", required=True, help="Environment variable name for the auth token.")
@click.option("--username-env", required=True, help="Environment variable name for the username.")
@click.option(
"--default-username",
default=None,
help="Default username if the env var is not set.",
)
@click.option(
"--optional",
is_flag=True,
default=False,
help="Skip silently if token is not set instead of raising.",
)
@click.option(
"--suppress-failure",
is_flag=True,
default=False,
help="Continue on login failure instead of raising (prints warning).",
)
def cli(
registry: str,
token_env: str,
username_env: str,
default_username: str | None,
optional: bool,
suppress_failure: bool,
) -> None:
"""Log in to a Docker registry using credentials from environment variables."""
username, token = _resolve_credentials(token_env, username_env, default_username)
if token is None:
if optional:
click.echo(_("[docker-login] Skipping {registry} (token {env} not set).", registry=registry, env=token_env))
return
raise click.ClickException(
_("{env} is not set. Set it in your .env file or pass it as an environment variable.", env=token_env),
)
if not username:
raise click.ClickException(
_("{env} is not set. Set it in your .env file.", env=username_env),
)
docker_login(registry, username, token, suppress_failure=suppress_failure)
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+39 -27
View File
@@ -27,6 +27,8 @@ from pathlib import Path
import click
from devx.i18n import _
# Coverage regex matches "TOTAL ... NN%" or "TOTAL ... NN.NN%"
_COVERAGE_RE = re.compile(r"TOTAL.*?(\d+(?:\.\d+)?)%")
_PASSED_RE = re.compile(r"(\d+) passed")
@@ -197,17 +199,19 @@ def read_version(repo_root: Path) -> str:
"""
pkg = detect_package_name(repo_root)
if pkg is None:
click.echo(" WARNING: No Python package found under src/ — version badge will show 'unknown'")
click.echo(_(" WARNING: No Python package found under src/ — version badge will show 'unknown'"))
return "unknown"
init_file = repo_root / "src" / pkg / "__init__.py"
if not init_file.exists():
click.echo(f" WARNING: {init_file} not found — version badge will show 'unknown'")
click.echo(_(" WARNING: {init_file} not found — version badge will show 'unknown'", init_file=init_file))
return "unknown"
content = init_file.read_text()
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
if match:
return match.group(1)
click.echo(f" WARNING: No __version__ found in {init_file} — version badge will show 'unknown'")
click.echo(
_(" WARNING: No __version__ found in {init_file} — version badge will show 'unknown'", init_file=init_file)
)
return "unknown"
@@ -278,11 +282,11 @@ def collect_coverage_and_tests(repo_root: Path) -> tuple[dict[str, str | int], d
"""
cov_target = detect_coverage_target(repo_root)
if cov_target is None:
click.echo(" WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)")
click.echo(_(" WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)"))
return make_badge("coverage", "unknown", "lightgrey"), make_badge("tests", "unknown", "lightgrey")
testpaths = detect_testpaths(repo_root)
click.echo(f" Test paths: {testpaths or '(pytest defaults)'}")
click.echo(_(" Test paths: {testpaths}", testpaths=testpaths or "(pytest defaults)"))
cmd = [
sys.executable,
@@ -302,18 +306,18 @@ def collect_coverage_and_tests(repo_root: Path) -> tuple[dict[str, str | int], d
if coverage is not None:
cov_badge = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage))
else:
click.echo(f" WARNING: Could not extract coverage from pytest output (rc={rc})")
click.echo(f" pytest stdout (last 300 chars): {stdout.strip()[-300:]}")
click.echo(f" pytest stderr (last 300 chars): {stderr.strip()[-300:]}")
click.echo(_(" WARNING: Could not extract coverage from pytest output (rc={rc})", rc=rc))
click.echo(_(" pytest stdout (last 300 chars): {stdout}", stdout=stdout.strip()[-300:]))
click.echo(_(" pytest stderr (last 300 chars): {stderr}", stderr=stderr.strip()[-300:]))
cov_badge = make_badge("coverage", "unknown", "red")
test_count = extract_test_count(combined)
if test_count is not None:
tests_badge = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red")
else:
click.echo(f" WARNING: Could not extract test count from pytest output (rc={rc})")
click.echo(f" pytest stdout (last 300 chars): {stdout.strip()[-300:]}")
click.echo(f" pytest stderr (last 300 chars): {stderr.strip()[-300:]}")
click.echo(_(" WARNING: Could not extract test count from pytest output (rc={rc})", rc=rc))
click.echo(_(" pytest stdout (last 300 chars): {stdout}", stdout=stdout.strip()[-300:]))
click.echo(_(" pytest stderr (last 300 chars): {stderr}", stderr=stderr.strip()[-300:]))
tests_badge = make_badge("tests", "unknown", "red")
return cov_badge, tests_badge
@@ -328,8 +332,8 @@ def collect_doc_coverage(repo_root: Path) -> dict[str, str | int]:
doc_pct = extract_doc_coverage(stdout)
if doc_pct is not None:
return make_badge("docs", f"{doc_pct}%", doc_coverage_color(doc_pct))
click.echo(f" WARNING: Could not extract doc coverage (rc={rc})")
click.echo(f" stderr: {stderr.strip()[:200]}")
click.echo(_(" WARNING: Could not extract doc coverage (rc={rc})", rc=rc))
click.echo(_(" stderr: {stderr}", stderr=stderr.strip()[:200]))
return make_badge("docs", "unknown", "red")
@@ -356,16 +360,16 @@ def collect_quality(repo_root: Path) -> dict[str, str | int]:
results.append(False)
# Distinguish "tool not installed" from "tool found issues"
if "No module named" in stderr or "not found" in stderr.lower():
click.echo(f" WARNING: {name} not installed — skipping (counted as pass)")
click.echo(_(" WARNING: {name} not installed — skipping (counted as pass)", name=name))
results[-1] = True
tool_names.append(f"{name}: not installed (skipped)")
else:
tool_names.append(f"{name}: FAIL")
click.echo(f" WARNING: {name} failed (rc={rc})")
click.echo(f" stderr: {stderr.strip()[:200]}")
click.echo(_(" WARNING: {name} failed (rc={rc})", name=name, rc=rc))
click.echo(_(" stderr: {stderr}", stderr=stderr.strip()[:200]))
all_pass = all(results)
click.echo(f" Quality checks: {', '.join(tool_names)}")
click.echo(_(" Quality checks: {checks}", checks=", ".join(tool_names)))
return make_badge("code quality", "A" if all_pass else "F", "brightgreen" if all_pass else "red")
@@ -377,28 +381,28 @@ def generate_badges(output_dir: Path, repo_root: Path | None = None) -> dict[str
repo_root: Repository root (auto-detected if None).
"""
root = repo_root or resolve_repo_root()
click.echo(f" Repo root: {root}")
click.echo(_(" Repo root: {root}", root=root))
pkg = detect_package_name(root)
click.echo(f" Package: {pkg or 'none'}")
click.echo(_(" Package: {pkg}", pkg=pkg or "none"))
badges: dict[str, dict[str, str | int]] = {}
# 1. Code coverage + test count (single pytest-cov run)
click.echo(" Collecting coverage and tests...")
click.echo(_(" Collecting coverage and tests..."))
cov_badge, tests_badge = collect_coverage_and_tests(root)
badges["coverage"] = cov_badge
badges["tests"] = tests_badge
# 2. Documentation coverage
click.echo(" Collecting doc coverage...")
click.echo(_(" Collecting doc coverage..."))
badges["docs"] = collect_doc_coverage(root)
# 3. Code quality (ruff + pyright + bandit)
click.echo(" Collecting code quality...")
click.echo(_(" Collecting code quality..."))
badges["quality"] = collect_quality(root)
# 4. Version
click.echo(" Collecting version...")
click.echo(_(" Collecting version..."))
version = read_version(root)
badges["version"] = make_badge("version", f"v{version}", "blue")
@@ -411,7 +415,7 @@ def generate_badges(output_dir: Path, repo_root: Path | None = None) -> dict[str
svg = render_svg(str(badge["label"]), str(badge["message"]), str(badge["color"]))
path = output_dir / f"{name}.svg"
path.write_text(svg)
click.echo(f" Generated: {path}")
click.echo(_(" Generated: {path}", path=path))
return badges
@@ -431,11 +435,19 @@ def cli(output_dir: str, repo_root: str | None) -> None:
"""Generate self-contained SVG badge files from project metrics."""
out = Path(output_dir)
root = Path(repo_root) if repo_root else None
click.echo(f"Generating badges in {out}...")
click.echo(_("Generating badges in {out}...", out=out))
badges = generate_badges(out, repo_root=root)
click.echo(f"\nGenerated {len(badges)} badges:")
click.echo(_("\nGenerated {count} badges:", count=len(badges)))
for name, badge in badges.items():
click.echo(f" {name}: {badge['label']}={badge['message']} ({badge['color']})")
click.echo(
_(
" {name}: {label}={message} ({color})",
name=name,
label=badge["label"],
message=badge["message"],
color=badge["color"],
)
)
if __name__ == "__main__": # pragma: no cover
+25 -1
View File
@@ -42,6 +42,8 @@ TEA_VERSION = "0.14.1"
HADOLINT_VERSION = "2.12.0"
TOFU_VERSION = "1.12.3"
def _arch() -> str:
"""Return the architecture string used by release assets (delegates to shared utility)."""
@@ -174,7 +176,27 @@ def install_hadolint() -> bool:
return True
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea", "hadolint"]
def install_tofu() -> bool:
"""Install OpenTofu if not already present. Returns True if installed/skipped.
Downloads the official release tarball from GitHub and extracts the
``tofu`` binary to ``~/.local/bin``.
"""
if _is_installed("tofu"):
click.echo("tofu: already installed")
return True
arch = _arch()
os_name = platform.system().lower()
url = (
f"https://github.com/opentofu/opentofu/releases/download/"
f"v{TOFU_VERSION}/tofu_{TOFU_VERSION}_{os_name}_{arch}.tar.gz"
)
dest = _download_and_extract_tarball(url, "tofu")
click.echo(f"tofu: installed to {dest}")
return True
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea", "hadolint", "tofu"]
def _install_tool(name: str) -> bool:
@@ -189,6 +211,8 @@ def _install_tool(name: str) -> bool:
return install_tea()
if name == "hadolint":
return install_hadolint()
if name == "tofu":
return install_tofu()
raise click.ClickException(f"Unknown tool: {name}")
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Rebase a pull request's head branch onto master via Gitea API.
Uses the Gitea ``POST /pulls/{index}/update?style=rebase`` endpoint to
rebase the PR's head branch server-side. This triggers a new
``pull_request synchronize`` event, which starts a new CI run.
This is useful when:
- You don't have the branch checked out locally
- You want to rebase a PR from another machine
- You want to trigger the auto-merge retry without local git operations
Usage::
# Rebase PR #42
python -m devx.tools.pr_rebase --pr 42
# Rebase current branch's PR (auto-detected)
python -m devx.tools.pr_rebase
The repository is auto-detected from ``DEVX_REPO_OWNER`` /
``DEVX_REPO_NAME`` or ``GITHUB_REPOSITORY`` environment variables.
"""
from __future__ import annotations
import os
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.api_clients import APIError, GiteaClient
from devx.config import GITEA_API_URL
from devx.i18n import _
from devx.tools._shared import detect_pr_number
@click.command()
@click.option("--pr", type=int, help="PR number (auto-detected if omitted).")
def main(pr: int | None) -> None:
"""Rebase a pull request's head branch onto master via Gitea API."""
load_dotenv()
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
raise click.ClickException(_("CI_GITEA_TOKEN is not set. Add it to .env or export it."))
pr_num = pr or detect_pr_number()
if not pr_num:
raise click.ClickException(
_(
"Could not detect PR number. Use --pr to specify it explicitly,\n"
"or run this command from a branch with an open PR.",
)
)
owner = os.environ.get("DEVX_REPO_OWNER", "")
repo = os.environ.get("DEVX_REPO_NAME", "")
if not owner or not repo:
github_repo = os.environ.get("GITHUB_REPOSITORY", "")
if "/" in github_repo:
owner, repo = github_repo.split("/", 1)
if not owner or not repo:
raise click.ClickException(
_(
"Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\n"
"or GITHUB_REPOSITORY environment variables.",
)
)
client = GiteaClient(GITEA_API_URL, token, owner, repo)
click.echo(_("Rebasing PR #{pr} via Gitea API...", pr=pr_num))
try:
client.update_pr_branch(pr_num, style="rebase")
except APIError as e:
raise click.ClickException(
_(
"Rebase failed with HTTP {status}: {message}",
status=e.status,
message=e.message,
)
) from None
click.echo(
_(
"PR #{pr} rebased successfully. A new CI run will start automatically.\n"
"If auto-merge is enabled (ready-to-merge label), the next CI run\n"
"will attempt to merge this PR.",
pr=pr_num,
)
)
if __name__ == "__main__": # pragma: no cover
main()
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Rebase current branch onto origin/master and force-push.
Fetches origin/master, rebases the current branch, and force-pushes with
``--force-with-lease``. This is the manual equivalent of what
``auto_merge.py`` does automatically via the Gitea API.
Usage::
# Rebase current branch onto master and force-push
python -m devx.tools.rebase
# Rebase without pushing (local only)
python -m devx.tools.rebase --no-push
The tool fails if:
- The rebase encounters conflicts (exits with rebase in progress)
- The force-push is rejected (remote has unexpected commits)
- Not on a branch (detached HEAD)
"""
from __future__ import annotations
import subprocess # nosec B404
import click
from devx.i18n import _
def _run_git(args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]:
"""Run a git command and return the result."""
return subprocess.run( # nosec B603, B607
["git", *args],
capture_output=True,
text=True,
check=check,
)
@click.command()
@click.option("--no-push", is_flag=True, help="Rebase locally without pushing.")
def main(no_push: bool) -> None:
"""Rebase current branch onto origin/master and force-push."""
# Ensure we're on a branch (check=False — we handle errors ourselves)
branch_result = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], check=False)
if branch_result.returncode != 0:
raise click.ClickException(_("Could not detect current branch: {error}", error=branch_result.stderr.strip()))
branch = branch_result.stdout.strip()
if branch == "HEAD":
raise click.ClickException(_("Cannot rebase: not on a branch (detached HEAD)."))
click.echo(_("Fetching origin/master..."))
fetch = _run_git(["fetch", "origin", "master"], check=False)
if fetch.returncode != 0:
raise click.ClickException(_("Fetch failed: {error}", error=fetch.stderr.strip()))
# Check if behind master
behind = _run_git(
["rev-list", "--count", "HEAD..origin/master"],
check=False,
)
behind_count = int(behind.stdout.strip()) if behind.stdout.strip().isdigit() else 0
if behind_count == 0:
click.echo(_("Branch is already up-to-date with origin/master."))
if not no_push:
click.echo(_("Nothing to push."))
return
click.echo(_("Branch is {count} commit(s) behind master. Rebasing...", count=behind_count))
rebase = _run_git(["rebase", "origin/master"], check=False)
if rebase.returncode != 0:
raise click.ClickException(
_(
"Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
error=rebase.stderr.strip() or rebase.stdout.strip(),
)
)
click.echo(_("Rebase successful."))
if not no_push:
click.echo(_("Force-pushing..."))
push = _run_git(["push", "--force-with-lease", "origin", branch], check=False)
if push.returncode != 0:
raise click.ClickException(
_(
"Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
error=push.stderr.strip(),
)
)
click.echo(_("Pushed {branch} to origin.", branch=branch))
if __name__ == "__main__": # pragma: no cover
main()
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Set up the project inside a pre-built CI image.
CI images (e.g. ``ci-quality:latest``) ship with a Python virtualenv at
``/opt/venv`` that already contains the runtime dependencies. This tool
links that venv to ``.venv`` in the project root and installs the project
itself in editable mode, optionally with extras.
If ``/opt/venv`` does not exist (local development), falls back to
``make setup-ci`` via ``subprocess``.
Usage::
python3 -m devx.tools.setup_image # runtime deps only
python3 -m devx.tools.setup_image --extras lint # runtime + lint deps
python3 -m devx.tools.setup_image --extras ci,lint
"""
from __future__ import annotations
import os
import subprocess # nosec B404
from pathlib import Path
import click
DEFAULT_VENV = ".venv"
OPT_VENV = "/opt/venv"
FALLBACK_TARGET = "setup-ci"
def _build_pip_extra_index_url(
gitea_host: str,
gitea_org: str,
username: str,
token: str,
) -> str:
"""Build the PIP_EXTRA_INDEX_URL for the Gitea PyPI registry.
Returns a URL of the form:
https://<user>:<token>@<host>/api/packages/<org>/pypi/simple/
"""
return f"https://{username}:{token}@{gitea_host}/api/packages/{gitea_org}/pypi/simple/"
def _install_in_image(
venv_link: str,
opt_venv: str,
extras: str,
gitea_host: str,
gitea_org: str,
) -> None:
"""Link /opt/venv to .venv, activate it, and pip install the project.
Sets ``PIP_EXTRA_INDEX_URL`` when ``CI_GITEA_TOKEN`` is available so
that private packages from the Gitea PyPI registry can be installed.
"""
# Symlink /opt/venv → .venv
link = Path(venv_link)
if link.exists() or link.is_symlink():
link.unlink()
link.symlink_to(opt_venv)
# Build pip install command
spec = f".[{extras}]" if extras else "."
pip_bin = str(Path(venv_link) / "bin" / "pip")
cmd = [pip_bin, "install", "--no-cache-dir", "-e", spec]
env = os.environ.copy()
token = env.get("CI_GITEA_TOKEN", "")
if token:
username = env.get("CI_GITEA_USERNAME", "emil")
env["PIP_EXTRA_INDEX_URL"] = _build_pip_extra_index_url(
gitea_host,
gitea_org,
username,
token,
)
click.echo(f"[setup-image] Linked {opt_venv}" + (f" with [{extras}]" if extras else "") + ".")
subprocess.run(cmd, check=True, env=env) # nosec B603
def _fallback_to_setup_ci() -> None:
"""Fall back to ``make setup-ci`` when /opt/venv is not present."""
click.echo(f"[setup-image] {OPT_VENV} not found — falling back to {FALLBACK_TARGET}")
subprocess.run( # nosec B603, B607
["make", FALLBACK_TARGET],
check=True,
)
@click.command()
@click.option(
"--venv",
default=DEFAULT_VENV,
show_default=True,
help="Path to the local venv symlink (e.g. .venv).",
)
@click.option(
"--opt-venv",
default=OPT_VENV,
show_default=True,
help="Path to the pre-built venv inside the CI image.",
)
@click.option(
"--extras",
default="",
help="Comma-separated dependency extras (e.g. 'ci,lint'). Empty for runtime only.",
)
@click.option(
"--gitea-host",
default="git.oblachno.oblachno.fyi",
show_default=True,
help="Gitea host for the PyPI registry.",
)
@click.option(
"--gitea-org",
default="oblachno-oss",
show_default=True,
help="Gitea org for the PyPI registry.",
)
def cli(
venv: str,
opt_venv: str,
extras: str,
gitea_host: str,
gitea_org: str,
) -> None:
"""Set up the project using a pre-built CI image venv."""
if Path(opt_venv).is_dir():
_install_in_image(venv, opt_venv, extras, gitea_host, gitea_org)
else:
_fallback_to_setup_ci()
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env python3
"""Set up SSH private key for CI jobs that need SSH access to remote hosts.
Writes the ``SSH_PRIVATE_KEY`` env var to ``~/.ssh/id_rsa``, starts
``ssh-agent``, and adds the key. Replaces the repeated inline shell
pattern in CI workflow files.
Usage::
python3 -m devx.tools.setup_ssh_key
Reads ``SSH_PRIVATE_KEY`` from the environment. Exits 0 on success,
1 on missing key.
"""
from __future__ import annotations
import os
import subprocess # nosec B404
import sys
from pathlib import Path
import click
from devx.i18n import _
def setup_ssh_key(private_key: str | None = None) -> bool:
"""Set up SSH private key and start ssh-agent.
Args:
private_key: The SSH private key content. If None, reads from
``SSH_PRIVATE_KEY`` environment variable.
Returns:
True if setup succeeded, False if key is missing.
"""
key = private_key or os.environ.get("SSH_PRIVATE_KEY", "")
if not key:
click.echo(_("SSH_PRIVATE_KEY not set — skipping SSH key setup"), err=True)
return False
ssh_dir = Path.home() / ".ssh"
ssh_dir.mkdir(parents=True, exist_ok=True)
key_path = ssh_dir / "id_rsa"
key_path.write_text(f"{key}\n", encoding="utf-8")
key_path.chmod(0o600)
# Start ssh-agent and add the key
agent_result = subprocess.run( # nosec B603, B607
["ssh-agent", "-s"],
capture_output=True,
text=True,
check=False,
)
if agent_result.returncode != 0:
click.echo(_("Failed to start ssh-agent: {error}", error=agent_result.stderr), err=True)
return False
# Parse ssh-agent output to set env vars
for raw_line in agent_result.stdout.splitlines():
stripped = raw_line.strip()
if "=" in stripped and ";" in stripped:
var, val = stripped.split("=", 1)
val = val.rstrip(";")
os.environ[var] = val
# Add the key (non-fatal if it fails — key may already be loaded)
subprocess.run( # nosec B603, B607
["ssh-add", str(key_path)],
capture_output=True,
text=True,
check=False,
)
return True
@click.command()
def cli() -> None:
"""Set up SSH private key from SSH_PRIVATE_KEY env var."""
if setup_ssh_key():
click.echo(_("SSH key set up successfully"))
sys.exit(0)
click.echo(_("SSH key setup skipped (no key provided)"), err=True)
sys.exit(1)
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""OpenTofu operations: init and validate across directories.
Handles initialization and validation of OpenTofu configurations across
multiple directories (modules + environments). Supports CI mode with
``-backend=false`` to avoid state backend access.
Usage::
python3 -m devx.tools.tofu_ops init --env staging
python3 -m devx.tools.tofu_ops validate
python3 -m devx.tools.tofu_ops validate --ci
"""
from __future__ import annotations
import subprocess # nosec B404
from pathlib import Path
import click
from devx.i18n import _
DEFAULT_ENV_DIRS = ["tofu/environments/{env}", "tofu/environments/dns"]
DEFAULT_VALIDATE_DIRS = [
"tofu/modules/hetzner-vm",
"tofu/modules/hetzner-network",
"tofu/environments/staging",
"tofu/environments/production",
"tofu/environments/dns",
]
def _run_tofu(cmd: list[str], cwd: Path) -> None:
"""Run a tofu command in the given directory, raising on failure."""
click.echo(_(" -> {dir}", dir=cwd))
result = subprocess.run( # nosec B603, B607
cmd,
cwd=str(cwd),
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
raise click.ClickException(
_("tofu command failed in {dir}: {error}", dir=cwd, error=result.stderr.strip()),
)
def tofu_init(env: str, root: str = ".", extra_dirs: list[str] | None = None) -> None:
"""Run ``tofu init`` in the environment directory and DNS directory.
Args:
env: Environment name (e.g. staging, production).
root: Repository root directory.
extra_dirs: Additional directory patterns to initialize.
"""
root_path = Path(root)
dirs = [d.format(env=env) for d in (extra_dirs or DEFAULT_ENV_DIRS)]
for dir_pattern in dirs:
dir_path = root_path / dir_pattern
if dir_path.is_dir():
click.echo(_("[tofu-init] Initializing {dir}...", dir=dir_path))
_run_tofu(["tofu", "init"], dir_path)
click.echo(_("[tofu-init] Done."))
def tofu_validate(
root: str = ".",
dirs: list[str] | None = None,
ci: bool = False,
) -> None:
"""Run ``tofu validate`` in all OpenTofu directories.
In CI mode, runs ``tofu init -backend=false`` before validate to avoid
state backend access.
Args:
root: Repository root directory.
dirs: List of directory paths to validate (relative to root).
ci: If True, use CI mode with -backend=false.
"""
root_path = Path(root)
target_dirs = dirs or DEFAULT_VALIDATE_DIRS
mode = "ci" if ci else "validate"
click.echo(_("[tofu-{mode}] Validating OpenTofu configurations...", mode=mode))
for dir_rel in target_dirs:
dir_path = root_path / dir_rel
if not dir_path.is_dir():
continue
if ci:
_run_tofu(["tofu", "init", "-backend=false", "-input=false"], dir_path)
_run_tofu(["tofu", "validate"], dir_path)
click.echo(_("[tofu-{mode}] All configurations valid.", mode=mode))
@click.group()
def cli() -> None:
"""OpenTofu operations."""
@cli.command()
@click.option("--env", required=True, help="Environment name (staging, production).")
@click.option("--root", default=".", help="Repository root directory.")
def init(env: str, root: str) -> None:
"""Initialize OpenTofu in an environment."""
tofu_init(env, root)
@cli.command()
@click.option("--root", default=".", help="Repository root directory.")
@click.option("--ci", is_flag=True, default=False, help="CI mode: use -backend=false.")
def validate(root: str, ci: bool) -> None:
"""Validate OpenTofu configurations."""
tofu_validate(root, ci=ci)
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+1380 -308
View File
@@ -55,6 +55,14 @@
"ru": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
"zh": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}"
},
"\nDone. Deleted {deleted}, kept {kept}, failed {failed}.": {
"bg": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
"de": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
"en": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
"pl": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
"ru": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
"zh": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}."
},
"\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.": {
"bg": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
"de": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
@@ -71,6 +79,14 @@
"ru": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
"zh": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report."
},
"\nGenerated {count} badges:": {
"bg": "\nGenerated {count} badges:",
"de": "\nGenerated {count} badges:",
"en": "\nGenerated {count} badges:",
"pl": "\nGenerated {count} badges:",
"ru": "\nGenerated {count} badges:",
"zh": "\nGenerated {count} badges:"
},
"\nIntegrity check FAILED ({count} issues):": {
"bg": "\nIntegrity check FAILED ({count} issues):",
"de": "\nIntegrity check FAILED ({count} issues):",
@@ -87,6 +103,14 @@
"ru": "\nIntegrity check passed — all {count} pages verified.",
"zh": "\nIntegrity check passed — all {count} pages verified."
},
"\nKeeping {kept}, would delete {count}": {
"bg": "\nKeeping {kept}, would delete {count}",
"de": "\nKeeping {kept}, would delete {count}",
"en": "\nKeeping {kept}, would delete {count}",
"pl": "\nKeeping {kept}, would delete {count}",
"ru": "\nKeeping {kept}, would delete {count}",
"zh": "\nKeeping {kept}, would delete {count}"
},
"\nLatest tag: {tag}": {
"bg": "\nLatest tag: {tag}",
"de": "\nLatest tag: {tag}",
@@ -119,6 +143,14 @@
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)."
},
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.": {
"bg": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"de": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"pl": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'."
},
"\nRunning full wiki integrity check...": {
"bg": "\nRunning full wiki integrity check...",
"de": "\nRunning full wiki integrity check...",
@@ -183,6 +215,14 @@
"ru": "\nWorkflow-only changes ({count}):",
"zh": "\nWorkflow-only changes ({count}):"
},
"\n[check_test_coverage] Fix: add the missing test file(s) before committing.": {
"bg": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
"de": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
"en": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
"pl": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
"ru": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
"zh": "\n[check_test_coverage] Fix: add the missing test file(s) before committing."
},
"\n[dry-run] Changelog:\n{changelog}": {
"bg": "\n[dry-run] Changelog:\n{changelog}",
"de": "\n[dry-run] Changelog:\n{changelog}",
@@ -199,6 +239,14 @@
"ru": "\n{label} files changed ({count}):",
"zh": "\n{label} files changed ({count}):"
},
"\n{separator}": {
"bg": "\n{separator}",
"de": "\n{separator}",
"en": "\n{separator}",
"pl": "\n{separator}",
"ru": "\n{separator}",
"zh": "\n{separator}"
},
"\n{tag} files ({count}):": {
"bg": "\n{tag} files ({count}):",
"de": "\n{tag} files ({count}):",
@@ -207,6 +255,38 @@
"ru": "\n{tag} files ({count}):",
"zh": "\n{tag} files ({count}):"
},
" Could not fetch logs: {error}": {
"bg": " Could not fetch logs: {error}",
"de": " Could not fetch logs: {error}",
"en": " Could not fetch logs: {error}",
"pl": " Could not fetch logs: {error}",
"ru": " Could not fetch logs: {error}",
"zh": " Could not fetch logs: {error}"
},
" pytest stderr (last 300 chars): {stderr}": {
"bg": " pytest stderr (last 300 chars): {stderr}",
"de": " pytest stderr (last 300 chars): {stderr}",
"en": " pytest stderr (last 300 chars): {stderr}",
"pl": " pytest stderr (last 300 chars): {stderr}",
"ru": " pytest stderr (last 300 chars): {stderr}",
"zh": " pytest stderr (last 300 chars): {stderr}"
},
" pytest stdout (last 300 chars): {stdout}": {
"bg": " pytest stdout (last 300 chars): {stdout}",
"de": " pytest stdout (last 300 chars): {stdout}",
"en": " pytest stdout (last 300 chars): {stdout}",
"pl": " pytest stdout (last 300 chars): {stdout}",
"ru": " pytest stdout (last 300 chars): {stdout}",
"zh": " pytest stdout (last 300 chars): {stdout}"
},
" stderr: {stderr}": {
"bg": " stderr: {stderr}",
"de": " stderr: {stderr}",
"en": " stderr: {stderr}",
"pl": " stderr: {stderr}",
"ru": " stderr: {stderr}",
"zh": " stderr: {stderr}"
},
" - Auto-delete branch after merge: yes": {
"bg": " - Автоматично изтриване на клон след сливане: да",
"de": " - Branch nach Merge automatisch löschen: ja",
@@ -215,6 +295,14 @@
"ru": " - Автоудаление ветки после слияния: да",
"zh": " - 合并后自动删除分支: 是"
},
" - Block admin merge override: yes": {
"bg": " - Блокиране на admin merge override: да",
"de": " - Admin-Merge-Override blockieren: ja",
"en": " - Block admin merge override: yes",
"pl": " - Blokuj admin merge override: tak",
"ru": " - Блокировать admin merge override: да",
"zh": " - 阻止管理员合并覆盖:是"
},
" - Block outdated branches: yes": {
"bg": " - Блокиране на остарели клонове: да",
"de": " - Veraltete Branches blockieren: ja",
@@ -263,6 +351,46 @@
"ru": " - Требуемые проверки статуса: {checks}",
"zh": " - 必需状态检查: {checks}"
},
" Auto-fixed trailing whitespace in {n} files": {
"bg": " Auto-fixed trailing whitespace in {n} files",
"de": " Auto-fixed trailing whitespace in {n} files",
"en": " Auto-fixed trailing whitespace in {n} files",
"pl": " Auto-fixed trailing whitespace in {n} files",
"ru": " Auto-fixed trailing whitespace in {n} files",
"zh": " Auto-fixed trailing whitespace in {n} files"
},
" Collecting code quality...": {
"bg": " Collecting code quality...",
"de": " Collecting code quality...",
"en": " Collecting code quality...",
"pl": " Collecting code quality...",
"ru": " Collecting code quality...",
"zh": " Collecting code quality..."
},
" Collecting coverage and tests...": {
"bg": " Collecting coverage and tests...",
"de": " Collecting coverage and tests...",
"en": " Collecting coverage and tests...",
"pl": " Collecting coverage and tests...",
"ru": " Collecting coverage and tests...",
"zh": " Collecting coverage and tests..."
},
" Collecting doc coverage...": {
"bg": " Collecting doc coverage...",
"de": " Collecting doc coverage...",
"en": " Collecting doc coverage...",
"pl": " Collecting doc coverage...",
"ru": " Collecting doc coverage...",
"zh": " Collecting doc coverage..."
},
" Collecting version...": {
"bg": " Collecting version...",
"de": " Collecting version...",
"en": " Collecting version...",
"pl": " Collecting version...",
"ru": " Collecting version...",
"zh": " Collecting version..."
},
" Created: {title}": {
"bg": " Created: {title}",
"de": " Created: {title}",
@@ -271,6 +399,14 @@
"ru": " Created: {title}",
"zh": " Created: {title}"
},
" Deleted: {version}": {
"bg": " Deleted: {version}",
"de": " Deleted: {version}",
"en": " Deleted: {version}",
"pl": " Deleted: {version}",
"ru": " Deleted: {version}",
"zh": " Deleted: {version}"
},
" FAIL: {title} — content mismatch or empty!": {
"bg": " FAIL: {title} — content mismatch or empty!",
"de": " FAIL: {title} — content mismatch or empty!",
@@ -279,13 +415,29 @@
"ru": " FAIL: {title} — content mismatch or empty!",
"zh": " FAIL: {title} — content mismatch or empty!"
},
" MISSING: devx {cmd}": {
"bg": " ЛИПСВА: devx {cmd}",
"de": " FEHLT: devx {cmd}",
"en": " MISSING: devx {cmd}",
"pl": " BRAK: devx {cmd}",
"ru": " ОТСУТСТВУЕТ: devx {cmd}",
"zh": " 缺失: devx {cmd}"
" FAILED to delete: {version}": {
"bg": " FAILED to delete: {version}",
"de": " FAILED to delete: {version}",
"en": " FAILED to delete: {version}",
"pl": " FAILED to delete: {version}",
"ru": " FAILED to delete: {version}",
"zh": " FAILED to delete: {version}"
},
" Generated: {path}": {
"bg": " Generated: {path}",
"de": " Generated: {path}",
"en": " Generated: {path}",
"pl": " Generated: {path}",
"ru": " Generated: {path}",
"zh": " Generated: {path}"
},
" MISSING: {cmd}": {
"bg": " MISSING: {cmd}",
"de": " MISSING: {cmd}",
"en": " MISSING: {cmd}",
"pl": " MISSING: {cmd}",
"ru": " MISSING: {cmd}",
"zh": " MISSING: {cmd}"
},
" MISSING: {module}": {
"bg": " MISSING: {module}",
@@ -303,13 +455,13 @@
"ru": " MISSING: {script}",
"zh": " MISSING: {script}"
},
" OK: devx {cmd}": {
"bg": " ОК: devx {cmd}",
"de": " OK: devx {cmd}",
"en": " OK: devx {cmd}",
"pl": " OK: devx {cmd}",
"ru": " ОК: devx {cmd}",
"zh": " 正常: devx {cmd}"
" OK: {cmd}": {
"bg": " OK: {cmd}",
"de": " OK: {cmd}",
"en": " OK: {cmd}",
"pl": " OK: {cmd}",
"ru": " OK: {cmd}",
"zh": " OK: {cmd}"
},
" OK: {module}": {
"bg": " OK: {module}",
@@ -335,6 +487,38 @@
"ru": " OK: {title} ({chars} chars)",
"zh": " OK: {title} ({chars} chars)"
},
" Package: {pkg}": {
"bg": " Package: {pkg}",
"de": " Package: {pkg}",
"en": " Package: {pkg}",
"pl": " Package: {pkg}",
"ru": " Package: {pkg}",
"zh": " Package: {pkg}"
},
" Quality checks: {checks}": {
"bg": " Quality checks: {checks}",
"de": " Quality checks: {checks}",
"en": " Quality checks: {checks}",
"pl": " Quality checks: {checks}",
"ru": " Quality checks: {checks}",
"zh": " Quality checks: {checks}"
},
" Repo root: {root}": {
"bg": " Repo root: {root}",
"de": " Repo root: {root}",
"en": " Repo root: {root}",
"pl": " Repo root: {root}",
"ru": " Repo root: {root}",
"zh": " Repo root: {root}"
},
" Test paths: {testpaths}": {
"bg": " Test paths: {testpaths}",
"de": " Test paths: {testpaths}",
"en": " Test paths: {testpaths}",
"pl": " Test paths: {testpaths}",
"ru": " Test paths: {testpaths}",
"zh": " Test paths: {testpaths}"
},
" Updated: {title}": {
"bg": " Updated: {title}",
"de": " Updated: {title}",
@@ -343,6 +527,126 @@
"ru": " Updated: {title}",
"zh": " Updated: {title}"
},
" WARNING: Could not extract coverage from pytest output (rc={rc})": {
"bg": " WARNING: Could not extract coverage from pytest output (rc={rc})",
"de": " WARNING: Could not extract coverage from pytest output (rc={rc})",
"en": " WARNING: Could not extract coverage from pytest output (rc={rc})",
"pl": " WARNING: Could not extract coverage from pytest output (rc={rc})",
"ru": " WARNING: Could not extract coverage from pytest output (rc={rc})",
"zh": " WARNING: Could not extract coverage from pytest output (rc={rc})"
},
" WARNING: Could not extract doc coverage (rc={rc})": {
"bg": " WARNING: Could not extract doc coverage (rc={rc})",
"de": " WARNING: Could not extract doc coverage (rc={rc})",
"en": " WARNING: Could not extract doc coverage (rc={rc})",
"pl": " WARNING: Could not extract doc coverage (rc={rc})",
"ru": " WARNING: Could not extract doc coverage (rc={rc})",
"zh": " WARNING: Could not extract doc coverage (rc={rc})"
},
" WARNING: Could not extract test count from pytest output (rc={rc})": {
"bg": " WARNING: Could not extract test count from pytest output (rc={rc})",
"de": " WARNING: Could not extract test count from pytest output (rc={rc})",
"en": " WARNING: Could not extract test count from pytest output (rc={rc})",
"pl": " WARNING: Could not extract test count from pytest output (rc={rc})",
"ru": " WARNING: Could not extract test count from pytest output (rc={rc})",
"zh": " WARNING: Could not extract test count from pytest output (rc={rc})"
},
" WARNING: No Python package found under src/ — version badge will show 'unknown'": {
"bg": " WARNING: No Python package found under src/ — version badge will show 'unknown'",
"de": " WARNING: No Python package found under src/ — version badge will show 'unknown'",
"en": " WARNING: No Python package found under src/ — version badge will show 'unknown'",
"pl": " WARNING: No Python package found under src/ — version badge will show 'unknown'",
"ru": " WARNING: No Python package found under src/ — version badge will show 'unknown'",
"zh": " WARNING: No Python package found under src/ — version badge will show 'unknown'"
},
" WARNING: No __version__ found in {init_file} — version badge will show 'unknown'": {
"bg": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'",
"de": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'",
"en": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'",
"pl": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'",
"ru": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'",
"zh": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'"
},
" WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)": {
"bg": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)",
"de": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)",
"en": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)",
"pl": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)",
"ru": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)",
"zh": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)"
},
" WARNING: {init_file} not found — version badge will show 'unknown'": {
"bg": " WARNING: {init_file} not found — version badge will show 'unknown'",
"de": " WARNING: {init_file} not found — version badge will show 'unknown'",
"en": " WARNING: {init_file} not found — version badge will show 'unknown'",
"pl": " WARNING: {init_file} not found — version badge will show 'unknown'",
"ru": " WARNING: {init_file} not found — version badge will show 'unknown'",
"zh": " WARNING: {init_file} not found — version badge will show 'unknown'"
},
" WARNING: {name} failed (rc={rc})": {
"bg": " WARNING: {name} failed (rc={rc})",
"de": " WARNING: {name} failed (rc={rc})",
"en": " WARNING: {name} failed (rc={rc})",
"pl": " WARNING: {name} failed (rc={rc})",
"ru": " WARNING: {name} failed (rc={rc})",
"zh": " WARNING: {name} failed (rc={rc})"
},
" WARNING: {name} not installed — skipping (counted as pass)": {
"bg": " WARNING: {name} not installed — skipping (counted as pass)",
"de": " WARNING: {name} not installed — skipping (counted as pass)",
"en": " WARNING: {name} not installed — skipping (counted as pass)",
"pl": " WARNING: {name} not installed — skipping (counted as pass)",
"ru": " WARNING: {name} not installed — skipping (counted as pass)",
"zh": " WARNING: {name} not installed — skipping (counted as pass)"
},
" [dry-run] Would delete: {version}": {
"bg": " [dry-run] Would delete: {version}",
"de": " [dry-run] Would delete: {version}",
"en": " [dry-run] Would delete: {version}",
"pl": " [dry-run] Would delete: {version}",
"ru": " [dry-run] Would delete: {version}",
"zh": " [dry-run] Would delete: {version}"
},
" {name}: {label}={message} ({color})": {
"bg": " {name}: {label}={message} ({color})",
"de": " {name}: {label}={message} ({color})",
"en": " {name}: {label}={message} ({color})",
"pl": " {name}: {label}={message} ({color})",
"ru": " {name}: {label}={message} ({color})",
"zh": " {name}: {label}={message} ({color})"
},
" {n} stale docs found (warnings only)": {
"bg": " {n} stale docs found (warnings only)",
"de": " {n} stale docs found (warnings only)",
"en": " {n} stale docs found (warnings only)",
"pl": " {n} stale docs found (warnings only)",
"ru": " {n} stale docs found (warnings only)",
"zh": " {n} stale docs found (warnings only)"
},
" {version} (created: {created})": {
"bg": " {version} (created: {created})",
"de": " {version} (created: {created})",
"en": " {version} (created: {created})",
"pl": " {version} (created: {created})",
"ru": " {version} (created: {created})",
"zh": " {version} (created: {created})"
},
"--checklist-categories must list at least 8 of 13 categories. Got {count}.": {
"bg": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"de": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"en": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"pl": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"ru": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"zh": "--checklist-categories must list at least 8 of 13 categories. Got {count}."
},
"--checklist-confirmed is required for APPROVE events.": {
"bg": "--checklist-confirmed is required for APPROVE events.",
"de": "--checklist-confirmed is required for APPROVE events.",
"en": "--checklist-confirmed is required for APPROVE events.",
"pl": "--checklist-confirmed is required for APPROVE events.",
"ru": "--checklist-confirmed is required for APPROVE events.",
"zh": "--checklist-confirmed is required for APPROVE events."
},
"--push requires --registry": {
"bg": "--push requires --registry",
"de": "--push requires --registry",
@@ -375,6 +679,14 @@
"ru": "API poll warning: {exc}",
"zh": "API poll warning: {exc}"
},
"Added label '{label}' to PR #{pr}.": {
"bg": "Added label '{label}' to PR #{pr}.",
"de": "Added label '{label}' to PR #{pr}.",
"en": "Added label '{label}' to PR #{pr}.",
"pl": "Added label '{label}' to PR #{pr}.",
"ru": "Added label '{label}' to PR #{pr}.",
"zh": "Added label '{label}' to PR #{pr}."
},
"Additional directory to scan (default: scripts, tests). Can be repeated.": {
"bg": "Additional directory to scan (default: scripts, tests). Can be repeated.",
"de": "Additional directory to scan (default: scripts, tests). Can be repeated.",
@@ -399,6 +711,54 @@
"ru": "Another molecule runner failed. Stopping this runner early.",
"zh": "Another molecule runner failed. Stopping this runner early."
},
"Assigned {count} files to runner {runner_index}": {
"bg": "Assigned {count} files to runner {runner_index}",
"de": "Assigned {count} files to runner {runner_index}",
"en": "Assigned {count} files to runner {runner_index}",
"pl": "Assigned {count} files to runner {runner_index}",
"ru": "Assigned {count} files to runner {runner_index}",
"zh": "Assigned {count} files to runner {runner_index}"
},
"Assigned {count} items to runner {runner_index}: {encoded}": {
"bg": "Assigned {count} items to runner {runner_index}: {encoded}",
"de": "Assigned {count} items to runner {runner_index}: {encoded}",
"en": "Assigned {count} items to runner {runner_index}: {encoded}",
"pl": "Assigned {count} items to runner {runner_index}: {encoded}",
"ru": "Assigned {count} items to runner {runner_index}: {encoded}",
"zh": "Assigned {count} items to runner {runner_index}: {encoded}"
},
"Badge push attempt {attempt}/{retries} failed — retrying: {error}": {
"bg": "Badge push attempt {attempt}/{retries} failed — retrying: {error}",
"de": "Badge push attempt {attempt}/{retries} failed — retrying: {error}",
"en": "Badge push attempt {attempt}/{retries} failed — retrying: {error}",
"pl": "Badge push attempt {attempt}/{retries} failed — retrying: {error}",
"ru": "Badge push attempt {attempt}/{retries} failed — retrying: {error}",
"zh": "Badge push attempt {attempt}/{retries} failed — retrying: {error}"
},
"Badge push failed after {retries} attempts: {error}": {
"bg": "Badge push failed after {retries} attempts: {error}",
"de": "Badge push failed after {retries} attempts: {error}",
"en": "Badge push failed after {retries} attempts: {error}",
"pl": "Badge push failed after {retries} attempts: {error}",
"ru": "Badge push failed after {retries} attempts: {error}",
"zh": "Badge push failed after {retries} attempts: {error}"
},
"Badges commit SHA: {sha}": {
"bg": "Badges commit SHA: {sha}",
"de": "Badges commit SHA: {sha}",
"en": "Badges commit SHA: {sha}",
"pl": "Badges commit SHA: {sha}",
"ru": "Badges commit SHA: {sha}",
"zh": "Badges commit SHA: {sha}"
},
"Badges pushed to badges branch": {
"bg": "Badges pushed to badges branch",
"de": "Badges pushed to badges branch",
"en": "Badges pushed to badges branch",
"pl": "Badges pushed to badges branch",
"ru": "Badges pushed to badges branch",
"zh": "Badges pushed to badges branch"
},
"Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description": {
"bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание",
"de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung",
@@ -415,14 +775,6 @@
"ru": "Ветка '{branch}' не содержит ID задачи.\n Ожидаемый формат: {prefix}-N-краткое-описание\n Пример: {prefix}-42-add-feature\n Исправление: переименуйте ветку или создайте задачу Vikunja:\n python -m devx.tools.create_task --title \"Заголовок задачи\"",
"zh": "分支 '{branch}' 不包含任务 ID。\n 预期格式: {prefix}-N-简短描述\n 示例: {prefix}-42-add-feature\n 修复: 重命名分支或先创建 Vikunja 任务:\n python -m devx.tools.create_task --title \"任务标题\""
},
"Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.": {
"bg": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"de": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"en": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"pl": "Gałąź jest w tyle za master. Wykonaj rebase ręcznie:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nNastępnie dodaj ponownie etykietę ready-to-merge.",
"ru": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"zh": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label."
},
"Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master": {
"bg": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
"de": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
@@ -463,6 +815,54 @@
"ru": "Bumping version: {current} -> v{new_version}",
"zh": "Bumping version: {current} -> v{new_version}"
},
"CI checks did not complete within timeout.": {
"bg": "CI checks did not complete within timeout.",
"de": "CI checks did not complete within timeout.",
"en": "CI checks did not complete within timeout.",
"pl": "CI checks did not complete within timeout.",
"ru": "CI checks did not complete within timeout.",
"zh": "CI checks did not complete within timeout."
},
"CI checks failed.": {
"bg": "CI checks failed.",
"de": "CI checks failed.",
"en": "CI checks failed.",
"pl": "CI checks failed.",
"ru": "CI checks failed.",
"zh": "CI checks failed."
},
"CI_GITEA_TOKEN environment variable required": {
"bg": "CI_GITEA_TOKEN environment variable required",
"de": "CI_GITEA_TOKEN environment variable required",
"en": "CI_GITEA_TOKEN environment variable required",
"pl": "CI_GITEA_TOKEN environment variable required",
"ru": "CI_GITEA_TOKEN environment variable required",
"zh": "CI_GITEA_TOKEN environment variable required"
},
"CI_GITEA_TOKEN is not set.": {
"bg": "CI_GITEA_TOKEN is not set.",
"de": "CI_GITEA_TOKEN is not set.",
"en": "CI_GITEA_TOKEN is not set.",
"pl": "CI_GITEA_TOKEN is not set.",
"ru": "CI_GITEA_TOKEN is not set.",
"zh": "CI_GITEA_TOKEN is not set."
},
"CI_GITEA_TOKEN is not set. Required to create a PR.": {
"bg": "CI_GITEA_TOKEN не е зададен. Необходим за създаване на PR.",
"de": "CI_GITEA_TOKEN nicht gesetzt. Erforderlich zum Erstellen eines PR.",
"en": "CI_GITEA_TOKEN is not set. Required to create a PR.",
"pl": "CI_GITEA_TOKEN nie jest ustawiony. Wymagany do utworzenia PR.",
"ru": "CI_GITEA_TOKEN не установлен. Требуется для создания PR.",
"zh": "CI_GITEA_TOKEN 未设置。创建 PR 所需。"
},
"CI_GITEA_TOKEN not set — skipping login configuration.": {
"bg": "CI_GITEA_TOKEN not set — skipping login configuration.",
"de": "CI_GITEA_TOKEN not set — skipping login configuration.",
"en": "CI_GITEA_TOKEN not set — skipping login configuration.",
"pl": "CI_GITEA_TOKEN not set — skipping login configuration.",
"ru": "CI_GITEA_TOKEN not set — skipping login configuration.",
"zh": "CI_GITEA_TOKEN not set — skipping login configuration."
},
"Checking CLI command documentation...": {
"bg": "Checking CLI command documentation...",
"de": "Checking CLI command documentation...",
@@ -471,6 +871,78 @@
"ru": "Checking CLI command documentation...",
"zh": "Checking CLI command documentation..."
},
"Checking docs structure...": {
"bg": "Checking docs structure...",
"de": "Checking docs structure...",
"en": "Checking docs structure...",
"pl": "Checking docs structure...",
"ru": "Checking docs structure...",
"zh": "Checking docs structure..."
},
"Checking duplicate headings...": {
"bg": "Checking duplicate headings...",
"de": "Checking duplicate headings...",
"en": "Checking duplicate headings...",
"pl": "Checking duplicate headings...",
"ru": "Checking duplicate headings...",
"zh": "Checking duplicate headings..."
},
"Checking for TODO/FIXME markers...": {
"bg": "Checking for TODO/FIXME markers...",
"de": "Checking for TODO/FIXME markers...",
"en": "Checking for TODO/FIXME markers...",
"pl": "Checking for TODO/FIXME markers...",
"ru": "Checking for TODO/FIXME markers...",
"zh": "Checking for TODO/FIXME markers..."
},
"Checking for stale docs...": {
"bg": "Checking for stale docs...",
"de": "Checking for stale docs...",
"en": "Checking for stale docs...",
"pl": "Checking for stale docs...",
"ru": "Checking for stale docs...",
"zh": "Checking for stale docs..."
},
"Checking heading hierarchy...": {
"bg": "Checking heading hierarchy...",
"de": "Checking heading hierarchy...",
"en": "Checking heading hierarchy...",
"pl": "Checking heading hierarchy...",
"ru": "Checking heading hierarchy...",
"zh": "Checking heading hierarchy..."
},
"Checking internal links...": {
"bg": "Checking internal links...",
"de": "Checking internal links...",
"en": "Checking internal links...",
"pl": "Checking internal links...",
"ru": "Checking internal links...",
"zh": "Checking internal links..."
},
"Checking required files...": {
"bg": "Checking required files...",
"de": "Checking required files...",
"en": "Checking required files...",
"pl": "Checking required files...",
"ru": "Checking required files...",
"zh": "Checking required files..."
},
"Checking status for PR #{pr_number}...": {
"bg": "Checking status for PR #{pr_number}...",
"de": "Checking status for PR #{pr_number}...",
"en": "Checking status for PR #{pr_number}...",
"pl": "Checking status for PR #{pr_number}...",
"ru": "Checking status for PR #{pr_number}...",
"zh": "Checking status for PR #{pr_number}..."
},
"Checking trailing whitespace...": {
"bg": "Checking trailing whitespace...",
"de": "Checking trailing whitespace...",
"en": "Checking trailing whitespace...",
"pl": "Checking trailing whitespace...",
"ru": "Checking trailing whitespace...",
"zh": "Checking trailing whitespace..."
},
"Command failed ({cmd}): {stderr}": {
"bg": "Command failed ({cmd}): {stderr}",
"de": "Command failed ({cmd}): {stderr}",
@@ -479,6 +951,22 @@
"ru": "Command failed ({cmd}): {stderr}",
"zh": "Command failed ({cmd}): {stderr}"
},
"Commit message: {msg}": {
"bg": "Commit message: {msg}",
"de": "Commit message: {msg}",
"en": "Commit message: {msg}",
"pl": "Commit message: {msg}",
"ru": "Commit message: {msg}",
"zh": "Commit message: {msg}"
},
"Commit: {sha}": {
"bg": "Commit: {sha}",
"de": "Commit: {sha}",
"en": "Commit: {sha}",
"pl": "Commit: {sha}",
"ru": "Commit: {sha}",
"zh": "Commit: {sha}"
},
"Comparing {base}..{head} ({count} files changed)": {
"bg": "Comparing {base}..{head} ({count} files changed)",
"de": "Comparing {base}..{head} ({count} files changed)",
@@ -495,6 +983,14 @@
"ru": "Конфигурация OK: [tool.devx] присутствует, версии devx согласованы.",
"zh": "配置正常: [tool.devx] 已存在, devx 版本一致。"
},
"Configuration validation failed.": {
"bg": "Configuration validation failed.",
"de": "Configuration validation failed.",
"en": "Configuration validation failed.",
"pl": "Configuration validation failed.",
"ru": "Configuration validation failed.",
"zh": "Configuration validation failed."
},
"Configuring branch protection for {branch}...": {
"bg": "Конфигуриране на защита на клона {branch}...",
"de": "Konfiguriere Branch-Schutz für {branch}...",
@@ -511,6 +1007,14 @@
"ru": "Настройка параметров репозитория...",
"zh": "正在配置仓库设置..."
},
"Configuring tea login '{name}' for {url}...": {
"bg": "Configuring tea login '{name}' for {url}...",
"de": "Configuring tea login '{name}' for {url}...",
"en": "Configuring tea login '{name}' for {url}...",
"pl": "Configuring tea login '{name}' for {url}...",
"ru": "Configuring tea login '{name}' for {url}...",
"zh": "Configuring tea login '{name}' for {url}..."
},
"Could not detect current branch: {error}": {
"bg": "Не може да се определи текущия клон: {error}",
"de": "Aktueller Branch konnte nicht erkannt werden: {error}",
@@ -519,6 +1023,14 @@
"ru": "Не удалось определить текущую ветку: {error}",
"zh": "无法检测当前分支: {error}"
},
"Could not determine head SHA for PR #{pr_number}.": {
"bg": "Could not determine head SHA for PR #{pr_number}.",
"de": "Could not determine head SHA for PR #{pr_number}.",
"en": "Could not determine head SHA for PR #{pr_number}.",
"pl": "Could not determine head SHA for PR #{pr_number}.",
"ru": "Could not determine head SHA for PR #{pr_number}.",
"zh": "Could not determine head SHA for PR #{pr_number}."
},
"Could not extract conventional commit message from PR commits.": {
"bg": "Could not extract conventional commit message from PR commits.",
"de": "Could not extract conventional commit message from PR commits.",
@@ -639,14 +1151,6 @@
"ru": "Dockerfile not found: {path}",
"zh": "Dockerfile not found: {path}"
},
"Each item must be a string or an object with 'id', got {type}": {
"bg": "Всеки елемент трябва да е низ или обект с 'id', получено {type}",
"de": "Jedes Element muss ein String oder ein Objekt mit 'id' sein, erhalten {type}",
"en": "Each item must be a string or an object with 'id', got {type}",
"pl": "Każdy element musi być ciągiem lub obiektem z 'id', otrzymano {type}",
"ru": "Каждый элемент должен быть строкой или объектом с 'id', получено {type}",
"zh": "每个元素必须是字符串或带有 'id' 的对象,得到 {type}"
},
"Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.": {
"bg": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
"de": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
@@ -695,6 +1199,22 @@
"ru": "ERROR: mapping.json not found at {path}",
"zh": "ERROR: mapping.json not found at {path}"
},
"Each item must be a string or an object with 'id', got {type}": {
"bg": "Всеки елемент трябва да е низ или обект с 'id', получено {type}",
"de": "Jedes Element muss ein String oder ein Objekt mit 'id' sein, erhalten {type}",
"en": "Each item must be a string or an object with 'id', got {type}",
"pl": "Każdy element musi być ciągiem lub obiektem z 'id', otrzymano {type}",
"ru": "Каждый элемент должен быть строкой или объектом с 'id', получено {type}",
"zh": "每个元素必须是字符串或带有 'id' 的对象,得到 {type}"
},
"FAIL: {n} documentation issues found:": {
"bg": "FAIL: {n} documentation issues found:",
"de": "FAIL: {n} documentation issues found:",
"en": "FAIL: {n} documentation issues found:",
"pl": "FAIL: {n} documentation issues found:",
"ru": "FAIL: {n} documentation issues found:",
"zh": "FAIL: {n} documentation issues found:"
},
"FAILED: {count} undocumented dependency/ies": {
"bg": "FAILED: {count} undocumented dependency/ies",
"de": "FAILED: {count} undocumented dependency/ies",
@@ -727,6 +1247,14 @@
"ru": "Failed to create issue via tea: {error}",
"zh": "Failed to create issue via tea: {error}"
},
"Failed to delete {count} image version(s)": {
"bg": "Failed to delete {count} image version(s)",
"de": "Failed to delete {count} image version(s)",
"en": "Failed to delete {count} image version(s)",
"pl": "Failed to delete {count} image version(s)",
"ru": "Failed to delete {count} image version(s)",
"zh": "Failed to delete {count} image version(s)"
},
"Failed to list versions for {name}: {error}": {
"bg": "Failed to list versions for {name}: {error}",
"de": "Failed to list versions for {name}: {error}",
@@ -735,6 +1263,14 @@
"ru": "Failed to list versions for {name}: {error}",
"zh": "Failed to list versions for {name}: {error}"
},
"Fetching logs for PR #{pr_number}...": {
"bg": "Fetching logs for PR #{pr_number}...",
"de": "Fetching logs for PR #{pr_number}...",
"en": "Fetching logs for PR #{pr_number}...",
"pl": "Fetching logs for PR #{pr_number}...",
"ru": "Fetching logs for PR #{pr_number}...",
"zh": "Fetching logs for PR #{pr_number}..."
},
"Found {count} existing wiki pages.": {
"bg": "Found {count} existing wiki pages.",
"de": "Found {count} existing wiki pages.",
@@ -759,6 +1295,14 @@
"ru": "Found {count} stale documentation reference(s)",
"zh": "Found {count} stale documentation reference(s)"
},
"Found {count} version(s):": {
"bg": "Found {count} version(s):",
"de": "Found {count} version(s):",
"en": "Found {count} version(s):",
"pl": "Found {count} version(s):",
"ru": "Found {count} version(s):",
"zh": "Found {count} version(s):"
},
"GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.": {
"bg": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"de": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
@@ -767,6 +1311,14 @@
"ru": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
"zh": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation."
},
"Generated {count} badge files": {
"bg": "Generated {count} badge files",
"de": "Generated {count} badge files",
"en": "Generated {count} badge files",
"pl": "Generated {count} badge files",
"ru": "Generated {count} badge files",
"zh": "Generated {count} badge files"
},
"Generated {file} with prefix '{prefix}'.": {
"bg": "Generated {file} with prefix '{prefix}'.",
"de": "Generated {file} with prefix '{prefix}'.",
@@ -775,6 +1327,14 @@
"ru": "Generated {file} with prefix '{prefix}'.",
"zh": "Generated {file} with prefix '{prefix}'."
},
"Generating badges in {out}...": {
"bg": "Generating badges in {out}...",
"de": "Generating badges in {out}...",
"en": "Generating badges in {out}...",
"pl": "Generating badges in {out}...",
"ru": "Generating badges in {out}...",
"zh": "Generating badges in {out}..."
},
"Gitea PyPI registry: {tag} already published — continuing.": {
"bg": "Gitea PyPI registry: {tag} вече е публикуван — продължава.",
"de": "Gitea PyPI-Registry: {tag} bereits veröffentlicht — wird fortgesetzt.",
@@ -903,6 +1463,38 @@
"ru": "Integration tests passed.",
"zh": "Integration tests passed."
},
"Invalid checklist category: {cat}. Must be numbers.": {
"bg": "Invalid checklist category: {cat}. Must be numbers.",
"de": "Invalid checklist category: {cat}. Must be numbers.",
"en": "Invalid checklist category: {cat}. Must be numbers.",
"pl": "Invalid checklist category: {cat}. Must be numbers.",
"ru": "Invalid checklist category: {cat}. Must be numbers.",
"zh": "Invalid checklist category: {cat}. Must be numbers."
},
"Items input must be a JSON array, got {type}": {
"bg": "Входните данни трябва да са JSON масив, получено {type}",
"de": "Eingabe muss ein JSON-Array sein, erhalten {type}",
"en": "Items input must be a JSON array, got {type}",
"pl": "Dane wejściowe muszą być tablicą JSON, otrzymano {type}",
"ru": "Входные данные должны быть JSON-массивом, получено {type}",
"zh": "输入必须是 JSON 数组,得到 {type}"
},
"Label '{label}' already on PR #{pr}.": {
"bg": "Label '{label}' already on PR #{pr}.",
"de": "Label '{label}' already on PR #{pr}.",
"en": "Label '{label}' already on PR #{pr}.",
"pl": "Label '{label}' already on PR #{pr}.",
"ru": "Label '{label}' already on PR #{pr}.",
"zh": "Label '{label}' already on PR #{pr}."
},
"Latest run: #{run_id} (status: {status})": {
"bg": "Latest run: #{run_id} (status: {status})",
"de": "Latest run: #{run_id} (status: {status})",
"en": "Latest run: #{run_id} (status: {status})",
"pl": "Latest run: #{run_id} (status: {status})",
"ru": "Latest run: #{run_id} (status: {status})",
"zh": "Latest run: #{run_id} (status: {status})"
},
"Lint failed — refusing to release. Fix lint errors first.\n{stderr}": {
"bg": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
"de": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
@@ -919,6 +1511,14 @@
"ru": "Lint passed.",
"zh": "Lint passed."
},
"Linting documentation in {root}...": {
"bg": "Linting documentation in {root}...",
"de": "Linting documentation in {root}...",
"en": "Linting documentation in {root}...",
"pl": "Linting documentation in {root}...",
"ru": "Linting documentation in {root}...",
"zh": "Linting documentation in {root}..."
},
"Manifest file not found: {path}": {
"bg": "Manifest file not found: {path}",
"de": "Manifest file not found: {path}",
@@ -959,6 +1559,14 @@
"ru": "Слияние не удалось: HTTP {status}: {message}\nПроверьте, что PR готов и у вас есть права на слияние.",
"zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。"
},
"Missing tests for changed files.": {
"bg": "Missing tests for changed files.",
"de": "Missing tests for changed files.",
"en": "Missing tests for changed files.",
"pl": "Missing tests for changed files.",
"ru": "Missing tests for changed files.",
"zh": "Missing tests for changed files."
},
"Module {mod} has no main() function": {
"bg": "Модул {mod} няма функция main()",
"de": "Modul {mod} hat keine main()-Funktion",
@@ -1015,6 +1623,30 @@
"ru": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) обновлена и отмечена как выполненная.",
"zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。"
},
"No CI checks found for commit {sha}.": {
"bg": "No CI checks found for commit {sha}.",
"de": "No CI checks found for commit {sha}.",
"en": "No CI checks found for commit {sha}.",
"pl": "No CI checks found for commit {sha}.",
"ru": "No CI checks found for commit {sha}.",
"zh": "No CI checks found for commit {sha}."
},
"No badge SVG files generated": {
"bg": "No badge SVG files generated",
"de": "No badge SVG files generated",
"en": "No badge SVG files generated",
"pl": "No badge SVG files generated",
"ru": "No badge SVG files generated",
"zh": "No badge SVG files generated"
},
"No badge URLs found to update — README already up to date": {
"bg": "No badge URLs found to update — README already up to date",
"de": "No badge URLs found to update — README already up to date",
"en": "No badge URLs found to update — README already up to date",
"pl": "No badge URLs found to update — README already up to date",
"ru": "No badge URLs found to update — README already up to date",
"zh": "No badge URLs found to update — README already up to date"
},
"No changes between {base} and {head}.": {
"bg": "No changes between {base} and {head}.",
"de": "No changes between {base} and {head}.",
@@ -1023,6 +1655,38 @@
"ru": "No changes between {base} and {head}.",
"zh": "No changes between {base} and {head}."
},
"No failed jobs.": {
"bg": "No failed jobs.",
"de": "No failed jobs.",
"en": "No failed jobs.",
"pl": "No failed jobs.",
"ru": "No failed jobs.",
"zh": "No failed jobs."
},
"No job matching '{job}' found.": {
"bg": "No job matching '{job}' found.",
"de": "No job matching '{job}' found.",
"en": "No job matching '{job}' found.",
"pl": "No job matching '{job}' found.",
"ru": "No job matching '{job}' found.",
"zh": "No job matching '{job}' found."
},
"No jobs found for run #{run_id}.": {
"bg": "No jobs found for run #{run_id}.",
"de": "No jobs found for run #{run_id}.",
"en": "No jobs found for run #{run_id}.",
"pl": "No jobs found for run #{run_id}.",
"ru": "No jobs found for run #{run_id}.",
"zh": "No jobs found for run #{run_id}."
},
"No open PR found for branch '{branch}'.": {
"bg": "No open PR found for branch '{branch}'.",
"de": "No open PR found for branch '{branch}'.",
"en": "No open PR found for branch '{branch}'.",
"pl": "No open PR found for branch '{branch}'.",
"ru": "No open PR found for branch '{branch}'.",
"zh": "No open PR found for branch '{branch}'."
},
"No staged changes — version and changelog already up to date.": {
"bg": "No staged changes — version and changelog already up to date.",
"de": "No staged changes — version and changelog already up to date.",
@@ -1087,6 +1751,14 @@
"ru": "No versions found.",
"zh": "No versions found."
},
"No workflow runs found for SHA {sha}.": {
"bg": "No workflow runs found for SHA {sha}.",
"de": "No workflow runs found for SHA {sha}.",
"en": "No workflow runs found for SHA {sha}.",
"pl": "No workflow runs found for SHA {sha}.",
"ru": "No workflow runs found for SHA {sha}.",
"zh": "No workflow runs found for SHA {sha}."
},
"Note: Self-approval not allowed. Posting COMMENT instead.": {
"bg": "Note: Self-approval not allowed. Posting COMMENT instead.",
"de": "Note: Self-approval not allowed. Posting COMMENT instead.",
@@ -1183,6 +1855,14 @@
"ru": "Ой! Публикация в PyPI не удалась:\n{stderr}",
"zh": "哎呀!PyPI 发布失败:\n{stderr}"
},
"PASS: All documentation checks passed!": {
"bg": "PASS: All documentation checks passed!",
"de": "PASS: All documentation checks passed!",
"en": "PASS: All documentation checks passed!",
"pl": "PASS: All documentation checks passed!",
"ru": "PASS: All documentation checks passed!",
"zh": "PASS: All documentation checks passed!"
},
"PASSED: {pair}": {
"bg": "PASSED: {pair}",
"de": "PASSED: {pair}",
@@ -1263,6 +1943,22 @@
"ru": "PYPI_TOKEN не задан и URL registry не настроен — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.",
"zh": "未设置 PYPI_TOKEN 且未配置 registry URL — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。"
},
"Package owner not specified. Use --owner or set [tool.devx] repo_owner.": {
"bg": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
"de": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
"en": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
"pl": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
"ru": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
"zh": "Package owner not specified. Use --owner or set [tool.devx] repo_owner."
},
"Package: {owner}/{name}": {
"bg": "Package: {owner}/{name}",
"de": "Package: {owner}/{name}",
"en": "Package: {owner}/{name}",
"pl": "Package: {owner}/{name}",
"ru": "Package: {owner}/{name}",
"zh": "Package: {owner}/{name}"
},
"Parsed owner={owner}, repo={repo} from DEVX_REPO_NAME": {
"bg": "Разбор на owner={owner}, repo={repo} от DEVX_REPO_NAME",
"de": "Owner={owner}, repo={repo} aus DEVX_REPO_NAME analysiert",
@@ -1359,6 +2055,14 @@
"ru": "Push failed for {tag}: {error}",
"zh": "Push failed for {tag}: {error}"
},
"Pushed README update with badge SHA {sha}": {
"bg": "Pushed README update with badge SHA {sha}",
"de": "Pushed README update with badge SHA {sha}",
"en": "Pushed README update with badge SHA {sha}",
"pl": "Pushed README update with badge SHA {sha}",
"ru": "Pushed README update with badge SHA {sha}",
"zh": "Pushed README update with badge SHA {sha}"
},
"Pushed release commit to master.": {
"bg": "Pushed release commit to master.",
"de": "Pushed release commit to master.",
@@ -1383,30 +2087,6 @@
"ru": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
"zh": "REPO argument is required (or set GITHUB_REPOSITORY env var)."
},
"CI_GITEA_TOKEN environment variable required": {
"bg": "CI_GITEA_TOKEN environment variable required",
"de": "CI_GITEA_TOKEN environment variable required",
"en": "CI_GITEA_TOKEN environment variable required",
"pl": "CI_GITEA_TOKEN environment variable required",
"ru": "CI_GITEA_TOKEN environment variable required",
"zh": "CI_GITEA_TOKEN environment variable required"
},
"Failed to delete {count} image version(s)": {
"bg": "Failed to delete {count} image version(s)",
"de": "Failed to delete {count} image version(s)",
"en": "Failed to delete {count} image version(s)",
"pl": "Failed to delete {count} image version(s)",
"ru": "Failed to delete {count} image version(s)",
"zh": "Failed to delete {count} image version(s)"
},
"CI_GITEA_TOKEN is not set. Required to create a PR.": {
"bg": "CI_GITEA_TOKEN не е зададен. Необходим за създаване на PR.",
"de": "CI_GITEA_TOKEN nicht gesetzt. Erforderlich zum Erstellen eines PR.",
"en": "CI_GITEA_TOKEN is not set. Required to create a PR.",
"pl": "CI_GITEA_TOKEN nie jest ustawiony. Wymagany do utworzenia PR.",
"ru": "CI_GITEA_TOKEN не установлен. Требуется для создания PR.",
"zh": "CI_GITEA_TOKEN 未设置。创建 PR 所需。"
},
"Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars": {
"bg": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars",
"de": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars",
@@ -1431,6 +2111,30 @@
"ru": "Registry login failed: {error}",
"zh": "Registry login failed: {error}"
},
"Regular merge commit — running all post-merge jobs.": {
"bg": "Regular merge commit — running all post-merge jobs.",
"de": "Regular merge commit — running all post-merge jobs.",
"en": "Regular merge commit — running all post-merge jobs.",
"pl": "Regular merge commit — running all post-merge jobs.",
"ru": "Regular merge commit — running all post-merge jobs.",
"zh": "Regular merge commit — running all post-merge jobs."
},
"Release commit — skipping all post-merge jobs.": {
"bg": "Release commit — skipping all post-merge jobs.",
"de": "Release commit — skipping all post-merge jobs.",
"en": "Release commit — skipping all post-merge jobs.",
"pl": "Release commit — skipping all post-merge jobs.",
"ru": "Release commit — skipping all post-merge jobs.",
"zh": "Release commit — skipping all post-merge jobs."
},
"Automated CI commit (badge) — skipping post-merge jobs.": {
"bg": "Automated CI commit (badge) — skipping post-merge jobs.",
"de": "Automated CI commit (badge) — skipping post-merge jobs.",
"en": "Automated CI commit (badge) — skipping post-merge jobs.",
"pl": "Automated CI commit (badge) — skipping post-merge jobs.",
"ru": "Automated CI commit (badge) — skipping post-merge jobs.",
"zh": "Automated CI commit (badge) — skipping post-merge jobs."
},
"Release creation failed: {error}": {
"bg": "Release creation failed: {error}",
"de": "Release creation failed: {error}",
@@ -1471,6 +2175,14 @@
"ru": "Repository in owner/name format",
"zh": "Repository in owner/name format"
},
"Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.": {
"bg": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
"de": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
"en": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
"pl": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
"ru": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
"zh": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var."
},
"Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.": {
"bg": "Собственикът на хранилището не е зададен. Използвайте --owner или DEVX_REPO_OWNER env var.",
"de": "Repository-Owner nicht gesetzt. Verwende --owner oder DEVX_REPO_OWNER env var.",
@@ -1479,6 +2191,14 @@
"ru": "Владелец репозитория не установлен. Используйте --owner или DEVX_REPO_OWNER env var.",
"zh": "仓库所有者未设置。使用 --owner 或 DEVX_REPO_OWNER 环境变量。"
},
"Review body must be at least 50 characters.": {
"bg": "Review body must be at least 50 characters.",
"de": "Review body must be at least 50 characters.",
"en": "Review body must be at least 50 characters.",
"pl": "Review body must be at least 50 characters.",
"ru": "Review body must be at least 50 characters.",
"zh": "Review body must be at least 50 characters."
},
"Roles directory not found: {path}": {
"bg": "Roles directory not found: {path}",
"de": "Roles directory not found: {path}",
@@ -1487,6 +2207,14 @@
"ru": "Roles directory not found: {path}",
"zh": "Roles directory not found: {path}"
},
"Runner count: {count}": {
"bg": "Runner count: {count}",
"de": "Runner count: {count}",
"en": "Runner count: {count}",
"pl": "Runner count: {count}",
"ru": "Runner count: {count}",
"zh": "Runner count: {count}"
},
"Runner index {index} out of range (0..{max})": {
"bg": "Индексът на runner {index} е извън диапазона (0..{max})",
"de": "Runner-Index {index} außerhalb des Bereichs (0..{max})",
@@ -1495,6 +2223,30 @@
"ru": "Индекс runner {index} вне диапазона (0..{max})",
"zh": "Runner 索引 {index} 超出范围 (0..{max})"
},
"Runner index {runner_index} is out of range (must be >= 1)": {
"bg": "Runner index {runner_index} is out of range (must be >= 1)",
"de": "Runner index {runner_index} is out of range (must be >= 1)",
"en": "Runner index {runner_index} is out of range (must be >= 1)",
"pl": "Runner index {runner_index} is out of range (must be >= 1)",
"ru": "Runner index {runner_index} is out of range (must be >= 1)",
"zh": "Runner index {runner_index} is out of range (must be >= 1)"
},
"Runner indices: {indices}": {
"bg": "Runner indices: {indices}",
"de": "Runner indices: {indices}",
"en": "Runner indices: {indices}",
"pl": "Runner indices: {indices}",
"ru": "Runner indices: {indices}",
"zh": "Runner indices: {indices}"
},
"Runner {i}: {labels}": {
"bg": "Runner {i}: {labels}",
"de": "Runner {i}: {labels}",
"en": "Runner {i}: {labels}",
"pl": "Runner {i}: {labels}",
"ru": "Runner {i}: {labels}",
"zh": "Runner {i}: {labels}"
},
"Running lint checks...": {
"bg": "Running lint checks...",
"de": "Running lint checks...",
@@ -1511,6 +2263,14 @@
"ru": "Running tests...",
"zh": "Running tests..."
},
"Running: {cmd}": {
"bg": "Running: {cmd}",
"de": "Running: {cmd}",
"en": "Running: {cmd}",
"pl": "Running: {cmd}",
"ru": "Running: {cmd}",
"zh": "Running: {cmd}"
},
"Running: {scenario} on {platform}": {
"bg": "Running: {scenario} on {platform}",
"de": "Running: {scenario} on {platform}",
@@ -1543,6 +2303,22 @@
"ru": "Skipping commit push — no staged changes.",
"zh": "Skipping commit push — no staged changes."
},
"Skipping — runner index {runner_index} > max runners {max_runners}": {
"bg": "Skipping — runner index {runner_index} > max runners {max_runners}",
"de": "Skipping — runner index {runner_index} > max runners {max_runners}",
"en": "Skipping — runner index {runner_index} > max runners {max_runners}",
"pl": "Skipping — runner index {runner_index} > max runners {max_runners}",
"ru": "Skipping — runner index {runner_index} > max runners {max_runners}",
"zh": "Skipping — runner index {runner_index} > max runners {max_runners}"
},
"Synced to latest origin/{branch}": {
"bg": "Synced to latest origin/{branch}",
"de": "Synced to latest origin/{branch}",
"en": "Synced to latest origin/{branch}",
"pl": "Synced to latest origin/{branch}",
"ru": "Synced to latest origin/{branch}",
"zh": "Synced to latest origin/{branch}"
},
"Syncing {count} documentation pages to wiki...": {
"bg": "Syncing {count} documentation pages to wiki...",
"de": "Syncing {count} documentation pages to wiki...",
@@ -1623,6 +2399,14 @@
"ru": "Tests passed.",
"zh": "Tests passed."
},
"Timeout reached after {timeout}s.": {
"bg": "Timeout reached after {timeout}s.",
"de": "Timeout reached after {timeout}s.",
"en": "Timeout reached after {timeout}s.",
"pl": "Timeout reached after {timeout}s.",
"ru": "Timeout reached after {timeout}s.",
"zh": "Timeout reached after {timeout}s."
},
"Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).": {
"bg": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
"de": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
@@ -1647,6 +2431,14 @@
"ru": "Unknown check category '{check}'. Available: all, user-facing{tags}",
"zh": "Unknown check category '{check}'. Available: all, user-facing{tags}"
},
"Updated badge URLs in {filename}": {
"bg": "Updated badge URLs in {filename}",
"de": "Updated badge URLs in {filename}",
"en": "Updated badge URLs in {filename}",
"pl": "Updated badge URLs in {filename}",
"ru": "Updated badge URLs in {filename}",
"zh": "Updated badge URLs in {filename}"
},
"Updated version in {init}": {
"bg": "Updated version in {init}",
"de": "Updated version in {init}",
@@ -1695,6 +2487,14 @@
"ru": "Version file: {file}",
"zh": "Version file: {file}"
},
"Version stays at v{version} — no version bump from git-cliff. Commits since last tag don't warrant a new release. Skipping.": {
"bg": "",
"de": "",
"en": "Version stays at v{version} — no version bump from git-cliff. Commits since last tag don't warrant a new release. Skipping.",
"pl": "",
"ru": "",
"zh": ""
},
"Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.": {
"bg": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
"de": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
@@ -1727,6 +2527,22 @@
"ru": "ВНИМАНИЕ: Файл .taskid ({file_id}) устарел и не совпадает с именем ветки ({branch_id}). Удалите .taskid из репозитория — имя ветки — единственный источник истины.",
"zh": "警告:.taskid 文件 ({file_id}) 已弃用,与分支名称 ({branch_id}) 不一致。请从仓库中删除 .taskid — 分支名称是唯一的真实来源。"
},
"WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.": {
"bg": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
"de": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
"en": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
"pl": "OSTRZEŻENIE: Nie można pobrać listy stron wiki po ponownych próbach. Sama synchronizacja zakończyła się sukcesem (zaktualizowano {count} stron), ale kontrola integralności nie mogła ich zweryfikować z powodu przejściowego problemu z API.",
"ru": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
"zh": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue."
},
"WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.": {
"bg": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
"de": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
"en": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
"pl": "OSTRZEŻENIE: Nie można ponownie pobrać listy stron wiki do weryfikacji. Pomijanie weryfikacji treści z powodu przejściowego problemu z API.",
"ru": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
"zh": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue."
},
"WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.": {
"bg": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не е зададен — пропускане на проверката за съществуване на задача. Задайте го в .env за пълна валидация.",
"de": "WARNUNG: VIKUNJA_TOKEN nicht gesetzt — Task-Existenzprüfung übersprungen. In .env setzen für volle Validierung.",
@@ -1735,6 +2551,14 @@
"ru": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не установлен — пропуск проверки существования задачи. Установите в .env для полной проверки.",
"zh": "警告: VIKUNJA_TOKEN 未设置 — 跳过任务存在性检查。在 .env 中设置以启用完整验证。"
},
"Waiting for CI checks to complete (timeout: {timeout}s)...": {
"bg": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"de": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"en": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"pl": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"ru": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"zh": "Waiting for CI checks to complete (timeout: {timeout}s)..."
},
"Warning: could not fetch tags from origin.": {
"bg": "Warning: could not fetch tags from origin.",
"de": "Warning: could not fetch tags from origin.",
@@ -1743,6 +2567,54 @@
"ru": "Warning: could not fetch tags from origin.",
"zh": "Warning: could not fetch tags from origin."
},
"Warning: instance-level runners query failed: {error}": {
"bg": "Warning: instance-level runners query failed: {error}",
"de": "Warning: instance-level runners query failed: {error}",
"en": "Warning: instance-level runners query failed: {error}",
"pl": "Warning: instance-level runners query failed: {error}",
"ru": "Warning: instance-level runners query failed: {error}",
"zh": "Warning: instance-level runners query failed: {error}"
},
"Warning: instance-level runners query returned HTTP {status}": {
"bg": "Warning: instance-level runners query returned HTTP {status}",
"de": "Warning: instance-level runners query returned HTTP {status}",
"en": "Warning: instance-level runners query returned HTTP {status}",
"pl": "Warning: instance-level runners query returned HTTP {status}",
"ru": "Warning: instance-level runners query returned HTTP {status}",
"zh": "Warning: instance-level runners query returned HTTP {status}"
},
"Warning: org-level runners query failed: {error}": {
"bg": "Warning: org-level runners query failed: {error}",
"de": "Warning: org-level runners query failed: {error}",
"en": "Warning: org-level runners query failed: {error}",
"pl": "Warning: org-level runners query failed: {error}",
"ru": "Warning: org-level runners query failed: {error}",
"zh": "Warning: org-level runners query failed: {error}"
},
"Warning: org-level runners query returned HTTP {status}": {
"bg": "Warning: org-level runners query returned HTTP {status}",
"de": "Warning: org-level runners query returned HTTP {status}",
"en": "Warning: org-level runners query returned HTTP {status}",
"pl": "Warning: org-level runners query returned HTTP {status}",
"ru": "Warning: org-level runners query returned HTTP {status}",
"zh": "Warning: org-level runners query returned HTTP {status}"
},
"Warning: repo-level runners query failed: {error}": {
"bg": "Warning: repo-level runners query failed: {error}",
"de": "Warning: repo-level runners query failed: {error}",
"en": "Warning: repo-level runners query failed: {error}",
"pl": "Warning: repo-level runners query failed: {error}",
"ru": "Warning: repo-level runners query failed: {error}",
"zh": "Warning: repo-level runners query failed: {error}"
},
"Warning: repo-level runners query returned HTTP {status}": {
"bg": "Warning: repo-level runners query returned HTTP {status}",
"de": "Warning: repo-level runners query returned HTTP {status}",
"en": "Warning: repo-level runners query returned HTTP {status}",
"pl": "Warning: repo-level runners query returned HTTP {status}",
"ru": "Warning: repo-level runners query returned HTTP {status}",
"zh": "Warning: repo-level runners query returned HTTP {status}"
},
"Wiki integrity check failed — {count} issue(s)": {
"bg": "Wiki integrity check failed — {count} issue(s)",
"de": "Wiki integrity check failed — {count} issue(s)",
@@ -1763,9 +2635,9 @@
"bg": "Wrote tag {tag} to GITHUB_OUTPUT.",
"de": "Wrote tag {tag} to GITHUB_OUTPUT.",
"en": "Wrote tag {tag} to GITHUB_OUTPUT.",
"pl": "Wrote tag {tag} to GITHUB_OUTPUT.",
"ru": "Wrote tag {tag} to GITHUB_OUTPUT.",
"zh": "Wrote tag {tag} to GITHUB_OUTPUT.",
"pl": "Wrote tag {tag} to GITHUB_OUTPUT."
"zh": "Wrote tag {tag} to GITHUB_OUTPUT."
},
"[check-dep-docs] Passed: all dependencies are documented": {
"bg": "[check-dep-docs] Passed: all dependencies are documented",
@@ -1879,6 +2751,14 @@
"ru": "завершён",
"zh": "已完成"
},
"count={count}": {
"bg": "count={count}",
"de": "count={count}",
"en": "count={count}",
"pl": "count={count}",
"ru": "count={count}",
"zh": "count={count}"
},
"devx version mismatch across extras: {detail}": {
"bg": "несъответствие на версията на devx между extras: {detail}",
"de": "devx-Versionskonflikt zwischen Extras: {detail}",
@@ -1943,6 +2823,14 @@
"ru": "неактивен",
"zh": "未激活"
},
"indices={indices}": {
"bg": "indices={indices}",
"de": "indices={indices}",
"en": "indices={indices}",
"pl": "indices={indices}",
"ru": "indices={indices}",
"zh": "indices={indices}"
},
"mapping.json keys and values must be strings, got {k}={v}": {
"bg": "mapping.json keys and values must be strings, got {k}={v}",
"de": "mapping.json keys and values must be strings, got {k}={v}",
@@ -1975,6 +2863,22 @@
"ru": "pyproject.toml не найден в текущей директории.",
"zh": "在当前目录中未找到 pyproject.toml。"
},
"tea login '{name}' already configured.": {
"bg": "tea login '{name}' already configured.",
"de": "tea login '{name}' already configured.",
"en": "tea login '{name}' already configured.",
"pl": "tea login '{name}' already configured.",
"ru": "tea login '{name}' already configured.",
"zh": "tea login '{name}' already configured."
},
"tea not installed — skipping login configuration.": {
"bg": "tea not installed — skipping login configuration.",
"de": "tea not installed — skipping login configuration.",
"en": "tea not installed — skipping login configuration.",
"pl": "tea not installed — skipping login configuration.",
"ru": "tea not installed — skipping login configuration.",
"zh": "tea not installed — skipping login configuration."
},
"unknown": {
"bg": "неизвестен",
"de": "unbekannt",
@@ -1991,292 +2895,460 @@
"ru": "{file} already exists. Use --force to overwrite.",
"zh": "{file} already exists. Use --force to overwrite."
},
"Version stays at v{version} — no version bump from git-cliff. Commits since last tag don't warrant a new release. Skipping.": {
"bg": "",
"de": "",
"en": "Version stays at v{version} — no version bump from git-cliff. Commits since last tag don't warrant a new release. Skipping.",
"pl": "",
"ru": "",
"zh": ""
"{separator}": {
"bg": "{separator}",
"de": "{separator}",
"en": "{separator}",
"pl": "{separator}",
"ru": "{separator}",
"zh": "{separator}"
},
"tea not installed — skipping login configuration.": {
"bg": "tea not installed — skipping login configuration.",
"de": "tea not installed — skipping login configuration.",
"en": "tea not installed — skipping login configuration.",
"pl": "tea not installed — skipping login configuration.",
"ru": "tea not installed — skipping login configuration.",
"zh": "tea not installed — skipping login configuration."
"Failed to push release commit after 3 attempts. Manual intervention required.": {
"bg": "Failed to push release commit after 3 attempts. Manual intervention required.",
"de": "Failed to push release commit after 3 attempts. Manual intervention required.",
"en": "Failed to push release commit after 3 attempts. Manual intervention required.",
"pl": "Failed to push release commit after 3 attempts. Manual intervention required.",
"ru": "Failed to push release commit after 3 attempts. Manual intervention required.",
"zh": "Failed to push release commit after 3 attempts. Manual intervention required."
},
"CI_GITEA_TOKEN not set — skipping login configuration.": {
"bg": "CI_GITEA_TOKEN not set — skipping login configuration.",
"de": "CI_GITEA_TOKEN not set — skipping login configuration.",
"en": "CI_GITEA_TOKEN not set — skipping login configuration.",
"pl": "CI_GITEA_TOKEN not set — skipping login configuration.",
"ru": "CI_GITEA_TOKEN not set — skipping login configuration.",
"zh": "CI_GITEA_TOKEN not set — skipping login configuration."
"Push attempt {n}/3 failed: {err}": {
"bg": "Push attempt {n}/3 failed: {err}",
"de": "Push attempt {n}/3 failed: {err}",
"en": "Push attempt {n}/3 failed: {err}",
"pl": "Push attempt {n}/3 failed: {err}",
"ru": "Push attempt {n}/3 failed: {err}",
"zh": "Push attempt {n}/3 failed: {err}"
},
"tea login '{name}' already configured.": {
"bg": "tea login '{name}' already configured.",
"de": "tea login '{name}' already configured.",
"en": "tea login '{name}' already configured.",
"pl": "tea login '{name}' already configured.",
"ru": "tea login '{name}' already configured.",
"zh": "tea login '{name}' already configured."
"Rebase attempt {n}/3 failed: {err}": {
"bg": "Rebase attempt {n}/3 failed: {err}",
"de": "Rebase attempt {n}/3 failed: {err}",
"en": "Rebase attempt {n}/3 failed: {err}",
"pl": "Rebase attempt {n}/3 failed: {err}",
"ru": "Rebase attempt {n}/3 failed: {err}",
"zh": "Rebase attempt {n}/3 failed: {err}"
},
"Configuring tea login '{name}' for {url}...": {
"bg": "Configuring tea login '{name}' for {url}...",
"de": "Configuring tea login '{name}' for {url}...",
"en": "Configuring tea login '{name}' for {url}...",
"pl": "Configuring tea login '{name}' for {url}...",
"ru": "Configuring tea login '{name}' for {url}...",
"zh": "Configuring tea login '{name}' for {url}..."
"Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.": {
"bg": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"de": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"en": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"pl": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"ru": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"zh": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label."
},
" Could not fetch logs: {error}": {
"en": " Could not fetch logs: {error}",
"bg": " Could not fetch logs: {error}",
"de": " Could not fetch logs: {error}",
"pl": " Could not fetch logs: {error}",
"ru": " Could not fetch logs: {error}",
"zh": " Could not fetch logs: {error}"
"Branch is already up-to-date with origin/master.": {
"bg": "Branch is already up-to-date with origin/master.",
"de": "Branch is already up-to-date with origin/master.",
"en": "Branch is already up-to-date with origin/master.",
"pl": "Branch is already up-to-date with origin/master.",
"ru": "Branch is already up-to-date with origin/master.",
"zh": "Branch is already up-to-date with origin/master."
},
"Added label '{label}' to PR #{pr}.": {
"en": "Added label '{label}' to PR #{pr}.",
"bg": "Added label '{label}' to PR #{pr}.",
"de": "Added label '{label}' to PR #{pr}.",
"pl": "Added label '{label}' to PR #{pr}.",
"ru": "Added label '{label}' to PR #{pr}.",
"zh": "Added label '{label}' to PR #{pr}."
"Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.": {
"bg": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
"de": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
"en": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
"pl": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
"ru": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
"zh": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR."
},
"CI checks did not complete within timeout.": {
"en": "CI checks did not complete within timeout.",
"bg": "CI checks did not complete within timeout.",
"de": "CI checks did not complete within timeout.",
"pl": "CI checks did not complete within timeout.",
"ru": "CI checks did not complete within timeout.",
"zh": "CI checks did not complete within timeout."
"Branch is {count} commit(s) behind master. Rebasing...": {
"bg": "Branch is {count} commit(s) behind master. Rebasing...",
"de": "Branch is {count} commit(s) behind master. Rebasing...",
"en": "Branch is {count} commit(s) behind master. Rebasing...",
"pl": "Branch is {count} commit(s) behind master. Rebasing...",
"ru": "Branch is {count} commit(s) behind master. Rebasing...",
"zh": "Branch is {count} commit(s) behind master. Rebasing..."
},
"CI checks failed.": {
"en": "CI checks failed.",
"bg": "CI checks failed.",
"de": "CI checks failed.",
"pl": "CI checks failed.",
"ru": "CI checks failed.",
"zh": "CI checks failed."
"CI_GITEA_TOKEN is not set. Add it to .env or export it.": {
"bg": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
"de": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
"en": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
"pl": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
"ru": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
"zh": "CI_GITEA_TOKEN is not set. Add it to .env or export it."
},
"CI_GITEA_TOKEN is not set.": {
"en": "CI_GITEA_TOKEN is not set.",
"bg": "CI_GITEA_TOKEN is not set.",
"de": "CI_GITEA_TOKEN is not set.",
"pl": "CI_GITEA_TOKEN is not set.",
"ru": "CI_GITEA_TOKEN is not set.",
"zh": "CI_GITEA_TOKEN is not set."
"Cannot rebase: not on a branch (detached HEAD).": {
"bg": "Cannot rebase: not on a branch (detached HEAD).",
"de": "Cannot rebase: not on a branch (detached HEAD).",
"en": "Cannot rebase: not on a branch (detached HEAD).",
"pl": "Cannot rebase: not on a branch (detached HEAD).",
"ru": "Cannot rebase: not on a branch (detached HEAD).",
"zh": "Cannot rebase: not on a branch (detached HEAD)."
},
"Checking status for PR #{pr_number}...": {
"en": "Checking status for PR #{pr_number}...",
"bg": "Checking status for PR #{pr_number}...",
"de": "Checking status for PR #{pr_number}...",
"pl": "Checking status for PR #{pr_number}...",
"ru": "Checking status for PR #{pr_number}...",
"zh": "Checking status for PR #{pr_number}..."
"Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.": {
"bg": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
"de": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
"en": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
"pl": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
"ru": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
"zh": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR."
},
"Commit: {sha}": {
"en": "Commit: {sha}",
"bg": "Commit: {sha}",
"de": "Commit: {sha}",
"pl": "Commit: {sha}",
"ru": "Commit: {sha}",
"zh": "Commit: {sha}"
"Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.": {
"bg": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
"de": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
"en": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
"pl": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
"ru": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
"zh": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables."
},
"Could not determine head SHA for PR #{pr_number}.": {
"en": "Could not determine head SHA for PR #{pr_number}.",
"bg": "Could not determine head SHA for PR #{pr_number}.",
"de": "Could not determine head SHA for PR #{pr_number}.",
"pl": "Could not determine head SHA for PR #{pr_number}.",
"ru": "Could not determine head SHA for PR #{pr_number}.",
"zh": "Could not determine head SHA for PR #{pr_number}."
"Fetch failed: {error}": {
"bg": "Fetch failed: {error}",
"de": "Fetch failed: {error}",
"en": "Fetch failed: {error}",
"pl": "Fetch failed: {error}",
"ru": "Fetch failed: {error}",
"zh": "Fetch failed: {error}"
},
"Fetching logs for PR #{pr_number}...": {
"en": "Fetching logs for PR #{pr_number}...",
"bg": "Fetching logs for PR #{pr_number}...",
"de": "Fetching logs for PR #{pr_number}...",
"pl": "Fetching logs for PR #{pr_number}...",
"ru": "Fetching logs for PR #{pr_number}...",
"zh": "Fetching logs for PR #{pr_number}..."
"Fetching origin/master...": {
"bg": "Fetching origin/master...",
"de": "Fetching origin/master...",
"en": "Fetching origin/master...",
"pl": "Fetching origin/master...",
"ru": "Fetching origin/master...",
"zh": "Fetching origin/master..."
},
"Label '{label}' already on PR #{pr}.": {
"en": "Label '{label}' already on PR #{pr}.",
"bg": "Label '{label}' already on PR #{pr}.",
"de": "Label '{label}' already on PR #{pr}.",
"pl": "Label '{label}' already on PR #{pr}.",
"ru": "Label '{label}' already on PR #{pr}.",
"zh": "Label '{label}' already on PR #{pr}."
"Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.": {
"bg": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
"de": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
"en": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
"pl": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
"ru": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
"zh": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again."
},
"Latest run: #{run_id} (status: {status})": {
"en": "Latest run: #{run_id} (status: {status})",
"bg": "Latest run: #{run_id} (status: {status})",
"de": "Latest run: #{run_id} (status: {status})",
"pl": "Latest run: #{run_id} (status: {status})",
"ru": "Latest run: #{run_id} (status: {status})",
"zh": "Latest run: #{run_id} (status: {status})"
"Force-pushing...": {
"bg": "Force-pushing...",
"de": "Force-pushing...",
"en": "Force-pushing...",
"pl": "Force-pushing...",
"ru": "Force-pushing...",
"zh": "Force-pushing..."
},
"No CI checks found for commit {sha}.": {
"en": "No CI checks found for commit {sha}.",
"bg": "No CI checks found for commit {sha}.",
"de": "No CI checks found for commit {sha}.",
"pl": "No CI checks found for commit {sha}.",
"ru": "No CI checks found for commit {sha}.",
"zh": "No CI checks found for commit {sha}."
"Nothing to push.": {
"bg": "Nothing to push.",
"de": "Nothing to push.",
"en": "Nothing to push.",
"pl": "Nothing to push.",
"ru": "Nothing to push.",
"zh": "Nothing to push."
},
"No failed jobs.": {
"en": "No failed jobs.",
"bg": "No failed jobs.",
"de": "No failed jobs.",
"pl": "No failed jobs.",
"ru": "No failed jobs.",
"zh": "No failed jobs."
"PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.": {
"bg": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
"de": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
"en": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
"pl": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
"ru": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
"zh": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR."
},
"No job matching '{job}' found.": {
"en": "No job matching '{job}' found.",
"bg": "No job matching '{job}' found.",
"de": "No job matching '{job}' found.",
"pl": "No job matching '{job}' found.",
"ru": "No job matching '{job}' found.",
"zh": "No job matching '{job}' found."
"Pushed {branch} to origin.": {
"bg": "Pushed {branch} to origin.",
"de": "Pushed {branch} to origin.",
"en": "Pushed {branch} to origin.",
"pl": "Pushed {branch} to origin.",
"ru": "Pushed {branch} to origin.",
"zh": "Pushed {branch} to origin."
},
"No jobs found for run #{run_id}.": {
"en": "No jobs found for run #{run_id}.",
"bg": "No jobs found for run #{run_id}.",
"de": "No jobs found for run #{run_id}.",
"pl": "No jobs found for run #{run_id}.",
"ru": "No jobs found for run #{run_id}.",
"zh": "No jobs found for run #{run_id}."
"Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue": {
"bg": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
"de": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
"en": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
"pl": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
"ru": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
"zh": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue"
},
"No open PR found for branch '{branch}'.": {
"en": "No open PR found for branch '{branch}'.",
"bg": "No open PR found for branch '{branch}'.",
"de": "No open PR found for branch '{branch}'.",
"pl": "No open PR found for branch '{branch}'.",
"ru": "No open PR found for branch '{branch}'.",
"zh": "No open PR found for branch '{branch}'."
"Rebase failed with HTTP {status}: {message}": {
"bg": "Rebase failed with HTTP {status}: {message}",
"de": "Rebase failed with HTTP {status}: {message}",
"en": "Rebase failed with HTTP {status}: {message}",
"pl": "Rebase failed with HTTP {status}: {message}",
"ru": "Rebase failed with HTTP {status}: {message}",
"zh": "Rebase failed with HTTP {status}: {message}"
},
"No workflow runs found for SHA {sha}.": {
"en": "No workflow runs found for SHA {sha}.",
"bg": "No workflow runs found for SHA {sha}.",
"de": "No workflow runs found for SHA {sha}.",
"pl": "No workflow runs found for SHA {sha}.",
"ru": "No workflow runs found for SHA {sha}.",
"zh": "No workflow runs found for SHA {sha}."
"Rebase successful.": {
"bg": "Rebase successful.",
"de": "Rebase successful.",
"en": "Rebase successful.",
"pl": "Rebase successful.",
"ru": "Rebase successful.",
"zh": "Rebase successful."
},
"Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.": {
"en": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
"bg": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
"de": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
"pl": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
"ru": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
"zh": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var."
"Rebasing PR #{pr} via Gitea API...": {
"bg": "Rebasing PR #{pr} via Gitea API...",
"de": "Rebasing PR #{pr} via Gitea API...",
"en": "Rebasing PR #{pr} via Gitea API...",
"pl": "Rebasing PR #{pr} via Gitea API...",
"ru": "Rebasing PR #{pr} via Gitea API...",
"zh": "Rebasing PR #{pr} via Gitea API..."
},
"Timeout reached after {timeout}s.": {
"en": "Timeout reached after {timeout}s.",
"bg": "Timeout reached after {timeout}s.",
"de": "Timeout reached after {timeout}s.",
"pl": "Timeout reached after {timeout}s.",
"ru": "Timeout reached after {timeout}s.",
"zh": "Timeout reached after {timeout}s."
"Ensuring standard labels...": {
"bg": "Ensuring standard labels...",
"de": "Ensuring standard labels...",
"en": "Ensuring standard labels...",
"pl": "Ensuring standard labels...",
"ru": "Ensuring standard labels...",
"zh": "Ensuring standard labels..."
},
"Waiting for CI checks to complete (timeout: {timeout}s)...": {
"en": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"bg": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"de": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"pl": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"ru": "Waiting for CI checks to complete (timeout: {timeout}s)...",
"zh": "Waiting for CI checks to complete (timeout: {timeout}s)..."
" - {count} standard labels verified": {
"bg": " - {count} standard labels verified",
"de": " - {count} standard labels verified",
"en": " - {count} standard labels verified",
"pl": " - {count} standard labels verified",
"ru": " - {count} standard labels verified",
"zh": " - {count} standard labels verified"
},
"\n[check_test_coverage] Fix: add the missing test file(s) before committing.": {
"en": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
"bg": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
"de": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
"pl": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
"ru": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
"zh": "\n[check_test_coverage] Fix: add the missing test file(s) before committing."
"[check-deps] Virtualenv .venv ready (Python {version}).": {
"en": "[check-deps] Virtualenv .venv ready (Python {version}).",
"bg": "[check-deps] Виртуална среда .venv готова (Python {version}).",
"de": "[check-deps] Virtuelle Umgebung .venv bereit (Python {version}).",
"pl": "[check-deps] Środowisko wirtualne .venv gotowe (Python {version}).",
"ru": "[check-deps] Виртуальное окружение .venv готово (Python {version}).",
"zh": "[check-deps] 虚拟环境 .venv 已就绪 (Python {version})。"
},
"Package owner not specified. Use --owner or set [tool.devx] repo_owner.": {
"en": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
"bg": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
"de": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
"pl": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
"ru": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
"zh": "Package owner not specified. Use --owner or set [tool.devx] repo_owner."
"{level}: {tool} not found.{hint}": {
"en": "{level}: {tool} not found.{hint}",
"bg": "{level}: {tool} не е намерен.{hint}",
"de": "{level}: {tool} nicht gefunden.{hint}",
"pl": "{level}: {tool} nie znaleziono.{hint}",
"ru": "{level}: {tool} не найден.{hint}",
"zh": "{level}: 未找到 {tool}。{hint}"
},
"Configuration validation failed.": {
"en": "Configuration validation failed.",
"bg": "Configuration validation failed.",
"de": "Configuration validation failed.",
"pl": "Configuration validation failed.",
"ru": "Configuration validation failed.",
"zh": "Configuration validation failed."
"WARN: Could not determine Python version in .venv.": {
"en": "WARN: Could not determine Python version in .venv.",
"bg": "ПРЕДУПРЕЖДЕНИЕ: Не може да се определи версията на Python в .venv.",
"de": "WARNUNG: Python-Version in .venv konnte nicht bestimmt werden.",
"pl": "OSTRZEŻENIE: Nie można określić wersji Python w .venv.",
"ru": "ПРЕДУПРЕЖДЕНИЕ: Не удалось определить версию Python в .venv.",
"zh": "警告: 无法确定 .venv 中的 Python 版本。"
},
"Missing tests for changed files.": {
"en": "Missing tests for changed files.",
"bg": "Missing tests for changed files.",
"de": "Missing tests for changed files.",
"pl": "Missing tests for changed files.",
"ru": "Missing tests for changed files.",
"zh": "Missing tests for changed files."
"WARN: Could not parse Python version '{version}'.": {
"en": "WARN: Could not parse Python version '{version}'.",
"bg": "ПРЕДУПРЕЖДЕНИЕ: Не може да се анализира версията на Python '{version}'.",
"de": "WARNUNG: Python-Version '{version}' konnte nicht analysiert werden.",
"pl": "OSTRZEŻENIE: Nie można przeanalizować wersji Python '{version}'.",
"ru": "ПРЕДУПРЕЖДЕНИЕ: Не удалось разобрать версию Python '{version}'.",
"zh": "警告: 无法解析 Python 版本 '{version}'。"
},
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.": {
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"bg": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"de": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"pl": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'."
"WARN: .venv not found. Run 'make setup-venv' to create it.": {
"en": "WARN: .venv not found. Run 'make setup-venv' to create it.",
"bg": "ПРЕДУПРЕЖДЕНИЕ: .venv не е намерен. Изпълнете 'make setup-venv' за създаване.",
"de": "WARNUNG: .venv nicht gefunden. Führen Sie 'make setup-venv' aus, um es zu erstellen.",
"pl": "OSTRZEŻENIE: Nie znaleziono .venv. Uruchom 'make setup-venv', aby utworzyć.",
"ru": "ПРЕДУПРЕЖДЕНИЕ: .venv не найден. Выполните 'make setup-venv' для создания.",
"zh": "警告: 未找到 .venv。运行 'make setup-venv' 来创建。"
},
"--checklist-categories must list at least 8 of 13 categories. Got {count}.": {
"en": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"bg": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"de": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"pl": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"ru": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
"zh": "--checklist-categories must list at least 8 of 13 categories. Got {count}."
"[docker-login] Logged in to {registry}.": {
"en": "[docker-login] Logged in to {registry}.",
"bg": "[docker-login] Влязъл в {registry}.",
"de": "[docker-login] Angemeldet bei {registry}.",
"pl": "[docker-login] Zalogowano do {registry}.",
"ru": "[docker-login] Выполнен вход в {registry}.",
"zh": "[docker-login] 已登录到 {registry}。"
},
"--checklist-confirmed is required for APPROVE events.": {
"en": "--checklist-confirmed is required for APPROVE events.",
"bg": "--checklist-confirmed is required for APPROVE events.",
"de": "--checklist-confirmed is required for APPROVE events.",
"pl": "--checklist-confirmed is required for APPROVE events.",
"ru": "--checklist-confirmed is required for APPROVE events.",
"zh": "--checklist-confirmed is required for APPROVE events."
"[docker-login] Login to {registry} failed (continuing).": {
"en": "[docker-login] Login to {registry} failed (continuing).",
"bg": "[docker-login] Влизането в {registry} не успя (продължава).",
"de": "[docker-login] Anmeldung bei {registry} fehlgeschlagen (wird fortgesetzt).",
"pl": "[docker-login] Logowanie do {registry} nie powiodło się (kontynuowanie).",
"ru": "[docker-login] Ошибка входа в {registry} (продолжаем).",
"zh": "[docker-login] 登录 {registry} 失败(继续)。"
},
"Invalid checklist category: {cat}. Must be numbers.": {
"en": "Invalid checklist category: {cat}. Must be numbers.",
"bg": "Invalid checklist category: {cat}. Must be numbers.",
"de": "Invalid checklist category: {cat}. Must be numbers.",
"pl": "Invalid checklist category: {cat}. Must be numbers.",
"ru": "Invalid checklist category: {cat}. Must be numbers.",
"zh": "Invalid checklist category: {cat}. Must be numbers."
"[docker-login] Skipping {registry} (token {env} not set).": {
"en": "[docker-login] Skipping {registry} (token {env} not set).",
"bg": "[docker-login] Пропускане на {registry} (токен {env} не е зададен).",
"de": "[docker-login] {registry} übersprungen (Token {env} nicht gesetzt).",
"pl": "[docker-login] Pomijanie {registry} (token {env} nie ustawiony).",
"ru": "[docker-login] Пропуск {registry} (токен {env} не задан).",
"zh": "[docker-login] 跳过 {registry}(未设置令牌 {env})。"
},
"Items input must be a JSON array, got {type}": {
"bg": "Входните данни трябва да са JSON масив, получено {type}",
"de": "Eingabe muss ein JSON-Array sein, erhalten {type}",
"en": "Items input must be a JSON array, got {type}",
"pl": "Dane wejściowe muszą być tablicą JSON, otrzymano {type}",
"ru": "Входные данные должны быть JSON-массивом, получено {type}",
"zh": "输入必须是 JSON 数组,得到 {type}"
"{env} is not set. Set it in your .env file.": {
"en": "{env} is not set. Set it in your .env file.",
"bg": "{env} не е зададен. Задайте го във вашия .env файл.",
"de": "{env} ist nicht gesetzt. Setzen Sie es in Ihrer .env-Datei.",
"pl": "{env} nie jest ustawiony. Ustaw go w pliku .env.",
"ru": "{env} не задан. Установите его в файле .env.",
"zh": "{env} 未设置。请在 .env 文件中设置。"
},
"Review body must be at least 50 characters.": {
"en": "Review body must be at least 50 characters.",
"bg": "Review body must be at least 50 characters.",
"de": "Review body must be at least 50 characters.",
"pl": "Review body must be at least 50 characters.",
"ru": "Review body must be at least 50 characters.",
"zh": "Review body must be at least 50 characters."
"{env} is not set. Set it in your .env file or pass it as an environment variable.": {
"en": "{env} is not set. Set it in your .env file or pass it as an environment variable.",
"bg": "{env} не е зададен. Задайте го във вашия .env файл или го подайте като променлива на средата.",
"de": "{env} ist nicht gesetzt. Setzen Sie es in Ihrer .env-Datei oder übergeben Sie es als Umgebungsvariable.",
"pl": "{env} nie jest ustawiony. Ustaw go w pliku .env lub przekaż jako zmienną środowiskową.",
"ru": "{env} не задан. Установите его в файле .env или передайте как переменную окружения.",
"zh": "{env} 未设置。请在 .env 文件中设置或作为环境变量传递。"
},
" - Block admin merge override: yes": {
"bg": " - Блокиране на admin merge override: да",
"de": " - Admin-Merge-Override blockieren: ja",
"en": " - Block admin merge override: yes",
"pl": " - Blokuj admin merge override: tak",
"ru": " - Блокировать admin merge override: да",
"zh": " - 阻止管理员合并覆盖:是"
"Login to {registry} failed: {error}": {
"en": "Login to {registry} failed: {error}",
"bg": "Влизането в {registry} не успя: {error}",
"de": "Anmeldung bei {registry} fehlgeschlagen: {error}",
"pl": "Logowanie do {registry} nie powiodło się: {error}",
"ru": "Ошибка входа в {registry}: {error}",
"zh": "登录 {registry} 失败: {error}"
},
"tofu command failed in {dir}: {error}": {
"en": "tofu command failed in {dir}: {error}",
"bg": "командата tofu не успя в {dir}: {error}",
"de": "tofu-Befehl fehlgeschlagen in {dir}: {error}",
"pl": "polecenie tofu nie powiodło się w {dir}: {error}",
"ru": "команда tofu не удалась в {dir}: {error}",
"zh": "tofu 命令在 {dir} 中失败: {error}"
},
"WARN: .venv has Python {version}, but >={req} is required.": {
"en": "WARN: .venv has Python {version}, but >={req} is required.",
"bg": "ПРЕДУПРЕЖДЕНИЕ: .venv има Python {version}, но се изисква >={req}.",
"de": "WARNUNG: .venv hat Python {version}, aber >={req} ist erforderlich.",
"pl": "OSTRZEŻENIE: .venv ma Python {version}, ale wymagane jest >={req}.",
"ru": "ПРЕДУПРЕЖДЕНИЕ: в .venv установлен Python {version}, но требуется >={req}.",
"zh": "警告: .venv 的 Python 版本为 {version},但要求 >={req}。"
},
" -> {dir}": {
"en": " -> {dir}",
"bg": " -> {dir}",
"de": " -> {dir}",
"pl": " -> {dir}",
"ru": " -> {dir}",
"zh": " -> {dir}"
},
"[tofu-init] Initializing {dir}...": {
"en": "[tofu-init] Initializing {dir}...",
"bg": "[tofu-init] Инициализиране на {dir}...",
"de": "[tofu-init] Initialisiere {dir}...",
"pl": "[tofu-init] Inicjalizacja {dir}...",
"ru": "[tofu-init] Инициализация {dir}...",
"zh": "[tofu-init] 正在初始化 {dir}..."
},
"[tofu-init] Done.": {
"en": "[tofu-init] Done.",
"bg": "[tofu-init] Готово.",
"de": "[tofu-init] Fertig.",
"pl": "[tofu-init] Gotowe.",
"ru": "[tofu-init] Готово.",
"zh": "[tofu-init] 完成。"
},
"[tofu-{mode}] Validating OpenTofu configurations...": {
"en": "[tofu-{mode}] Validating OpenTofu configurations...",
"bg": "[tofu-{mode}] Проверка на OpenTofu конфигурациите...",
"de": "[tofu-{mode}] Validiere OpenTofu-Konfigurationen...",
"pl": "[tofu-{mode}] Sprawdzanie konfiguracji OpenTofu...",
"ru": "[tofu-{mode}] Проверка конфигураций OpenTofu...",
"zh": "[tofu-{mode}] 正在验证 OpenTofu 配置..."
},
"[tofu-{mode}] All configurations valid.": {
"en": "[tofu-{mode}] All configurations valid.",
"bg": "[tofu-{mode}] Всички конфигурации са валидни.",
"de": "[tofu-{mode}] Alle Konfigurationen gültig.",
"pl": "[tofu-{mode}] Wszystkie konfiguracje są poprawne.",
"ru": "[tofu-{mode}] Все конфигурации валидны.",
"zh": "[tofu-{mode}] 所有配置有效。"
},
"[check-deps] Verifying tools...": {
"en": "[check-deps] Verifying tools...",
"bg": "[check-deps] Проверка на инструментите...",
"de": "[check-deps] Werkzeuge werden überprüft...",
"pl": "[check-deps] Sprawdzanie narzędzi...",
"ru": "[check-deps] Проверка инструментов...",
"zh": "[check-deps] 正在验证工具..."
},
" {tool}: found at {path}": {
"en": " {tool}: found at {path}",
"bg": " {tool}: намерен на {path}",
"de": " {tool}: gefunden unter {path}",
"pl": " {tool}: znaleziono w {path}",
"ru": " {tool}: найден в {path}",
"zh": " {tool}: 在 {path} 找到"
},
" Run 'make install-checkmake' to install the Makefile linter.": {
"en": " Run 'make install-checkmake' to install the Makefile linter.",
"bg": " Изпълнете 'make install-checkmake' за инсталиране на Makefile линтера.",
"de": " Führen Sie 'make install-checkmake' aus, um den Makefile-Linter zu installieren.",
"pl": " Uruchom 'make install-checkmake', aby zainstalować linter Makefile.",
"ru": " Выполните 'make install-checkmake' для установки линтера Makefile.",
"zh": " 运行 'make install-checkmake' 来安装 Makefile 检查器。"
},
"Required tools missing.": {
"en": "Required tools missing.",
"bg": "Липсват задължителни инструменти.",
"de": "Erforderliche Werkzeuge fehlen.",
"pl": "Brak wymaganych narzędzi.",
"ru": "Отсутствуют обязательные инструменты.",
"zh": "缺少必需的工具。"
},
"[check-deps] All core tools present.": {
"en": "[check-deps] All core tools present.",
"bg": "[check-deps] Всички основни инструменти са налични.",
"de": "[check-deps] Alle Kernwerkzeuge vorhanden.",
"pl": "[check-deps] Wszystkie podstawowe narzędzia są dostępne.",
"ru": "[check-deps] Все основные инструменты доступны.",
"zh": "[check-deps] 所有核心工具均已就绪。"
},
"SSH_PRIVATE_KEY not set — skipping SSH key setup": {
"en": "SSH_PRIVATE_KEY not set — skipping SSH key setup",
"bg": "SSH_PRIVATE_KEY не е зададен — пропускане на SSH ключ настройката",
"de": "SSH_PRIVATE_KEY nicht gesetzt — SSH-Schlüssel-Setup übersprungen",
"pl": "SSH_PRIVATE_KEY nie ustawione — pomijanie konfiguracji klucza SSH",
"ru": "SSH_PRIVATE_KEY не задан — пропуск настройки SSH-ключа",
"zh": "SSH_PRIVATE_KEY 未设置 — 跳过 SSH 密钥设置"
},
"Failed to start ssh-agent: {error}": {
"en": "Failed to start ssh-agent: {error}",
"bg": "Неуспешно стартиране на ssh-agent: {error}",
"de": "Starten von ssh-agent fehlgeschlagen: {error}",
"pl": "Nie udało się uruchomić ssh-agent: {error}",
"ru": "Не удалось запустить ssh-agent: {error}",
"zh": "启动 ssh-agent 失败: {error}"
},
"SSH key set up successfully": {
"en": "SSH key set up successfully",
"bg": "SSH ключът е настроен успешно",
"de": "SSH-Schlüssel erfolgreich eingerichtet",
"pl": "Klucz SSH skonfigurowany pomyślnie",
"ru": "SSH-ключ успешно настроен",
"zh": "SSH 密钥设置成功"
},
"SSH key setup skipped (no key provided)": {
"en": "SSH key setup skipped (no key provided)",
"bg": "Настройката на SSH ключ е пропусната (не е предоставен ключ)",
"de": "SSH-Schlüssel-Setup übersprungen (kein Schlüssel bereitgestellt)",
"pl": "Pominięto konfigurację klucza SSH (brak klucza)",
"ru": "Настройка SSH-ключа пропущена (ключ не предоставлен)",
"zh": "SSH 密钥设置已跳过(未提供密钥)"
},
"Found {count} unsafe identity check(s) in integration tests.": {
"en": "Found {count} unsafe identity check(s) in integration tests.",
"bg": "Намерени са {count} небрежни проверки за идентичност в интеграционните тестове.",
"de": "{count} unsichere Identitätsprüfung(en) in Integrationstests gefunden.",
"pl": "Znaleziono {count} niebezpiecznych sprawdzeń tożsamości w testach integracyjnych.",
"ru": "Найдено {count} небезопасных проверок идентичности в интеграционных тестах.",
"zh": "在集成测试中发现 {count} 个不安全的身份检查。"
},
"Use string comparison or _is_truthy()/_is_falsy() helpers instead. Add '{marker}' to suppress individual lines.": {
"en": "Use string comparison or _is_truthy()/_is_falsy() helpers instead. Add '{marker}' to suppress individual lines.",
"bg": "Използвайте сравнение на низове или _is_truthy()/_is_falsy() помощници. Добавете '{marker}' за потискане на отделни редове.",
"de": "Verwenden Sie String-Vergleich oder _is_truthy()/_is_falsy() Hilfsfunktionen. Fügen Sie '{marker}' hinzu, um einzelne Zeilen zu unterdrücken.",
"pl": "Użyj porównania ciągów lub pomocników _is_truthy()/_is_falsy(). Dodaj '{marker}', aby pominąć pojedyncze linie.",
"ru": "Используйте строковое сравнение или помощники _is_truthy()/_is_falsy(). Добавьте '{marker}' для подавления отдельных строк.",
"zh": "使用字符串比较或 _is_truthy()/_is_falsy() 辅助函数。添加 '{marker}' 以抑制个别行。"
},
"[check-api-identity-checks] Passed: no unsafe identity checks found": {
"en": "[check-api-identity-checks] Passed: no unsafe identity checks found",
"bg": "[check-api-identity-checks] Мина: не са намерени небрежни проверки за идентичност",
"de": "[check-api-identity-checks] Bestanden: keine unsicheren Identitätsprüfungen gefunden",
"pl": "[check-api-identity-checks] Passed: nie znaleziono niebezpiecznych sprawdzeń tożsamości",
"ru": "[check-api-identity-checks] Пройдено: небезопасных проверок идентичности не найдено",
"zh": "[check-api-identity-checks] 通过:未发现不安全的身份检查"
},
"Directory to scan (default: tests/integration). Can be repeated.": {
"en": "Directory to scan (default: tests/integration). Can be repeated.",
"bg": "Директория за сканиране (по подразбиране: tests/integration). Може да се повтаря.",
"de": "Zu scannendes Verzeichnis (Standard: tests/integration). Kann wiederholt werden.",
"pl": "Katalog do skanowania (domyślnie: tests/integration). Można powtarzać.",
"ru": "Директория для сканирования (по умолчанию: tests/integration). Можно повторять.",
"zh": "要扫描的目录(默认:tests/integration)。可重复。"
},
"Failed to list existing wiki pages: {error}. Aborting to avoid creating duplicate pages.": {
"bg": "Неуспешно извличане на съществуващи wiki страници: {error}. Прекратяване, за да се избегне създаване на дублирани страници.",
"de": "Abrufen bestehender Wiki-Seiten fehlgeschlagen: {error}. Abbruch, um doppelte Seiten zu vermeiden.",
"en": "Failed to list existing wiki pages: {error}. Aborting to avoid creating duplicate pages.",
"pl": "Nie udało się wylistować istniejących stron wiki: {error}. Przerywanie, aby uniknąć tworzenia zduplikowanych stron.",
"ru": "Не удалось получить список существующих wiki-страниц: {error}. Прерывание, чтобы избежать создания дубликатов страниц.",
"zh": "列出现有 wiki 页面失败:{error}。正在中止以避免创建重复页面。"
}
}
+3
View File
@@ -0,0 +1,3 @@
"""Shared utility functions for devx and consumer projects."""
from __future__ import annotations
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Utilities for handling API response values.
Many APIs return boolean values as strings (``"true"``, ``"false"``)
rather than native JSON booleans. The Mattermost ``/api/v4/config/client``
endpoint is a notable example. These helpers handle both string and
boolean responses safely.
Usage::
from devx.utils.api import is_truthy, is_falsy
if not is_truthy(config.get("EnableOpenServer")):
raise ValueError("EnableOpenServer not enabled")
"""
from __future__ import annotations
def is_truthy(value: str | bool | None) -> bool:
"""Check if an API config value is truthy.
The API may return strings (``"true"``/``"false"``) or native
booleans. This helper handles both.
Args:
value: The value to check (string, bool, or None).
Returns:
True if the value represents a truthy boolean.
"""
if isinstance(value, bool):
return value
return str(value).lower() == "true"
def is_falsy(value: str | bool | None) -> bool:
"""Check if an API config value is falsy.
The API may return strings (``"true"``/``"false"``) or native
booleans. This helper handles both.
Args:
value: The value to check (string, bool, or None).
Returns:
True if the value represents a falsy boolean.
"""
if isinstance(value, bool):
return not value
return str(value).lower() == "false"
+12
View File
@@ -233,6 +233,18 @@ class TestGiteaClient:
json={"Do": "squash", "MergeTitleField": "fix: bug"},
)
def test_update_pr_branch(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response())
client.update_pr_branch(7, style="rebase")
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/pulls/7/update",
timeout=DEFAULT_TIMEOUT,
params={"style": "rebase"},
)
def test_get_pr_labels(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response([{"name": "ready-to-merge"}]))
+54
View File
@@ -0,0 +1,54 @@
"""Unit tests for devx.utils.api."""
from __future__ import annotations
from devx.utils.api import is_falsy, is_truthy
class TestIsTruthy:
def test_string_true(self) -> None:
assert is_truthy("true") is True
def test_string_true_uppercase(self) -> None:
assert is_truthy("True") is True
def test_boolean_true(self) -> None:
assert is_truthy(True) is True
def test_string_false(self) -> None:
assert is_truthy("false") is False
def test_boolean_false(self) -> None:
assert is_truthy(False) is False
def test_none(self) -> None:
assert is_truthy(None) is False
def test_empty_string(self) -> None:
assert is_truthy("") is False
def test_random_string(self) -> None:
assert is_truthy("random") is False
class TestIsFalsy:
def test_string_false(self) -> None:
assert is_falsy("false") is True
def test_string_false_uppercase(self) -> None:
assert is_falsy("False") is True
def test_boolean_false(self) -> None:
assert is_falsy(False) is True
def test_string_true(self) -> None:
assert is_falsy("true") is False
def test_boolean_true(self) -> None:
assert is_falsy(True) is False
def test_none(self) -> None:
assert is_falsy(None) is False
def test_empty_string(self) -> None:
assert is_falsy("") is False
+53 -12
View File
@@ -218,6 +218,20 @@ class TestExtractConventionalMsg:
]
assert extract_conventional_msg(commits) == "feat(api): add endpoint"
def test_strips_task_id_prefix(self) -> None:
"""Commit messages with a task ID prefix should have it stripped."""
commits = [
{"commit": {"message": "DEVX-12: fix: resolve timeout"}},
]
assert extract_conventional_msg(commits) == "fix: resolve timeout"
def test_strips_task_id_prefix_fallback(self) -> None:
"""Fallback to newest commit should also strip task ID prefix."""
commits = [
{"commit": {"message": "DEVX-12: random message"}},
]
assert extract_conventional_msg(commits) == "random message"
# -- run_cmd --
@@ -292,14 +306,13 @@ class TestMain:
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_merge_behind_master_raises_no_rebase(
def test_merge_behind_master_auto_rebases(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
"""When branch is behind master, auto-merge should NOT rebase.
"""When branch is behind master, auto-merge rebases via Gitea API.
Auto-rebasing creates a feedback loop: the force-push triggers a new
pull_request synchronize event, which starts a new CI run, which runs
auto-merge again, which rebases again, etc.
The rebase triggers a new CI run. The next auto-merge attempt will
find the branch up-to-date and merge successfully.
"""
monkeypatch.chdir(tmp_path)
@@ -315,12 +328,40 @@ class TestMain:
main,
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code != 0
assert result.exit_code == 0
assert "behind master" in result.output.lower()
assert "rebase manually" in result.output.lower()
# Must NOT have called merge_pr twice (no retry after rebase)
assert "auto-rebasing" in result.output.lower()
# Should have called update_pr_branch to trigger server-side rebase
mock_client.update_pr_branch.assert_called_once_with(7, style="rebase")
# Must NOT have called merge_pr twice (no immediate retry)
assert mock_client.merge_pr.call_count == 1
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_merge_behind_master_rebase_failure_raises(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
"""When auto-rebase fails, raise with manual rebase instructions."""
monkeypatch.chdir(tmp_path)
mock_client = MagicMock()
mock_client.get_pr_commits.return_value = [
{"commit": {"message": "fix: resolve timeout"}},
]
mock_client.merge_pr.side_effect = APIError(405, "HEAD branch is behind master")
mock_client.update_pr_branch.side_effect = APIError(409, "Conflict during rebase")
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code != 0
assert "auto-rebase failed" in result.output.lower()
assert "rebase manually" in result.output.lower()
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
@@ -386,10 +427,10 @@ class TestMain:
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_merge_behind_master_does_not_force_push(
def test_merge_behind_master_does_not_run_git_commands(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
"""Verify no git commands are run when branch is behind master."""
"""When behind master, auto-merge uses API rebase — no local git commands."""
monkeypatch.chdir(tmp_path)
mock_client = MagicMock()
@@ -405,8 +446,8 @@ class TestMain:
main,
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code != 0
# No git commands should be run (no rebase, no push)
assert result.exit_code == 0
# No local git commands should be run (rebase is via API)
mock_run.assert_not_called()
@@ -0,0 +1,176 @@
"""Unit tests for devx.tools.check_api_identity_checks."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
from click.testing import CliRunner
from devx.tools.check_api_identity_checks import (
DEFAULT_NOQA_MARKER,
DEFAULT_SCAN_DIRS,
DEFAULT_SKIP_PATTERNS,
_load_config,
_matches_skip_pattern,
cli,
find_identity_checks,
)
class TestFindIdentityChecks:
def test_detects_is_true(self, tmp_path: Path) -> None:
f = tmp_path / "test_foo.py"
f.write_text("assert config.get('x') is True\n")
issues = find_identity_checks(f, tmp_path, DEFAULT_NOQA_MARKER)
assert len(issues) == 1
assert "is True" in issues[0]
def test_detects_is_false(self, tmp_path: Path) -> None:
f = tmp_path / "test_foo.py"
f.write_text("if config.get('x') is False:\n pass\n")
issues = find_identity_checks(f, tmp_path, DEFAULT_NOQA_MARKER)
assert len(issues) == 1
assert "is False" in issues[0]
def test_detects_is_not_true(self, tmp_path: Path) -> None:
f = tmp_path / "test_foo.py"
f.write_text("if config.get('x') is not True:\n fail()\n")
issues = find_identity_checks(f, tmp_path, DEFAULT_NOQA_MARKER)
assert len(issues) == 1
assert "is not True" in issues[0]
def test_detects_is_not_false(self, tmp_path: Path) -> None:
f = tmp_path / "test_foo.py"
f.write_text("if config.get('x') is not False:\n fail()\n")
issues = find_identity_checks(f, tmp_path, DEFAULT_NOQA_MARKER)
assert len(issues) == 1
assert "is not False" in issues[0]
def test_noqa_suppresses(self, tmp_path: Path) -> None:
f = tmp_path / "test_foo.py"
f.write_text("assert config.get('x') is True # noqa\n")
issues = find_identity_checks(f, tmp_path, DEFAULT_NOQA_MARKER)
assert len(issues) == 0
def test_no_false_positives(self, tmp_path: Path) -> None:
f = tmp_path / "test_foo.py"
f.write_text("assert config.get('x') == 'true'\nassert config.get('y') == True\nx = True\nif x:\n pass\n")
issues = find_identity_checks(f, tmp_path, DEFAULT_NOQA_MARKER)
assert len(issues) == 0
def test_multiple_issues(self, tmp_path: Path) -> None:
f = tmp_path / "test_foo.py"
f.write_text("if config.get('a') is True:\n pass\nif config.get('b') is not False:\n pass\n")
issues = find_identity_checks(f, tmp_path, DEFAULT_NOQA_MARKER)
assert len(issues) == 2
def test_file_not_found(self, tmp_path: Path) -> None:
f = tmp_path / "nonexistent.py"
issues = find_identity_checks(f, tmp_path, DEFAULT_NOQA_MARKER)
assert issues == []
class TestMatchesSkipPattern:
def test_matches_helpers(self) -> None:
assert _matches_skip_pattern(Path("test_mattermost_helpers.py"), DEFAULT_SKIP_PATTERNS)
def test_does_not_match_regular(self) -> None:
assert not _matches_skip_pattern(Path("test_mattermost.py"), DEFAULT_SKIP_PATTERNS)
def test_empty_patterns(self) -> None:
assert not _matches_skip_pattern(Path("test_anything.py"), [])
class TestLoadConfig:
def test_defaults(self) -> None:
with patch("devx.tools.check_api_identity_checks._load_pyproject_devx") as mock:
mock.return_value = {}
scan_dirs, skip_patterns, noqa = _load_config()
assert scan_dirs == DEFAULT_SCAN_DIRS
assert skip_patterns == DEFAULT_SKIP_PATTERNS
assert noqa == DEFAULT_NOQA_MARKER
def test_custom_config(self) -> None:
with patch("devx.tools.check_api_identity_checks._load_pyproject_devx") as mock:
mock.return_value = {
"check_api_identity_checks": {
"scan_dirs": ["tests/api"],
"skip_patterns": ["test_*_unit.py"],
"noqa_marker": "# allow",
}
}
scan_dirs, skip_patterns, noqa = _load_config()
assert scan_dirs == ["tests/api"]
assert skip_patterns == ["test_*_unit.py"]
assert noqa == "# allow"
def test_invalid_config_returns_defaults(self) -> None:
with patch("devx.tools.check_api_identity_checks._load_pyproject_devx") as mock:
mock.return_value = {"check_api_identity_checks": "not a dict"}
scan_dirs, _, _ = _load_config()
assert scan_dirs == DEFAULT_SCAN_DIRS
class TestCli:
def test_no_issues(self, tmp_path: Path) -> None:
runner = CliRunner()
with (
patch("devx.tools.check_api_identity_checks._load_config") as mock_cfg,
patch("devx.tools.check_api_identity_checks.Path.cwd", return_value=tmp_path),
):
mock_cfg.return_value = (["tests/integration"], DEFAULT_SKIP_PATTERNS, DEFAULT_NOQA_MARKER)
(tmp_path / "tests" / "integration").mkdir(parents=True)
(tmp_path / "tests" / "integration" / "test_foo.py").write_text("assert config.get('x') == 'true'\n")
result = runner.invoke(cli, [])
assert result.exit_code == 0
assert "Passed" in result.output
def test_with_issues(self, tmp_path: Path) -> None:
runner = CliRunner()
with (
patch("devx.tools.check_api_identity_checks._load_config") as mock_cfg,
patch("devx.tools.check_api_identity_checks.Path.cwd", return_value=tmp_path),
):
mock_cfg.return_value = (["tests/integration"], DEFAULT_SKIP_PATTERNS, DEFAULT_NOQA_MARKER)
(tmp_path / "tests" / "integration").mkdir(parents=True)
(tmp_path / "tests" / "integration" / "test_foo.py").write_text(
"if config.get('x') is not True:\n fail()\n"
)
result = runner.invoke(cli, [])
assert result.exit_code != 0
assert "is not True" in result.output
def test_skips_helpers(self, tmp_path: Path) -> None:
runner = CliRunner()
with (
patch("devx.tools.check_api_identity_checks._load_config") as mock_cfg,
patch("devx.tools.check_api_identity_checks.Path.cwd", return_value=tmp_path),
):
mock_cfg.return_value = (["tests/integration"], DEFAULT_SKIP_PATTERNS, DEFAULT_NOQA_MARKER)
(tmp_path / "tests" / "integration").mkdir(parents=True)
(tmp_path / "tests" / "integration" / "test_foo_helpers.py").write_text("assert x is True\n")
result = runner.invoke(cli, [])
assert result.exit_code == 0
def test_nonexistent_dir(self, tmp_path: Path) -> None:
runner = CliRunner()
with (
patch("devx.tools.check_api_identity_checks._load_config") as mock_cfg,
patch("devx.tools.check_api_identity_checks.Path.cwd", return_value=tmp_path),
):
mock_cfg.return_value = (["nonexistent"], DEFAULT_SKIP_PATTERNS, DEFAULT_NOQA_MARKER)
result = runner.invoke(cli, [])
assert result.exit_code == 0
def test_custom_scan_dir(self, tmp_path: Path) -> None:
runner = CliRunner()
with (
patch("devx.tools.check_api_identity_checks._load_config") as mock_cfg,
patch("devx.tools.check_api_identity_checks.Path.cwd", return_value=tmp_path),
):
mock_cfg.return_value = (["other"], DEFAULT_SKIP_PATTERNS, DEFAULT_NOQA_MARKER)
(tmp_path / "custom").mkdir()
(tmp_path / "custom" / "test_foo.py").write_text("if x is True:\n pass\n")
result = runner.invoke(cli, ["--scan-dir", "custom"])
assert result.exit_code != 0
+108
View File
@@ -0,0 +1,108 @@
"""Unit tests for devx.tools.check_deps."""
from pathlib import Path
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from devx.tools.check_deps import (
_check_python_version,
_check_tool,
cli,
)
class TestCheckTool:
@patch("devx.tools.check_deps.shutil.which", return_value="/usr/bin/tofu")
def test_found(self, mock_which: MagicMock) -> None:
assert _check_tool("tofu") is True
@patch("devx.tools.check_deps.shutil.which", return_value=None)
def test_not_found_required(self, mock_which: MagicMock) -> None:
assert _check_tool("tofu") is False
@patch("devx.tools.check_deps.shutil.which", return_value=None)
def test_not_found_optional(self, mock_which: MagicMock) -> None:
assert _check_tool("checkmake", optional=True) is False
class TestCheckPythonVersion:
@patch("devx.tools.check_deps.subprocess.run")
def test_valid_version(self, mock_run: MagicMock, tmp_path: Path) -> None:
venv_bin = tmp_path / "bin"
venv_bin.mkdir()
(venv_bin / "python").touch()
mock_run.return_value = MagicMock(returncode=0, stdout="Python 3.12.3\n", stderr="")
_check_python_version(venv_bin)
@patch("devx.tools.check_deps.subprocess.run")
def test_old_version(self, mock_run: MagicMock, tmp_path: Path) -> None:
venv_bin = tmp_path / "bin"
venv_bin.mkdir()
(venv_bin / "python").touch()
mock_run.return_value = MagicMock(returncode=0, stdout="Python 3.11.0\n", stderr="")
_check_python_version(venv_bin)
def test_no_venv(self, tmp_path: Path) -> None:
venv_bin = tmp_path / "bin"
_check_python_version(venv_bin)
@patch("devx.tools.check_deps.subprocess.run")
def test_command_fails(self, mock_run: MagicMock, tmp_path: Path) -> None:
venv_bin = tmp_path / "bin"
venv_bin.mkdir()
(venv_bin / "python").touch()
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
_check_python_version(venv_bin)
@patch("devx.tools.check_deps.subprocess.run")
def test_unparseable_version(self, mock_run: MagicMock, tmp_path: Path) -> None:
venv_bin = tmp_path / "bin"
venv_bin.mkdir()
(venv_bin / "python").touch()
mock_run.return_value = MagicMock(returncode=0, stdout="garbage\n", stderr="")
_check_python_version(venv_bin)
class TestCli:
@patch("devx.tools.check_deps._check_python_version")
@patch("devx.tools.check_deps._check_tool")
def test_all_present(self, mock_check: MagicMock, mock_py: MagicMock) -> None:
mock_check.return_value = True
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 0
assert "All core tools present" in result.output
@patch("devx.tools.check_deps._check_python_version")
@patch("devx.tools.check_deps._check_tool")
def test_missing_required(self, mock_check: MagicMock, mock_py: MagicMock) -> None:
mock_check.side_effect = lambda name, optional=False: name != "tofu"
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code != 0
@patch("devx.tools.check_deps._check_python_version")
@patch("devx.tools.check_deps._check_tool")
def test_missing_optional_with_fallback(self, mock_check: MagicMock, mock_py: MagicMock, tmp_path: Path) -> None:
checkmake_bin = tmp_path / "checkmake"
checkmake_bin.touch()
def _side(name: str, optional: bool = False) -> bool:
return name != "checkmake"
mock_check.side_effect = _side
runner = CliRunner()
result = runner.invoke(cli, ["--checkmake-bin", str(checkmake_bin)])
assert result.exit_code == 0
@patch("devx.tools.check_deps._check_python_version")
@patch("devx.tools.check_deps._check_tool")
def test_missing_optional_no_fallback(self, mock_check: MagicMock, mock_py: MagicMock) -> None:
def _side(name: str, optional: bool = False) -> bool:
return name != "checkmake"
mock_check.side_effect = _side
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 0
+5 -5
View File
@@ -69,16 +69,16 @@ class TestParsePerTestDurations:
assert len(durations) == 1
assert durations[0] == ("tests/test_foo.py::test_bar", 0.01)
def test_parses_setup_and_teardown(self) -> None:
def test_ignores_setup_and_teardown(self) -> None:
"""Only 'call' durations are counted — setup includes import overhead."""
output = (
"0.02s setup tests/test_foo.py::test_bar\n"
"0.68s setup tests/test_foo.py::test_bar\n"
"0.01s call tests/test_foo.py::test_bar\n"
"0.00s teardown tests/test_foo.py::test_bar\n"
)
durations = parse_per_test_durations(output)
assert len(durations) == 3
names = [d[0] for d in durations]
assert "tests/test_foo.py::test_bar" in names
assert len(durations) == 1
assert durations[0] == ("tests/test_foo.py::test_bar", 0.01)
def test_sorted_slowest_first(self) -> None:
output = "0.01s call tests/test_a.py::test_slow\n0.50s call tests/test_b.py::test_fast\n"
+8
View File
@@ -184,6 +184,14 @@ class TestMain:
result = runner.invoke(check_translations.main, ["--translations", str(trans_file)])
assert result.exit_code == 0
def test_no_translations_file_skips(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When no translations file is found, should pass with skip message."""
monkeypatch.chdir(tmp_path)
runner = CliRunner()
result = runner.invoke(check_translations.main, [])
assert result.exit_code == 0
assert "No translations file found" in result.output
class TestPrintResult:
def test_prints_all_good(self, capsys: pytest.CaptureFixture[str]) -> None:
+21
View File
@@ -87,6 +87,13 @@ class TestCiCommands:
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.doc_coverage", [])
@patch("devx.cli._run_module")
def test_ci_lint_docs(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["ci", "lint-docs", "--", "--root", "."])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.ci.lint_docs", ["--root", "."])
@patch("devx.cli._run_module")
def test_ci_notify_failure(self, mock_run: MagicMock) -> None:
runner = CliRunner()
@@ -194,6 +201,20 @@ class TestToolsCommands:
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.tools.setup", [])
@patch("devx.cli._run_module")
def test_tools_rebase(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["tools", "rebase", "--", "--no-push"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.tools.rebase", ["--no-push"])
@patch("devx.cli._run_module")
def test_tools_pr_rebase(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["tools", "pr-rebase", "--", "--pr", "42"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.tools.pr_rebase", ["--pr", "42"])
class TestMoleculeCommands:
@patch("devx.cli._run_module")
+19
View File
@@ -8,6 +8,7 @@ from click.testing import CliRunner
from devx.exceptions import APIError
from devx.tools.configure_repo import (
_STANDARD_LABELS,
_default_branch_protection_config,
_default_repo_settings_config,
_handle_http_error,
@@ -58,6 +59,7 @@ class TestConfigureRepo:
mock_client.ensure_branch_protection.assert_called_once()
mock_client.update_repo_settings.assert_called_once()
assert mock_client.ensure_label.call_count == len(_STANDARD_LABELS)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
@@ -100,6 +102,21 @@ class TestConfigureRepo:
mock_client.ensure_branch_protection.assert_called_once_with("develop", custom_bp)
mock_client.update_repo_settings.assert_called_once_with(custom_rs)
# Labels are created regardless of custom configs
assert mock_client.ensure_label.call_count == len(_STANDARD_LABELS)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
def test_configure_repo_creates_all_standard_labels(self, mock_client_cls: MagicMock) -> None:
"""Verify all standard labels are ensured with correct names."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
configure_repo(token="tok", owner="owner", repo="repo")
created_names = [call.args[0] for call in mock_client.ensure_label.call_args_list]
expected_names = [lbl["name"] for lbl in _STANDARD_LABELS]
assert created_names == expected_names
class TestMain:
@@ -114,6 +131,7 @@ class TestMain:
assert result.exit_code == 0
mock_client.ensure_branch_protection.assert_called_once()
mock_client.update_repo_settings.assert_called_once()
assert mock_client.ensure_label.call_count == len(_STANDARD_LABELS)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
@@ -125,6 +143,7 @@ class TestMain:
result = runner.invoke(main, ["--repo", "myrepo", "--owner", "myorg"])
assert result.exit_code == 0
mock_client.ensure_branch_protection.assert_called_once()
assert mock_client.ensure_label.call_count == len(_STANDARD_LABELS)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
+48 -2
View File
@@ -38,6 +38,31 @@ class TestIsReleaseCommit:
assert detect_release_commit.is_release_commit("") is False
class TestIsBadgeCommit:
def test_badge_commit(self) -> None:
assert detect_release_commit.is_badge_commit("chore: update badge URLs to commit abc123 [skip ci]") is True
def test_regular_chore(self) -> None:
assert detect_release_commit.is_badge_commit("chore: cleanup deps") is False
def test_empty(self) -> None:
assert detect_release_commit.is_badge_commit("") is False
class TestIsAutomatedCommit:
def test_release_is_automated(self) -> None:
assert detect_release_commit.is_automated_commit("release: v1.0.0 [skip ci]") is True
def test_badge_is_automated(self) -> None:
assert detect_release_commit.is_automated_commit("chore: update badge URLs to commit abc123 [skip ci]") is True
def test_regular_is_not_automated(self) -> None:
assert detect_release_commit.is_automated_commit("OBL-INFRA-363: fix: something") is False
def test_empty(self) -> None:
assert detect_release_commit.is_automated_commit("") is False
class TestWriteGithubOutput:
def test_write(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
@@ -62,7 +87,26 @@ class TestMain:
assert result.exit_code == 0
assert "Release commit" in result.output
with open(gh_file) as f:
assert "is-release=true" in f.read()
content = f.read()
assert "is-release=true" in content
assert "is-automated=true" in content
def test_badge_commit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object(
detect_release_commit,
"get_commit_message",
return_value="chore: update badge URLs to commit abc123 [skip ci]",
):
runner = CliRunner()
result = runner.invoke(detect_release_commit.main, [])
assert result.exit_code == 0
assert "Automated CI commit" in result.output
with open(gh_file) as f:
content = f.read()
assert "is-release=false" in content
assert "is-automated=true" in content
def test_regular_commit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
@@ -73,4 +117,6 @@ class TestMain:
assert result.exit_code == 0
assert "Regular merge commit" in result.output
with open(gh_file) as f:
assert "is-release=false" in f.read()
content = f.read()
assert "is-release=false" in content
assert "is-automated=false" in content
+86 -27
View File
@@ -12,10 +12,13 @@ from devx.ci.doc_coverage import (
main,
)
# Path to devx's own source directory (for testing)
DEVX_SRC_DIR = Path(__file__).resolve().parent.parent.parent / "src" / "devx"
class TestExtractCliCommands:
def test_extracts_commands(self) -> None:
commands = extract_cli_commands()
commands = extract_cli_commands(DEVX_SRC_DIR)
# devx CLI has commands under ci, tools, and molecule groups
assert "auto-merge" in commands
assert "release" in commands
@@ -24,39 +27,30 @@ class TestExtractCliCommands:
assert "install-tools" in commands
def test_returns_list(self) -> None:
commands = extract_cli_commands()
commands = extract_cli_commands(DEVX_SRC_DIR)
assert isinstance(commands, list)
assert len(commands) > 0
def test_no_cli_file(self, monkeypatch: pytest.MonkeyPatch) -> None:
def test_no_cli_file(self, tmp_path: Path) -> None:
"""Returns empty list when CLI file doesn't exist."""
from devx.ci import doc_coverage
monkeypatch.setattr(doc_coverage, "CLI_FILE", Path("/nonexistent/cli.py"))
commands = extract_cli_commands()
commands = extract_cli_commands(tmp_path)
assert commands == []
def test_def_fallback_no_explicit_name(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
def test_def_fallback_no_explicit_name(self, tmp_path: Path) -> None:
"""When a command decorator has no explicit name, falls back to the def name."""
from devx.ci import doc_coverage
fake_cli = tmp_path / "cli.py"
fake_cli.write_text("@click.group()\ndef cli():\n pass\n@cli.command()\ndef my_command():\n pass\n")
monkeypatch.setattr(doc_coverage, "CLI_FILE", fake_cli)
commands = extract_cli_commands()
commands = extract_cli_commands(tmp_path)
assert "my_command" in commands
def test_command_decorator_no_def_fallback(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
def test_command_decorator_no_def_fallback(self, tmp_path: Path) -> None:
"""When a command decorator has no name and no following def, it is skipped."""
from devx.ci import doc_coverage
fake_cli = tmp_path / "cli.py"
# The last @cli.command() has no explicit name and no def statement after it
fake_cli.write_text(
"@click.group()\ndef cli():\n pass\n@cli.command()\ndef real_cmd():\n pass\n@cli.command()\npass\n"
)
monkeypatch.setattr(doc_coverage, "CLI_FILE", fake_cli)
commands = extract_cli_commands()
commands = extract_cli_commands(tmp_path)
# real_cmd should be found via def fallback; the bare @cli.command() is skipped
assert "real_cmd" in commands
assert "pass" not in commands
@@ -96,19 +90,29 @@ class TestMain:
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
# Get actual commands from the CLI
commands = extract_cli_commands()
src = tmp_path / "src" / "devx"
src.mkdir(parents=True)
(src / "ci").mkdir()
(src / "__init__.py").write_text("")
(src / "ci" / "__init__.py").write_text("")
# Create a fake cli.py with some commands
(src / "cli.py").write_text(
"@click.group()\ndef cli():\n pass\n"
"@cli.command('release')\ndef release():\n pass\n"
"@cli.command('setup')\ndef setup():\n pass\n"
)
# Create a fake module and CI script
(src / "config.py").write_text("# config module")
(src / "ci" / "auto_merge.py").write_text("# auto_merge script")
# Write cli-commands.md with all commands
cli_content = "\n".join(f"## {cmd}" for cmd in commands)
cli_content = "## release\n\n## setup\n"
(docs / "user" / "cli-commands.md").write_text(cli_content)
# Write architecture.md with all modules
from devx.ci.doc_coverage import REQUIRED_MODULES, REQUIRED_SCRIPTS
(docs / "tech" / "architecture.md").write_text(" ".join(REQUIRED_MODULES))
(docs / "tech" / "architecture.md").write_text("config.py")
# Write ci-cd-workflow.md with all scripts
(docs / "tech" / "ci-cd-workflow.md").write_text(" ".join(REQUIRED_SCRIPTS))
(docs / "tech" / "ci-cd-workflow.md").write_text("auto_merge.py")
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs)])
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src)])
assert result.exit_code == 0
assert "100%" in result.output
@@ -117,11 +121,21 @@ class TestMain:
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
src = tmp_path / "src" / "devx"
src.mkdir(parents=True)
(src / "ci").mkdir()
(src / "__init__.py").write_text("")
(src / "ci" / "__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")
(src / "ci" / "auto_merge.py").write_text("# auto_merge")
(docs / "user" / "cli-commands.md").write_text("No commands here.")
(docs / "tech" / "architecture.md").write_text("No modules here.")
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs), "--fail-on-missing"])
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src), "--fail-on-missing"])
assert result.exit_code == 1
def test_missing_docs_warn_only(self, tmp_path: Path) -> None:
@@ -129,10 +143,55 @@ class TestMain:
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
src = tmp_path / "src" / "devx"
src.mkdir(parents=True)
(src / "ci").mkdir()
(src / "__init__.py").write_text("")
(src / "ci" / "__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")
(src / "ci" / "auto_merge.py").write_text("# auto_merge")
(docs / "user" / "cli-commands.md").write_text("No commands here.")
(docs / "tech" / "architecture.md").write_text("No modules here.")
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs)])
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src)])
assert result.exit_code == 0
assert "MISSING" in result.output
def test_auto_detect_scripts_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When src/ doesn't exist but scripts/ does, auto-detect it."""
monkeypatch.chdir(tmp_path)
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
scripts = tmp_path / "scripts"
scripts.mkdir()
(scripts / "cli.py").write_text(
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
)
(scripts / "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)])
assert result.exit_code == 0
def test_no_source_dir_falls_back_to_required(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
"""When no source dir exists, falls back to REQUIRED_MODULES/SCRIPTS."""
monkeypatch.chdir(tmp_path)
docs = tmp_path / "docs"
(docs / "user").mkdir(parents=True)
(docs / "tech").mkdir(parents=True)
(docs / "user" / "cli-commands.md").write_text("")
from devx.ci.doc_coverage import REQUIRED_MODULES, REQUIRED_SCRIPTS
(docs / "tech" / "architecture.md").write_text(" ".join(REQUIRED_MODULES))
(docs / "tech" / "ci-cd-workflow.md").write_text(" ".join(REQUIRED_SCRIPTS))
runner = CliRunner()
result = runner.invoke(main, ["--docs-dir", str(docs)])
# No source dir found, so no CLI commands, but modules/scripts from REQUIRED lists
assert result.exit_code == 0
+149
View File
@@ -0,0 +1,149 @@
"""Unit tests for devx.tools.docker_login."""
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from devx.tools.docker_login import (
_resolve_credentials,
cli,
docker_login,
)
class TestDockerLogin:
@patch("devx.tools.docker_login.subprocess.run")
def test_success(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
assert docker_login("registry.io", "user", "tok") is True
@patch("devx.tools.docker_login.subprocess.run")
def test_failure_raises(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="auth failed")
with pytest.raises(Exception, match="auth failed"):
docker_login("registry.io", "user", "tok")
@patch("devx.tools.docker_login.subprocess.run")
def test_failure_suppressed(self, mock_run: MagicMock) -> None:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="auth failed")
assert docker_login("registry.io", "user", "tok", suppress_failure=True) is False
class TestResolveCredentials:
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "CI_GITEA_USERNAME": "emil"}, clear=True)
def test_both_set(self) -> None:
user, token = _resolve_credentials("CI_GITEA_TOKEN", "CI_GITEA_USERNAME", None)
assert user == "emil"
assert token == "tok"
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
def test_token_only_with_default(self) -> None:
user, token = _resolve_credentials("CI_GITEA_TOKEN", "CI_GITEA_USERNAME", "emil")
assert user == "emil"
assert token == "tok"
@patch.dict("os.environ", {}, clear=True)
def test_no_token(self) -> None:
user, token = _resolve_credentials("CI_GITEA_TOKEN", "CI_GITEA_USERNAME", "emil")
assert user is None
assert token is None
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
def test_no_username_no_default(self) -> None:
user, token = _resolve_credentials("CI_GITEA_TOKEN", "CI_GITEA_USERNAME", None)
assert user == ""
assert token == "tok"
class TestCli:
@patch("devx.tools.docker_login.docker_login")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "CI_GITEA_USERNAME": "emil"}, clear=True)
def test_required_login(self, mock_login: MagicMock) -> None:
mock_login.return_value = True
runner = CliRunner()
result = runner.invoke(
cli,
["--registry", "reg.io", "--token-env", "CI_GITEA_TOKEN", "--username-env", "CI_GITEA_USERNAME"],
)
assert result.exit_code == 0
mock_login.assert_called_once()
@patch("devx.tools.docker_login.docker_login")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
def test_default_username(self, mock_login: MagicMock) -> None:
mock_login.return_value = True
runner = CliRunner()
result = runner.invoke(
cli,
[
"--registry",
"reg.io",
"--token-env",
"CI_GITEA_TOKEN",
"--username-env",
"CI_GITEA_USERNAME",
"--default-username",
"emil",
],
)
assert result.exit_code == 0
mock_login.assert_called_once_with("reg.io", "emil", "tok", suppress_failure=False)
@patch.dict("os.environ", {}, clear=True)
def test_required_no_token_raises(self) -> None:
runner = CliRunner()
result = runner.invoke(
cli,
["--registry", "reg.io", "--token-env", "CI_GITEA_TOKEN", "--username-env", "CI_GITEA_USERNAME"],
)
assert result.exit_code != 0
@patch.dict("os.environ", {}, clear=True)
def test_optional_no_token_skips(self) -> None:
runner = CliRunner()
result = runner.invoke(
cli,
[
"--registry",
"reg.io",
"--token-env",
"CI_GITEA_TOKEN",
"--username-env",
"CI_GITEA_USERNAME",
"--optional",
],
)
assert result.exit_code == 0
assert "Skipping" in result.output
@patch("devx.tools.docker_login.docker_login")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
def test_no_username_raises(self, mock_login: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(
cli,
["--registry", "reg.io", "--token-env", "CI_GITEA_TOKEN", "--username-env", "CI_GITEA_USERNAME"],
)
assert result.exit_code != 0
mock_login.assert_not_called()
@patch("devx.tools.docker_login.docker_login")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "CI_GITEA_USERNAME": "emil"}, clear=True)
def test_suppress_failure(self, mock_login: MagicMock) -> None:
mock_login.return_value = False
runner = CliRunner()
result = runner.invoke(
cli,
[
"--registry",
"reg.io",
"--token-env",
"CI_GITEA_TOKEN",
"--username-env",
"CI_GITEA_USERNAME",
"--suppress-failure",
],
)
assert result.exit_code == 0
mock_login.assert_called_once_with("reg.io", "emil", "tok", suppress_failure=True)
+35 -1
View File
@@ -240,6 +240,35 @@ class TestInstallHadolint:
assert (tmp_path / "hadolint").exists()
class TestInstallTofu:
def test_already_installed(self) -> None:
with patch.object(install_tools, "_is_installed", return_value=True):
assert install_tools.install_tofu() is True
def test_install(self, tmp_path: Path) -> None:
import io
import tarfile
tarball_path = tmp_path / "archive.tar.gz"
binary_content = b"fake tofu"
with tarfile.open(tarball_path, "w:gz") as tar:
info = tarfile.TarInfo(name="tofu")
info.size = len(binary_content)
tar.addfile(info, io.BytesIO(binary_content))
with patch.object(install_tools, "_is_installed", return_value=False):
with patch.object(install_tools, "TARGET_DIR", tmp_path):
with patch.object(platform, "machine", return_value="x86_64"):
with patch.object(platform, "system", return_value="Linux"):
with patch.object(
install_tools,
"_download",
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
):
assert install_tools.install_tofu() is True
assert (tmp_path / "tofu").exists()
class TestListTools:
def test_list(self, tmp_path: Path) -> None:
with patch.object(install_tools, "TARGET_DIR", tmp_path):
@@ -274,6 +303,11 @@ class TestInstallTool:
assert install_tools._install_tool("hadolint") is True
mock.assert_called_once()
def test_tofu(self) -> None:
with patch.object(install_tools, "install_tofu", return_value=True) as mock:
assert install_tools._install_tool("tofu") is True
mock.assert_called_once()
def test_unknown_tool(self) -> None:
with pytest.raises(ClickException, match="Unknown tool"):
install_tools._install_tool("unknown")
@@ -292,7 +326,7 @@ class TestMain:
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
result = runner.invoke(install_tools.main, [])
assert result.exit_code == 0
assert mock_install.call_count == 5
assert mock_install.call_count == 6
def test_install_specific_tool(self) -> None:
runner = CliRunner()
+436
View File
@@ -0,0 +1,436 @@
"""Unit tests for devx.ci.lint_docs."""
from __future__ import annotations
import json
from datetime import datetime, timedelta
from pathlib import Path
from click.testing import CliRunner
from devx.ci.lint_docs import (
check_docs_structure,
check_duplicate_headings,
check_heading_hierarchy,
check_internal_links,
check_required_files,
check_stale_docs,
check_todo_fixme,
check_trailing_whitespace,
extract_headings,
extract_links,
main,
slugify,
strip_code_blocks,
)
class TestSlugify:
def test_basic(self) -> None:
assert slugify("Hello World") == "hello-world"
def test_special_chars(self) -> None:
assert slugify("Hello, World!") == "hello-world"
def test_multiple_spaces(self) -> None:
assert slugify("Hello World") == "hello-world"
def test_trailing_dash(self) -> None:
assert slugify("Hello World -") == "hello-world--"
def test_empty(self) -> None:
assert slugify("") == ""
class TestExtractHeadings:
def test_extracts_headings(self, tmp_path: Path) -> None:
f = tmp_path / "test.md"
f.write_text("# Title\n\n## Section\n\n### Subsection\n")
headings = extract_headings(f)
assert "title" in headings
assert headings["title"] == 1
assert "section" in headings
assert headings["section"] == 2
assert "subsection" in headings
assert headings["subsection"] == 3
def test_no_headings(self, tmp_path: Path) -> None:
f = tmp_path / "test.md"
f.write_text("Just some text.\nNo headings here.\n")
headings = extract_headings(f)
assert headings == {}
def test_ignores_headings_in_code_blocks(self, tmp_path: Path) -> None:
"""Headings inside code blocks should not be detected."""
f = tmp_path / "test.md"
f.write_text("# Title\n\n```bash\n# Not a heading\n## Also not\n```\n\n## Real Section\n")
headings = extract_headings(f)
assert "title" in headings
assert "real-section" in headings
assert "not-a-heading" not in headings
assert "also-not" not in headings
class TestStripCodeBlocks:
def test_strips_fenced_blocks(self) -> None:
content = "Before\n```bash\n# comment\n```\nAfter"
result = strip_code_blocks(content)
assert "# comment" not in result
assert "Before" in result
assert "After" in result
def test_strips_multiple_blocks(self) -> None:
content = "# Title\n```python\ncode1\n```\nText\n```yaml\ncode2\n```\nEnd"
result = strip_code_blocks(content)
assert "code1" not in result
assert "code2" not in result
assert "Text" in result
assert "End" in result
def test_no_code_blocks(self) -> None:
content = "# Title\n\nSome text."
result = strip_code_blocks(content)
assert result == content
def test_preserves_line_numbers(self) -> None:
content = "Line1\n```\nLine3\n```\nLine5"
result = strip_code_blocks(content)
lines = result.splitlines()
assert len(lines) == 5
assert lines[0] == "Line1"
assert lines[4] == "Line5"
class TestExtractLinks:
def test_extracts_internal_links(self, tmp_path: Path) -> None:
f = tmp_path / "test.md"
f.write_text("[link](other.md)\n[external](https://example.com)\n[anchor](#section)\n")
links = extract_links(f)
# Should return internal + anchor links (not http or mailto)
assert len(links) == 2
assert links[0][2] == "other.md"
assert links[1][2] == "#section"
def test_extracts_links_with_anchors(self, tmp_path: Path) -> None:
f = tmp_path / "test.md"
f.write_text("[link](other.md#section)\n")
links = extract_links(f)
assert len(links) == 1
assert links[0][2] == "other.md#section"
def test_skips_mailto(self, tmp_path: Path) -> None:
f = tmp_path / "test.md"
f.write_text("[email](mailto:test@example.com)\n")
links = extract_links(f)
assert links == []
class TestCheckRequiredFiles:
def test_all_present(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("# README")
(tmp_path / "AGENTS.md").write_text("# AGENTS")
(tmp_path / "CHANGELOG.md").write_text("# CHANGELOG")
issues = check_required_files(tmp_path)
assert issues == []
def test_missing_files(self, tmp_path: Path) -> None:
issues = check_required_files(tmp_path)
assert len(issues) == 3
assert any("README.md" in i for i in issues)
assert any("AGENTS.md" in i for i in issues)
assert any("CHANGELOG.md" in i for i in issues)
class TestCheckDocsStructure:
def test_all_present(self, tmp_path: Path) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home")
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
issues = check_docs_structure(tmp_path, docs)
assert issues == []
def test_missing_docs_dir(self, tmp_path: Path) -> None:
issues = check_docs_structure(tmp_path, tmp_path / "docs")
assert len(issues) == 1
assert "Docs directory not found" in issues[0]
def test_missing_index(self, tmp_path: Path) -> None:
docs = tmp_path / "docs"
docs.mkdir()
issues = check_docs_structure(tmp_path, docs)
assert any("index.md" in i for i in issues)
def test_invalid_mapping_json(self, tmp_path: Path) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home")
(docs / "mapping.json").write_text("{invalid json")
issues = check_docs_structure(tmp_path, docs)
assert any("invalid JSON" in i for i in issues)
def test_empty_mapping(self, tmp_path: Path) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home")
(docs / "mapping.json").write_text("{}")
issues = check_docs_structure(tmp_path, docs)
assert any("empty" in i for i in issues)
def test_mapping_not_object(self, tmp_path: Path) -> None:
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home")
(docs / "mapping.json").write_text("[]")
issues = check_docs_structure(tmp_path, docs)
assert any("JSON object" in i for i in issues)
class TestCheckInternalLinks:
def test_valid_links(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("[link](docs/guide.md)\n")
docs = tmp_path / "docs"
docs.mkdir()
(docs / "guide.md").write_text("# Guide\n")
issues = check_internal_links(tmp_path, docs)
assert issues == []
def test_broken_file_link(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("[link](nonexistent.md)\n")
issues = check_internal_links(tmp_path, tmp_path / "docs")
assert len(issues) == 1
assert "file not found" in issues[0]
def test_broken_anchor(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("[link](#missing-section)\n")
issues = check_internal_links(tmp_path, tmp_path / "docs")
assert len(issues) == 1
assert "broken anchor" in issues[0]
def test_broken_anchor_in_target(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("[link](guide.md#missing)\n")
(tmp_path / "guide.md").write_text("# Guide\n")
issues = check_internal_links(tmp_path, tmp_path / "docs")
assert len(issues) == 1
assert "broken anchor" in issues[0]
def test_valid_anchor_in_target(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("[link](guide.md#section)\n")
(tmp_path / "guide.md").write_text("# Section\n")
issues = check_internal_links(tmp_path, tmp_path / "docs")
assert issues == []
def test_wiki_page_link_skipped(self, tmp_path: Path) -> None:
"""Links matching wiki page names in mapping.json should be skipped."""
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("[Architecture](Architecture)\n")
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home", "tech/architecture.md": "Architecture"}))
issues = check_internal_links(tmp_path, docs)
assert issues == []
def test_non_wiki_page_no_extension_skipped(self, tmp_path: Path) -> None:
"""Links without file extension and no slash should be skipped (can't verify)."""
(tmp_path / "README.md").write_text("[SomePage](SomePage)\n")
issues = check_internal_links(tmp_path, tmp_path / "docs")
assert issues == []
def test_broken_anchor_in_target_with_content(self, tmp_path: Path) -> None:
"""Broken anchor in an existing target file should be flagged."""
(tmp_path / "README.md").write_text("[link](guide.md#missing)\n")
(tmp_path / "guide.md").write_text("# Real Title\n\nSome content here.\n")
issues = check_internal_links(tmp_path, tmp_path / "docs")
assert len(issues) == 1
assert "broken anchor" in issues[0]
def test_valid_anchor_in_target_with_content(self, tmp_path: Path) -> None:
"""Valid anchor in an existing target file should pass."""
(tmp_path / "README.md").write_text("[link](guide.md#real-title)\n")
(tmp_path / "guide.md").write_text("# Real Title\n\nSome content.\n")
issues = check_internal_links(tmp_path, tmp_path / "docs")
assert issues == []
def test_invalid_mapping_json_ignored(self, tmp_path: Path) -> None:
"""Invalid mapping.json should not crash link checking."""
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("[link](guide.md)\n")
(docs / "guide.md").write_text("# Guide\n")
(docs / "mapping.json").write_text("{invalid json")
issues = check_internal_links(tmp_path, docs)
# Should still work — just without wiki page mappings
assert issues == []
class TestCheckHeadingHierarchy:
def test_valid_hierarchy(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("# Title\n## Section\n### Sub\n")
issues = check_heading_hierarchy(tmp_path)
assert issues == []
def test_skipped_level(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("# Title\n### Sub\n")
issues = check_heading_hierarchy(tmp_path)
assert len(issues) == 1
assert "hierarchy skip" in issues[0]
class TestCheckTodoFixme:
def test_no_todo(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("Just some text.\n")
issues = check_todo_fixme(tmp_path)
assert issues == []
def test_found_todo(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("TODO: fix this later\n")
issues = check_todo_fixme(tmp_path)
assert len(issues) == 1
assert "TODO" in issues[0]
def test_found_fixme(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("FIXME: broken code\n")
issues = check_todo_fixme(tmp_path)
assert len(issues) == 1
assert "FIXME" in issues[0]
def test_ignores_todo_in_rules(self, tmp_path: Path) -> None:
"""References to 'TODO' in rules docs should not be flagged."""
(tmp_path / "README.md").write_text("Best practices (no `print()`, no `TODO`/`FIXME`)\n")
issues = check_todo_fixme(tmp_path)
assert issues == []
def test_ignores_todo_without_colon(self, tmp_path: Path) -> None:
"""'TODO' without a colon should not be flagged."""
(tmp_path / "README.md").write_text("The TODO list is empty\n")
issues = check_todo_fixme(tmp_path)
assert issues == []
class TestCheckTrailingWhitespace:
def test_no_trailing(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("No trailing whitespace here\n")
issues = check_trailing_whitespace(tmp_path)
assert issues == []
def test_trailing_spaces(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("Trailing spaces \n")
issues = check_trailing_whitespace(tmp_path)
assert len(issues) == 1
assert "trailing whitespace" in issues[0]
def test_trailing_tabs(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("Trailing tabs\t\n")
issues = check_trailing_whitespace(tmp_path)
assert len(issues) == 1
class TestCheckStaleDocs:
def test_fresh_doc(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("Fresh content\n")
issues = check_stale_docs(tmp_path)
assert issues == []
def test_stale_doc(self, tmp_path: Path) -> None:
f = tmp_path / "README.md"
f.write_text("Old content\n")
# Set mtime to 200 days ago
old_time = (datetime.now() - timedelta(days=200)).timestamp()
import os
os.utime(f, (old_time, old_time))
issues = check_stale_docs(tmp_path)
assert len(issues) == 1
assert "stale" in issues[0]
class TestCheckDuplicateHeadings:
def test_no_duplicates(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("# Title\n## Section\n")
issues = check_duplicate_headings(tmp_path)
assert issues == []
def test_duplicates(self, tmp_path: Path) -> None:
(tmp_path / "README.md").write_text("# Title\n# Title\n")
issues = check_duplicate_headings(tmp_path)
assert len(issues) == 1
assert "duplicate heading" in issues[0]
def test_changelog_excluded(self, tmp_path: Path) -> None:
"""CHANGELOG.md should be excluded from duplicate heading checks."""
(tmp_path / "CHANGELOG.md").write_text("# Features\n# Features\n# Features\n")
issues = check_duplicate_headings(tmp_path)
assert issues == []
class TestMain:
def test_passes_clean_repo(self, tmp_path: Path) -> None:
"""A clean repo with all files should pass."""
(tmp_path / "README.md").write_text("# Title\n\nContent here.\n")
(tmp_path / "AGENTS.md").write_text("# AGENTS\n\nContent here.\n")
(tmp_path / "CHANGELOG.md").write_text("# Changelog\n\nContent here.\n")
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n")
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
runner = CliRunner()
result = runner.invoke(main, ["--root", str(tmp_path)])
assert result.exit_code == 0
assert "PASS" in result.output
def test_fails_on_missing_files(self, tmp_path: Path) -> None:
"""Missing required files should fail."""
runner = CliRunner()
result = runner.invoke(main, ["--root", str(tmp_path)])
assert result.exit_code == 1
assert "FAIL" in result.output
def test_fix_trailing_whitespace(self, tmp_path: Path) -> None:
"""--fix should auto-fix trailing whitespace."""
(tmp_path / "README.md").write_text("# Title\n\nContent here. \n")
(tmp_path / "AGENTS.md").write_text("# AGENTS\n\nContent here.\n")
(tmp_path / "CHANGELOG.md").write_text("# Changelog\n\nContent here.\n")
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n")
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
runner = CliRunner()
result = runner.invoke(main, ["--root", str(tmp_path), "--fix"])
assert result.exit_code == 0
# Verify whitespace was fixed
content = (tmp_path / "README.md").read_text()
assert "Content here. \n" not in content
assert "Content here.\n" in content
def test_no_check_links(self, tmp_path: Path) -> None:
"""--no-check-links should skip link checking."""
(tmp_path / "README.md").write_text("# Title\n[broken](nonexistent.md)\n")
(tmp_path / "AGENTS.md").write_text("# AGENTS\n")
(tmp_path / "CHANGELOG.md").write_text("# Changelog\n")
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n")
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
runner = CliRunner()
result = runner.invoke(main, ["--root", str(tmp_path), "--no-check-links"])
assert result.exit_code == 0
def test_stale_docs_warning(self, tmp_path: Path) -> None:
"""--check-stale should warn but not fail."""
(tmp_path / "README.md").write_text("# Title\n")
(tmp_path / "AGENTS.md").write_text("# AGENTS\n")
(tmp_path / "CHANGELOG.md").write_text("# Changelog\n")
docs = tmp_path / "docs"
docs.mkdir()
(docs / "index.md").write_text("# Home\n")
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
# Make README stale
import os
f = tmp_path / "README.md"
old_time = (datetime.now() - timedelta(days=200)).timestamp()
os.utime(f, (old_time, old_time))
runner = CliRunner()
result = runner.invoke(main, ["--root", str(tmp_path), "--check-stale"])
# Stale docs are warnings, not errors
assert result.exit_code == 0
assert "stale" in result.output
+30
View File
@@ -516,6 +516,36 @@ class TestCheckDocumentation:
check_documentation(files, result)
assert any("Documentation: OK" in s for s in result.summary)
def test_tofu_changes_without_docs_warns(self) -> None:
result = ReviewResult()
files = [{"filename": "tofu/modules/hetzner-vm/main.tf"}]
check_documentation(files, result)
assert any("WARNING" in s for s in result.summary)
def test_workflow_changes_info(self) -> None:
result = ReviewResult()
files = [{"filename": ".gitea/workflows/ci.yml"}]
check_documentation(files, result)
assert any("INFO" in s for s in result.summary)
def test_todo_in_doc_patch_warns(self) -> None:
result = ReviewResult()
files = [{"filename": "docs/guide.md", "patch": "+TODO: fix this later\n+Some content\n"}]
check_documentation(files, result)
assert any("TODO" in s for s in result.summary)
def test_todo_in_readme_patch_warns(self) -> None:
result = ReviewResult()
files = [{"filename": "README.md", "patch": "+FIXME: broken\n"}]
check_documentation(files, result)
assert any("FIXME" in s for s in result.summary)
def test_no_todo_in_doc_patch_ok(self) -> None:
result = ReviewResult()
files = [{"filename": "docs/guide.md", "patch": "+Some content\n"}]
check_documentation(files, result)
assert not any("TODO" in s for s in result.summary)
class TestCheckTestCoverage:
def test_src_changes_without_tests_warns(self) -> None:
+348
View File
@@ -0,0 +1,348 @@
"""Tests for devx.tools.rebase, devx.tools.pr_rebase, and detect_pr_number."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from devx.tools.pr_rebase import main as pr_rebase_main
from devx.tools.rebase import main as rebase_main
_FULL_ENV = {
"CI_GITEA_TOKEN": "tok",
"DEVX_REPO_OWNER": "owner",
"DEVX_REPO_NAME": "repo",
}
class TestRunGitHelper:
"""Tests for the _run_git helper function."""
@patch("devx.tools.rebase.subprocess.run")
def test_run_git_with_check(self, mock_run: MagicMock) -> None:
"""_run_git passes check=True by default."""
from devx.tools.rebase import _run_git
mock_run.return_value = MagicMock(stdout="ok\n", returncode=0)
result = _run_git(["status"])
mock_run.assert_called_once_with(
["git", "status"],
capture_output=True,
text=True,
check=True,
)
assert result.stdout == "ok\n"
@patch("devx.tools.rebase.subprocess.run")
def test_run_git_without_check(self, mock_run: MagicMock) -> None:
"""_run_git passes check=False when specified."""
from devx.tools.rebase import _run_git
mock_run.return_value = MagicMock(stdout="", stderr="err", returncode=1)
result = _run_git(["rebase", "origin/master"], check=False)
mock_run.assert_called_once_with(
["git", "rebase", "origin/master"],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 1
class TestDetectPrNumber:
"""Tests for the detect_pr_number helper in _shared."""
@patch("devx.tools._shared.subprocess.run")
@patch.dict("os.environ", _FULL_ENV, clear=True)
@patch("devx.api_clients.GiteaClient")
def test_detect_pr_found(self, mock_client_cls: MagicMock, mock_run: MagicMock) -> None:
"""detect_pr_number returns PR number when branch has an open PR."""
from devx.tools._shared import detect_pr_number
mock_run.return_value = MagicMock(stdout="feature-branch\n", returncode=0)
mock_client = MagicMock()
mock_client.list_prs.return_value = [
{"number": 42, "head": {"ref": "feature-branch"}},
{"number": 99, "head": {"ref": "other-branch"}},
]
mock_client_cls.return_value = mock_client
result = detect_pr_number()
assert result == 42
@patch("devx.tools._shared.subprocess.run")
@patch.dict("os.environ", _FULL_ENV, clear=True)
@patch("devx.api_clients.GiteaClient")
def test_detect_pr_not_found(self, mock_client_cls: MagicMock, mock_run: MagicMock) -> None:
"""detect_pr_number returns None when no open PR matches branch."""
from devx.tools._shared import detect_pr_number
mock_run.return_value = MagicMock(stdout="no-pr-branch\n", returncode=0)
mock_client = MagicMock()
mock_client.list_prs.return_value = [
{"number": 42, "head": {"ref": "other-branch"}},
]
mock_client_cls.return_value = mock_client
result = detect_pr_number()
assert result is None
@patch("devx.tools._shared.subprocess.run")
def test_detect_pr_detached_head(self, mock_run: MagicMock) -> None:
"""detect_pr_number returns None on detached HEAD."""
from devx.tools._shared import detect_pr_number
mock_run.return_value = MagicMock(stdout="HEAD\n", returncode=0)
result = detect_pr_number()
assert result is None
@patch("devx.tools._shared.subprocess.run")
def test_detect_pr_git_failure(self, mock_run: MagicMock) -> None:
"""detect_pr_number returns None when git command fails."""
from devx.tools._shared import detect_pr_number
mock_run.return_value = MagicMock(stdout="", stderr="error", returncode=1)
result = detect_pr_number()
assert result is None
@patch("devx.tools._shared.subprocess.run")
@patch.dict("os.environ", {}, clear=True)
def test_detect_pr_no_token(self, mock_run: MagicMock) -> None:
"""detect_pr_number returns None when CI_GITEA_TOKEN is not set."""
from devx.tools._shared import detect_pr_number
mock_run.return_value = MagicMock(stdout="feature\n", returncode=0)
result = detect_pr_number()
assert result is None
@patch("devx.tools._shared.subprocess.run")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "GITHUB_REPOSITORY": "owner/repo"}, clear=True)
@patch("devx.api_clients.GiteaClient")
def test_detect_pr_github_repo_fallback(self, mock_client_cls: MagicMock, mock_run: MagicMock) -> None:
"""detect_pr_number uses GITHUB_REPOSITORY as fallback for owner/repo."""
from devx.tools._shared import detect_pr_number
mock_run.return_value = MagicMock(stdout="feature\n", returncode=0)
mock_client = MagicMock()
mock_client.list_prs.return_value = [{"number": 7, "head": {"ref": "feature"}}]
mock_client_cls.return_value = mock_client
result = detect_pr_number()
assert result == 7
@patch("devx.tools._shared.subprocess.run")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "GITHUB_REPOSITORY": "invalid-no-slash"}, clear=True)
def test_detect_pr_github_repo_no_slash(self, mock_run: MagicMock) -> None:
"""GITHUB_REPOSITORY without slash is ignored, returns None."""
from devx.tools._shared import detect_pr_number
mock_run.return_value = MagicMock(stdout="feature\n", returncode=0)
result = detect_pr_number()
assert result is None
@patch("devx.tools._shared.subprocess.run")
@patch.dict("os.environ", _FULL_ENV, clear=True)
@patch("devx.api_clients.GiteaClient")
def test_detect_pr_api_error_returns_none(self, mock_client_cls: MagicMock, mock_run: MagicMock) -> None:
"""detect_pr_number returns None when API call fails (best-effort)."""
from devx.api_clients import APIError
from devx.tools._shared import detect_pr_number
mock_run.return_value = MagicMock(stdout="feature\n", returncode=0)
mock_client = MagicMock()
mock_client.list_prs.side_effect = APIError(401, "Unauthorized")
mock_client_cls.return_value = mock_client
result = detect_pr_number()
assert result is None
class TestRebaseTool:
"""Tests for the local rebase tool (devx.tools.rebase)."""
@patch("devx.tools.rebase._run_git")
def test_rebase_already_up_to_date(self, mock_run_git: MagicMock) -> None:
"""When branch is up-to-date, no rebase or push happens."""
mock_run_git.side_effect = [
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
MagicMock(stdout="", returncode=0), # fetch
MagicMock(stdout="0\n", returncode=0), # rev-list --count
]
runner = CliRunner()
result = runner.invoke(rebase_main, [])
assert result.exit_code == 0
assert "already up-to-date" in result.output.lower()
@patch("devx.tools.rebase._run_git")
def test_rebase_behind_master_success(self, mock_run_git: MagicMock) -> None:
"""When behind master, rebase and force-push."""
mock_run_git.side_effect = [
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
MagicMock(stdout="", returncode=0), # fetch
MagicMock(stdout="2\n", returncode=0), # rev-list --count (behind by 2)
MagicMock(stdout="", stderr="", returncode=0), # rebase
MagicMock(stdout="", stderr="", returncode=0), # push
]
runner = CliRunner()
result = runner.invoke(rebase_main, [])
assert result.exit_code == 0
assert "2 commit(s) behind" in result.output
assert "rebase successful" in result.output.lower()
assert "pushed" in result.output.lower()
@patch("devx.tools.rebase._run_git")
def test_rebase_no_push_flag(self, mock_run_git: MagicMock) -> None:
"""With --no-push, rebase happens but no push."""
mock_run_git.side_effect = [
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
MagicMock(stdout="", returncode=0), # fetch
MagicMock(stdout="1\n", returncode=0), # rev-list --count
MagicMock(stdout="", stderr="", returncode=0), # rebase
]
runner = CliRunner()
result = runner.invoke(rebase_main, ["--no-push"])
assert result.exit_code == 0
assert "rebase successful" in result.output.lower()
# Only 4 git calls (no push)
assert mock_run_git.call_count == 4
@patch("devx.tools.rebase._run_git")
def test_rebase_detached_head_fails(self, mock_run_git: MagicMock) -> None:
"""Detached HEAD should fail immediately."""
mock_run_git.return_value = MagicMock(stdout="HEAD\n", returncode=0)
runner = CliRunner()
result = runner.invoke(rebase_main, [])
assert result.exit_code != 0
assert "detached" in result.output.lower()
@patch("devx.tools.rebase._run_git")
def test_rebase_branch_detection_failure(self, mock_run_git: MagicMock) -> None:
"""Git rev-parse failure should exit with error."""
mock_run_git.return_value = MagicMock(stdout="", stderr="fatal: not a repo", returncode=1)
runner = CliRunner()
result = runner.invoke(rebase_main, [])
assert result.exit_code != 0
assert "could not detect" in result.output.lower()
@patch("devx.tools.rebase._run_git")
def test_rebase_conflict_fails(self, mock_run_git: MagicMock) -> None:
"""Rebase conflict should exit with error."""
mock_run_git.side_effect = [
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
MagicMock(stdout="", returncode=0), # fetch
MagicMock(stdout="1\n", returncode=0), # rev-list --count
MagicMock(stdout="", stderr="CONFLICT", returncode=1), # rebase fails
]
runner = CliRunner()
result = runner.invoke(rebase_main, [])
assert result.exit_code != 0
assert "rebase failed" in result.output.lower()
@patch("devx.tools.rebase._run_git")
def test_rebase_fetch_failure(self, mock_run_git: MagicMock) -> None:
"""Fetch failure should exit with error."""
mock_run_git.side_effect = [
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
MagicMock(stdout="", stderr="network error", returncode=1), # fetch fails
]
runner = CliRunner()
result = runner.invoke(rebase_main, [])
assert result.exit_code != 0
assert "fetch failed" in result.output.lower()
@patch("devx.tools.rebase._run_git")
def test_rebase_push_failure(self, mock_run_git: MagicMock) -> None:
"""Force-push rejection should exit with error."""
mock_run_git.side_effect = [
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
MagicMock(stdout="", returncode=0), # fetch
MagicMock(stdout="1\n", returncode=0), # rev-list --count
MagicMock(stdout="", stderr="", returncode=0), # rebase
MagicMock(stdout="", stderr="rejected", returncode=1), # push fails
]
runner = CliRunner()
result = runner.invoke(rebase_main, [])
assert result.exit_code != 0
assert "force-push failed" in result.output.lower()
class TestPrRebaseTool:
"""Tests for the server-side PR rebase tool (devx.tools.pr_rebase)."""
@patch.dict("os.environ", _FULL_ENV, clear=True)
@patch("devx.tools.pr_rebase.GiteaClient")
def test_pr_rebase_success(self, mock_client_cls: MagicMock) -> None:
"""Successful API rebase prints confirmation."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(pr_rebase_main, ["--pr", "42"])
assert result.exit_code == 0
assert "rebased successfully" in result.output.lower()
mock_client.update_pr_branch.assert_called_once_with(42, style="rebase")
@patch.dict("os.environ", _FULL_ENV, clear=True)
@patch("devx.tools.pr_rebase.GiteaClient")
def test_pr_rebase_api_error(self, mock_client_cls: MagicMock) -> None:
"""API error during rebase exits with error."""
from devx.api_clients import APIError
mock_client = MagicMock()
mock_client.update_pr_branch.side_effect = APIError(409, "Conflict")
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(pr_rebase_main, ["--pr", "42"])
assert result.exit_code != 0
assert "rebase failed" in result.output.lower()
@patch("devx.tools.pr_rebase.load_dotenv")
@patch.dict("os.environ", {}, clear=True)
def test_pr_rebase_no_token(self, _mock_load: MagicMock) -> None:
"""Missing CI_GITEA_TOKEN should fail."""
runner = CliRunner()
result = runner.invoke(pr_rebase_main, ["--pr", "42"])
assert result.exit_code != 0
assert "CI_GITEA_TOKEN" in result.output
@patch.dict("os.environ", _FULL_ENV, clear=True)
@patch("devx.tools.pr_rebase.detect_pr_number", return_value=None)
def test_pr_rebase_no_pr_detected(self, _mock_detect: MagicMock) -> None:
"""When PR number can't be auto-detected, fail with instructions."""
runner = CliRunner()
result = runner.invoke(pr_rebase_main, [])
assert result.exit_code != 0
assert "could not detect" in result.output.lower()
@patch("devx.tools.pr_rebase.load_dotenv")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.tools.pr_rebase.GiteaClient")
def test_pr_rebase_no_repo_env(self, _mock_client: MagicMock, _mock_load: MagicMock) -> None:
"""Missing repo env vars should fail."""
runner = CliRunner()
result = runner.invoke(pr_rebase_main, ["--pr", "42"])
assert result.exit_code != 0
assert "DEVX_REPO_OWNER" in result.output
@patch("devx.tools.pr_rebase.load_dotenv")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "GITHUB_REPOSITORY": "owner/repo"}, clear=True)
@patch("devx.tools.pr_rebase.GiteaClient")
def test_pr_rebase_github_repo_fallback(self, mock_client_cls: MagicMock, _mock_load: MagicMock) -> None:
"""GITHUB_REPOSITORY env var is used as fallback for owner/repo."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(pr_rebase_main, ["--pr", "42"])
assert result.exit_code == 0
mock_client.update_pr_branch.assert_called_once_with(42, style="rebase")
+147
View File
@@ -1234,6 +1234,153 @@ class TestMain:
assert "already existed" in result.output
mock_tag.assert_called_once_with("0.2.0", "changelog", False)
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.run_tests")
@patch("devx.ci.release.create_and_push_tag", return_value=True)
@patch("devx.ci.release.commit_release_changes", return_value=True)
@patch("devx.ci.release.update_changelog")
@patch("devx.ci.release.update_init_version")
@patch("devx.ci.release.get_changelog", return_value="changelog")
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.ci.release.time.sleep")
@patch("devx.ci.release.run_cmd")
def test_push_retry_succeeds_after_rebase_failure(
self,
mock_run_cmd: MagicMock,
mock_sleep: MagicMock,
mock_has: MagicMock,
mock_bumped: MagicMock,
mock_latest: MagicMock,
mock_changelog: MagicMock,
mock_update_init: MagicMock,
mock_update_changelog: MagicMock,
mock_commit: MagicMock,
mock_tag: MagicMock,
mock_run_tests: MagicMock,
mock_user: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None:
"""Push should retry after rebase failure and succeed on second attempt."""
ok = MagicMock(returncode=0, stdout="master\n", stderr="")
rebase_fail = MagicMock(returncode=1, stdout="", stderr="conflict")
rebase_abort = MagicMock(returncode=0, stdout="", stderr="")
push_ok = MagicMock(returncode=0, stdout="", stderr="")
# git rev-parse → ok, git log -1 → ok (non-release msg)
# pull --rebase → fail, rebase --abort → ok
# pull --rebase → ok, push → ok
mock_run_cmd.side_effect = [ok, ok, rebase_fail, rebase_abort, ok, push_ok]
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "Rebase attempt 1/3 failed" in result.output
assert "Pushed release commit to master" in result.output
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.run_tests")
@patch("devx.ci.release.create_and_push_tag", return_value=True)
@patch("devx.ci.release.commit_release_changes", return_value=True)
@patch("devx.ci.release.update_changelog")
@patch("devx.ci.release.update_init_version")
@patch("devx.ci.release.get_changelog", return_value="changelog")
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.ci.release.time.sleep")
@patch("devx.ci.release.run_cmd")
def test_push_fails_after_all_retries(
self,
mock_run_cmd: MagicMock,
mock_sleep: MagicMock,
mock_has: MagicMock,
mock_bumped: MagicMock,
mock_latest: MagicMock,
mock_changelog: MagicMock,
mock_update_init: MagicMock,
mock_update_changelog: MagicMock,
mock_commit: MagicMock,
mock_tag: MagicMock,
mock_run_tests: MagicMock,
mock_user: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None:
"""Push should fail after 3 unsuccessful rebase attempts."""
ok = MagicMock(returncode=0, stdout="master\n", stderr="")
rebase_fail = MagicMock(returncode=1, stdout="", stderr="conflict")
rebase_abort = MagicMock(returncode=0, stdout="", stderr="")
# git rev-parse → ok, git log -1 → ok
# 3 attempts: pull --rebase → fail, rebase --abort → ok
mock_run_cmd.side_effect = [
ok,
ok,
rebase_fail,
rebase_abort, # attempt 1
rebase_fail,
rebase_abort, # attempt 2
rebase_fail,
rebase_abort, # attempt 3
]
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code != 0
assert "Failed to push release commit after 3 attempts" in result.output
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.run_tests")
@patch("devx.ci.release.create_and_push_tag", return_value=True)
@patch("devx.ci.release.commit_release_changes", return_value=True)
@patch("devx.ci.release.update_changelog")
@patch("devx.ci.release.update_init_version")
@patch("devx.ci.release.get_changelog", return_value="changelog")
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.ci.release.time.sleep")
@patch("devx.ci.release.run_cmd")
def test_push_retry_succeeds_after_push_failure(
self,
mock_run_cmd: MagicMock,
mock_sleep: MagicMock,
mock_has: MagicMock,
mock_bumped: MagicMock,
mock_latest: MagicMock,
mock_changelog: MagicMock,
mock_update_init: MagicMock,
mock_update_changelog: MagicMock,
mock_commit: MagicMock,
mock_tag: MagicMock,
mock_run_tests: MagicMock,
mock_user: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None:
"""Push should retry after push rejection and succeed on second attempt."""
ok = MagicMock(returncode=0, stdout="master\n", stderr="")
rebase_ok = MagicMock(returncode=0, stdout="", stderr="")
push_fail = MagicMock(returncode=1, stdout="", stderr="non-fast-forward")
push_ok = MagicMock(returncode=0, stdout="", stderr="")
# git rev-parse → ok, git log -1 → ok
# attempt 1: pull --rebase → ok, push → fail
# attempt 2: pull --rebase → ok, push → ok
mock_run_cmd.side_effect = [ok, ok, rebase_ok, push_fail, rebase_ok, push_ok]
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "Push attempt 1/3 failed" in result.output
assert "Pushed release commit to master" in result.output
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
+262
View File
@@ -0,0 +1,262 @@
"""Unit tests for devx.tools.setup_image."""
import os
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from devx.tools.setup_image import (
_build_pip_extra_index_url,
_fallback_to_setup_ci,
_install_in_image,
cli,
)
class TestBuildPipExtraIndexUrl:
def test_basic_url(self) -> None:
url = _build_pip_extra_index_url(
"git.oblachno.oblachno.fyi",
"oblachno-oss",
"emil",
"tok123",
)
assert url == "https://emil:tok123@git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple/"
def test_custom_host_org(self) -> None:
url = _build_pip_extra_index_url(
"gitea.example.com",
"my-org",
"user",
"secret",
)
assert url == "https://user:secret@gitea.example.com/api/packages/my-org/pypi/simple/"
class TestInstallInImage:
@patch("devx.tools.setup_image.subprocess.run")
@patch("devx.tools.setup_image.Path")
def test_link_and_install_no_token(self, mock_path: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None:
venv_link = tmp_path / ".venv"
mock_path.return_value.exists.return_value = False
mock_path.return_value.is_symlink.return_value = False
mock_path.return_value.symlink_to = MagicMock()
with patch.dict(os.environ, {}, clear=True):
_install_in_image(str(venv_link), "/opt/venv", "", "host", "org")
mock_path.return_value.symlink_to.assert_called_once_with("/opt/venv")
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
assert "--no-cache-dir" in cmd
assert "-e" in cmd
assert "." in cmd
# No extras → spec is "."
assert ".[]" not in " ".join(cmd)
@patch("devx.tools.setup_image.subprocess.run")
@patch("devx.tools.setup_image.Path")
def test_link_and_install_with_extras(
self,
mock_path: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
venv_link = tmp_path / ".venv"
mock_path.return_value.exists.return_value = False
mock_path.return_value.is_symlink.return_value = False
mock_path.return_value.symlink_to = MagicMock()
with patch.dict(os.environ, {}, clear=True):
_install_in_image(str(venv_link), "/opt/venv", "ci,lint", "host", "org")
cmd = mock_run.call_args[0][0]
assert ".[ci,lint]" in cmd
@patch("devx.tools.setup_image.subprocess.run")
@patch("devx.tools.setup_image.Path")
def test_install_with_token_sets_pip_extra_index_url(
self,
mock_path: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
venv_link = tmp_path / ".venv"
mock_path.return_value.exists.return_value = False
mock_path.return_value.is_symlink.return_value = False
mock_path.return_value.symlink_to = MagicMock()
with patch.dict(
os.environ,
{"CI_GITEA_TOKEN": "tok123", "CI_GITEA_USERNAME": "emil"},
clear=True,
):
_install_in_image(str(venv_link), "/opt/venv", "lint", "git.host", "org")
env = mock_run.call_args[1]["env"]
assert "PIP_EXTRA_INDEX_URL" in env
assert "emil:tok123@git.host" in env["PIP_EXTRA_INDEX_URL"]
@patch("devx.tools.setup_image.subprocess.run")
@patch("devx.tools.setup_image.Path")
def test_install_with_token_defaults_username(
self,
mock_path: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
venv_link = tmp_path / ".venv"
mock_path.return_value.exists.return_value = False
mock_path.return_value.is_symlink.return_value = False
mock_path.return_value.symlink_to = MagicMock()
with patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True):
_install_in_image(str(venv_link), "/opt/venv", "", "host", "org")
env = mock_run.call_args[1]["env"]
assert "emil:tok123@host" in env["PIP_EXTRA_INDEX_URL"]
@patch("devx.tools.setup_image.subprocess.run")
@patch("devx.tools.setup_image.Path")
def test_install_removes_existing_link(
self,
mock_path: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
venv_link = tmp_path / ".venv"
mock_path.return_value.exists.return_value = True
mock_path.return_value.is_symlink.return_value = False
mock_path.return_value.unlink = MagicMock()
mock_path.return_value.symlink_to = MagicMock()
with patch.dict(os.environ, {}, clear=True):
_install_in_image(str(venv_link), "/opt/venv", "", "host", "org")
mock_path.return_value.unlink.assert_called_once()
@patch("devx.tools.setup_image.subprocess.run")
@patch("devx.tools.setup_image.Path")
def test_install_removes_existing_symlink(
self,
mock_path: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
venv_link = tmp_path / ".venv"
mock_path.return_value.exists.return_value = False
mock_path.return_value.is_symlink.return_value = True
mock_path.return_value.unlink = MagicMock()
mock_path.return_value.symlink_to = MagicMock()
with patch.dict(os.environ, {}, clear=True):
_install_in_image(str(venv_link), "/opt/venv", "", "host", "org")
mock_path.return_value.unlink.assert_called_once()
@patch("devx.tools.setup_image.subprocess.run")
@patch("devx.tools.setup_image.Path")
def test_install_failure_raises(self, mock_path: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None:
venv_link = tmp_path / ".venv"
mock_path.return_value.exists.return_value = False
mock_path.return_value.is_symlink.return_value = False
mock_path.return_value.symlink_to = MagicMock()
mock_run.side_effect = subprocess.CalledProcessError(1, ["pip"])
with patch.dict(os.environ, {}, clear=True):
with pytest.raises(subprocess.CalledProcessError):
_install_in_image(str(venv_link), "/opt/venv", "", "host", "org")
class TestFallbackToSetupCi:
@patch("devx.tools.setup_image.subprocess.run")
def test_fallback_runs_make_setup_ci(self, mock_run: MagicMock) -> None:
_fallback_to_setup_ci()
mock_run.assert_called_once_with(["make", "setup-ci"], check=True)
@patch("devx.tools.setup_image.subprocess.run")
def test_fallback_failure_raises(self, mock_run: MagicMock) -> None:
mock_run.side_effect = subprocess.CalledProcessError(1, ["make"])
with pytest.raises(subprocess.CalledProcessError):
_fallback_to_setup_ci()
class TestCli:
@patch("devx.tools.setup_image._install_in_image")
@patch("devx.tools.setup_image.Path")
def test_cli_with_opt_venv_present(
self,
mock_path: MagicMock,
mock_install: MagicMock,
) -> None:
mock_path.return_value.is_dir.return_value = True
runner = CliRunner()
result = runner.invoke(cli, ["--extras", "ci,lint"])
assert result.exit_code == 0
mock_install.assert_called_once()
@patch("devx.tools.setup_image._fallback_to_setup_ci")
@patch("devx.tools.setup_image.Path")
def test_cli_falls_back_when_no_opt_venv(
self,
mock_path: MagicMock,
mock_fallback: MagicMock,
) -> None:
mock_path.return_value.is_dir.return_value = False
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 0
mock_fallback.assert_called_once()
@patch("devx.tools.setup_image._install_in_image")
@patch("devx.tools.setup_image.Path")
def test_cli_default_values(
self,
mock_path: MagicMock,
mock_install: MagicMock,
) -> None:
mock_path.return_value.is_dir.return_value = True
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 0
call_args = mock_install.call_args[0]
assert call_args[0] == ".venv"
assert call_args[1] == "/opt/venv"
assert call_args[2] == "" # no extras
assert call_args[3] == "git.oblachno.oblachno.fyi"
assert call_args[4] == "oblachno-oss"
@patch("devx.tools.setup_image._install_in_image")
@patch("devx.tools.setup_image.Path")
def test_cli_custom_venv_and_gitea(
self,
mock_path: MagicMock,
mock_install: MagicMock,
) -> None:
mock_path.return_value.is_dir.return_value = True
runner = CliRunner()
result = runner.invoke(
cli,
["--venv", ".custom-venv", "--gitea-host", "gitea.io", "--gitea-org", "myorg"],
)
assert result.exit_code == 0
call_args = mock_install.call_args[0]
assert call_args[0] == ".custom-venv"
assert call_args[3] == "gitea.io"
assert call_args[4] == "myorg"
@patch("devx.tools.setup_image._install_in_image")
@patch("devx.tools.setup_image.Path")
def test_cli_with_extras(
self,
mock_path: MagicMock,
mock_install: MagicMock,
) -> None:
mock_path.return_value.is_dir.return_value = True
runner = CliRunner()
result = runner.invoke(cli, ["--extras", "lint"])
assert result.exit_code == 0
assert mock_install.call_args[0][2] == "lint"
+153
View File
@@ -0,0 +1,153 @@
"""Unit tests for devx.tools.setup_ssh_key."""
from __future__ import annotations
import os
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from devx.tools.setup_ssh_key import cli, setup_ssh_key
class TestSetupSshKey:
def test_success(self, tmp_path, monkeypatch) -> None:
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("SSH_PRIVATE_KEY", "-----BEGIN KEY-----\nfake\n-----END KEY-----")
with (
patch("subprocess.run") as mock_run,
patch("pathlib.Path.chmod"),
):
agent_result = MagicMock()
agent_result.returncode = 0
agent_result.stdout = "SSH_AUTH_SOCK=/tmp/agent.sock;\nSSH_AGENT_PID=12345;\n"
agent_result.stderr = ""
add_result = MagicMock()
add_result.returncode = 0
add_result.stdout = ""
add_result.stderr = ""
mock_run.side_effect = [agent_result, add_result]
assert setup_ssh_key() is True
assert mock_run.call_count == 2
def test_missing_key(self, monkeypatch) -> None:
monkeypatch.delenv("SSH_PRIVATE_KEY", raising=False)
assert setup_ssh_key() is False
def test_empty_key(self, monkeypatch) -> None:
monkeypatch.setenv("SSH_PRIVATE_KEY", "")
assert setup_ssh_key() is False
def test_explicit_key_param(self, tmp_path, monkeypatch) -> None:
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.delenv("SSH_PRIVATE_KEY", raising=False)
with (
patch("subprocess.run") as mock_run,
patch("pathlib.Path.chmod"),
):
agent_result = MagicMock()
agent_result.returncode = 0
agent_result.stdout = "SSH_AUTH_SOCK=/tmp/agent.sock;\n"
agent_result.stderr = ""
add_result = MagicMock()
add_result.returncode = 0
add_result.stdout = ""
add_result.stderr = ""
mock_run.side_effect = [agent_result, add_result]
assert setup_ssh_key("-----BEGIN KEY-----\nfake\n-----END KEY-----") is True
def test_ssh_agent_failure(self, tmp_path, monkeypatch) -> None:
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("SSH_PRIVATE_KEY", "fake-key")
with (
patch("subprocess.run") as mock_run,
patch("pathlib.Path.chmod"),
):
agent_result = MagicMock()
agent_result.returncode = 1
agent_result.stdout = ""
agent_result.stderr = "ssh-agent failed"
mock_run.return_value = agent_result
assert setup_ssh_key() is False
def test_key_file_written(self, tmp_path, monkeypatch) -> None:
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("SSH_PRIVATE_KEY", "my-secret-key")
with (
patch("subprocess.run") as mock_run,
patch("pathlib.Path.chmod") as mock_chmod,
):
agent_result = MagicMock()
agent_result.returncode = 0
agent_result.stdout = "SSH_AUTH_SOCK=/tmp/agent.sock;\n"
agent_result.stderr = ""
add_result = MagicMock()
add_result.returncode = 0
add_result.stdout = ""
add_result.stderr = ""
mock_run.side_effect = [agent_result, add_result]
setup_ssh_key()
key_file = tmp_path / ".ssh" / "id_rsa"
assert key_file.exists()
assert "my-secret-key" in key_file.read_text()
mock_chmod.assert_called_with(0o600)
def test_env_vars_set_from_agent(self, tmp_path, monkeypatch) -> None:
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("SSH_PRIVATE_KEY", "fake-key")
with (
patch("subprocess.run") as mock_run,
patch("pathlib.Path.chmod"),
):
agent_result = MagicMock()
agent_result.returncode = 0
agent_result.stdout = "SSH_AUTH_SOCK=/tmp/agent.sock;\nSSH_AGENT_PID=999;\n"
agent_result.stderr = ""
add_result = MagicMock()
add_result.returncode = 0
add_result.stdout = ""
add_result.stderr = ""
mock_run.side_effect = [agent_result, add_result]
setup_ssh_key()
assert os.environ.get("SSH_AUTH_SOCK") == "/tmp/agent.sock"
assert os.environ.get("SSH_AGENT_PID") == "999"
def test_agent_output_without_env_vars(self, tmp_path, monkeypatch) -> None:
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("SSH_PRIVATE_KEY", "fake-key")
monkeypatch.delenv("SSH_AUTH_SOCK", raising=False)
with (
patch("subprocess.run") as mock_run,
patch("pathlib.Path.chmod"),
):
agent_result = MagicMock()
agent_result.returncode = 0
agent_result.stdout = "Agent started\nsome message without equals\n"
agent_result.stderr = ""
add_result = MagicMock()
add_result.returncode = 0
add_result.stdout = ""
add_result.stderr = ""
mock_run.side_effect = [agent_result, add_result]
assert setup_ssh_key() is True
assert os.environ.get("SSH_AUTH_SOCK") is None
class TestCli:
def test_success(self, tmp_path, monkeypatch) -> None:
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("SSH_PRIVATE_KEY", "fake-key")
runner = CliRunner()
with patch("devx.tools.setup_ssh_key.setup_ssh_key") as mock_setup:
mock_setup.return_value = True
result = runner.invoke(cli, [])
assert result.exit_code == 0
assert "successfully" in result.output
def test_no_key(self, monkeypatch) -> None:
monkeypatch.delenv("SSH_PRIVATE_KEY", raising=False)
runner = CliRunner()
with patch("devx.tools.setup_ssh_key.setup_ssh_key") as mock_setup:
mock_setup.return_value = False
result = runner.invoke(cli, [])
assert result.exit_code == 1
+80 -5
View File
@@ -21,6 +21,7 @@ from devx.ci.sync_wiki import (
verify_wiki_integrity,
verify_wiki_page,
)
from devx.exceptions import APIError
class TestEncodeContent:
@@ -97,13 +98,11 @@ class TestReadDocContent:
class TestListWikiPages:
def test_returns_empty_on_api_error(self) -> None:
from devx.exceptions import APIError
def test_raises_on_api_error(self) -> None:
client = MagicMock()
client._request.side_effect = APIError(404, "not found")
result = list_wiki_pages(client)
assert result == {}
with pytest.raises(APIError):
list_wiki_pages(client)
def test_returns_page_dict(self) -> None:
client = MagicMock()
@@ -284,6 +283,43 @@ class TestVerifyWikiIntegrity:
failures = verify_wiki_integrity(client, mapping, synced)
assert len(failures) >= 3 # count mismatch, missing FAQ, stale Stale, empty Home
def test_transient_api_failure_returns_empty(self) -> None:
"""When the wiki API is unavailable after retries, integrity check
should return no failures (sync already succeeded)."""
client = MagicMock()
# _list_wiki_pages_with_retry raises APIError (retries exhausted)
with patch("devx.ci.sync_wiki._list_wiki_pages_with_retry", side_effect=APIError(0, "timeout")):
mapping = {"index.md": "Home", "faq.md": "FAQ"}
synced = {"Home": "# Home", "FAQ": "# FAQ"}
failures = verify_wiki_integrity(client, mapping, synced)
assert failures == []
def test_transient_api_failure_recovers_on_retry(self) -> None:
"""When the wiki API recovers after a retry, integrity check proceeds normally."""
client = MagicMock()
pages = {"Home": "Home", "FAQ": "FAQ"}
contents = {"Home": "# Home", "FAQ": "# FAQ"}
def mock_request(method, path, **kwargs):
resp = MagicMock()
if path == "/wiki/pages":
page_list = [{"title": t, "sub_url": s} for t, s in pages.items()]
resp.json.return_value = page_list
elif path.startswith("/wiki/page/"):
sub_url = path.replace("/wiki/page/", "")
content = contents.get(sub_url, "")
encoded = base64.b64encode(content.encode()).decode("ascii") if content else ""
resp.json.return_value = {"content_base64": encoded}
return resp
client._request.side_effect = mock_request
mapping = {"index.md": "Home", "faq.md": "FAQ"}
synced = {"Home": "# Home", "FAQ": "# FAQ"}
failures = verify_wiki_integrity(client, mapping, synced)
assert failures == []
class TestMain:
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@@ -498,3 +534,42 @@ class TestMain:
result = runner.invoke(main, ["--dry-run", "--strict", "--repo", "owner/repo"])
assert result.exit_code == 0
assert "Integrity check" not in result.output
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_initial_list_api_error_aborts(self, mock_client_cls: MagicMock) -> None:
"""When the initial page list fails, sync aborts to avoid duplicate pages."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"):
with patch("devx.ci.sync_wiki.list_wiki_pages", side_effect=APIError(0, "timeout")):
with patch("devx.ci.sync_wiki.sync_page", return_value="created"):
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo"])
assert result.exit_code != 0
assert "Failed to list existing wiki pages" in result.output
assert "Aborting" in result.output
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_verify_skips_when_refetch_fails(self, mock_client_cls: MagicMock) -> None:
"""When --verify re-fetch fails after retries, verification is skipped gracefully."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"):
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
with patch("devx.ci.sync_wiki.sync_page", return_value="updated"):
with patch(
"devx.ci.sync_wiki._list_wiki_pages_with_retry",
side_effect=APIError(0, "timeout"),
):
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo", "--verify"])
assert result.exit_code == 0
assert "Skipping content verification" in result.output
+115
View File
@@ -0,0 +1,115 @@
"""Unit tests for devx.tools.tofu_ops."""
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from devx.tools.tofu_ops import (
_run_tofu,
cli,
tofu_init,
tofu_validate,
)
class TestRunTofu:
@patch("devx.tools.tofu_ops.subprocess.run")
def test_success(self, mock_run: MagicMock, tmp_path: Path) -> None:
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
_run_tofu(["tofu", "init"], tmp_path)
mock_run.assert_called_once()
@patch("devx.tools.tofu_ops.subprocess.run")
def test_failure_raises(self, mock_run: MagicMock, tmp_path: Path) -> None:
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
with pytest.raises(Exception, match="error"):
_run_tofu(["tofu", "validate"], tmp_path)
class TestTofuInit:
@patch("devx.tools.tofu_ops._run_tofu")
def test_init_existing_dirs(self, mock_run: MagicMock, tmp_path: Path) -> None:
(tmp_path / "tofu/environments/staging").mkdir(parents=True)
(tmp_path / "tofu/environments/dns").mkdir(parents=True)
tofu_init("staging", root=str(tmp_path))
assert mock_run.call_count == 2
@patch("devx.tools.tofu_ops._run_tofu")
def test_init_skips_missing_dirs(self, mock_run: MagicMock, tmp_path: Path) -> None:
(tmp_path / "tofu/environments/staging").mkdir(parents=True)
# dns dir doesn't exist
tofu_init("staging", root=str(tmp_path))
assert mock_run.call_count == 1
@patch("devx.tools.tofu_ops._run_tofu")
def test_init_no_dirs_exist(self, mock_run: MagicMock, tmp_path: Path) -> None:
tofu_init("staging", root=str(tmp_path))
mock_run.assert_not_called()
@patch("devx.tools.tofu_ops._run_tofu")
def test_init_custom_dirs(self, mock_run: MagicMock, tmp_path: Path) -> None:
(tmp_path / "custom/dir").mkdir(parents=True)
tofu_init("staging", root=str(tmp_path), extra_dirs=["custom/dir"])
assert mock_run.call_count == 1
class TestTofuValidate:
@patch("devx.tools.tofu_ops._run_tofu")
def test_validate_all_dirs(self, mock_run: MagicMock, tmp_path: Path) -> None:
for d in [
"tofu/modules/hetzner-vm",
"tofu/modules/hetzner-network",
"tofu/environments/staging",
"tofu/environments/production",
"tofu/environments/dns",
]:
(tmp_path / d).mkdir(parents=True)
tofu_validate(root=str(tmp_path))
assert mock_run.call_count == 5
@patch("devx.tools.tofu_ops._run_tofu")
def test_validate_skips_missing(self, mock_run: MagicMock, tmp_path: Path) -> None:
(tmp_path / "tofu/environments/staging").mkdir(parents=True)
tofu_validate(root=str(tmp_path))
assert mock_run.call_count == 1
@patch("devx.tools.tofu_ops._run_tofu")
def test_validate_ci_mode(self, mock_run: MagicMock, tmp_path: Path) -> None:
(tmp_path / "tofu/environments/staging").mkdir(parents=True)
tofu_validate(root=str(tmp_path), ci=True)
# CI mode runs init + validate = 2 calls per dir
assert mock_run.call_count == 2
first_call = mock_run.call_args_list[0][0][0]
assert "init" in first_call
assert "-backend=false" in first_call
@patch("devx.tools.tofu_ops._run_tofu")
def test_validate_custom_dirs(self, mock_run: MagicMock, tmp_path: Path) -> None:
(tmp_path / "custom").mkdir()
tofu_validate(root=str(tmp_path), dirs=["custom"])
assert mock_run.call_count == 1
class TestCli:
@patch("devx.tools.tofu_ops.tofu_init")
def test_init_command(self, mock_init: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["init", "--env", "staging"])
assert result.exit_code == 0
mock_init.assert_called_once_with("staging", ".")
@patch("devx.tools.tofu_ops.tofu_validate")
def test_validate_command(self, mock_validate: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["validate"])
assert result.exit_code == 0
mock_validate.assert_called_once_with(".", ci=False)
@patch("devx.tools.tofu_ops.tofu_validate")
def test_validate_ci_command(self, mock_validate: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["validate", "--ci"])
assert result.exit_code == 0
mock_validate.assert_called_once_with(".", ci=True)