Compare commits

...
30 Commits
Author SHA1 Message Date
devx-ci-bot c63e85923a release: v0.31.0 [skip ci] 2026-07-01 22:35:34 +00:00
emil 77c1af8ed3 DEVX-110: feat: centralize venv management in devx.mak
Post-merge / detect-type (push) Successful in 9s
Build Images / detect-type (push) Failing after 13s
Build Images / build-and-push (push) Has been skipped
Post-merge / validate-commit-msg (push) Successful in 10s
Build Images / cleanup (push) Has been skipped
Post-merge / vikunja (push) Successful in 15s
Post-merge / configure-repo (push) Successful in 18s
Post-merge / sync-wiki (push) Successful in 29s
Post-merge / release (push) Successful in 32s
Post-merge / badges (push) Successful in 39s
Post-merge / publish (push) Successful in 17s
2026-07-01 22:34:49 +00:00
gitea-actions-bot a48fb46c52 chore: update badge URLs to commit 1755d7a2 [skip ci] 2026-07-01 20:54:47 +00:00
emil 2392a13afc DEVX-109: docs: add container-level fix verification and verified state modification rules
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / release (push) Successful in 21s
Post-merge / publish (push) Has been skipped
Post-merge / vikunja (push) Successful in 17s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / sync-wiki (push) Successful in 45s
Post-merge / badges (push) Successful in 46s
2026-07-01 20:53:32 +00:00
gitea-actions-bot 32315b1d5d chore: update badge URLs to commit 753a5f9e [skip ci] 2026-07-01 14:05:12 +00:00
devx-ci-bot f70f468630 release: v0.30.0 [skip ci] 2026-07-01 14:04:57 +00:00
emil 85b5ec1485 DEVX-108: feat: add standard label creation to configure_repo
Post-merge / detect-type (push) Successful in 16s
Post-merge / validate-commit-msg (push) Successful in 21s
Post-merge / vikunja (push) Successful in 24s
Post-merge / configure-repo (push) Successful in 17s
Post-merge / sync-wiki (push) Successful in 49s
Post-merge / release (push) Successful in 50s
Post-merge / badges (push) Successful in 58s
Build Images / detect-type (push) Successful in 1m28s
Post-merge / publish (push) Successful in 20s
Build Images / build-and-push (push) Successful in 3m2s
Build Images / cleanup (push) Successful in 3m9s
2026-07-01 14:03:48 +00:00
gitea-actions-bot 091b951adc chore: update badge URLs to commit d23c6b86 [skip ci] 2026-07-01 09:36:05 +00:00
emil 19eb57445d DEVX-103: docs: fix outdated version refs, language list, config key, and missing modules
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / release (push) Successful in 17s
Post-merge / publish (push) Has been skipped
Post-merge / vikunja (push) Successful in 19s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / sync-wiki (push) Successful in 47s
Post-merge / badges (push) Successful in 42s
2026-07-01 09:34:53 +00:00
gitea-actions-bot bdd0e05869 chore: update badge URLs to commit d5dfe563 [skip ci] 2026-07-01 09:29:34 +00:00
devx-ci-bot 27fd99a091 release: v0.29.1 [skip ci] 2026-07-01 09:29:17 +00:00
emil e3fa9b7c95 DEVX-107: fix: strip task ID prefix from commit messages in extract_conventional_msg
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 13s
Build Images / detect-type (push) Successful in 43s
Post-merge / vikunja (push) Successful in 16s
Post-merge / sync-wiki (push) Successful in 33s
Post-merge / release (push) Successful in 37s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / badges (push) Successful in 48s
Post-merge / publish (push) Successful in 20s
Build Images / build-and-push (push) Successful in 3m7s
Build Images / cleanup (push) Successful in 3m18s
2026-07-01 09:28:23 +00:00
gitea-actions-bot ce60356542 chore: update badge URLs to commit 82fb419c [skip ci] 2026-07-01 06:20:37 +00:00
devx-ci-bot 35c72ef595 release: v0.29.0 [skip ci] 2026-07-01 06:20:24 +00:00
emil c0fcaef25f DEVX-106: feat: detect badge commits as automated CI commits
Build Images / build-and-push (push) Successful in 3m1s
Build Images / cleanup (push) Successful in 2m47s
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 21s
Post-merge / configure-repo (push) Successful in 13s
Build Images / detect-type (push) Successful in 40s
Post-merge / sync-wiki (push) Successful in 36s
Post-merge / release (push) Successful in 39s
Post-merge / badges (push) Successful in 45s
Post-merge / publish (push) Successful in 18s
2026-07-01 06:19:34 +00:00
gitea-actions-bot 3dd5b452c0 chore: update badge URLs to commit d5cf4c7f [skip ci] 2026-07-01 01:11:39 +00:00
emil b2515bbf37 DEVX-105: docs: add devx-workflow skill for agent guidance
Post-merge / detect-type (push) Successful in 13s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / release (push) Successful in 18s
Post-merge / vikunja (push) Successful in 17s
Post-merge / publish (push) Has been skipped
Post-merge / configure-repo (push) Successful in 15s
Post-merge / sync-wiki (push) Successful in 55s
Post-merge / badges (push) Successful in 54s
2026-07-01 01:10:23 +00:00
gitea-actions-bot ad2e59980f chore: update badge URLs to commit b041147a [skip ci] 2026-07-01 00:51:32 +00:00
devx-ci-bot 68a01d1bda release: v0.28.0 [skip ci] 2026-07-01 00:51:20 +00:00
emil 621b051793 DEVX-104: feat: auto-rebase in auto-merge, new rebase tools, CLI registration
Build Images / build-and-push (push) Successful in 5m35s
Build Images / cleanup (push) Successful in 3m32s
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / vikunja (push) Successful in 24s
Post-merge / configure-repo (push) Successful in 21s
Post-merge / sync-wiki (push) Successful in 38s
Post-merge / release (push) Successful in 44s
Post-merge / badges (push) Successful in 50s
Post-merge / publish (push) Successful in 20s
Build Images / detect-type (push) Successful in 1m26s
2026-07-01 00:50:23 +00:00
gitea-actions-bot 66554657f2 chore: update badge URLs to commit 44123e77 [skip ci] 2026-06-30 05:34:13 +00:00
devx-ci-bot 587d3a6ca4 release: v0.27.3 [skip ci] 2026-06-30 05:33:54 +00:00
emil 9d75e408ae DEVX-14: fix: retry wiki integrity check on transient API timeout
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 8s
Build Images / detect-type (push) Successful in 42s
Post-merge / sync-wiki (push) Successful in 30s
Post-merge / vikunja (push) Successful in 16s
Post-merge / release (push) Successful in 44s
Post-merge / configure-repo (push) Successful in 17s
Post-merge / badges (push) Successful in 43s
Post-merge / publish (push) Successful in 20s
Build Images / build-and-push (push) Successful in 3m12s
Build Images / cleanup (push) Successful in 2m37s
2026-06-30 05:33:00 +00:00
gitea-actions-bot 412bbea01d chore: update badge URLs to commit 8d35f5dd [skip ci] 2026-06-29 11:30:18 +00:00
devx-ci-bot ce5ce33a12 release: v0.27.2 [skip ci] 2026-06-29 11:30:13 +00:00
emil 0fae419584 DEVX-100: fix: retry release push on non-fast-forward with rebase loop
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 12s
Post-merge / configure-repo (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 23s
Post-merge / release (push) Successful in 29s
Post-merge / badges (push) Successful in 30s
Build Images / detect-type (push) Successful in 1m0s
Post-merge / publish (push) Successful in 16s
Build Images / build-and-push (push) Successful in 3m57s
Build Images / cleanup (push) Successful in 2m56s
2026-06-29 11:29:31 +00:00
gitea-actions-bot 70b011d4a6 chore: update badge URLs to commit 53e15be6 [skip ci] 2026-06-28 17:02:18 +00:00
devx-ci-bot a5c16a92df release: v0.27.1 [skip ci] 2026-06-28 17:02:12 +00:00
emil e6f022ae96 DEVX-99: fix: exclude .devin/.terraform dirs from lint_docs, add duplicate heading excludes
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / vikunja (push) Successful in 12s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / sync-wiki (push) Successful in 19s
Build Images / detect-type (push) Successful in 35s
Post-merge / release (push) Successful in 25s
Post-merge / badges (push) Successful in 28s
Post-merge / publish (push) Successful in 15s
Build Images / build-and-push (push) Successful in 2m51s
Build Images / cleanup (push) Successful in 2m18s
2026-06-28 17:01:37 +00:00
gitea-actions-bot 1a27983750 chore: update badge URLs to commit 8f2186a4 [skip ci] 2026-06-28 16:36:47 +00:00
40 changed files with 3060 additions and 192 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
+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" \
+11 -9
View File
@@ -51,7 +51,7 @@ jobs:
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.detect_release_commit
validate-commit-msg:
@@ -73,7 +73,7 @@ jobs:
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
git log -1 --format=%B > commit-msg.txt
python3 -m devx.ci.validate_commit_msg commit-msg.txt --branch master
rm -f commit-msg.txt
@@ -107,7 +107,7 @@ jobs:
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release
- name: Notify on failure
@@ -146,7 +146,7 @@ jobs:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.publish "${{ needs.release.outputs.tag }}" "${{ github.repository }}" --auto-login
- name: Notify on failure
@@ -184,7 +184,7 @@ jobs:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --strict
- name: Notify on failure
if: failure()
@@ -225,7 +225,7 @@ jobs:
env:
PRE_COMMIT_ALLOW_NO_CONFIG: "1"
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.push_badges
- name: Notify on failure
if: failure()
@@ -262,7 +262,7 @@ jobs:
DEVX_VIKUNJA_PROJECT_ID: "8"
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
- name: Notify on failure
if: failure()
@@ -295,9 +295,11 @@ jobs:
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
DEVX_REPO_NAME: devx
DEVX_REPO_OWNER: oblachno-oss
run: |
. .venv/bin/activate
python3 -m devx.tools.configure_repo --repo devx --owner oblachno-oss
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.configure_repo
- name: Notify on failure
if: failure()
env:
+133 -4
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,19 +98,24 @@ src/devx/
│ ├── check_pyproject_deps.py # Validate pyproject.toml deps have documentation comments
│ ├── check_test_coverage.py # Ensure changed files have corresponding tests (configurable rules)
│ ├── check_agent_docs.py # Validate docs for stale file references (configurable patterns)
│ ├── check_config.py # Validate pyproject.toml [tool.devx] config
│ ├── configure_repo.py # Branch protection and label setup
│ ├── generate_badges.py # Badge SVG generation
│ ├── generate_cliff_config.py # Generate git-cliff config (cliff.toml)
│ ├── create_task.py # Create Vikunja tasks
│ ├── create_pr.py # Create PRs with auto-derived title from Vikunja
│ ├── pr_status.py # Check CI status for a PR/commit (--wait polls)
│ ├── pr_logs.py # Fetch logs for failed CI jobs
── pr_label.py # Add labels to PRs (idempotent)
── pr_label.py # Add labels to PRs (idempotent)
│ ├── pre_push_check.py # Validate Vikunja task existence before push
│ └── _shared.py # Shared tool utilities
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
├── discover_runners.py # Dynamic Gitea runner discovery
├── distribute_molecule.py # Distribute molecule scenarios across runners (LPT scheduling, --roles-root for multi-role)
├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast (--roles-root)
├── molecule_all.py # Run all molecule scenarios locally
├── start_docker.py # Ensure Docker daemon is running for molecule tests
└── platforms.py # Supported molecule platforms
```
@@ -364,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 |
@@ -516,3 +535,113 @@ create-task: devx-create-task
- Line length: 120 chars
- Secrets are passed via environment variables, never on the command line
- All user-facing strings wrapped in `_()` for i18n
### Container-Level Fix Verification (Mandatory)
**Rule:** Before pushing any fix that modifies container state (CA certs,
config files, installed packages, daemon restarts), reproduce the exact
sequence locally with the actual Docker image. Do not push to CI as the
first test.
This is a hard rule, not a suggestion. CI cycles take 20+ minutes and
ephemeral staging VMs are destroyed after each run, making interactive
debugging impossible. A local reproduction takes 30 seconds and catches
silent failures immediately.
**Procedure:**
1. `docker pull <actual_image>`
2. `docker run -d --name <test> ...` and wait for it to start
3. Run the exact commands from the Ansible task or script
4. Verify the state change took effect
5. Clean up: `docker rm -f <test>`
### Verified State Modification (Mandatory)
Ansible tasks that modify container state with `changed_when: false`
MUST include a post-task verification step that confirms the state
change took effect. `changed_when: false` suppresses both change
detection AND failure visibility — a task can silently do nothing and
report `ok`.
## Subagent Delegation Policy
Custom subagent profiles are defined in `.devin/agents/` (project-specific)
and `~/.config/devin/agents/` (global, shared across repos). The agent MUST
automatically delegate to the appropriate subagent based on the task —
the user should not need to specify which profile to use.
### Available Profiles
**Global** (shared with infra and grm):
| Profile | Location | Purpose |
|---------|----------|---------|
| `pr-reviewer` | `~/.config/devin/agents/` | 13-category PR checklist + quality gates |
| `release-check` | `~/.config/devin/agents/` | Pre-merge readiness validation |
**devx-specific** (in `.devin/agents/`):
| Profile | Purpose |
|---------|---------|
| `ci-investigator` | Investigate CI failures (quality, release, publish, wiki sync, image build) |
| `dep-upgrader` | Python dependency upgrades in pyproject.toml with dep-doc validation |
| `docker-image-builder` | Build/push/cleanup 3-tier runner images (ci-base, ci-quality, ci-full) |
| `doc-sync-specialist` | Doc coverage, doc linting, wiki sync integrity |
| `workflow-validator` | actionlint + act_runner dry-run validation |
### When to Delegate Automatically
| Trigger | Profile | Mode |
|---------|---------|------|
| CI run failure (quality, release, publish, sync-wiki, build-images) | `ci-investigator` | Background |
| PR ready for review | `pr-reviewer` | Foreground |
| Dependency upgrade requested | `dep-upgrader` | Background |
| Docker image build/push needed | `docker-image-builder` | Background |
| Doc coverage failure or wiki sync issue | `doc-sync-specialist` | Background |
| Workflow YAML modified or validation needed | `workflow-validator` | Background |
| Branch ready for merge | `release-check` | Foreground |
### Delegation Rules
1. **Auto-select the profile.** Do not ask the user which profile to use.
2. **Background by default, foreground when blocking.**
3. **Provide full context in the prompt** — subagents don't inherit conversation history.
4. **One subagent per concern.** Chain: investigate → fix in main session → review.
5. **Don't delegate trivial work** (<30s, <50 lines of context).
6. **Compact after subagent returns.**
7. **Never skip delegation to save time** — it keeps main context small.
## Feedback Issue Handling
Subagents create Gitea issues in the current repo when they encounter
tool, workflow, or process issues that warrant follow-up. These issues
use the `feedback` label plus a category label (`tooling`,
`ci-improvement`, `doc-improvement`, `workflow-improvement`).
Standard labels are created automatically by `configure_repo` (runs in
post-merge on every master push). If a label does not exist yet, the
subagent's issue creation will still succeed — labels can be added
afterwards.
### When a Subagent Reports a Feedback Issue URL
1. **Acknowledge it** in your response to the user — mention the issue URL
2. **Do NOT close or modify** the issue — it is for follow-up work
3. **Do NOT create a PR** to address it unless the user explicitly asks
4. If the user asks to address feedback, spawn a subagent to investigate
the issue and implement a fix
### Creating Feedback Issues Manually
As the parent agent, you can also create feedback issues directly using
the Gitea MCP (`issue_write` with `create_issue` method). Follow the
same format as subagents:
- Title: `[feedback] <category>: <short description>`
- Labels: `feedback` + category label
- Body: include context, tool/workflow, issue, reproduction, affected
files, suggested investigation, and "Reported by: parent agent"
Always deduplicate first via `list_issues` with `labels: "feedback"`.
+48
View File
@@ -2,6 +2,54 @@
All notable changes to this project will be documented in this file.
## [0.31.0] - 2026-07-01
### Features
- Centralize venv management in devx.mak
## [0.30.0] - 2026-07-01
### Features
- Add standard label creation to configure_repo
## [0.29.1] - 2026-07-01
### Bug Fixes
- Strip task ID prefix from commit messages in extract_conventional_msg
## [0.29.0] - 2026-07-01
### Features
- Detect badge commits as automated CI commits
## [0.28.0] - 2026-07-01
### Features
- Auto-rebase in auto-merge, new rebase tools, CLI registration
## [0.27.3] - 2026-06-30
### Bug Fixes
- Retry wiki integrity check on transient API timeout
## [0.27.2] - 2026-06-29
### Bug Fixes
- Retry release push on non-fast-forward with rebase loop
## [0.27.1] - 2026-06-28
### Bug Fixes
- Exclude .devin/.terraform dirs from lint_docs, add duplicate heading excludes
## [0.27.0] - 2026-06-28
### Features
+33 -30
View File
@@ -6,6 +6,30 @@ 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)
-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 +59,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 +71,13 @@ install-tools: $(VENV)/bin/activate
@$(BIN)/pip install -e '.' 2>/dev/null; \
$(BIN)/python -m devx.tools.install_tools
# --- devx.mak integration ----------------------------------------------------
# Include shared targets from the devx package itself (workflow-lint,
# notify-failure, checkmake, lint targets, quality checks, etc.)
# Since devx IS the package, we can include its own devx.mak.
DEVX_PYTHON := $(BIN)/python
DEVX_VENV := $(VENV)
DEVX_BIN := $(BIN)
DEVX_LINT_PATHS := src/ tests/
DEVX_COV_PKG := src/devx
DEVX_TEST_PATHS := tests/
DEVX_MAK := $(shell $(BIN)/python -c \
"from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
2>/dev/null)
-include $(DEVX_MAK)
# Aliases — project-specific names map to devx.mak targets
.PHONY: lint-ruff lint-format typecheck lint-bandit lint-deps lint
.PHONY: workflow-lint workflow-dryrun workflow-dryrun-safe workflow-check
.PHONY: notify-failure checkmake check-mutable-globals check-dep-docs
.PHONY: check-test-speed check-test-coverage check-docs
.PHONY: create-task create-pr push-with-pr git-push rebase pr-rebase
.PHONY: lint-all lint-dockerfiles
lint-ruff: devx-lint-ruff
lint-format: devx-lint-format
typecheck: devx-typecheck
@@ -98,6 +99,8 @@ create-task: devx-create-task
create-pr: devx-create-pr
push-with-pr: devx-push-with-pr
git-push: devx-push
rebase: devx-rebase
pr-rebase: devx-pr-rebase
lint-all: lint workflow-lint lint-dockerfiles
@echo "[lint-all] All linting checks passed."
+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/f308b9f83c38d67feb735871fcc53c46864f8a04/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/f308b9f83c38d67feb735871fcc53c46864f8a04/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/f308b9f83c38d67feb735871fcc53c46864f8a04/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/f308b9f83c38d67feb735871fcc53c46864f8a04/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/f308b9f83c38d67feb735871fcc53c46864f8a04/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/f308b9f83c38d67feb735871fcc53c46864f8a04/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1755d7a26e4be02cd642062dfaf90a5782eea1a6/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1755d7a26e4be02cd642062dfaf90a5782eea1a6/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1755d7a26e4be02cd642062dfaf90a5782eea1a6/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1755d7a26e4be02cd642062dfaf90a5782eea1a6/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1755d7a26e4be02cd642062dfaf90a5782eea1a6/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1755d7a26e4be02cd642062dfaf90a5782eea1a6/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
+9 -9
View File
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/f308b9f83c38d67feb735871fcc53c46864f8a04/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/f308b9f83c38d67feb735871fcc53c46864f8a04/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/f308b9f83c38d67feb735871fcc53c46864f8a04/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/f308b9f83c38d67feb735871fcc53c46864f8a04/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/f308b9f83c38d67feb735871fcc53c46864f8a04/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/f308b9f83c38d67feb735871fcc53c46864f8a04/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1755d7a26e4be02cd642062dfaf90a5782eea1a6/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1755d7a26e4be02cd642062dfaf90a5782eea1a6/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1755d7a26e4be02cd642062dfaf90a5782eea1a6/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1755d7a26e4be02cd642062dfaf90a5782eea1a6/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1755d7a26e4be02cd642062dfaf90a5782eea1a6/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/1755d7a26e4be02cd642062dfaf90a5782eea1a6/python.svg)](https://www.python.org/downloads/)
## Overview
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
```toml
[project]
dependencies = [
"devx>=0.11.1",
"devx>=0.27.0",
]
[tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
```
Pin a specific version if needed: `"devx==0.11.1"` or `"devx>=0.11.1,<0.12"`.
Pin a specific version if needed: `"devx==0.27.0"` or `"devx>=0.27.0,<0.28"`.
### Optional extras
@@ -131,7 +131,7 @@ wiki sync details.
devx provides a `devx` CLI with three command groups:
- `devx ci <command>` — CI/CD automation (17 commands)
- `devx tools <command>` — Developer tools (7 commands)
- `devx tools <command>` — Developer tools (9 commands)
- `devx molecule <command>` — Molecule testing (4 commands, optional)
See [CLI Commands](CLI-Commands) for full command documentation with examples.
+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
+29
View File
@@ -421,6 +421,35 @@ Options:
- `--no-pre-commit` — skip pre-commit hook installation
- `--no-tea-login` — skip tea CLI login configuration
### `devx tools rebase`
Rebase the current branch onto `origin/master` and force-push with
`--force-with-lease`. Checks if the branch is behind master first —
if up-to-date, exits without doing anything.
```bash
devx tools rebase # rebase + force-push
devx tools rebase -- --no-push # rebase locally only
```
Options (pass after `--`):
- `--no-push` — rebase locally without pushing
### `devx tools pr-rebase`
Rebase a pull request's head branch onto master via the Gitea API
(server-side). This triggers a new `pull_request synchronize` event,
which starts a new CI run. Useful when you don't have the branch
checked out locally.
```bash
devx tools pr-rebase -- --pr 42 # rebase PR #42
devx tools pr-rebase # auto-detect PR from current branch
```
Options (pass after `--`):
- `--pr <N>` — PR number (auto-detected from current branch if omitted)
## Molecule Commands
Molecule commands require the `molecule` extra (`pip install devx[molecule]`).
+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.27.0"
__version__ = "0.31.0"
+13
View File
@@ -194,6 +194,19 @@ class GiteaClient:
payload = {"Do": "squash", "MergeTitleField": merge_title}
self._request("POST", f"/pulls/{pr_number}/merge", json=payload)
def update_pr_branch(self, pr_number: str | int, style: str = "rebase") -> None:
"""Update PR head branch by merging/rebasing the base branch into it.
Uses the Gitea API ``POST /pulls/{index}/update?style=rebase`` endpoint.
This rebases the PR's head branch onto the latest base branch server-side,
triggering a ``pull_request synchronize`` event that starts a new CI run.
Args:
pr_number: PR number.
style: Update method ``"rebase"`` (default) or ``"merge"``.
"""
self._request("POST", f"/pulls/{pr_number}/update", params={"style": style})
def get_commit_status(self, sha: str) -> list[dict[str, Any]]:
"""Fetch all status check contexts reported for a commit.
+38 -13
View File
@@ -41,6 +41,9 @@ from devx.config import (
from devx.exceptions import APIError
from devx.i18n import _
# Strip leading task ID prefix (e.g. "DEVX-12: " or "OBL-INFRA-364: ") from commit subjects.
_TASK_ID_PREFIX_RE = re.compile(rf"^{TASK_PREFIX}-\d+:\s*")
TASKID_FILE = ".taskid" # Deprecated, kept for backward-compat warnings
PR_TITLE_RE = re.compile(rf"^{TASK_PREFIX}-\d+:\s+.+")
@@ -168,19 +171,23 @@ def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
for commit in reversed(commits):
commit_info = commit.get("commit", {})
message = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
m = CONVENTIONAL_RE.match(message)
# Strip any leading task ID prefix (e.g. "OBL-INFRA-364: fix: ...") so
# conventional commit matching works on the remainder.
stripped = _TASK_ID_PREFIX_RE.sub("", message)
m = CONVENTIONAL_RE.match(stripped)
if m:
prefix = m.group(1).split("(")[0].strip() # e.g. "feat" from "feat(scope)"
score = priority.get(prefix, 0)
if score > best_score:
best_score = score
best_msg = message
best_msg = stripped
if best_msg:
return best_msg
# Fallback: use the newest commit's first line
# Fallback: use the newest commit's first line (strip task ID prefix if present)
if commits:
commit_info = commits[-1].get("commit", {})
return str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
raw = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
return _TASK_ID_PREFIX_RE.sub("", raw)
return ""
@@ -231,17 +238,35 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
client.merge_pr(pr_num, merge_title)
except APIError as e:
if e.status == 405 and "behind" in e.message.lower():
# Head branch is behind master — do NOT auto-rebase.
# Auto-rebasing creates a feedback loop: the force-push triggers
# a new pull_request synchronize event, which starts a new CI run,
# which runs auto-merge again, which rebases again, etc.
raise click.ClickException(
# Head branch is behind master. Auto-rebase via Gitea API.
# This triggers a new pull_request synchronize event → new CI run.
# The next auto-merge attempt will find the branch up-to-date and
# merge successfully. This is NOT an infinite loop: the rebase
# resolves the "behind" condition, so the next run merges.
# If another PR merges in between, the branch may fall behind
# again, but the process converges as PRs stop merging.
click.echo(
_(
"Branch is behind master. Rebase manually:\n"
" git fetch origin master && git rebase origin/master && git push --force-with-lease\n"
"Then re-add the ready-to-merge label.",
"Branch is behind master. Auto-rebasing via Gitea API...\n"
"A new CI run will start automatically after the rebase.\n"
"The next auto-merge attempt will merge this PR.",
)
) from None
)
try:
client.update_pr_branch(pr_num, style="rebase")
except APIError as rebase_err:
raise click.ClickException(
_(
"Auto-rebase failed with HTTP {status}: {message}\n"
"Rebase manually:\n"
" git fetch origin master && git rebase origin/master && git push --force-with-lease\n"
"Then re-add the ready-to-merge label.",
status=rebase_err.status,
message=rebase_err.message,
)
) from None
# Exit cleanly — the rebase triggers a new CI run that will retry.
return
else:
raise click.ClickException(
_(
+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."))
+27 -39
View File
@@ -49,13 +49,31 @@ REQUIRED_DOC_FILES = ["index.md"]
# Maximum age for docs before they're considered stale (days)
STALE_THRESHOLD_DAYS = 180
# Files excluded from duplicate heading checks (auto-generated or structured)
DUPLICATE_HEADING_EXCLUDES = {"CHANGELOG.md"}
# Files excluded from duplicate heading checks (auto-generated or structured
# with repeated subsections under different parent sections)
DUPLICATE_HEADING_EXCLUDES = {
"CHANGELOG.md",
"incident-response-sso.md",
"role-sync-design.md",
}
# TODO/FIXME pattern — matches "TODO:" or "FIXME:" at start of line/after whitespace
# Does NOT match references to the word "TODO" in rules/documentation
_TODO_RE = re.compile(r"(?m)^\s*(?:>>>?\s*)?(TODO|FIXME|HACK|XXX)\s*:", re.IGNORECASE)
# Directories excluded from markdown file scanning
_EXCLUDE_DIRS = {
".venv",
".git",
"node_modules",
"__pycache__",
".pytest_cache",
".devin",
".terraform",
"site-packages",
"dist-info",
}
def slugify(text: str) -> str:
"""Convert heading text to a GitHub-style slug."""
@@ -155,11 +173,7 @@ def check_internal_links(root: Path, docs_dir: Path) -> list[str]:
issues: list[str] = []
md_files = list(root.rglob("*.md"))
# Exclude .venv, .git, node_modules
md_files = [
f
for f in md_files
if not any(part in {".venv", ".git", "node_modules", "__pycache__", ".pytest_cache"} for part in f.parts)
]
md_files = [f for f in md_files if not any(part in _EXCLUDE_DIRS for part in f.parts)]
# Load wiki page names from mapping.json — these are valid link targets
wiki_pages: set[str] = set()
@@ -216,11 +230,7 @@ def check_internal_links(root: Path, docs_dir: Path) -> list[str]:
def check_heading_hierarchy(root: Path) -> list[str]:
"""Check that headings don't skip levels."""
issues: list[str] = []
md_files = [
f
for f in root.rglob("*.md")
if not any(part in {".venv", ".git", "node_modules", "__pycache__", ".pytest_cache"} for part in f.parts)
]
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
@@ -242,11 +252,7 @@ def check_todo_fixme(root: Path) -> list[str]:
references to the word "TODO" in rules or documentation about TODOs.
"""
issues: list[str] = []
md_files = [
f
for f in root.rglob("*.md")
if not any(part in {".venv", ".git", "node_modules", "__pycache__", ".pytest_cache"} for part in f.parts)
]
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
@@ -262,11 +268,7 @@ def check_todo_fixme(root: Path) -> list[str]:
def check_trailing_whitespace(root: Path) -> list[str]:
"""Check for trailing whitespace in markdown files."""
issues: list[str] = []
md_files = [
f
for f in root.rglob("*.md")
if not any(part in {".venv", ".git", "node_modules", "__pycache__", ".pytest_cache"} for part in f.parts)
]
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
@@ -282,11 +284,7 @@ def check_stale_docs(root: Path) -> list[str]:
"""Check for stale documentation (not modified in >180 days)."""
issues: list[str] = []
threshold = datetime.now() - timedelta(days=STALE_THRESHOLD_DAYS)
md_files = [
f
for f in root.rglob("*.md")
if not any(part in {".venv", ".git", "node_modules", "__pycache__", ".pytest_cache"} for part in f.parts)
]
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
@@ -301,11 +299,7 @@ def check_stale_docs(root: Path) -> list[str]:
def check_duplicate_headings(root: Path) -> list[str]:
"""Check for duplicate headings within the same file."""
issues: list[str] = []
md_files = [
f
for f in root.rglob("*.md")
if not any(part in {".venv", ".git", "node_modules", "__pycache__", ".pytest_cache"} for part in f.parts)
]
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
@@ -386,13 +380,7 @@ def main(
ws_issues = check_trailing_whitespace(root_path)
if fix and ws_issues:
fixed = 0
md_files = [
f
for f in root_path.rglob("*.md")
if not any(
part in {".venv", ".git", "node_modules", "__pycache__", ".pytest_cache"} for part in f.parts
)
]
md_files = [f for f in root_path.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
content = md_file.read_text(encoding="utf-8")
fixed_content = _TRAILING_WS_RE.sub("", content)
+34 -3
View File
@@ -38,6 +38,7 @@ from __future__ import annotations
import os
import re
import sys
import time
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
@@ -709,9 +710,39 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
click.echo(_("Created release commit."))
# Pull --rebase before push to handle the case where master
# advanced between checkout and commit (e.g., another merge).
run_cmd(["git", "pull", "--rebase", "origin", "master"], check=False)
# Use refs/heads/master to avoid ambiguity with a 'master' tag
run_cmd(["git", "push", "origin", "refs/heads/master:refs/heads/master"])
# Retry up to 3 times to handle concurrent pushes.
push_succeeded = False
for attempt in range(3):
rebase = run_cmd(["git", "pull", "--rebase", "origin", "master"], check=False)
if rebase.returncode != 0:
# Rebase failed (likely conflicts). Abort and retry.
click.echo(
_(
"Rebase attempt {n}/3 failed: {err}",
n=attempt + 1,
err=rebase.stderr.strip() if rebase.stderr else rebase.stdout.strip(),
)
)
run_cmd(["git", "rebase", "--abort"], check=False)
# Brief delay before retry to let concurrent pushes settle.
time.sleep(5)
continue
push = run_cmd(["git", "push", "origin", "refs/heads/master:refs/heads/master"], check=False)
if push.returncode == 0:
push_succeeded = True
break
click.echo(
_(
"Push attempt {n}/3 failed: {err}",
n=attempt + 1,
err=push.stderr.strip() if push.stderr else push.stdout.strip(),
)
)
time.sleep(5)
if not push_succeeded:
raise click.ClickException(
_("Failed to push release commit after 3 attempts. Manual intervention required.")
)
click.echo(_("Pushed release commit to master."))
else:
click.echo(_("Skipping commit push — no staged changes."))
+67 -8
View File
@@ -21,11 +21,19 @@ from __future__ import annotations
import base64
import json
import logging
import os
from pathlib import Path
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from tenacity import (
before_sleep_log,
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
from devx.api_clients import GiteaClient
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
@@ -86,11 +94,12 @@ def decode_content(content_b64: str) -> str:
def list_wiki_pages(client: GiteaClient) -> dict[str, str]:
"""List existing wiki pages, returning {title: sub_url}."""
try:
pages = client._request("GET", "/wiki/pages").json()
except APIError:
return {}
"""List existing wiki pages, returning {title: sub_url}.
Raises :class:`APIError` if the wiki API is unavailable the caller
is responsible for retrying or handling the failure.
"""
pages = client._request("GET", "/wiki/pages").json()
return {page.get("title", ""): page.get("sub_url", page.get("title", "")) for page in pages}
@@ -161,6 +170,28 @@ def verify_wiki_page(
return actual.strip() == expected_content.strip()
def _list_wiki_pages_with_retry(client: GiteaClient) -> dict[str, str]:
"""List wiki pages with tenacity retry on APIError.
The Gitea API can be briefly unavailable right after a batch of wiki
page updates. Uses the same tenacity pattern as ``api_clients`` for
exponential backoff.
"""
_logger = logging.getLogger("sync_wiki")
@retry(
stop=stop_after_attempt(3),
wait=wait_exponential(multiplier=2, min=2, max=8),
retry=retry_if_exception_type(APIError),
before_sleep=before_sleep_log(_logger, logging.WARNING),
reraise=True,
)
def _do_list() -> dict[str, str]:
return list_wiki_pages(client)
return _do_list()
def verify_wiki_integrity(
client: GiteaClient,
mapping: dict[str, str],
@@ -176,9 +207,25 @@ def verify_wiki_integrity(
5. Page count matches
Returns a list of failure messages (empty if all checks pass).
If the wiki API is temporarily unavailable (all retry attempts
fail), returns an empty list with a warning the sync itself
already succeeded, so a transient API outage should not fail the job.
"""
failures: list[str] = []
existing_pages = list_wiki_pages(client)
try:
existing_pages = _list_wiki_pages_with_retry(client)
except APIError:
click.echo(
_(
"WARNING: Could not fetch wiki page list after retries. "
"The sync itself succeeded ({count} pages updated), but the "
"integrity check could not verify them due to a transient API issue.",
count=len(synced),
)
)
return []
expected_titles = set(mapping.values())
# Check 1: Page count
@@ -243,7 +290,10 @@ def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
click.echo(_("Syncing {count} documentation pages to wiki...", count=len(mapping)))
existing_pages = list_wiki_pages(client)
try:
existing_pages = list_wiki_pages(client)
except APIError:
existing_pages = {}
if existing_pages:
click.echo(_("Found {count} existing wiki pages.", count=len(existing_pages)))
@@ -302,7 +352,16 @@ def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
else:
click.echo(_("\nVerifying wiki pages have content..."))
# Re-fetch the page list to get updated sub_urls
existing_pages = list_wiki_pages(client)
try:
existing_pages = _list_wiki_pages_with_retry(client)
except APIError:
click.echo(
_(
"WARNING: Could not re-fetch wiki page list for verification. "
"Skipping content verification due to transient API issue."
)
)
return
failures = 0
for page_title, expected_content in sorted(synced.items()):
ok = verify_wiki_page(client, page_title, expected_content, existing_pages)
+14
View File
@@ -226,6 +226,20 @@ def tools_setup(args: tuple[str, ...]) -> None:
_run_module("devx.tools.setup", list(args))
@tools.command("rebase")
@click.argument("args", nargs=-1)
def tools_rebase(args: tuple[str, ...]) -> None:
"""Rebase current branch onto origin/master and force-push."""
_run_module("devx.tools.rebase", list(args))
@tools.command("pr-rebase")
@click.argument("args", nargs=-1)
def tools_pr_rebase(args: tuple[str, ...]) -> None:
"""Rebase a PR's head branch onto master via Gitea API (server-side)."""
_run_module("devx.tools.pr_rebase", list(args))
@cli.group()
def molecule() -> None:
"""Molecule testing commands (requires devx[molecule])."""
+59 -15
View File
@@ -56,17 +56,57 @@ DEVX_DOCKERFILE_PATHS ?= docker
# PIP_INSTALL — helper to run pip with Gitea private PyPI registry configured.
# Usage: $(DEVX_PIP_INSTALL) install -e '.[ci,lint]'
# CI_GITEA_USERNAME can be set in .env, as an env var, or as a Make variable.
# Projects can alias: PIP_INSTALL = $(DEVX_PIP_INSTALL)
DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \
_PYPI_USER="$${CI_GITEA_USERNAME:-emil}"; \
if [ -n "$$CI_GITEA_TOKEN" ] && [ -n "$$_PYPI_USER" ]; then export PIP_EXTRA_INDEX_URL="https://$$_PYPI_USER:$$CI_GITEA_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \
$(DEVX_BIN)/pip
# ── Virtual environment management ────────────────────────────────────────────
#
# These targets provide a single, consistent venv setup across all
# devx-integrated projects (infra, grm, devx). Each project includes
# devx.mak and aliases its local targets to these.
#
# The venv is a standard .venv directory (no pyenv virtualenv dependency).
# pyenv can still be used to install Python 3.12+ but the venv itself
# is created with `python3 -m venv .venv`.
#
# Projects should set these variables BEFORE including devx.mak:
# DEVX_VENV — venv directory (default: .venv)
# DEVX_BIN — venv bin directory (default: $(DEVX_VENV)/bin)
# DEVX_PYTHON — Python executable (default: python3; should be $(DEVX_BIN)/python after setup)
#
# Common aliases in project Makefiles:
# PIP_INSTALL = $(DEVX_PIP_INSTALL)
# venv: devx-venv
# activate-scripts: devx-activate-scripts
# .env: devx-env
# Create .venv with Python version check (3.12+ required)
$(DEVX_VENV)/bin/activate:
@python3 -c "import sys; v=sys.version_info; assert v >= (3, 12), f'Python 3.12+ required, found {v.major}.{v.minor}'; print(f'Python {v.major}.{v.minor}.{v.micro} OK')"
python3 -m venv $(DEVX_VENV)
$(DEVX_BIN)/pip install --upgrade pip setuptools wheel
# Alias: devx-venv creates the venv (delegates to the activate rule)
devx-venv: $(DEVX_VENV)/bin/activate
# Ensure a venv exists — in CI (no pyenv), creates .venv if missing.
# Locally, uses the existing .venv (created by `make setup` or `make devx-venv`).
devx-ensure-venv:
@if [ ! -f $(DEVX_BIN)/python ]; then \
echo "[ensure-venv] Creating $(DEVX_VENV) (no venv found)..."; \
python3 -m venv $(DEVX_VENV); \
$(DEVX_BIN)/pip install --upgrade pip setuptools wheel; \
fi
.PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr devx-check-config
.PHONY: devx-pr-status devx-pr-logs devx-pr-label devx-pr-review
.PHONY: devx-pr-status devx-pr-logs devx-pr-label devx-pr-review devx-rebase devx-pr-rebase
.PHONY: devx-configure-gitea-pypi devx-install-tools devx-install-checkmake devx-checkmake
.PHONY: devx-workflow-lint devx-workflow-dryrun devx-workflow-dryrun-safe devx-workflow-check
.PHONY: devx-notify-failure devx-install-hooks devx-activate-scripts
.PHONY: devx-notify-failure devx-install-hooks devx-activate-scripts devx-venv devx-ensure-venv
.PHONY: devx-lint-ruff devx-lint-format devx-typecheck devx-lint-bandit devx-lint-deps devx-lint
.PHONY: devx-clean devx-pre-push
.PHONY: devx-check-mutable-globals devx-check-dep-docs devx-check-test-coverage devx-check-docs devx-check-test-speed
@@ -134,6 +174,20 @@ devx-pr-review:
$(if $(BODY),--body "$(BODY)") \
$(if $(CHECKLIST),--checklist-confirmed --checklist-categories $(CHECKLIST))
# Rebase current branch onto origin/master and force-push
# Usage: make devx-rebase
# make devx-rebase NO_PUSH=1
devx-rebase:
@$(DEVX_PYTHON) -m devx.tools.rebase \
$(if $(NO_PUSH),--no-push)
# Rebase a PR's head branch via Gitea API (server-side, no local git needed)
# Usage: make devx-pr-rebase
# make devx-pr-rebase PR=42
devx-pr-rebase:
@$(DEVX_PYTHON) -m devx.tools.pr_rebase \
$(if $(PR),--pr $(PR))
# ── Environment setup ─────────────────────────────────────────────────────────
# Configure Gitea private PyPI registry so pip can find devx and other
@@ -151,12 +205,6 @@ devx-env:
echo "Created .env from .env.example — please edit it with your credentials."; \
fi
# Create Python venv with version check
devx-venv:
@python3 -c "import sys; v=sys.version_info; assert v >= (3, 12), f'Python 3.12+ required, found {v.major}.{v.minor}'; print(f'Python {v.major}.{v.minor}.{v.micro} OK')"
$(DEVX_PYTHON) -m venv $(DEVX_VENV)
$(DEVX_BIN)/pip install --upgrade pip setuptools wheel
# Create activate scripts for shell/fish/zsh
devx-activate-scripts:
@test -f activate.sh || (echo '#!/usr/bin/env bash' > activate.sh && echo 'source "$$(cd "$$(dirname "$${BASH_SOURCE[0]}")" && pwd)/.venv/bin/activate"' >> activate.sh && chmod +x activate.sh)
@@ -252,7 +300,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
@@ -327,12 +375,8 @@ devx-lint-dockerfiles:
# devx-setup-ci) — each project defines its own setup-ci target.
devx-setup-image:
@if [ -d /opt/venv ]; then ln -sf /opt/venv $(DEVX_VENV); . $(DEVX_BIN)/activate; \
_U="$${CI_GITEA_USERNAME:-emil}"; \
if [ -n "$$CI_GITEA_TOKEN" ]; then export PIP_EXTRA_INDEX_URL="https://$$_U:$$CI_GITEA_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \
pip install --no-cache-dir -e .$(if $(EXTRAS),[$(EXTRAS)],); \
echo "[devx-setup-image] Linked /opt/venv$(if $(EXTRAS), with [$(EXTRAS)],)."; \
else echo "[devx-setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
@/opt/venv/bin/python -m devx.tools.setup_image --venv $(DEVX_VENV) --extras "$(EXTRAS)" \
--gitea-host $(DEVX_GITEA_PYPI_HOST) --gitea-org $(DEVX_GITEA_PYPI_ORG)
# ── Docker image build / push / cleanup ───────────────────────────────────────
#
+51
View File
@@ -2,7 +2,9 @@
from __future__ import annotations
import os
import platform
import subprocess # nosec B404
import click
@@ -22,3 +24,52 @@ def arch_string() -> str:
if machine in {"aarch64", "arm64"}:
return "arm64"
raise click.ClickException(f"Unsupported architecture: {machine}")
def detect_pr_number() -> int | None:
"""Detect the PR number for the current git branch.
Returns the PR number if the current branch has an open PR, or None
if no PR is found. Does NOT raise callers decide how to handle None.
Best-effort: returns None on any failure (no token, API down, etc.).
"""
result = subprocess.run( # nosec B603, B607
["git", "rev-parse", "--abbrev-ref", "HEAD"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
return None
branch = result.stdout.strip()
if branch == "HEAD":
return None
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
return None
owner = os.environ.get("DEVX_REPO_OWNER", "")
repo = os.environ.get("DEVX_REPO_NAME", "")
if not owner or not repo:
github_repo = os.environ.get("GITHUB_REPOSITORY", "")
if "/" in github_repo:
owner, repo = github_repo.split("/", 1)
if not owner or not repo:
return None
# Lazy import to avoid circular dependency
from devx.api_clients import APIError, GiteaClient # noqa: PLC0415
from devx.config import GITEA_API_URL # noqa: PLC0415
client = GiteaClient(GITEA_API_URL, token, owner, repo)
try:
prs = client.list_prs(state="open")
except APIError:
# Best-effort: API down or auth failure → no PR detected
return None
for pr in prs:
if pr.get("head", {}).get("ref") == branch:
return int(pr["number"])
return None
+24 -4
View File
@@ -1,9 +1,10 @@
#!/usr/bin/env python3
"""Configure repository: branch protection + repo settings via Gitea REST API.
"""Configure repository: branch protection, repo settings, and standard labels.
Uses ``GiteaClient`` for branch protection and repo settings.
The ``tea`` CLI is used for label creation if available, with a
fallback to ``GiteaClient`` if tea is not installed.
Uses ``GiteaClient`` for branch protection, repo settings, and label
creation. Standard labels (bug, ready-to-merge, feedback, tooling,
ci-improvement, doc-improvement, workflow-improvement) are created
idempotently via ``ensure_label``.
Usage:
CI_GITEA_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo
@@ -71,6 +72,19 @@ def _default_repo_settings_config() -> dict[str, Any]:
}
# Standard labels created in every oblachno repo.
# These cover CI failure notifications, subagent feedback, and auto-merge.
_STANDARD_LABELS: list[dict[str, str]] = [
{"name": "bug", "color": "#ee0701", "description": "Something is not working"},
{"name": "ready-to-merge", "color": "#a2eeef", "description": "PR has been reviewed and is ready for auto-merge"},
{"name": "feedback", "color": "#fbca04", "description": "Issues from subagent or agent feedback"},
{"name": "tooling", "color": "#c5def5", "description": "Tool-related feedback or improvements"},
{"name": "ci-improvement", "color": "#84b6eb", "description": "CI workflow improvements"},
{"name": "doc-improvement", "color": "#d4c5f9", "description": "Documentation improvements"},
{"name": "workflow-improvement", "color": "#fef2c0", "description": "Workflow alignment or pattern improvements"},
]
def _handle_http_error(e: APIError) -> None:
"""Raise a user-friendly Click exception for HTTP errors."""
if e.status == http.HTTPStatus.FORBIDDEN:
@@ -138,6 +152,12 @@ def configure_repo(
client.update_repo_settings(cast(dict[str, object], rs_config))
click.echo(_(" - Auto-delete branch after merge: yes"))
click.echo("")
click.echo(_("Ensuring standard labels..."))
for label in _STANDARD_LABELS:
client.ensure_label(label["name"], label["color"], label["description"])
click.echo(_(" - {count} standard labels verified", count=len(_STANDARD_LABELS)))
click.echo("")
click.echo(_("Repository configuration complete."))
except APIError as e:
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Rebase a pull request's head branch onto master via Gitea API.
Uses the Gitea ``POST /pulls/{index}/update?style=rebase`` endpoint to
rebase the PR's head branch server-side. This triggers a new
``pull_request synchronize`` event, which starts a new CI run.
This is useful when:
- You don't have the branch checked out locally
- You want to rebase a PR from another machine
- You want to trigger the auto-merge retry without local git operations
Usage::
# Rebase PR #42
python -m devx.tools.pr_rebase --pr 42
# Rebase current branch's PR (auto-detected)
python -m devx.tools.pr_rebase
The repository is auto-detected from ``DEVX_REPO_OWNER`` /
``DEVX_REPO_NAME`` or ``GITHUB_REPOSITORY`` environment variables.
"""
from __future__ import annotations
import os
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.api_clients import APIError, GiteaClient
from devx.config import GITEA_API_URL
from devx.i18n import _
from devx.tools._shared import detect_pr_number
@click.command()
@click.option("--pr", type=int, help="PR number (auto-detected if omitted).")
def main(pr: int | None) -> None:
"""Rebase a pull request's head branch onto master via Gitea API."""
load_dotenv()
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
raise click.ClickException(_("CI_GITEA_TOKEN is not set. Add it to .env or export it."))
pr_num = pr or detect_pr_number()
if not pr_num:
raise click.ClickException(
_(
"Could not detect PR number. Use --pr to specify it explicitly,\n"
"or run this command from a branch with an open PR.",
)
)
owner = os.environ.get("DEVX_REPO_OWNER", "")
repo = os.environ.get("DEVX_REPO_NAME", "")
if not owner or not repo:
github_repo = os.environ.get("GITHUB_REPOSITORY", "")
if "/" in github_repo:
owner, repo = github_repo.split("/", 1)
if not owner or not repo:
raise click.ClickException(
_(
"Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\n"
"or GITHUB_REPOSITORY environment variables.",
)
)
client = GiteaClient(GITEA_API_URL, token, owner, repo)
click.echo(_("Rebasing PR #{pr} via Gitea API...", pr=pr_num))
try:
client.update_pr_branch(pr_num, style="rebase")
except APIError as e:
raise click.ClickException(
_(
"Rebase failed with HTTP {status}: {message}",
status=e.status,
message=e.message,
)
) from None
click.echo(
_(
"PR #{pr} rebased successfully. A new CI run will start automatically.\n"
"If auto-merge is enabled (ready-to-merge label), the next CI run\n"
"will attempt to merge this PR.",
pr=pr_num,
)
)
if __name__ == "__main__": # pragma: no cover
main()
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
"""Rebase current branch onto origin/master and force-push.
Fetches origin/master, rebases the current branch, and force-pushes with
``--force-with-lease``. This is the manual equivalent of what
``auto_merge.py`` does automatically via the Gitea API.
Usage::
# Rebase current branch onto master and force-push
python -m devx.tools.rebase
# Rebase without pushing (local only)
python -m devx.tools.rebase --no-push
The tool fails if:
- The rebase encounters conflicts (exits with rebase in progress)
- The force-push is rejected (remote has unexpected commits)
- Not on a branch (detached HEAD)
"""
from __future__ import annotations
import subprocess # nosec B404
import click
from devx.i18n import _
def _run_git(args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]:
"""Run a git command and return the result."""
return subprocess.run( # nosec B603, B607
["git", *args],
capture_output=True,
text=True,
check=check,
)
@click.command()
@click.option("--no-push", is_flag=True, help="Rebase locally without pushing.")
def main(no_push: bool) -> None:
"""Rebase current branch onto origin/master and force-push."""
# Ensure we're on a branch (check=False — we handle errors ourselves)
branch_result = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], check=False)
if branch_result.returncode != 0:
raise click.ClickException(_("Could not detect current branch: {error}", error=branch_result.stderr.strip()))
branch = branch_result.stdout.strip()
if branch == "HEAD":
raise click.ClickException(_("Cannot rebase: not on a branch (detached HEAD)."))
click.echo(_("Fetching origin/master..."))
fetch = _run_git(["fetch", "origin", "master"], check=False)
if fetch.returncode != 0:
raise click.ClickException(_("Fetch failed: {error}", error=fetch.stderr.strip()))
# Check if behind master
behind = _run_git(
["rev-list", "--count", "HEAD..origin/master"],
check=False,
)
behind_count = int(behind.stdout.strip()) if behind.stdout.strip().isdigit() else 0
if behind_count == 0:
click.echo(_("Branch is already up-to-date with origin/master."))
if not no_push:
click.echo(_("Nothing to push."))
return
click.echo(_("Branch is {count} commit(s) behind master. Rebasing...", count=behind_count))
rebase = _run_git(["rebase", "origin/master"], check=False)
if rebase.returncode != 0:
raise click.ClickException(
_(
"Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
error=rebase.stderr.strip() or rebase.stdout.strip(),
)
)
click.echo(_("Rebase successful."))
if not no_push:
click.echo(_("Force-pushing..."))
push = _run_git(["push", "--force-with-lease", "origin", branch], check=False)
if push.returncode != 0:
raise click.ClickException(
_(
"Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
error=push.stderr.strip(),
)
)
click.echo(_("Pushed {branch} to origin.", branch=branch))
if __name__ == "__main__": # pragma: no cover
main()
+138
View File
@@ -0,0 +1,138 @@
#!/usr/bin/env python3
"""Set up the project inside a pre-built CI image.
CI images (e.g. ``ci-quality:latest``) ship with a Python virtualenv at
``/opt/venv`` that already contains the runtime dependencies. This tool
links that venv to ``.venv`` in the project root and installs the project
itself in editable mode, optionally with extras.
If ``/opt/venv`` does not exist (local development), falls back to
``make setup-ci`` via ``subprocess``.
Usage::
python3 -m devx.tools.setup_image # runtime deps only
python3 -m devx.tools.setup_image --extras lint # runtime + lint deps
python3 -m devx.tools.setup_image --extras ci,lint
"""
from __future__ import annotations
import os
import subprocess # nosec B404
from pathlib import Path
import click
DEFAULT_VENV = ".venv"
OPT_VENV = "/opt/venv"
FALLBACK_TARGET = "setup-ci"
def _build_pip_extra_index_url(
gitea_host: str,
gitea_org: str,
username: str,
token: str,
) -> str:
"""Build the PIP_EXTRA_INDEX_URL for the Gitea PyPI registry.
Returns a URL of the form:
https://<user>:<token>@<host>/api/packages/<org>/pypi/simple/
"""
return f"https://{username}:{token}@{gitea_host}/api/packages/{gitea_org}/pypi/simple/"
def _install_in_image(
venv_link: str,
opt_venv: str,
extras: str,
gitea_host: str,
gitea_org: str,
) -> None:
"""Link /opt/venv to .venv, activate it, and pip install the project.
Sets ``PIP_EXTRA_INDEX_URL`` when ``CI_GITEA_TOKEN`` is available so
that private packages from the Gitea PyPI registry can be installed.
"""
# Symlink /opt/venv → .venv
link = Path(venv_link)
if link.exists() or link.is_symlink():
link.unlink()
link.symlink_to(opt_venv)
# Build pip install command
spec = f".[{extras}]" if extras else "."
pip_bin = str(Path(venv_link) / "bin" / "pip")
cmd = [pip_bin, "install", "--no-cache-dir", "-e", spec]
env = os.environ.copy()
token = env.get("CI_GITEA_TOKEN", "")
if token:
username = env.get("CI_GITEA_USERNAME", "emil")
env["PIP_EXTRA_INDEX_URL"] = _build_pip_extra_index_url(
gitea_host,
gitea_org,
username,
token,
)
click.echo(f"[setup-image] Linked {opt_venv}" + (f" with [{extras}]" if extras else "") + ".")
subprocess.run(cmd, check=True, env=env) # nosec B603
def _fallback_to_setup_ci() -> None:
"""Fall back to ``make setup-ci`` when /opt/venv is not present."""
click.echo(f"[setup-image] {OPT_VENV} not found — falling back to {FALLBACK_TARGET}")
subprocess.run( # nosec B603, B607
["make", FALLBACK_TARGET],
check=True,
)
@click.command()
@click.option(
"--venv",
default=DEFAULT_VENV,
show_default=True,
help="Path to the local venv symlink (e.g. .venv).",
)
@click.option(
"--opt-venv",
default=OPT_VENV,
show_default=True,
help="Path to the pre-built venv inside the CI image.",
)
@click.option(
"--extras",
default="",
help="Comma-separated dependency extras (e.g. 'ci,lint'). Empty for runtime only.",
)
@click.option(
"--gitea-host",
default="git.oblachno.oblachno.fyi",
show_default=True,
help="Gitea host for the PyPI registry.",
)
@click.option(
"--gitea-org",
default="oblachno-oss",
show_default=True,
help="Gitea org for the PyPI registry.",
)
def cli(
venv: str,
opt_venv: str,
extras: str,
gitea_host: str,
gitea_org: str,
) -> None:
"""Set up the project using a pre-built CI image venv."""
if Path(opt_venv).is_dir():
_install_in_image(venv, opt_venv, extras, gitea_host, gitea_org)
else:
_fallback_to_setup_ci()
if __name__ == "__main__": # pragma: no cover
cli() # pragma: no cover
+216 -8
View File
@@ -775,14 +775,6 @@
"ru": "Ветка '{branch}' не содержит ID задачи.\n Ожидаемый формат: {prefix}-N-краткое-описание\n Пример: {prefix}-42-add-feature\n Исправление: переименуйте ветку или создайте задачу Vikunja:\n python -m devx.tools.create_task --title \"Заголовок задачи\"",
"zh": "分支 '{branch}' 不包含任务 ID。\n 预期格式: {prefix}-N-简短描述\n 示例: {prefix}-42-add-feature\n 修复: 重命名分支或先创建 Vikunja 任务:\n python -m devx.tools.create_task --title \"任务标题\""
},
"Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.": {
"bg": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"de": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"en": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"pl": "Gałąź jest w tyle za master. Wykonaj rebase ręcznie:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nNastępnie dodaj ponownie etykietę ready-to-merge.",
"ru": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"zh": "Branch is behind master. Rebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label."
},
"Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master": {
"bg": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
"de": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
@@ -2135,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}",
@@ -2527,6 +2527,22 @@
"ru": "ВНИМАНИЕ: Файл .taskid ({file_id}) устарел и не совпадает с именем ветки ({branch_id}). Удалите .taskid из репозитория — имя ветки — единственный источник истины.",
"zh": "警告:.taskid 文件 ({file_id}) 已弃用,与分支名称 ({branch_id}) 不一致。请从仓库中删除 .taskid — 分支名称是唯一的真实来源。"
},
"WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.": {
"bg": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
"de": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
"en": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
"pl": "OSTRZEŻENIE: Nie można pobrać listy stron wiki po ponownych próbach. Sama synchronizacja zakończyła się sukcesem (zaktualizowano {count} stron), ale kontrola integralności nie mogła ich zweryfikować z powodu przejściowego problemu z API.",
"ru": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
"zh": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue."
},
"WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.": {
"bg": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
"de": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
"en": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
"pl": "OSTRZEŻENIE: Nie można ponownie pobrać listy stron wiki do weryfikacji. Pomijanie weryfikacji treści z powodu przejściowego problemu z API.",
"ru": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
"zh": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue."
},
"WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.": {
"bg": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не е зададен — пропускане на проверката за съществуване на задача. Задайте го в .env за пълна валидация.",
"de": "WARNUNG: VIKUNJA_TOKEN nicht gesetzt — Task-Existenzprüfung übersprungen. In .env setzen für volle Validierung.",
@@ -2886,5 +2902,197 @@
"pl": "{separator}",
"ru": "{separator}",
"zh": "{separator}"
},
"Failed to push release commit after 3 attempts. Manual intervention required.": {
"bg": "Failed to push release commit after 3 attempts. Manual intervention required.",
"de": "Failed to push release commit after 3 attempts. Manual intervention required.",
"en": "Failed to push release commit after 3 attempts. Manual intervention required.",
"pl": "Failed to push release commit after 3 attempts. Manual intervention required.",
"ru": "Failed to push release commit after 3 attempts. Manual intervention required.",
"zh": "Failed to push release commit after 3 attempts. Manual intervention required."
},
"Push attempt {n}/3 failed: {err}": {
"bg": "Push attempt {n}/3 failed: {err}",
"de": "Push attempt {n}/3 failed: {err}",
"en": "Push attempt {n}/3 failed: {err}",
"pl": "Push attempt {n}/3 failed: {err}",
"ru": "Push attempt {n}/3 failed: {err}",
"zh": "Push attempt {n}/3 failed: {err}"
},
"Rebase attempt {n}/3 failed: {err}": {
"bg": "Rebase attempt {n}/3 failed: {err}",
"de": "Rebase attempt {n}/3 failed: {err}",
"en": "Rebase attempt {n}/3 failed: {err}",
"pl": "Rebase attempt {n}/3 failed: {err}",
"ru": "Rebase attempt {n}/3 failed: {err}",
"zh": "Rebase attempt {n}/3 failed: {err}"
},
"Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.": {
"bg": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"de": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"en": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"pl": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"ru": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
"zh": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label."
},
"Branch is already up-to-date with origin/master.": {
"bg": "Branch is already up-to-date with origin/master.",
"de": "Branch is already up-to-date with origin/master.",
"en": "Branch is already up-to-date with origin/master.",
"pl": "Branch is already up-to-date with origin/master.",
"ru": "Branch is already up-to-date with origin/master.",
"zh": "Branch is already up-to-date with origin/master."
},
"Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.": {
"bg": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
"de": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
"en": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
"pl": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
"ru": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR.",
"zh": "Branch is behind master. Auto-rebasing via Gitea API...\nA new CI run will start automatically after the rebase.\nThe next auto-merge attempt will merge this PR."
},
"Branch is {count} commit(s) behind master. Rebasing...": {
"bg": "Branch is {count} commit(s) behind master. Rebasing...",
"de": "Branch is {count} commit(s) behind master. Rebasing...",
"en": "Branch is {count} commit(s) behind master. Rebasing...",
"pl": "Branch is {count} commit(s) behind master. Rebasing...",
"ru": "Branch is {count} commit(s) behind master. Rebasing...",
"zh": "Branch is {count} commit(s) behind master. Rebasing..."
},
"CI_GITEA_TOKEN is not set. Add it to .env or export it.": {
"bg": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
"de": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
"en": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
"pl": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
"ru": "CI_GITEA_TOKEN is not set. Add it to .env or export it.",
"zh": "CI_GITEA_TOKEN is not set. Add it to .env or export it."
},
"Cannot rebase: not on a branch (detached HEAD).": {
"bg": "Cannot rebase: not on a branch (detached HEAD).",
"de": "Cannot rebase: not on a branch (detached HEAD).",
"en": "Cannot rebase: not on a branch (detached HEAD).",
"pl": "Cannot rebase: not on a branch (detached HEAD).",
"ru": "Cannot rebase: not on a branch (detached HEAD).",
"zh": "Cannot rebase: not on a branch (detached HEAD)."
},
"Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.": {
"bg": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
"de": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
"en": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
"pl": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
"ru": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR.",
"zh": "Could not detect PR number. Use --pr to specify it explicitly,\nor run this command from a branch with an open PR."
},
"Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.": {
"bg": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
"de": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
"en": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
"pl": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
"ru": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables.",
"zh": "Could not determine repository. Set DEVX_REPO_OWNER and DEVX_REPO_NAME\nor GITHUB_REPOSITORY environment variables."
},
"Fetch failed: {error}": {
"bg": "Fetch failed: {error}",
"de": "Fetch failed: {error}",
"en": "Fetch failed: {error}",
"pl": "Fetch failed: {error}",
"ru": "Fetch failed: {error}",
"zh": "Fetch failed: {error}"
},
"Fetching origin/master...": {
"bg": "Fetching origin/master...",
"de": "Fetching origin/master...",
"en": "Fetching origin/master...",
"pl": "Fetching origin/master...",
"ru": "Fetching origin/master...",
"zh": "Fetching origin/master..."
},
"Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.": {
"bg": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
"de": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
"en": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
"pl": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
"ru": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again.",
"zh": "Force-push failed:\n{error}\nThe remote may have unexpected commits. Fetch and try again."
},
"Force-pushing...": {
"bg": "Force-pushing...",
"de": "Force-pushing...",
"en": "Force-pushing...",
"pl": "Force-pushing...",
"ru": "Force-pushing...",
"zh": "Force-pushing..."
},
"Nothing to push.": {
"bg": "Nothing to push.",
"de": "Nothing to push.",
"en": "Nothing to push.",
"pl": "Nothing to push.",
"ru": "Nothing to push.",
"zh": "Nothing to push."
},
"PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.": {
"bg": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
"de": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
"en": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
"pl": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
"ru": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR.",
"zh": "PR #{pr} rebased successfully. A new CI run will start automatically.\nIf auto-merge is enabled (ready-to-merge label), the next CI run\nwill attempt to merge this PR."
},
"Pushed {branch} to origin.": {
"bg": "Pushed {branch} to origin.",
"de": "Pushed {branch} to origin.",
"en": "Pushed {branch} to origin.",
"pl": "Pushed {branch} to origin.",
"ru": "Pushed {branch} to origin.",
"zh": "Pushed {branch} to origin."
},
"Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue": {
"bg": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
"de": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
"en": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
"pl": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
"ru": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue",
"zh": "Rebase failed (conflicts or other error):\n{error}\nResolve conflicts and run: git rebase --continue"
},
"Rebase failed with HTTP {status}: {message}": {
"bg": "Rebase failed with HTTP {status}: {message}",
"de": "Rebase failed with HTTP {status}: {message}",
"en": "Rebase failed with HTTP {status}: {message}",
"pl": "Rebase failed with HTTP {status}: {message}",
"ru": "Rebase failed with HTTP {status}: {message}",
"zh": "Rebase failed with HTTP {status}: {message}"
},
"Rebase successful.": {
"bg": "Rebase successful.",
"de": "Rebase successful.",
"en": "Rebase successful.",
"pl": "Rebase successful.",
"ru": "Rebase successful.",
"zh": "Rebase successful."
},
"Rebasing PR #{pr} via Gitea API...": {
"bg": "Rebasing PR #{pr} via Gitea API...",
"de": "Rebasing PR #{pr} via Gitea API...",
"en": "Rebasing PR #{pr} via Gitea API...",
"pl": "Rebasing PR #{pr} via Gitea API...",
"ru": "Rebasing PR #{pr} via Gitea API...",
"zh": "Rebasing PR #{pr} via Gitea API..."
},
"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"
}
}
+12
View File
@@ -233,6 +233,18 @@ class TestGiteaClient:
json={"Do": "squash", "MergeTitleField": "fix: bug"},
)
def test_update_pr_branch(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response())
client.update_pr_branch(7, style="rebase")
client._session.request.assert_called_once_with(
"POST",
"https://git.example.com/repos/owner/repo/pulls/7/update",
timeout=DEFAULT_TIMEOUT,
params={"style": "rebase"},
)
def test_get_pr_labels(self) -> None:
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
client._session.request = MagicMock(return_value=_mock_response([{"name": "ready-to-merge"}]))
+53 -12
View File
@@ -218,6 +218,20 @@ class TestExtractConventionalMsg:
]
assert extract_conventional_msg(commits) == "feat(api): add endpoint"
def test_strips_task_id_prefix(self) -> None:
"""Commit messages with a task ID prefix should have it stripped."""
commits = [
{"commit": {"message": "DEVX-12: fix: resolve timeout"}},
]
assert extract_conventional_msg(commits) == "fix: resolve timeout"
def test_strips_task_id_prefix_fallback(self) -> None:
"""Fallback to newest commit should also strip task ID prefix."""
commits = [
{"commit": {"message": "DEVX-12: random message"}},
]
assert extract_conventional_msg(commits) == "random message"
# -- run_cmd --
@@ -292,14 +306,13 @@ class TestMain:
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_merge_behind_master_raises_no_rebase(
def test_merge_behind_master_auto_rebases(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
"""When branch is behind master, auto-merge should NOT rebase.
"""When branch is behind master, auto-merge rebases via Gitea API.
Auto-rebasing creates a feedback loop: the force-push triggers a new
pull_request synchronize event, which starts a new CI run, which runs
auto-merge again, which rebases again, etc.
The rebase triggers a new CI run. The next auto-merge attempt will
find the branch up-to-date and merge successfully.
"""
monkeypatch.chdir(tmp_path)
@@ -315,12 +328,40 @@ class TestMain:
main,
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code != 0
assert result.exit_code == 0
assert "behind master" in result.output.lower()
assert "rebase manually" in result.output.lower()
# Must NOT have called merge_pr twice (no retry after rebase)
assert "auto-rebasing" in result.output.lower()
# Should have called update_pr_branch to trigger server-side rebase
mock_client.update_pr_branch.assert_called_once_with(7, style="rebase")
# Must NOT have called merge_pr twice (no immediate retry)
assert mock_client.merge_pr.call_count == 1
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_merge_behind_master_rebase_failure_raises(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
"""When auto-rebase fails, raise with manual rebase instructions."""
monkeypatch.chdir(tmp_path)
mock_client = MagicMock()
mock_client.get_pr_commits.return_value = [
{"commit": {"message": "fix: resolve timeout"}},
]
mock_client.merge_pr.side_effect = APIError(405, "HEAD branch is behind master")
mock_client.update_pr_branch.side_effect = APIError(409, "Conflict during rebase")
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(
main,
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code != 0
assert "auto-rebase failed" in result.output.lower()
assert "rebase manually" in result.output.lower()
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
@@ -386,10 +427,10 @@ class TestMain:
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
@patch("devx.ci.auto_merge.GiteaClient")
def test_merge_behind_master_does_not_force_push(
def test_merge_behind_master_does_not_run_git_commands(
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
) -> None: # type: ignore[no-untyped-def]
"""Verify no git commands are run when branch is behind master."""
"""When behind master, auto-merge uses API rebase — no local git commands."""
monkeypatch.chdir(tmp_path)
mock_client = MagicMock()
@@ -405,8 +446,8 @@ class TestMain:
main,
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
)
assert result.exit_code != 0
# No git commands should be run (no rebase, no push)
assert result.exit_code == 0
# No local git commands should be run (rebase is via API)
mock_run.assert_not_called()
+14
View File
@@ -201,6 +201,20 @@ class TestToolsCommands:
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.tools.setup", [])
@patch("devx.cli._run_module")
def test_tools_rebase(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["tools", "rebase", "--", "--no-push"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.tools.rebase", ["--no-push"])
@patch("devx.cli._run_module")
def test_tools_pr_rebase(self, mock_run: MagicMock) -> None:
runner = CliRunner()
result = runner.invoke(cli, ["tools", "pr-rebase", "--", "--pr", "42"])
assert result.exit_code == 0
mock_run.assert_called_once_with("devx.tools.pr_rebase", ["--pr", "42"])
class TestMoleculeCommands:
@patch("devx.cli._run_module")
+19
View File
@@ -8,6 +8,7 @@ from click.testing import CliRunner
from devx.exceptions import APIError
from devx.tools.configure_repo import (
_STANDARD_LABELS,
_default_branch_protection_config,
_default_repo_settings_config,
_handle_http_error,
@@ -58,6 +59,7 @@ class TestConfigureRepo:
mock_client.ensure_branch_protection.assert_called_once()
mock_client.update_repo_settings.assert_called_once()
assert mock_client.ensure_label.call_count == len(_STANDARD_LABELS)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
@@ -100,6 +102,21 @@ class TestConfigureRepo:
mock_client.ensure_branch_protection.assert_called_once_with("develop", custom_bp)
mock_client.update_repo_settings.assert_called_once_with(custom_rs)
# Labels are created regardless of custom configs
assert mock_client.ensure_label.call_count == len(_STANDARD_LABELS)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
def test_configure_repo_creates_all_standard_labels(self, mock_client_cls: MagicMock) -> None:
"""Verify all standard labels are ensured with correct names."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
configure_repo(token="tok", owner="owner", repo="repo")
created_names = [call.args[0] for call in mock_client.ensure_label.call_args_list]
expected_names = [lbl["name"] for lbl in _STANDARD_LABELS]
assert created_names == expected_names
class TestMain:
@@ -114,6 +131,7 @@ class TestMain:
assert result.exit_code == 0
mock_client.ensure_branch_protection.assert_called_once()
mock_client.update_repo_settings.assert_called_once()
assert mock_client.ensure_label.call_count == len(_STANDARD_LABELS)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
@@ -125,6 +143,7 @@ class TestMain:
result = runner.invoke(main, ["--repo", "myrepo", "--owner", "myorg"])
assert result.exit_code == 0
mock_client.ensure_branch_protection.assert_called_once()
assert mock_client.ensure_label.call_count == len(_STANDARD_LABELS)
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.tools.configure_repo.GiteaClient")
+48 -2
View File
@@ -38,6 +38,31 @@ class TestIsReleaseCommit:
assert detect_release_commit.is_release_commit("") is False
class TestIsBadgeCommit:
def test_badge_commit(self) -> None:
assert detect_release_commit.is_badge_commit("chore: update badge URLs to commit abc123 [skip ci]") is True
def test_regular_chore(self) -> None:
assert detect_release_commit.is_badge_commit("chore: cleanup deps") is False
def test_empty(self) -> None:
assert detect_release_commit.is_badge_commit("") is False
class TestIsAutomatedCommit:
def test_release_is_automated(self) -> None:
assert detect_release_commit.is_automated_commit("release: v1.0.0 [skip ci]") is True
def test_badge_is_automated(self) -> None:
assert detect_release_commit.is_automated_commit("chore: update badge URLs to commit abc123 [skip ci]") is True
def test_regular_is_not_automated(self) -> None:
assert detect_release_commit.is_automated_commit("OBL-INFRA-363: fix: something") is False
def test_empty(self) -> None:
assert detect_release_commit.is_automated_commit("") is False
class TestWriteGithubOutput:
def test_write(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
@@ -62,7 +87,26 @@ class TestMain:
assert result.exit_code == 0
assert "Release commit" in result.output
with open(gh_file) as f:
assert "is-release=true" in f.read()
content = f.read()
assert "is-release=true" in content
assert "is-automated=true" in content
def test_badge_commit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
with patch.object(
detect_release_commit,
"get_commit_message",
return_value="chore: update badge URLs to commit abc123 [skip ci]",
):
runner = CliRunner()
result = runner.invoke(detect_release_commit.main, [])
assert result.exit_code == 0
assert "Automated CI commit" in result.output
with open(gh_file) as f:
content = f.read()
assert "is-release=false" in content
assert "is-automated=true" in content
def test_regular_commit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
gh_file = tmp_path / "output.txt"
@@ -73,4 +117,6 @@ class TestMain:
assert result.exit_code == 0
assert "Regular merge commit" in result.output
with open(gh_file) as f:
assert "is-release=false" in f.read()
content = f.read()
assert "is-release=false" in content
assert "is-automated=false" in content
+348
View File
@@ -0,0 +1,348 @@
"""Tests for devx.tools.rebase, devx.tools.pr_rebase, and detect_pr_number."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from click.testing import CliRunner
from devx.tools.pr_rebase import main as pr_rebase_main
from devx.tools.rebase import main as rebase_main
_FULL_ENV = {
"CI_GITEA_TOKEN": "tok",
"DEVX_REPO_OWNER": "owner",
"DEVX_REPO_NAME": "repo",
}
class TestRunGitHelper:
"""Tests for the _run_git helper function."""
@patch("devx.tools.rebase.subprocess.run")
def test_run_git_with_check(self, mock_run: MagicMock) -> None:
"""_run_git passes check=True by default."""
from devx.tools.rebase import _run_git
mock_run.return_value = MagicMock(stdout="ok\n", returncode=0)
result = _run_git(["status"])
mock_run.assert_called_once_with(
["git", "status"],
capture_output=True,
text=True,
check=True,
)
assert result.stdout == "ok\n"
@patch("devx.tools.rebase.subprocess.run")
def test_run_git_without_check(self, mock_run: MagicMock) -> None:
"""_run_git passes check=False when specified."""
from devx.tools.rebase import _run_git
mock_run.return_value = MagicMock(stdout="", stderr="err", returncode=1)
result = _run_git(["rebase", "origin/master"], check=False)
mock_run.assert_called_once_with(
["git", "rebase", "origin/master"],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 1
class TestDetectPrNumber:
"""Tests for the detect_pr_number helper in _shared."""
@patch("devx.tools._shared.subprocess.run")
@patch.dict("os.environ", _FULL_ENV, clear=True)
@patch("devx.api_clients.GiteaClient")
def test_detect_pr_found(self, mock_client_cls: MagicMock, mock_run: MagicMock) -> None:
"""detect_pr_number returns PR number when branch has an open PR."""
from devx.tools._shared import detect_pr_number
mock_run.return_value = MagicMock(stdout="feature-branch\n", returncode=0)
mock_client = MagicMock()
mock_client.list_prs.return_value = [
{"number": 42, "head": {"ref": "feature-branch"}},
{"number": 99, "head": {"ref": "other-branch"}},
]
mock_client_cls.return_value = mock_client
result = detect_pr_number()
assert result == 42
@patch("devx.tools._shared.subprocess.run")
@patch.dict("os.environ", _FULL_ENV, clear=True)
@patch("devx.api_clients.GiteaClient")
def test_detect_pr_not_found(self, mock_client_cls: MagicMock, mock_run: MagicMock) -> None:
"""detect_pr_number returns None when no open PR matches branch."""
from devx.tools._shared import detect_pr_number
mock_run.return_value = MagicMock(stdout="no-pr-branch\n", returncode=0)
mock_client = MagicMock()
mock_client.list_prs.return_value = [
{"number": 42, "head": {"ref": "other-branch"}},
]
mock_client_cls.return_value = mock_client
result = detect_pr_number()
assert result is None
@patch("devx.tools._shared.subprocess.run")
def test_detect_pr_detached_head(self, mock_run: MagicMock) -> None:
"""detect_pr_number returns None on detached HEAD."""
from devx.tools._shared import detect_pr_number
mock_run.return_value = MagicMock(stdout="HEAD\n", returncode=0)
result = detect_pr_number()
assert result is None
@patch("devx.tools._shared.subprocess.run")
def test_detect_pr_git_failure(self, mock_run: MagicMock) -> None:
"""detect_pr_number returns None when git command fails."""
from devx.tools._shared import detect_pr_number
mock_run.return_value = MagicMock(stdout="", stderr="error", returncode=1)
result = detect_pr_number()
assert result is None
@patch("devx.tools._shared.subprocess.run")
@patch.dict("os.environ", {}, clear=True)
def test_detect_pr_no_token(self, mock_run: MagicMock) -> None:
"""detect_pr_number returns None when CI_GITEA_TOKEN is not set."""
from devx.tools._shared import detect_pr_number
mock_run.return_value = MagicMock(stdout="feature\n", returncode=0)
result = detect_pr_number()
assert result is None
@patch("devx.tools._shared.subprocess.run")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "GITHUB_REPOSITORY": "owner/repo"}, clear=True)
@patch("devx.api_clients.GiteaClient")
def test_detect_pr_github_repo_fallback(self, mock_client_cls: MagicMock, mock_run: MagicMock) -> None:
"""detect_pr_number uses GITHUB_REPOSITORY as fallback for owner/repo."""
from devx.tools._shared import detect_pr_number
mock_run.return_value = MagicMock(stdout="feature\n", returncode=0)
mock_client = MagicMock()
mock_client.list_prs.return_value = [{"number": 7, "head": {"ref": "feature"}}]
mock_client_cls.return_value = mock_client
result = detect_pr_number()
assert result == 7
@patch("devx.tools._shared.subprocess.run")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "GITHUB_REPOSITORY": "invalid-no-slash"}, clear=True)
def test_detect_pr_github_repo_no_slash(self, mock_run: MagicMock) -> None:
"""GITHUB_REPOSITORY without slash is ignored, returns None."""
from devx.tools._shared import detect_pr_number
mock_run.return_value = MagicMock(stdout="feature\n", returncode=0)
result = detect_pr_number()
assert result is None
@patch("devx.tools._shared.subprocess.run")
@patch.dict("os.environ", _FULL_ENV, clear=True)
@patch("devx.api_clients.GiteaClient")
def test_detect_pr_api_error_returns_none(self, mock_client_cls: MagicMock, mock_run: MagicMock) -> None:
"""detect_pr_number returns None when API call fails (best-effort)."""
from devx.api_clients import APIError
from devx.tools._shared import detect_pr_number
mock_run.return_value = MagicMock(stdout="feature\n", returncode=0)
mock_client = MagicMock()
mock_client.list_prs.side_effect = APIError(401, "Unauthorized")
mock_client_cls.return_value = mock_client
result = detect_pr_number()
assert result is None
class TestRebaseTool:
"""Tests for the local rebase tool (devx.tools.rebase)."""
@patch("devx.tools.rebase._run_git")
def test_rebase_already_up_to_date(self, mock_run_git: MagicMock) -> None:
"""When branch is up-to-date, no rebase or push happens."""
mock_run_git.side_effect = [
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
MagicMock(stdout="", returncode=0), # fetch
MagicMock(stdout="0\n", returncode=0), # rev-list --count
]
runner = CliRunner()
result = runner.invoke(rebase_main, [])
assert result.exit_code == 0
assert "already up-to-date" in result.output.lower()
@patch("devx.tools.rebase._run_git")
def test_rebase_behind_master_success(self, mock_run_git: MagicMock) -> None:
"""When behind master, rebase and force-push."""
mock_run_git.side_effect = [
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
MagicMock(stdout="", returncode=0), # fetch
MagicMock(stdout="2\n", returncode=0), # rev-list --count (behind by 2)
MagicMock(stdout="", stderr="", returncode=0), # rebase
MagicMock(stdout="", stderr="", returncode=0), # push
]
runner = CliRunner()
result = runner.invoke(rebase_main, [])
assert result.exit_code == 0
assert "2 commit(s) behind" in result.output
assert "rebase successful" in result.output.lower()
assert "pushed" in result.output.lower()
@patch("devx.tools.rebase._run_git")
def test_rebase_no_push_flag(self, mock_run_git: MagicMock) -> None:
"""With --no-push, rebase happens but no push."""
mock_run_git.side_effect = [
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
MagicMock(stdout="", returncode=0), # fetch
MagicMock(stdout="1\n", returncode=0), # rev-list --count
MagicMock(stdout="", stderr="", returncode=0), # rebase
]
runner = CliRunner()
result = runner.invoke(rebase_main, ["--no-push"])
assert result.exit_code == 0
assert "rebase successful" in result.output.lower()
# Only 4 git calls (no push)
assert mock_run_git.call_count == 4
@patch("devx.tools.rebase._run_git")
def test_rebase_detached_head_fails(self, mock_run_git: MagicMock) -> None:
"""Detached HEAD should fail immediately."""
mock_run_git.return_value = MagicMock(stdout="HEAD\n", returncode=0)
runner = CliRunner()
result = runner.invoke(rebase_main, [])
assert result.exit_code != 0
assert "detached" in result.output.lower()
@patch("devx.tools.rebase._run_git")
def test_rebase_branch_detection_failure(self, mock_run_git: MagicMock) -> None:
"""Git rev-parse failure should exit with error."""
mock_run_git.return_value = MagicMock(stdout="", stderr="fatal: not a repo", returncode=1)
runner = CliRunner()
result = runner.invoke(rebase_main, [])
assert result.exit_code != 0
assert "could not detect" in result.output.lower()
@patch("devx.tools.rebase._run_git")
def test_rebase_conflict_fails(self, mock_run_git: MagicMock) -> None:
"""Rebase conflict should exit with error."""
mock_run_git.side_effect = [
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
MagicMock(stdout="", returncode=0), # fetch
MagicMock(stdout="1\n", returncode=0), # rev-list --count
MagicMock(stdout="", stderr="CONFLICT", returncode=1), # rebase fails
]
runner = CliRunner()
result = runner.invoke(rebase_main, [])
assert result.exit_code != 0
assert "rebase failed" in result.output.lower()
@patch("devx.tools.rebase._run_git")
def test_rebase_fetch_failure(self, mock_run_git: MagicMock) -> None:
"""Fetch failure should exit with error."""
mock_run_git.side_effect = [
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
MagicMock(stdout="", stderr="network error", returncode=1), # fetch fails
]
runner = CliRunner()
result = runner.invoke(rebase_main, [])
assert result.exit_code != 0
assert "fetch failed" in result.output.lower()
@patch("devx.tools.rebase._run_git")
def test_rebase_push_failure(self, mock_run_git: MagicMock) -> None:
"""Force-push rejection should exit with error."""
mock_run_git.side_effect = [
MagicMock(stdout="feature-branch\n", returncode=0), # rev-parse
MagicMock(stdout="", returncode=0), # fetch
MagicMock(stdout="1\n", returncode=0), # rev-list --count
MagicMock(stdout="", stderr="", returncode=0), # rebase
MagicMock(stdout="", stderr="rejected", returncode=1), # push fails
]
runner = CliRunner()
result = runner.invoke(rebase_main, [])
assert result.exit_code != 0
assert "force-push failed" in result.output.lower()
class TestPrRebaseTool:
"""Tests for the server-side PR rebase tool (devx.tools.pr_rebase)."""
@patch.dict("os.environ", _FULL_ENV, clear=True)
@patch("devx.tools.pr_rebase.GiteaClient")
def test_pr_rebase_success(self, mock_client_cls: MagicMock) -> None:
"""Successful API rebase prints confirmation."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(pr_rebase_main, ["--pr", "42"])
assert result.exit_code == 0
assert "rebased successfully" in result.output.lower()
mock_client.update_pr_branch.assert_called_once_with(42, style="rebase")
@patch.dict("os.environ", _FULL_ENV, clear=True)
@patch("devx.tools.pr_rebase.GiteaClient")
def test_pr_rebase_api_error(self, mock_client_cls: MagicMock) -> None:
"""API error during rebase exits with error."""
from devx.api_clients import APIError
mock_client = MagicMock()
mock_client.update_pr_branch.side_effect = APIError(409, "Conflict")
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(pr_rebase_main, ["--pr", "42"])
assert result.exit_code != 0
assert "rebase failed" in result.output.lower()
@patch("devx.tools.pr_rebase.load_dotenv")
@patch.dict("os.environ", {}, clear=True)
def test_pr_rebase_no_token(self, _mock_load: MagicMock) -> None:
"""Missing CI_GITEA_TOKEN should fail."""
runner = CliRunner()
result = runner.invoke(pr_rebase_main, ["--pr", "42"])
assert result.exit_code != 0
assert "CI_GITEA_TOKEN" in result.output
@patch.dict("os.environ", _FULL_ENV, clear=True)
@patch("devx.tools.pr_rebase.detect_pr_number", return_value=None)
def test_pr_rebase_no_pr_detected(self, _mock_detect: MagicMock) -> None:
"""When PR number can't be auto-detected, fail with instructions."""
runner = CliRunner()
result = runner.invoke(pr_rebase_main, [])
assert result.exit_code != 0
assert "could not detect" in result.output.lower()
@patch("devx.tools.pr_rebase.load_dotenv")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.tools.pr_rebase.GiteaClient")
def test_pr_rebase_no_repo_env(self, _mock_client: MagicMock, _mock_load: MagicMock) -> None:
"""Missing repo env vars should fail."""
runner = CliRunner()
result = runner.invoke(pr_rebase_main, ["--pr", "42"])
assert result.exit_code != 0
assert "DEVX_REPO_OWNER" in result.output
@patch("devx.tools.pr_rebase.load_dotenv")
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "GITHUB_REPOSITORY": "owner/repo"}, clear=True)
@patch("devx.tools.pr_rebase.GiteaClient")
def test_pr_rebase_github_repo_fallback(self, mock_client_cls: MagicMock, _mock_load: MagicMock) -> None:
"""GITHUB_REPOSITORY env var is used as fallback for owner/repo."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
runner = CliRunner()
result = runner.invoke(pr_rebase_main, ["--pr", "42"])
assert result.exit_code == 0
mock_client.update_pr_branch.assert_called_once_with(42, style="rebase")
+147
View File
@@ -1234,6 +1234,153 @@ class TestMain:
assert "already existed" in result.output
mock_tag.assert_called_once_with("0.2.0", "changelog", False)
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.run_tests")
@patch("devx.ci.release.create_and_push_tag", return_value=True)
@patch("devx.ci.release.commit_release_changes", return_value=True)
@patch("devx.ci.release.update_changelog")
@patch("devx.ci.release.update_init_version")
@patch("devx.ci.release.get_changelog", return_value="changelog")
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.ci.release.time.sleep")
@patch("devx.ci.release.run_cmd")
def test_push_retry_succeeds_after_rebase_failure(
self,
mock_run_cmd: MagicMock,
mock_sleep: MagicMock,
mock_has: MagicMock,
mock_bumped: MagicMock,
mock_latest: MagicMock,
mock_changelog: MagicMock,
mock_update_init: MagicMock,
mock_update_changelog: MagicMock,
mock_commit: MagicMock,
mock_tag: MagicMock,
mock_run_tests: MagicMock,
mock_user: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None:
"""Push should retry after rebase failure and succeed on second attempt."""
ok = MagicMock(returncode=0, stdout="master\n", stderr="")
rebase_fail = MagicMock(returncode=1, stdout="", stderr="conflict")
rebase_abort = MagicMock(returncode=0, stdout="", stderr="")
push_ok = MagicMock(returncode=0, stdout="", stderr="")
# git rev-parse → ok, git log -1 → ok (non-release msg)
# pull --rebase → fail, rebase --abort → ok
# pull --rebase → ok, push → ok
mock_run_cmd.side_effect = [ok, ok, rebase_fail, rebase_abort, ok, push_ok]
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "Rebase attempt 1/3 failed" in result.output
assert "Pushed release commit to master" in result.output
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.run_tests")
@patch("devx.ci.release.create_and_push_tag", return_value=True)
@patch("devx.ci.release.commit_release_changes", return_value=True)
@patch("devx.ci.release.update_changelog")
@patch("devx.ci.release.update_init_version")
@patch("devx.ci.release.get_changelog", return_value="changelog")
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.ci.release.time.sleep")
@patch("devx.ci.release.run_cmd")
def test_push_fails_after_all_retries(
self,
mock_run_cmd: MagicMock,
mock_sleep: MagicMock,
mock_has: MagicMock,
mock_bumped: MagicMock,
mock_latest: MagicMock,
mock_changelog: MagicMock,
mock_update_init: MagicMock,
mock_update_changelog: MagicMock,
mock_commit: MagicMock,
mock_tag: MagicMock,
mock_run_tests: MagicMock,
mock_user: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None:
"""Push should fail after 3 unsuccessful rebase attempts."""
ok = MagicMock(returncode=0, stdout="master\n", stderr="")
rebase_fail = MagicMock(returncode=1, stdout="", stderr="conflict")
rebase_abort = MagicMock(returncode=0, stdout="", stderr="")
# git rev-parse → ok, git log -1 → ok
# 3 attempts: pull --rebase → fail, rebase --abort → ok
mock_run_cmd.side_effect = [
ok,
ok,
rebase_fail,
rebase_abort, # attempt 1
rebase_fail,
rebase_abort, # attempt 2
rebase_fail,
rebase_abort, # attempt 3
]
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code != 0
assert "Failed to push release commit after 3 attempts" in result.output
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
@patch("devx.ci.release.has_user_facing_changes", return_value=True)
@patch("devx.ci.release.run_tests")
@patch("devx.ci.release.create_and_push_tag", return_value=True)
@patch("devx.ci.release.commit_release_changes", return_value=True)
@patch("devx.ci.release.update_changelog")
@patch("devx.ci.release.update_init_version")
@patch("devx.ci.release.get_changelog", return_value="changelog")
@patch("devx.ci.release.get_latest_tag", return_value="v0.1.0")
@patch("devx.ci.release.get_bumped_version", return_value="0.2.0")
@patch("devx.ci.release.has_unreleased_changes", return_value=True)
@patch("devx.ci.release.time.sleep")
@patch("devx.ci.release.run_cmd")
def test_push_retry_succeeds_after_push_failure(
self,
mock_run_cmd: MagicMock,
mock_sleep: MagicMock,
mock_has: MagicMock,
mock_bumped: MagicMock,
mock_latest: MagicMock,
mock_changelog: MagicMock,
mock_update_init: MagicMock,
mock_update_changelog: MagicMock,
mock_commit: MagicMock,
mock_tag: MagicMock,
mock_run_tests: MagicMock,
mock_user: MagicMock,
mock_ft: MagicMock,
mock_vtc: MagicMock,
) -> None:
"""Push should retry after push rejection and succeed on second attempt."""
ok = MagicMock(returncode=0, stdout="master\n", stderr="")
rebase_ok = MagicMock(returncode=0, stdout="", stderr="")
push_fail = MagicMock(returncode=1, stdout="", stderr="non-fast-forward")
push_ok = MagicMock(returncode=0, stdout="", stderr="")
# git rev-parse → ok, git log -1 → ok
# attempt 1: pull --rebase → ok, push → fail
# attempt 2: pull --rebase → ok, push → ok
mock_run_cmd.side_effect = [ok, ok, rebase_ok, push_fail, rebase_ok, push_ok]
runner = CliRunner()
result = runner.invoke(main, [])
assert result.exit_code == 0
assert "Push attempt 1/3 failed" in result.output
assert "Pushed release commit to master" in result.output
@patch.dict("os.environ", {})
@patch("devx.ci.release.verify_tag_consistency", return_value=[])
@patch("devx.ci.release.fetch_tags")
+262
View File
@@ -0,0 +1,262 @@
"""Unit tests for devx.tools.setup_image."""
import os
import subprocess
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from click.testing import CliRunner
from devx.tools.setup_image import (
_build_pip_extra_index_url,
_fallback_to_setup_ci,
_install_in_image,
cli,
)
class TestBuildPipExtraIndexUrl:
def test_basic_url(self) -> None:
url = _build_pip_extra_index_url(
"git.oblachno.oblachno.fyi",
"oblachno-oss",
"emil",
"tok123",
)
assert url == "https://emil:tok123@git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple/"
def test_custom_host_org(self) -> None:
url = _build_pip_extra_index_url(
"gitea.example.com",
"my-org",
"user",
"secret",
)
assert url == "https://user:secret@gitea.example.com/api/packages/my-org/pypi/simple/"
class TestInstallInImage:
@patch("devx.tools.setup_image.subprocess.run")
@patch("devx.tools.setup_image.Path")
def test_link_and_install_no_token(self, mock_path: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None:
venv_link = tmp_path / ".venv"
mock_path.return_value.exists.return_value = False
mock_path.return_value.is_symlink.return_value = False
mock_path.return_value.symlink_to = MagicMock()
with patch.dict(os.environ, {}, clear=True):
_install_in_image(str(venv_link), "/opt/venv", "", "host", "org")
mock_path.return_value.symlink_to.assert_called_once_with("/opt/venv")
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
assert "--no-cache-dir" in cmd
assert "-e" in cmd
assert "." in cmd
# No extras → spec is "."
assert ".[]" not in " ".join(cmd)
@patch("devx.tools.setup_image.subprocess.run")
@patch("devx.tools.setup_image.Path")
def test_link_and_install_with_extras(
self,
mock_path: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
venv_link = tmp_path / ".venv"
mock_path.return_value.exists.return_value = False
mock_path.return_value.is_symlink.return_value = False
mock_path.return_value.symlink_to = MagicMock()
with patch.dict(os.environ, {}, clear=True):
_install_in_image(str(venv_link), "/opt/venv", "ci,lint", "host", "org")
cmd = mock_run.call_args[0][0]
assert ".[ci,lint]" in cmd
@patch("devx.tools.setup_image.subprocess.run")
@patch("devx.tools.setup_image.Path")
def test_install_with_token_sets_pip_extra_index_url(
self,
mock_path: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
venv_link = tmp_path / ".venv"
mock_path.return_value.exists.return_value = False
mock_path.return_value.is_symlink.return_value = False
mock_path.return_value.symlink_to = MagicMock()
with patch.dict(
os.environ,
{"CI_GITEA_TOKEN": "tok123", "CI_GITEA_USERNAME": "emil"},
clear=True,
):
_install_in_image(str(venv_link), "/opt/venv", "lint", "git.host", "org")
env = mock_run.call_args[1]["env"]
assert "PIP_EXTRA_INDEX_URL" in env
assert "emil:tok123@git.host" in env["PIP_EXTRA_INDEX_URL"]
@patch("devx.tools.setup_image.subprocess.run")
@patch("devx.tools.setup_image.Path")
def test_install_with_token_defaults_username(
self,
mock_path: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
venv_link = tmp_path / ".venv"
mock_path.return_value.exists.return_value = False
mock_path.return_value.is_symlink.return_value = False
mock_path.return_value.symlink_to = MagicMock()
with patch.dict(os.environ, {"CI_GITEA_TOKEN": "tok123"}, clear=True):
_install_in_image(str(venv_link), "/opt/venv", "", "host", "org")
env = mock_run.call_args[1]["env"]
assert "emil:tok123@host" in env["PIP_EXTRA_INDEX_URL"]
@patch("devx.tools.setup_image.subprocess.run")
@patch("devx.tools.setup_image.Path")
def test_install_removes_existing_link(
self,
mock_path: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
venv_link = tmp_path / ".venv"
mock_path.return_value.exists.return_value = True
mock_path.return_value.is_symlink.return_value = False
mock_path.return_value.unlink = MagicMock()
mock_path.return_value.symlink_to = MagicMock()
with patch.dict(os.environ, {}, clear=True):
_install_in_image(str(venv_link), "/opt/venv", "", "host", "org")
mock_path.return_value.unlink.assert_called_once()
@patch("devx.tools.setup_image.subprocess.run")
@patch("devx.tools.setup_image.Path")
def test_install_removes_existing_symlink(
self,
mock_path: MagicMock,
mock_run: MagicMock,
tmp_path: Path,
) -> None:
venv_link = tmp_path / ".venv"
mock_path.return_value.exists.return_value = False
mock_path.return_value.is_symlink.return_value = True
mock_path.return_value.unlink = MagicMock()
mock_path.return_value.symlink_to = MagicMock()
with patch.dict(os.environ, {}, clear=True):
_install_in_image(str(venv_link), "/opt/venv", "", "host", "org")
mock_path.return_value.unlink.assert_called_once()
@patch("devx.tools.setup_image.subprocess.run")
@patch("devx.tools.setup_image.Path")
def test_install_failure_raises(self, mock_path: MagicMock, mock_run: MagicMock, tmp_path: Path) -> None:
venv_link = tmp_path / ".venv"
mock_path.return_value.exists.return_value = False
mock_path.return_value.is_symlink.return_value = False
mock_path.return_value.symlink_to = MagicMock()
mock_run.side_effect = subprocess.CalledProcessError(1, ["pip"])
with patch.dict(os.environ, {}, clear=True):
with pytest.raises(subprocess.CalledProcessError):
_install_in_image(str(venv_link), "/opt/venv", "", "host", "org")
class TestFallbackToSetupCi:
@patch("devx.tools.setup_image.subprocess.run")
def test_fallback_runs_make_setup_ci(self, mock_run: MagicMock) -> None:
_fallback_to_setup_ci()
mock_run.assert_called_once_with(["make", "setup-ci"], check=True)
@patch("devx.tools.setup_image.subprocess.run")
def test_fallback_failure_raises(self, mock_run: MagicMock) -> None:
mock_run.side_effect = subprocess.CalledProcessError(1, ["make"])
with pytest.raises(subprocess.CalledProcessError):
_fallback_to_setup_ci()
class TestCli:
@patch("devx.tools.setup_image._install_in_image")
@patch("devx.tools.setup_image.Path")
def test_cli_with_opt_venv_present(
self,
mock_path: MagicMock,
mock_install: MagicMock,
) -> None:
mock_path.return_value.is_dir.return_value = True
runner = CliRunner()
result = runner.invoke(cli, ["--extras", "ci,lint"])
assert result.exit_code == 0
mock_install.assert_called_once()
@patch("devx.tools.setup_image._fallback_to_setup_ci")
@patch("devx.tools.setup_image.Path")
def test_cli_falls_back_when_no_opt_venv(
self,
mock_path: MagicMock,
mock_fallback: MagicMock,
) -> None:
mock_path.return_value.is_dir.return_value = False
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 0
mock_fallback.assert_called_once()
@patch("devx.tools.setup_image._install_in_image")
@patch("devx.tools.setup_image.Path")
def test_cli_default_values(
self,
mock_path: MagicMock,
mock_install: MagicMock,
) -> None:
mock_path.return_value.is_dir.return_value = True
runner = CliRunner()
result = runner.invoke(cli, [])
assert result.exit_code == 0
call_args = mock_install.call_args[0]
assert call_args[0] == ".venv"
assert call_args[1] == "/opt/venv"
assert call_args[2] == "" # no extras
assert call_args[3] == "git.oblachno.oblachno.fyi"
assert call_args[4] == "oblachno-oss"
@patch("devx.tools.setup_image._install_in_image")
@patch("devx.tools.setup_image.Path")
def test_cli_custom_venv_and_gitea(
self,
mock_path: MagicMock,
mock_install: MagicMock,
) -> None:
mock_path.return_value.is_dir.return_value = True
runner = CliRunner()
result = runner.invoke(
cli,
["--venv", ".custom-venv", "--gitea-host", "gitea.io", "--gitea-org", "myorg"],
)
assert result.exit_code == 0
call_args = mock_install.call_args[0]
assert call_args[0] == ".custom-venv"
assert call_args[3] == "gitea.io"
assert call_args[4] == "myorg"
@patch("devx.tools.setup_image._install_in_image")
@patch("devx.tools.setup_image.Path")
def test_cli_with_extras(
self,
mock_path: MagicMock,
mock_install: MagicMock,
) -> None:
mock_path.return_value.is_dir.return_value = True
runner = CliRunner()
result = runner.invoke(cli, ["--extras", "lint"])
assert result.exit_code == 0
assert mock_install.call_args[0][2] == "lint"
+79 -5
View File
@@ -21,6 +21,7 @@ from devx.ci.sync_wiki import (
verify_wiki_integrity,
verify_wiki_page,
)
from devx.exceptions import APIError
class TestEncodeContent:
@@ -97,13 +98,11 @@ class TestReadDocContent:
class TestListWikiPages:
def test_returns_empty_on_api_error(self) -> None:
from devx.exceptions import APIError
def test_raises_on_api_error(self) -> None:
client = MagicMock()
client._request.side_effect = APIError(404, "not found")
result = list_wiki_pages(client)
assert result == {}
with pytest.raises(APIError):
list_wiki_pages(client)
def test_returns_page_dict(self) -> None:
client = MagicMock()
@@ -284,6 +283,43 @@ class TestVerifyWikiIntegrity:
failures = verify_wiki_integrity(client, mapping, synced)
assert len(failures) >= 3 # count mismatch, missing FAQ, stale Stale, empty Home
def test_transient_api_failure_returns_empty(self) -> None:
"""When the wiki API is unavailable after retries, integrity check
should return no failures (sync already succeeded)."""
client = MagicMock()
# _list_wiki_pages_with_retry raises APIError (retries exhausted)
with patch("devx.ci.sync_wiki._list_wiki_pages_with_retry", side_effect=APIError(0, "timeout")):
mapping = {"index.md": "Home", "faq.md": "FAQ"}
synced = {"Home": "# Home", "FAQ": "# FAQ"}
failures = verify_wiki_integrity(client, mapping, synced)
assert failures == []
def test_transient_api_failure_recovers_on_retry(self) -> None:
"""When the wiki API recovers after a retry, integrity check proceeds normally."""
client = MagicMock()
pages = {"Home": "Home", "FAQ": "FAQ"}
contents = {"Home": "# Home", "FAQ": "# FAQ"}
def mock_request(method, path, **kwargs):
resp = MagicMock()
if path == "/wiki/pages":
page_list = [{"title": t, "sub_url": s} for t, s in pages.items()]
resp.json.return_value = page_list
elif path.startswith("/wiki/page/"):
sub_url = path.replace("/wiki/page/", "")
content = contents.get(sub_url, "")
encoded = base64.b64encode(content.encode()).decode("ascii") if content else ""
resp.json.return_value = {"content_base64": encoded}
return resp
client._request.side_effect = mock_request
mapping = {"index.md": "Home", "faq.md": "FAQ"}
synced = {"Home": "# Home", "FAQ": "# FAQ"}
failures = verify_wiki_integrity(client, mapping, synced)
assert failures == []
class TestMain:
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
@@ -498,3 +534,41 @@ class TestMain:
result = runner.invoke(main, ["--dry-run", "--strict", "--repo", "owner/repo"])
assert result.exit_code == 0
assert "Integrity check" not in result.output
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_initial_list_api_error_treated_as_empty(self, mock_client_cls: MagicMock) -> None:
"""When the initial page list fails, sync proceeds treating wiki as empty."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"):
with patch("devx.ci.sync_wiki.list_wiki_pages", side_effect=APIError(0, "timeout")):
with patch("devx.ci.sync_wiki.sync_page", return_value="created"):
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo"])
assert result.exit_code == 0
assert "Created: Home" in result.output
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
@patch("devx.ci.sync_wiki.GiteaClient")
def test_verify_skips_when_refetch_fails(self, mock_client_cls: MagicMock) -> None:
"""When --verify re-fetch fails after retries, verification is skipped gracefully."""
mock_client = MagicMock()
mock_client_cls.return_value = mock_client
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
mock_mapping.exists.return_value = True
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"):
with patch("devx.ci.sync_wiki.list_wiki_pages", return_value={"Home": "Home"}):
with patch("devx.ci.sync_wiki.sync_page", return_value="updated"):
with patch(
"devx.ci.sync_wiki._list_wiki_pages_with_retry",
side_effect=APIError(0, "timeout"),
):
runner = CliRunner()
result = runner.invoke(main, ["--repo", "owner/repo", "--verify"])
assert result.exit_code == 0
assert "Skipping content verification" in result.output