Compare commits

...
41 Commits
Author SHA1 Message Date
devx-ci-bot 951ba7de7a release: v0.33.4 [skip ci] 2026-07-06 06:18:39 +00:00
emil e796b06a91 DEVX-117: refactor: remove project-specific references from devx
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 15s
Post-merge / configure-repo (push) Successful in 19s
Post-merge / release (push) Successful in 45s
Post-merge / sync-wiki (push) Successful in 46s
Post-merge / badges (push) Successful in 46s
Post-merge / publish (push) Successful in 17s
2026-07-06 06:17:52 +00:00
gitea-actions-bot 990f2fa612 chore: update badge URLs to commit 7802ce60 [skip ci] 2026-07-06 04:56:16 +00:00
devx-ci-bot a7f5f47564 release: v0.33.3 [skip ci] 2026-07-06 04:56:04 +00:00
emil d623a64344 DEVX-115: fix: make wiki sync resilient to API timeouts and stale page lists
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 16s
Post-merge / release (push) Successful in 39s
Post-merge / configure-repo (push) Successful in 17s
Post-merge / sync-wiki (push) Successful in 45s
Post-merge / badges (push) Successful in 47s
Post-merge / publish (push) Successful in 18s
2026-07-06 04:55:06 +00:00
gitea-actions-bot 268a4e7988 chore: update badge URLs to commit b07bea6f [skip ci] 2026-07-05 20:47:45 +00:00
emil 7daaf9e4a9 DEVX-114: ci: add testing-and-debugging skill for devx repo
Post-merge / detect-type (push) Successful in 13s
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 17s
Post-merge / configure-repo (push) Successful in 20s
Post-merge / sync-wiki (push) Successful in 43s
Post-merge / badges (push) Successful in 54s
2026-07-05 20:46:21 +00:00
gitea-actions-bot b7c9334881 chore: update badge URLs to commit 83595808 [skip ci] 2026-07-05 19:18:21 +00:00
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
62 changed files with 3799 additions and 223 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
@@ -0,0 +1,98 @@
# testing-and-debugging
Make targets for testing, debugging, and CI investigation. **Use these
instead of raw `pytest`, `ruff`, or `actionlint` commands.**
## Why Make Targets
Make targets encapsulate the correct venv activation, PYTHONPATH, env
vars, and flags. Running raw commands bypasses venv activation and
produces false failures (missing dependencies, wrong Python version).
## Unit Tests
| Task | Command | Notes |
|------|---------|-------|
| Run all unit tests | `make test-unit` | Fast, no coverage |
| Run with coverage | `make pytest-cov` | **Required before push** — enforces 100% |
| Run single test | `make pytest-cov TEST=tests/test_foo.py::test_bar` | |
| Check test speed | `make check-test-speed` | Fails if tests > 10s total or > 0.5s each |
| Check test coverage | `make check-test-coverage` | Fails if source changed but tests didn't |
## Linting
| Task | Command | Notes |
|------|---------|-------|
| Full lint | `make lint-all` | ruff + workflow-lint + lint-dockerfiles |
| Ruff only | `make lint-ruff` | |
| Format check | `make lint-format` | |
| Type check | `make typecheck` | pyright |
| Bandit | `make lint-bandit` | Security linter |
| Workflow lint | `make workflow-check` | actionlint + act_runner dry-run |
| Dockerfile lint | `make lint-dockerfiles` | hadolint on all Dockerfiles |
| Check mutable globals | `make check-mutable-globals` | Detects module-level mutable state |
| Check dep docs | `make check-dep-docs` | Verifies pyproject.toml deps have comments |
## Pre-Push Verification
**Before pushing any branch:**
```bash
make pre-push
```
This runs `lint-all` + `pytest-cov`. The pre-push git hook only
validates the Vikunja task exists — it does NOT run tests. You must
run `make pre-push` manually.
## CI Failure Investigation
When investigating a CI failure:
1. **Fetch logs via MCP** — use `mcp_call_tool` with gitea server,
`actions_run_read` method, `download_job_log` tool
2. **Reproduce locally** — use `make pytest-cov` or `make lint-all`
depending on which CI job failed
3. **Never run raw pytest** — always use the make target
## Virtual Environment
All commands run inside `.venv`. `make` targets handle activation
automatically. For raw commands (rare), activate first:
```bash
source activate.sh # bash/zsh
source activate.fish # fish
source activate.zsh # zsh
```
If `.venv` doesn't exist, run `make setup` first.
## Common Pitfalls
### Coverage Verification Before Push
**Always run `make pytest-cov` before pushing** — CI enforces 100%
coverage and will fail the PR if any lines are uncovered. This is the
most common cause of CI quality job failures after code changes. The
pre-push git hook only validates Vikunja task existence, not tests.
### API Response Type Checking
Never use `is True`/`is False` identity checks on API response values.
Many APIs return boolean values as strings (`"true"`/`"false"`). Use
the `is_truthy()`/`is_falsy()` helpers from `devx.utils.api` or compare
against string values.
### Time Mocking in Tests
Always mock `time.sleep` and `time.monotonic` in unit tests using
`@patch` decorators. Real sleep calls make tests slow and exceed test
speed limits (10s total, 0.5s per test).
### Mutable Global State
The `check-mutable-globals` tool detects module-level mutable state
(lists, dicts, sets) that can cause test pollution. Avoid module-level
mutable defaults — use factory functions or `None` with initialization
inside functions.
+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:
+14 -14
View File
@@ -19,47 +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
. .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
@@ -88,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 }}" \
@@ -115,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
@@ -137,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 }}"
@@ -173,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" \
@@ -192,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" \
+15 -10
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
@@ -169,7 +169,10 @@ jobs:
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
timeout-minutes: 15
concurrency:
group: sync-wiki-${{ github.repository }}
cancel-in-progress: false
defaults:
run:
shell: bash
@@ -184,7 +187,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 +228,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 +265,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 +298,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
+132 -13
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)
@@ -84,21 +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)
│ ├── rebase.py # Rebase current branch onto origin/master + force-push
│ └── pr_rebase.py # Rebase a PR's head branch via Gitea API (server-side)
│ ├── 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
```
@@ -183,12 +200,6 @@ the PR. Then add the `ready-to-merge` label. The auto-merge workflow will:
5. The post-merge workflow marks the Vikunja task as done
6. The release workflow automatically versions, tags, and publishes
**If the branch is behind master** (another PR merged first), auto-merge
automatically rebases the PR's head branch via the Gitea API
(`POST /pulls/{index}/update?style=rebase`). This triggers a new CI run.
The next auto-merge attempt will find the branch up-to-date and merge
successfully. No manual intervention needed.
> **IMPORTANT**: Never manually merge PRs via the API. Always use the auto-merge
> workflow by adding the `ready-to-merge` label.
@@ -372,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 |
@@ -417,8 +428,6 @@ projects.
| `devx-pr-logs` | Fetch logs for failed CI jobs (`PR=`, `JOB=`, `TAIL=`) |
| `devx-pr-label` | Add a label to a PR (`PR=`, `LABEL=ready-to-merge`) |
| `devx-pr-review` | Post a review on a PR (`PR=`, `EVENT=`, `BODY=`, `CHECKLIST=`) |
| `devx-rebase` | Rebase current branch onto origin/master + force-push (`NO_PUSH=1` for local only) |
| `devx-pr-rebase` | Rebase a PR's head branch via Gitea API — server-side, no local git needed (`PR=`) |
| `devx-check-config` | Validate devx configuration |
| `devx-configure-gitea-pypi` | Configure Gitea private PyPI registry |
| `devx-env` | Create .env from .env.example |
@@ -526,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 across all projects):
| 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"`.
+66
View File
@@ -2,6 +2,72 @@
All notable changes to this project will be documented in this file.
## [0.33.4] - 2026-07-06
### Refactor
- Remove project-specific references from devx
## [0.33.3] - 2026-07-06
### Bug Fixes
- Make wiki sync resilient to API timeouts and stale page lists
## [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
+38 -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
@@ -108,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."
+9 -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/44123e779809a0f92a268ab55f841a9fad72ac9e/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/44123e779809a0f92a268ab55f841a9fad72ac9e/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/44123e779809a0f92a268ab55f841a9fad72ac9e/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/44123e779809a0f92a268ab55f841a9fad72ac9e/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/44123e779809a0f92a268ab55f841a9fad72ac9e/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/44123e779809a0f92a268ab55f841a9fad72ac9e/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7802ce6061b3c947821bf4884d66a053d378a456/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7802ce6061b3c947821bf4884d66a053d378a456/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7802ce6061b3c947821bf4884d66a053d378a456/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7802ce6061b3c947821bf4884d66a053d378a456/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7802ce6061b3c947821bf4884d66a053d378a456/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7802ce6061b3c947821bf4884d66a053d378a456/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
+8 -8
View File
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![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/44123e779809a0f92a268ab55f841a9fad72ac9e/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/44123e779809a0f92a268ab55f841a9fad72ac9e/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/44123e779809a0f92a268ab55f841a9fad72ac9e/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/44123e779809a0f92a268ab55f841a9fad72ac9e/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/44123e779809a0f92a268ab55f841a9fad72ac9e/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/44123e779809a0f92a268ab55f841a9fad72ac9e/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7802ce6061b3c947821bf4884d66a053d378a456/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7802ce6061b3c947821bf4884d66a053d378a456/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7802ce6061b3c947821bf4884d66a053d378a456/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7802ce6061b3c947821bf4884d66a053d378a456/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7802ce6061b3c947821bf4884d66a053d378a456/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7802ce6061b3c947821bf4884d66a053d378a456/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
+1 -1
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
+4 -4
View File
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
```toml
[project]
dependencies = [
"devx>=0.26.0",
"devx>=0.27.0",
]
[project.optional-dependencies]
dev = [
"devx[dev]>=0.26.0",
"devx[dev]>=0.27.0",
]
```
@@ -115,8 +115,8 @@ Add `[tool.devx]` section to `pyproject.toml` for project-specific config:
vikunja_project_id = 6
[tool.devx.classify]
# File patterns that are workflow-only (no release needed)
workflow_only = [
# File patterns that are infrastructure (no release needed)
infrastructure = [
".gitea/**",
"docs/**",
"tests/**",
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.28.0"
__version__ = "0.33.4"
+11 -4
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 ""
-1
View File
@@ -185,7 +185,6 @@ def main(translations: tuple[Path, ...], source_dir: str | None) -> None:
# 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"):
+20 -2
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.
@@ -21,6 +24,7 @@ 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:
@@ -41,15 +45,29 @@ 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(_("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."))
elif is_automated:
click.echo(_("Automated CI commit (badge) — skipping post-merge jobs."))
else:
click.echo(_("Regular merge commit — running all post-merge jobs."))
+2 -2
View File
@@ -7,7 +7,7 @@ ordering, then assigned to *max_runners* groups using LPT (Longest
Processing Time first) scheduling.
Each item is a string (e.g. an Ansible ``--limit`` pattern like
``observability`` or ``infra-314-vm``). Optionally, items can be objects
``observability`` or ``customer-1-vm``). Optionally, items can be objects
with ``{"id": "...", "weight": N}`` to provide explicit weights.
The assigned group for *runner_index* is written to ``$GITHUB_ENV`` as
@@ -15,7 +15,7 @@ The assigned group for *runner_index* is written to ``$GITHUB_ENV`` as
Usage::
echo '["observability", "infra-314-vm"]' | \\
echo '["observability", "customer-1-vm"]' | \\
python3 -m devx.ci.distribute_items \\
--runner-index 1 --max-runners 3 \\
--github-env --skip-if-excess
+47 -18
View File
@@ -122,6 +122,9 @@ def sync_page(
"""Create or update a single wiki page.
Returns "created", "updated", or "skipped" (if dry-run).
If a create fails with HTTP 400 "already exists" (the page list was
stale), re-lists the wiki and falls back to an update.
"""
if dry_run:
click.echo(_("[dry-run] Would sync page: {title} ({chars} chars)", title=page_title, chars=len(content)))
@@ -144,16 +147,36 @@ def sync_page(
return "updated"
# Create new page via POST /wiki/new
client._request(
"POST",
"/wiki/new",
json={
"title": page_title,
"content_base64": content_b64,
"message": f"Sync from docs/ — create {page_title}",
},
)
return "created"
try:
client._request(
"POST",
"/wiki/new",
json={
"title": page_title,
"content_base64": content_b64,
"message": f"Sync from docs/ — create {page_title}",
},
)
return "created"
except APIError as e:
if e.status == 400 and "already exists" in e.message.lower():
# The page list was stale (e.g. after a timeout-retry returned
# incomplete data). Re-list and fall back to update.
click.echo(_(" Page '{title}' already exists (stale list). Re-listing and updating...", title=page_title))
fresh_pages = _list_wiki_pages_with_retry(client)
if page_title in fresh_pages:
sub_url = fresh_pages[page_title]
client._request(
"PATCH",
f"/wiki/page/{sub_url}",
json={
"title": page_title,
"content_base64": content_b64,
"message": f"Sync from docs/ — update {page_title} (create→update fallback)",
},
)
return "updated"
raise
def verify_wiki_page(
@@ -173,15 +196,15 @@ def verify_wiki_page(
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.
The Gitea wiki API can be slow (it renders pages on each request)
and may time out. Uses 5 attempts with exponential backoff to handle
transient slowness.
"""
_logger = logging.getLogger("sync_wiki")
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=2, min=2, max=8),
stop=stop_after_attempt(5),
wait=wait_exponential(multiplier=2, min=2, max=16),
retry=retry_if_exception_type(APIError),
before_sleep=before_sleep_log(_logger, logging.WARNING),
reraise=True,
@@ -291,9 +314,15 @@ def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
click.echo(_("Syncing {count} documentation pages to wiki...", count=len(mapping)))
try:
existing_pages = list_wiki_pages(client)
except APIError:
existing_pages = {}
existing_pages = _list_wiki_pages_with_retry(client)
except APIError as e:
raise click.ClickException(
_(
"Failed to list existing wiki pages after retries: {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)))
+53 -14
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. 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 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
@@ -165,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)
@@ -266,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
@@ -293,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)
@@ -341,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 ───────────────────────────────────────
#
+1 -1
View File
@@ -16,7 +16,7 @@ Outputs:
- (default): prints both as ``count=N`` and ``indices=[0,1,...]``
Usage:
python3 -m devx.molecule.discover_runners --owner oblachno-oss --repo grm
python3 -m devx.molecule.discover_runners --owner my-org --repo my-repo
python3 -m devx.molecule.discover_runners --indices
python3 -m devx.molecule.discover_runners --count
"""
+1 -1
View File
@@ -137,7 +137,7 @@ def build_multi_role_pairs(
# --- Molecule weight configuration ---
#
# Weights are loaded from ``[tool.devx.molecule.weights]`` in
# ``pyproject.toml``. Each project (infra, grm, …) contributes its own
# ``pyproject.toml``. Each project contributes its own
# weights calibrated from actual CI execution times.
#
# Two key formats are supported:
+2 -2
View File
@@ -15,9 +15,9 @@ exits early with code 1.
Usage::
# Single-role (grm-style)
# Single-role
python3 -m devx.molecule.molecule_ci_guard pair1 pair2 ...
# Multi-role (infra-style)
# Multi-role
python3 -m devx.molecule.molecule_ci_guard --roles-root ansible/roles pair1 pair2 ...
Environment variables:
+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]:
+25 -5
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:
@@ -162,7 +182,7 @@ def main(repo: str | None, owner: str | None, branch: str, api_url: str | None)
if not repo:
raise click.ClickException(_("ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME."))
# If DEVX_REPO_NAME contains a slash (e.g. "oblachno/infra"), split into owner/repo.
# If DEVX_REPO_NAME contains a slash (e.g. "my-org/my-repo"), split into owner/repo.
# This prevents 404s when workflows set DEVX_REPO_NAME to the full path.
if "/" in repo and owner is None:
parts = repo.split("/", 1)
+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
+2 -2
View File
@@ -68,8 +68,8 @@ def detect_package_name(repo_root: Path) -> str | None:
Looks for the first subdirectory under ``src/`` that contains
an ``__init__.py`` file with ``__version__``.
Returns the package directory name (e.g., ``devx``,
``gitea_runner_manager``) or ``None`` if no package is found.
Returns the package directory name (e.g., ``devx``) or ``None`` if
no package is found.
"""
src_dir = repo_root / "src"
if not src_dir.is_dir():
+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}")
+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
+288
View File
@@ -2127,6 +2127,14 @@
"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}",
@@ -3070,5 +3078,285 @@
"pl": "Rebasing PR #{pr} via Gitea API...",
"ru": "Rebasing PR #{pr} via Gitea API...",
"zh": "Rebasing PR #{pr} via Gitea API..."
},
"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..."
},
" - {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"
},
"[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})。"
},
"{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}"
},
"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 版本。"
},
"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}'。"
},
"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' 来创建。"
},
"[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}。"
},
"[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} 失败(继续)。"
},
"[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})。"
},
"{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 文件中设置。"
},
"{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 文件中设置或作为环境变量传递。"
},
"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 after retries: {error}. Aborting to avoid creating duplicate pages.": {
"bg": "Неуспешно извличане на съществуващи wiki страници след повторни опити: {error}. Прекратяване, за да се избегне създаване на дублирани страници.",
"de": "Abrufen bestehender Wiki-Seiten nach Wiederholungen fehlgeschlagen: {error}. Abbruch, um doppelte Seiten zu vermeiden.",
"en": "Failed to list existing wiki pages after retries: {error}. Aborting to avoid creating duplicate pages.",
"pl": "Nie udało się wylistować istniejących stron wiki po ponownych próbach: {error}. Przerywanie, aby uniknąć tworzenia zduplikowanych stron.",
"ru": "Не удалось получить список существующих wiki-страниц после повторных попыток: {error}. Прерывание, чтобы избежать создания дубликатов страниц.",
"zh": "重试后列出现有 wiki 页面失败:{error}。正在中止以避免创建重复页面。"
},
" Page '{title}' already exists (stale list). Re-listing and updating...": {
"bg": " Страницата '{title}' вече съществува (остарял списък). Пресписване и обновяване...",
"de": " Seite '{title}' existiert bereits (veraltete Liste). Neu auflisten und aktualisieren...",
"en": " Page '{title}' already exists (stale list). Re-listing and updating...",
"pl": " Strona '{title}' już istnieje (nieaktualna lista). Ponowne listowanie i aktualizacja...",
"ru": " Страница '{title}' уже существует (устаревший список). Повторное получение списка и обновление...",
"zh": " 页面 '{title}' 已存在(列表过期)。重新列出并更新..."
}
}
+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"
+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
+14
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 --
@@ -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"
+28 -9
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")
@@ -166,7 +185,7 @@ class TestMain:
args = mock_client.ensure_branch_protection.call_args
assert args[0][0] == "develop"
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "DEVX_REPO_NAME": "oblachno/infra"}, clear=True)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "DEVX_REPO_NAME": "my-org/my-repo"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
def test_main_parses_owner_repo_from_env(self, mock_client_cls: MagicMock) -> None:
"""DEVX_REPO_NAME with 'owner/repo' format should be split."""
@@ -178,15 +197,15 @@ class TestMain:
assert result.exit_code == 0
# Verify GiteaClient was constructed with parsed owner and repo (positional)
call_args = mock_client_cls.call_args
assert call_args[0][2] == "oblachno" # owner is 3rd positional arg
assert call_args[0][3] == "infra" # repo is 4th positional arg
assert call_args[0][2] == "my-org" # owner is 3rd positional arg
assert call_args[0][3] == "my-repo" # repo is 4th positional arg
@patch.dict(
"os.environ",
{"CI_GITEA_TOKEN": "tok", "DEVX_REPO_NAME": "infra", "DEVX_REPO_OWNER": "oblachno"},
{"CI_GITEA_TOKEN": "tok", "DEVX_REPO_NAME": "my-repo", "DEVX_REPO_OWNER": "my-org"},
clear=True,
)
@patch("devx.tools.configure_repo.REPO_OWNER", "oblachno")
@patch("devx.tools.configure_repo.REPO_OWNER", "my-org")
@patch("devx.tools.configure_repo.GiteaClient")
def test_main_no_slash_when_owner_set_separately(self, mock_client_cls: MagicMock) -> None:
"""When DEVX_REPO_OWNER is set, DEVX_REPO_NAME should not be split."""
@@ -197,12 +216,12 @@ class TestMain:
result = runner.invoke(main, [])
assert result.exit_code == 0
call_args = mock_client_cls.call_args
assert call_args[0][2] == "oblachno" # owner
assert call_args[0][3] == "infra" # repo
assert call_args[0][2] == "my-org" # owner
assert call_args[0][3] == "my-repo" # repo
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.REPO_NAME", "devx")
@patch("devx.tools.configure_repo.REPO_OWNER", "oblachno-oss")
@patch("devx.tools.configure_repo.REPO_OWNER", "my-org")
@patch("devx.tools.configure_repo.GiteaClient")
def test_main_repo_from_pyproject(self, mock_client_cls: MagicMock) -> None:
"""When no env var is set, repo name should come from pyproject.toml."""
@@ -213,5 +232,5 @@ class TestMain:
result = runner.invoke(main, [])
assert result.exit_code == 0
call_args = mock_client_cls.call_args
assert call_args[0][2] == "oblachno-oss" # owner
assert call_args[0][2] == "my-org" # owner
assert call_args[0][3] == "devx" # repo
+6 -6
View File
@@ -25,14 +25,14 @@ class TestExtractTaskId:
class TestGetRepoName:
@patch.dict("os.environ", {"DEVX_REPO_NAME": "infra"})
@patch.dict("os.environ", {"DEVX_REPO_NAME": "my-repo"})
def test_from_env(self) -> None:
assert get_repo_name() == "infra"
assert get_repo_name() == "my-repo"
@patch("devx.tools.create_pr.REPO_NAME", "devx")
@patch.dict("os.environ", {"GITHUB_REPOSITORY": "oblachno/infra"}, clear=True)
@patch.dict("os.environ", {"GITHUB_REPOSITORY": "my-org/my-repo"}, clear=True)
def test_env_overrides_pyproject(self) -> None:
assert get_repo_name() == "infra"
assert get_repo_name() == "my-repo"
@patch("devx.tools.create_pr.REPO_NAME", "devx")
@patch.dict("os.environ", {}, clear=True)
@@ -40,9 +40,9 @@ class TestGetRepoName:
assert get_repo_name() == "devx"
@patch("devx.tools.create_pr.REPO_NAME", "")
@patch.dict("os.environ", {"GITHUB_REPOSITORY": "oblachno/infra"}, clear=True)
@patch.dict("os.environ", {"GITHUB_REPOSITORY": "my-org/my-repo"}, clear=True)
def test_from_github(self) -> None:
assert get_repo_name() == "infra"
assert get_repo_name() == "my-repo"
@patch("devx.tools.create_pr.REPO_NAME", "")
@patch.dict("os.environ", {}, clear=True)
+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
+1 -1
View File
@@ -592,7 +592,7 @@ class TestLptDistribute:
def test_load_balance_with_varying_weights(self) -> None:
"""LPT should produce better load balance than round-robin."""
items = list(range(7))
# Simulate infra-like weights: 2 heavy, 2 medium, 3 light
# Simulate multi-role-like weights: 2 heavy, 2 medium, 3 light
weights = [10, 10, 7, 7, 3, 3, 3]
groups = _lpt_distribute(items, weights, 3)
loads = [sum(weights[i] for i in g) for g in groups]
+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()
+3 -3
View File
@@ -105,7 +105,7 @@ class TestCli:
"RUN_ID": "123",
"JOB_NAME": "integration-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/infra",
"GITEA_REPOSITORY": "my-org/my-repo",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
@@ -152,7 +152,7 @@ class TestCli:
"RUN_ID": "123",
"JOB_NAME": "integration-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/infra",
"GITEA_REPOSITORY": "my-org/my-repo",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
@@ -197,7 +197,7 @@ class TestCli:
"RUN_ID": "123",
"JOB_NAME": "integration-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/infra",
"GITEA_REPOSITORY": "my-org/my-repo",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
+4 -4
View File
@@ -224,7 +224,7 @@ class TestCli:
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/grm",
"GITEA_REPOSITORY": "my-org/my-repo",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
@@ -292,7 +292,7 @@ class TestCli:
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/grm",
"GITEA_REPOSITORY": "my-org/my-repo",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
@@ -375,7 +375,7 @@ class TestCli:
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/grm",
"GITEA_REPOSITORY": "my-org/my-repo",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
@@ -422,7 +422,7 @@ class TestCli:
"RUN_ID": "123",
"JOB_NAME": "molecule-tests",
"MATRIX_INDEX": "0",
"GITEA_REPOSITORY": "oblachno-oss/grm",
"GITEA_REPOSITORY": "my-org/my-repo",
"PATH": os.environ.get("PATH", ""),
},
clear=True,
+5 -5
View File
@@ -713,7 +713,7 @@ class TestMain:
def test_dry_run_does_not_post(self, mock_client_class: MagicMock, mock_run: MagicMock) -> None:
mock_run.return_value = ReviewResult()
runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm", "--dry-run"], env={"CI_GITEA_TOKEN": "fake"})
result = runner.invoke(main, ["42", "my-org/my-repo", "--dry-run"], env={"CI_GITEA_TOKEN": "fake"})
assert result.exit_code == 0
assert "[dry-run]" in result.output
mock_client_class.return_value.create_review.assert_not_called()
@@ -724,7 +724,7 @@ class TestMain:
mock_run.return_value = ReviewResult()
mock_client_class.return_value.create_review.return_value = {"id": 123}
runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"CI_GITEA_TOKEN": "fake"})
result = runner.invoke(main, ["42", "my-org/my-repo"], env={"CI_GITEA_TOKEN": "fake"})
assert result.exit_code == 0
assert "Review #123" in result.output
mock_client_class.return_value.create_review.assert_called_once()
@@ -740,7 +740,7 @@ class TestMain:
{"id": 124},
]
runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"CI_GITEA_TOKEN": "fake"})
result = runner.invoke(main, ["42", "my-org/my-repo"], env={"CI_GITEA_TOKEN": "fake"})
assert result.exit_code == 0
assert "Review #124" in result.output
assert client.create_review.call_count == 2
@@ -753,12 +753,12 @@ class TestMain:
client = mock_client_class.return_value
client.create_review.side_effect = APIError(500, "Internal server error")
runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"CI_GITEA_TOKEN": "fake"})
result = runner.invoke(main, ["42", "my-org/my-repo"], env={"CI_GITEA_TOKEN": "fake"})
assert result.exit_code != 0
def test_no_token_raises(self) -> None:
runner = CliRunner()
result = runner.invoke(main, ["42", "oblachno-oss/grm"], env={"CI_GITEA_TOKEN": ""})
result = runner.invoke(main, ["42", "my-org/my-repo"], env={"CI_GITEA_TOKEN": ""})
assert result.exit_code != 0
assert "CI_GITEA_TOKEN" in result.output
+7 -7
View File
@@ -84,7 +84,7 @@ class TestPushToBadgesBranch:
class TestUpdateBadgeUrls:
def test_replaces_branch_url(self) -> None:
content = "[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/tests.svg)]"
content = "[![Tests](https://git.oblachno.oblachno.fyi/my-org/my-repo/raw/branch/badges/tests.svg)]"
result = push_badges.update_badge_urls(content, "abc123def456")
assert "raw/commit/abc123def456/tests.svg" in result
assert "raw/branch/badges" not in result
@@ -93,7 +93,7 @@ class TestUpdateBadgeUrls:
"""Old commit SHA URLs should be replaced with the new one."""
old_sha = "aabb123456789012345678901234567890123456" # 40 hex chars
new_sha = "ccdd123456789012345678901234567890123456" # 40 hex chars
content = f"[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/commit/{old_sha}/tests.svg)]"
content = f"[![Tests](https://git.oblachno.oblachno.fyi/my-org/my-repo/raw/commit/{old_sha}/tests.svg)]"
result = push_badges.update_badge_urls(content, new_sha)
assert f"raw/commit/{new_sha}/tests.svg" in result
assert old_sha not in result
@@ -105,16 +105,16 @@ class TestUpdateBadgeUrls:
def test_multiple_badges(self) -> None:
content = (
"[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/coverage.svg)]\n"
"[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/tests.svg)]\n"
"[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/version.svg)]"
"[![Coverage](https://git.oblachno.oblachno.fyi/my-org/my-repo/raw/branch/badges/coverage.svg)]\n"
"[![Tests](https://git.oblachno.oblachno.fyi/my-org/my-repo/raw/branch/badges/tests.svg)]\n"
"[![Version](https://git.oblachno.oblachno.fyi/my-org/my-repo/raw/branch/badges/version.svg)]"
)
result = push_badges.update_badge_urls(content, "abc123def456")
assert result.count("raw/commit/abc123def456/") == 3
assert "raw/branch/badges" not in result
def test_preserves_non_badge_urls(self) -> None:
content = "[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/actions/workflows/ci.yml/badge.svg)]"
content = "[![CI](https://git.oblachno.oblachno.fyi/my-org/my-repo/actions/workflows/ci.yml/badge.svg)]"
result = push_badges.update_badge_urls(content, "abc123")
assert result == content
@@ -122,7 +122,7 @@ class TestUpdateBadgeUrls:
class TestUpdateReadmeWithBadgeSha:
def test_updates_readme(self, tmp_path: Path) -> None:
readme = tmp_path / "README.md"
readme.write_text("[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/grm/raw/branch/badges/tests.svg)]")
readme.write_text("[![Tests](https://git.oblachno.oblachno.fyi/my-org/my-repo/raw/branch/badges/tests.svg)]")
with patch("subprocess.run"):
push_badges.update_readme_with_badge_sha("abc123def456", repo_root=tmp_path)
content = readme.read_text()
+1 -1
View File
@@ -1147,7 +1147,7 @@ class TestMain:
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None:
"""Release is skipped when only workflow/infra files changed."""
"""Release is skipped when only workflow/infrastructure files changed."""
mock_run_cmd.return_value = MagicMock(returncode=0, stdout="master\n", stderr="")
runner = CliRunner()
result = runner.invoke(main, [])
+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
+40 -12
View File
@@ -171,6 +171,35 @@ class TestSyncPage:
assert "content" not in payload
assert base64.b64decode(payload["content_base64"]).decode("utf-8") == "# Updated"
def test_create_falls_back_to_update_on_already_exists(self) -> None:
"""When create fails with 400 'already exists', re-list and update."""
client = MagicMock()
# First call: POST /wiki/new → 400 already exists
# Second call: PATCH /wiki/page/{sub_url} → success
create_error = APIError(400, "wiki page already exists [title: Test-Page]")
client._request.side_effect = [create_error, MagicMock()]
with patch("devx.ci.sync_wiki._list_wiki_pages_with_retry", return_value={"Test-Page": "Test-Page.-"}):
result = sync_page(client, "Test-Page", "# Content", {}, dry_run=False)
assert result == "updated"
# Verify PATCH was called (second call)
patch_call = client._request.call_args_list[1]
assert patch_call.args[0] == "PATCH"
assert "/wiki/page/Test-Page.-" in patch_call.args[1]
def test_create_raises_non_400_error(self) -> None:
"""Non-400 errors from create should propagate, not trigger fallback."""
client = MagicMock()
client._request.side_effect = APIError(500, "server error")
with pytest.raises(APIError):
sync_page(client, "Test-Page", "# Content", {}, dry_run=False)
def test_create_raises_400_not_already_exists(self) -> None:
"""400 errors that don't mention 'already exists' should propagate."""
client = MagicMock()
client._request.side_effect = APIError(400, "invalid title")
with pytest.raises(APIError):
sync_page(client, "Test-Page", "# Content", {}, dry_run=False)
class TestVerifyWikiPage:
def test_verifies_matching_content(self) -> None:
@@ -537,20 +566,21 @@ class TestMain:
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_initial_list_api_error_treated_as_empty(self, mock_client_cls: MagicMock) -> None:
"""When the initial page list fails, sync proceeds treating wiki as empty."""
def test_initial_list_api_error_aborts(self, mock_client_cls: MagicMock) -> None:
"""When the initial page list fails after retries, 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._list_wiki_pages_with_retry", 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 "Created: Home" in result.output
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")
@@ -558,17 +588,15 @@ class TestMain:
"""When --verify re-fetch fails after retries, verification is skipped gracefully."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
# Initial list succeeds, but verify re-fetch fails
list_side_effect = [{"Home": "Home"}, APIError(0, "timeout")]
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._list_wiki_pages_with_retry", side_effect=list_side_effect):
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"])
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)
+12 -12
View File
@@ -166,9 +166,9 @@ class TestCustomPrefix:
f.write(content)
return path
@patch.dict("os.environ", {"DEVX_TASK_PREFIX": "GRM"})
def test_master_accepts_grm_prefix(self) -> None:
"""Master branch accepts GRM-N: prefix when DEVX_TASK_PREFIX=GRM."""
@patch.dict("os.environ", {"DEVX_TASK_PREFIX": "PROJ"})
def test_master_accepts_proj_prefix(self) -> None:
"""Master branch accepts PROJ-N: prefix when DEVX_TASK_PREFIX=GRM."""
import importlib
import devx.ci.validate_commit_msg as vcm
@@ -177,7 +177,7 @@ class TestCustomPrefix:
importlib.reload(devx.config)
importlib.reload(vcm)
try:
msg_path = self._write_msg("GRM-66: fix: add scripts/** to infrastructure")
msg_path = self._write_msg("PROJ-66: fix: add scripts/** to infrastructure")
with patch("devx.ci.validate_commit_msg.get_branch", return_value="master"):
runner = CliRunner()
result = runner.invoke(vcm.main, [msg_path])
@@ -188,8 +188,8 @@ class TestCustomPrefix:
importlib.reload(devx.config)
importlib.reload(vcm)
@patch.dict("os.environ", {"DEVX_TASK_PREFIX": "GRM"})
def test_master_rejects_devx_prefix_when_grm_configured(self) -> None:
@patch.dict("os.environ", {"DEVX_TASK_PREFIX": "PROJ"})
def test_master_rejects_devx_prefix_when_proj_configured(self) -> None:
"""Master branch rejects DEVX-N: prefix when DEVX_TASK_PREFIX=GRM."""
import importlib
@@ -204,16 +204,16 @@ class TestCustomPrefix:
runner = CliRunner()
result = runner.invoke(vcm.main, [msg_path])
assert result.exit_code == 1
assert "GRM-N" in result.output
assert "PROJ-N" in result.output
os.unlink(msg_path)
finally:
os.environ.pop("DEVX_TASK_PREFIX", None)
importlib.reload(devx.config)
importlib.reload(vcm)
@patch.dict("os.environ", {"DEVX_TASK_PREFIX": "GRM"})
def test_feature_branch_rejects_grm_prefix(self) -> None:
"""Feature branch rejects GRM-N: prefix when DEVX_TASK_PREFIX=GRM."""
@patch.dict("os.environ", {"DEVX_TASK_PREFIX": "PROJ"})
def test_feature_branch_rejects_proj_prefix(self) -> None:
"""Feature branch rejects PROJ-N: prefix when DEVX_TASK_PREFIX=GRM."""
import importlib
import devx.ci.validate_commit_msg as vcm
@@ -222,8 +222,8 @@ class TestCustomPrefix:
importlib.reload(devx.config)
importlib.reload(vcm)
try:
msg_path = self._write_msg("GRM-66: fix: should not have prefix on branch")
with patch("devx.ci.validate_commit_msg.get_branch", return_value="GRM-66-fix"):
msg_path = self._write_msg("PROJ-66: fix: should not have prefix on branch")
with patch("devx.ci.validate_commit_msg.get_branch", return_value="PROJ-66-fix"):
runner = CliRunner()
result = runner.invoke(vcm.main, [msg_path])
assert result.exit_code == 1