Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7f5f47564 | ||
|
|
d623a64344 | ||
|
|
268a4e7988 | ||
|
|
7daaf9e4a9 | ||
|
|
b7c9334881 | ||
|
|
3406639f13 | ||
|
|
9f02ccb40d | ||
|
|
5206158603 | ||
|
|
489cc8343a | ||
|
|
20ea80135c | ||
|
|
53b1d300aa | ||
|
|
5b9e92f324 | ||
|
|
2c0118111d | ||
|
|
333641f862 | ||
|
|
f21b01dce2 | ||
|
|
ff80745eea | ||
|
|
319807f41c | ||
|
|
e652d3bb75 | ||
|
|
d59de06652 | ||
|
|
ae37a8e3e4 | ||
|
|
c63e85923a | ||
|
|
77c1af8ed3 | ||
|
|
a48fb46c52 | ||
|
|
2392a13afc | ||
|
|
32315b1d5d | ||
|
|
f70f468630 | ||
|
|
85b5ec1485 | ||
|
|
091b951adc | ||
|
|
19eb57445d | ||
|
|
bdd0e05869 | ||
|
|
27fd99a091 | ||
|
|
e3fa9b7c95 | ||
|
|
ce60356542 | ||
|
|
35c72ef595 | ||
|
|
c0fcaef25f | ||
|
|
3dd5b452c0 | ||
|
|
b2515bbf37 | ||
|
|
ad2e59980f | ||
|
|
68a01d1bda | ||
|
|
621b051793 | ||
|
|
66554657f2 | ||
|
|
587d3a6ca4 | ||
|
|
9d75e408ae | ||
|
|
412bbea01d |
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
# devx-workflow
|
||||||
|
|
||||||
|
Quick reference for devx tools when working on the devx repo itself.
|
||||||
|
|
||||||
|
## PR Workflow (use these, not raw git/tea/MCP)
|
||||||
|
|
||||||
|
| Task | Command |
|
||||||
|
|------|---------|
|
||||||
|
| Create Vikunja task | `make create-task -- --title "..." --description "..."` |
|
||||||
|
| Create PR | `make create-pr` |
|
||||||
|
| Push + create PR | `make push-with-pr` |
|
||||||
|
| Check CI status | `make devx-pr-status` or `make devx-pr-status PR=42 WAIT=1` |
|
||||||
|
| Fetch CI failure logs | `make devx-pr-logs` or `make devx-pr-logs PR=42 JOB=quality TAIL=50` |
|
||||||
|
| Add ready-to-merge label | `make devx-pr-label` or `make devx-pr-label PR=42` |
|
||||||
|
| Post PR review | `make devx-pr-review PR=42 EVENT=APPROVE BODY="..." CHECKLIST=1,2,3,4,5,6,7,8,9,10,11,12,13` |
|
||||||
|
| Rebase current branch | `make rebase` |
|
||||||
|
| Rebase PR via API | `make pr-rebase` or `make pr-rebase PR=42` |
|
||||||
|
|
||||||
|
## Auto-merge Behavior
|
||||||
|
|
||||||
|
When the `ready-to-merge` label is added and all CI checks pass:
|
||||||
|
1. Auto-merge validates PR title format (`DEVX-N: <vikunja task title>`)
|
||||||
|
2. If branch is behind master, auto-merge **rebases via Gitea API** automatically
|
||||||
|
3. The rebase triggers a new CI run; the next auto-merge attempt merges
|
||||||
|
4. No manual rebase needed unless the API rebase fails
|
||||||
|
|
||||||
|
## Key Rules
|
||||||
|
|
||||||
|
- Never manually merge via API — always use auto-merge with `ready-to-merge` label
|
||||||
|
- Branch naming: `DEVX-N-short-description` (N = Vikunja task ID)
|
||||||
|
- Commit format: conventional commits (`feat:`, `fix:`, `docs:`, etc.)
|
||||||
|
- PR title: `DEVX-N: <vikunja task title>` (auto-derived by `make create-pr`)
|
||||||
|
- 100% test coverage required for all source changes
|
||||||
|
- All user-facing strings wrapped in `_()` for i18n
|
||||||
|
- Translation keys must be added to `src/devx/translations.json`
|
||||||
|
- New CLI commands must be documented in `docs/user/cli-commands.md`
|
||||||
|
- New tools must be registered in `src/devx/cli.py` and added to Make targets
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# testing-and-debugging
|
||||||
|
|
||||||
|
Make targets for testing, debugging, and CI investigation. **Use these
|
||||||
|
instead of raw `pytest`, `ruff`, or `actionlint` commands.**
|
||||||
|
|
||||||
|
## Why Make Targets
|
||||||
|
|
||||||
|
Make targets encapsulate the correct venv activation, PYTHONPATH, env
|
||||||
|
vars, and flags. Running raw commands bypasses venv activation and
|
||||||
|
produces false failures (missing dependencies, wrong Python version).
|
||||||
|
|
||||||
|
## Unit Tests
|
||||||
|
|
||||||
|
| Task | Command | Notes |
|
||||||
|
|------|---------|-------|
|
||||||
|
| Run all unit tests | `make test-unit` | Fast, no coverage |
|
||||||
|
| Run with coverage | `make pytest-cov` | **Required before push** — enforces 100% |
|
||||||
|
| Run single test | `make pytest-cov TEST=tests/test_foo.py::test_bar` | |
|
||||||
|
| Check test speed | `make check-test-speed` | Fails if tests > 10s total or > 0.5s each |
|
||||||
|
| Check test coverage | `make check-test-coverage` | Fails if source changed but tests didn't |
|
||||||
|
|
||||||
|
## Linting
|
||||||
|
|
||||||
|
| Task | Command | Notes |
|
||||||
|
|------|---------|-------|
|
||||||
|
| Full lint | `make lint-all` | ruff + workflow-lint + lint-dockerfiles |
|
||||||
|
| Ruff only | `make lint-ruff` | |
|
||||||
|
| Format check | `make lint-format` | |
|
||||||
|
| Type check | `make typecheck` | pyright |
|
||||||
|
| Bandit | `make lint-bandit` | Security linter |
|
||||||
|
| Workflow lint | `make workflow-check` | actionlint + act_runner dry-run |
|
||||||
|
| Dockerfile lint | `make lint-dockerfiles` | hadolint on all Dockerfiles |
|
||||||
|
| Check mutable globals | `make check-mutable-globals` | Detects module-level mutable state |
|
||||||
|
| Check dep docs | `make check-dep-docs` | Verifies pyproject.toml deps have comments |
|
||||||
|
|
||||||
|
## Pre-Push Verification
|
||||||
|
|
||||||
|
**Before pushing any branch:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make pre-push
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs `lint-all` + `pytest-cov`. The pre-push git hook only
|
||||||
|
validates the Vikunja task exists — it does NOT run tests. You must
|
||||||
|
run `make pre-push` manually.
|
||||||
|
|
||||||
|
## CI Failure Investigation
|
||||||
|
|
||||||
|
When investigating a CI failure:
|
||||||
|
|
||||||
|
1. **Fetch logs via MCP** — use `mcp_call_tool` with gitea server,
|
||||||
|
`actions_run_read` method, `download_job_log` tool
|
||||||
|
2. **Reproduce locally** — use `make pytest-cov` or `make lint-all`
|
||||||
|
depending on which CI job failed
|
||||||
|
3. **Never run raw pytest** — always use the make target
|
||||||
|
|
||||||
|
## Virtual Environment
|
||||||
|
|
||||||
|
All commands run inside `.venv`. `make` targets handle activation
|
||||||
|
automatically. For raw commands (rare), activate first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
source activate.sh # bash/zsh
|
||||||
|
source activate.fish # fish
|
||||||
|
source activate.zsh # zsh
|
||||||
|
```
|
||||||
|
|
||||||
|
If `.venv` doesn't exist, run `make setup` first.
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### Coverage Verification Before Push
|
||||||
|
|
||||||
|
**Always run `make pytest-cov` before pushing** — CI enforces 100%
|
||||||
|
coverage and will fail the PR if any lines are uncovered. This is the
|
||||||
|
most common cause of CI quality job failures after code changes. The
|
||||||
|
pre-push git hook only validates Vikunja task existence, not tests.
|
||||||
|
|
||||||
|
### API Response Type Checking
|
||||||
|
|
||||||
|
Never use `is True`/`is False` identity checks on API response values.
|
||||||
|
Many APIs return boolean values as strings (`"true"`/`"false"`). Use
|
||||||
|
the `is_truthy()`/`is_falsy()` helpers from `devx.utils.api` or compare
|
||||||
|
against string values.
|
||||||
|
|
||||||
|
### Time Mocking in Tests
|
||||||
|
|
||||||
|
Always mock `time.sleep` and `time.monotonic` in unit tests using
|
||||||
|
`@patch` decorators. Real sleep calls make tests slow and exceed test
|
||||||
|
speed limits (10s total, 0.5s per test).
|
||||||
|
|
||||||
|
### Mutable Global State
|
||||||
|
|
||||||
|
The `check-mutable-globals` tool detects module-level mutable state
|
||||||
|
(lists, dicts, sets) that can cause test pollution. Avoid module-level
|
||||||
|
mutable defaults — use factory functions or `None` with initialization
|
||||||
|
inside functions.
|
||||||
@@ -5,7 +5,9 @@ name: Build Images
|
|||||||
# devx and all dependencies into the image.
|
# devx and all dependencies into the image.
|
||||||
#
|
#
|
||||||
# Triggers:
|
# Triggers:
|
||||||
# - On push to master (after post-merge release completes)
|
# - After post-merge workflow completes successfully (workflow_run)
|
||||||
|
# This ensures images are only rebuilt AFTER the release is published
|
||||||
|
# to PyPI, so the image always has the latest released version.
|
||||||
# - Manually via workflow_dispatch
|
# - Manually via workflow_dispatch
|
||||||
#
|
#
|
||||||
# The workflow builds 3 tier images in sequence:
|
# The workflow builds 3 tier images in sequence:
|
||||||
@@ -15,12 +17,10 @@ name: Build Images
|
|||||||
# After pushing, a cleanup job removes old versions (keeps last 2 + latest).
|
# After pushing, a cleanup job removes old versions (keeps last 2 + latest).
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
workflow_run:
|
||||||
|
workflows: ["Post-merge"]
|
||||||
|
types: [completed]
|
||||||
branches: [master]
|
branches: [master]
|
||||||
paths:
|
|
||||||
- docker/**
|
|
||||||
- pyproject.toml
|
|
||||||
- src/devx/**
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
@@ -49,7 +49,11 @@ jobs:
|
|||||||
|
|
||||||
build-and-push:
|
build-and-push:
|
||||||
needs: [detect-type]
|
needs: [detect-type]
|
||||||
if: needs.detect-type.outputs.is-release == 'false'
|
if: >-
|
||||||
|
needs.detect-type.outputs.is-release == 'false' && (
|
||||||
|
github.event_name == 'workflow_dispatch' ||
|
||||||
|
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
|
||||||
|
)
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
+14
-14
@@ -19,47 +19,47 @@ jobs:
|
|||||||
run: make setup-image
|
run: make setup-image
|
||||||
- name: Lint all
|
- name: Lint all
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
make lint-all
|
make lint-all
|
||||||
- name: Unit tests with 100% coverage
|
- name: Unit tests with 100% coverage
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
make pytest-cov
|
make pytest-cov
|
||||||
- name: Check unit test speed
|
- name: Check unit test speed
|
||||||
env:
|
env:
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
|
python3 -m devx.tools.check_test_speed --max-seconds 6 --max-single-seconds 0.5
|
||||||
- name: Documentation coverage check
|
- name: Documentation coverage check
|
||||||
env:
|
env:
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
python3 -m devx.ci.doc_coverage --fail-on-missing
|
python3 -m devx.ci.doc_coverage --fail-on-missing
|
||||||
- name: Documentation lint check
|
- name: Documentation lint check
|
||||||
env:
|
env:
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
python3 -m devx.ci.lint_docs --root .
|
python3 -m devx.ci.lint_docs --root .
|
||||||
- name: Translation completeness check
|
- name: Translation completeness check
|
||||||
env:
|
env:
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
python3 -m devx.ci.check_translations
|
python3 -m devx.ci.check_translations
|
||||||
- name: Dependency security scan
|
- name: Dependency security scan
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
# Install pip in venv if missing (needed by pip-audit)
|
# Install pip in venv if missing (needed by pip-audit)
|
||||||
.venv/bin/python -m ensurepip 2>/dev/null || true
|
.venv/bin/python -m ensurepip 2>/dev/null || true
|
||||||
PIPAPI_PYTHON_LOCATION=$PWD/.venv/bin/python \
|
PIPAPI_PYTHON_LOCATION=$PWD/.venv/bin/python \
|
||||||
pip-audit --desc --skip-editable 2>&1 || true
|
pip-audit --desc --skip-editable 2>&1 || true
|
||||||
- name: Workflow dry-run validation
|
- name: Workflow dry-run validation
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
# Best-effort: only runs if act_runner is installed
|
# Best-effort: only runs if act_runner is installed
|
||||||
if command -v act_runner >/dev/null 2>&1; then
|
if command -v act_runner >/dev/null 2>&1; then
|
||||||
@@ -88,7 +88,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
python3 -m devx.ci.classify_changes \
|
python3 -m devx.ci.classify_changes \
|
||||||
--base "origin/master" \
|
--base "origin/master" \
|
||||||
--head "${{ github.event.pull_request.head.sha || github.sha }}" \
|
--head "${{ github.event.pull_request.head.sha || github.sha }}" \
|
||||||
@@ -115,7 +115,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
python3 -m devx.ci.release --dry-run
|
python3 -m devx.ci.release --dry-run
|
||||||
|
|
||||||
@@ -137,7 +137,7 @@ jobs:
|
|||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
python3 -m devx.ci.pr_review \
|
python3 -m devx.ci.pr_review \
|
||||||
"${{ github.event.number }}" \
|
"${{ github.event.number }}" \
|
||||||
"${{ github.repository }}"
|
"${{ github.repository }}"
|
||||||
@@ -173,7 +173,7 @@ jobs:
|
|||||||
REPOSITORY: ${{ github.repository }}
|
REPOSITORY: ${{ github.repository }}
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
python3 -m devx.ci.pr_review \
|
python3 -m devx.ci.pr_review \
|
||||||
"$PR_NUMBER" \
|
"$PR_NUMBER" \
|
||||||
"$REPOSITORY" \
|
"$REPOSITORY" \
|
||||||
@@ -192,7 +192,7 @@ jobs:
|
|||||||
REPOSITORY: ${{ github.repository }}
|
REPOSITORY: ${{ github.repository }}
|
||||||
PR_NUMBER: ${{ github.event.number }}
|
PR_NUMBER: ${{ github.event.number }}
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
python3 -m devx.ci.auto_merge \
|
python3 -m devx.ci.auto_merge \
|
||||||
"$HEAD_REF" \
|
"$HEAD_REF" \
|
||||||
"$PR_TITLE" \
|
"$PR_TITLE" \
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
python3 -m devx.ci.detect_release_commit
|
python3 -m devx.ci.detect_release_commit
|
||||||
|
|
||||||
validate-commit-msg:
|
validate-commit-msg:
|
||||||
@@ -73,7 +73,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
git log -1 --format=%B > commit-msg.txt
|
git log -1 --format=%B > commit-msg.txt
|
||||||
python3 -m devx.ci.validate_commit_msg commit-msg.txt --branch master
|
python3 -m devx.ci.validate_commit_msg commit-msg.txt --branch master
|
||||||
rm -f commit-msg.txt
|
rm -f commit-msg.txt
|
||||||
@@ -107,7 +107,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
python3 -m devx.ci.release
|
python3 -m devx.ci.release
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
@@ -146,7 +146,7 @@ jobs:
|
|||||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
export PATH="$HOME/.local/bin:$PATH"
|
export PATH="$HOME/.local/bin:$PATH"
|
||||||
python3 -m devx.ci.publish "${{ needs.release.outputs.tag }}" "${{ github.repository }}" --auto-login
|
python3 -m devx.ci.publish "${{ needs.release.outputs.tag }}" "${{ github.repository }}" --auto-login
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
@@ -169,7 +169,10 @@ jobs:
|
|||||||
if: needs.detect-type.outputs.is-release == 'false'
|
if: needs.detect-type.outputs.is-release == 'false'
|
||||||
runs-on: docker
|
runs-on: docker
|
||||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||||
timeout-minutes: 10
|
timeout-minutes: 15
|
||||||
|
concurrency:
|
||||||
|
group: sync-wiki-${{ github.repository }}
|
||||||
|
cancel-in-progress: false
|
||||||
defaults:
|
defaults:
|
||||||
run:
|
run:
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -184,7 +187,7 @@ jobs:
|
|||||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --strict
|
python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --strict
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
@@ -225,7 +228,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
PRE_COMMIT_ALLOW_NO_CONFIG: "1"
|
PRE_COMMIT_ALLOW_NO_CONFIG: "1"
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
python3 -m devx.ci.push_badges
|
python3 -m devx.ci.push_badges
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
@@ -262,7 +265,7 @@ jobs:
|
|||||||
DEVX_VIKUNJA_PROJECT_ID: "8"
|
DEVX_VIKUNJA_PROJECT_ID: "8"
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
|
python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
@@ -295,9 +298,11 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||||
PYTHONPATH: src
|
PYTHONPATH: src
|
||||||
|
DEVX_REPO_NAME: devx
|
||||||
|
DEVX_REPO_OWNER: oblachno-oss
|
||||||
run: |
|
run: |
|
||||||
. .venv/bin/activate
|
. .venv/bin/activate 2>/dev/null || true
|
||||||
python3 -m devx.tools.configure_repo --repo devx --owner oblachno-oss
|
python3 -m devx.tools.configure_repo
|
||||||
- name: Notify on failure
|
- name: Notify on failure
|
||||||
if: failure()
|
if: failure()
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -49,6 +49,44 @@ repos:
|
|||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
stages: [pre-commit]
|
stages: [pre-commit]
|
||||||
|
|
||||||
|
- id: checkmake
|
||||||
|
name: checkmake Makefile linter
|
||||||
|
entry: make checkmake
|
||||||
|
language: system
|
||||||
|
files: (Makefile|\.mak)$
|
||||||
|
pass_filenames: false
|
||||||
|
stages: [pre-commit]
|
||||||
|
|
||||||
|
- id: check-test-speed
|
||||||
|
name: unit test speed check
|
||||||
|
entry: .venv/bin/python -m devx.tools.check_test_speed --max-seconds 6 --max-single-seconds 0.5
|
||||||
|
language: system
|
||||||
|
types: [python]
|
||||||
|
pass_filenames: false
|
||||||
|
stages: [pre-commit]
|
||||||
|
|
||||||
|
- id: check-translations
|
||||||
|
name: translation completeness check
|
||||||
|
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.check_translations
|
||||||
|
language: system
|
||||||
|
files: ^src/devx/translations\.json$
|
||||||
|
pass_filenames: false
|
||||||
|
stages: [pre-commit]
|
||||||
|
|
||||||
|
- id: doc-coverage
|
||||||
|
name: documentation coverage check
|
||||||
|
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.doc_coverage --fail-on-missing
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
stages: [pre-commit]
|
||||||
|
|
||||||
|
- id: lint-docs
|
||||||
|
name: documentation lint check
|
||||||
|
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.lint_docs --root .
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
stages: [pre-commit]
|
||||||
|
|
||||||
- id: pytest-cov
|
- id: pytest-cov
|
||||||
name: pytest with 100% coverage
|
name: pytest with 100% coverage
|
||||||
entry: make pytest-cov
|
entry: make pytest-cov
|
||||||
|
|||||||
@@ -1,5 +1,19 @@
|
|||||||
# AGENTS.md — Project Conventions for devx
|
# 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
|
## Build & Test Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -52,14 +66,14 @@ src/devx/
|
|||||||
├── gitea_cli.py # TeaCLI — wrapper around tea CLI with JSON parsing
|
├── gitea_cli.py # TeaCLI — wrapper around tea CLI with JSON parsing
|
||||||
├── i18n.py # Translation system (gettext-based, translations.json)
|
├── i18n.py # Translation system (gettext-based, translations.json)
|
||||||
├── exceptions.py # Custom exception types
|
├── 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)
|
├── ci/ # CI/CD automation modules (run by workflows)
|
||||||
│ ├── release.py # Automated versioning, tagging, changelog
|
│ ├── release.py # Automated versioning, tagging, changelog
|
||||||
│ ├── publish.py # Build and publish to Gitea PyPI registry (--skip-build for non-Python repos)
|
│ ├── 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
|
│ ├── 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)
|
│ ├── check_auto_merge_ready.py # Pre-merge validation gate (branch, PR title, Vikunja, behind-master)
|
||||||
│ ├── _shared.py # Shared utilities (get_latest_tag)
|
│ ├── _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
|
│ ├── detect_release_commit.py # Detect release commits on master
|
||||||
│ ├── validate_commit_msg.py # Conventional commit validation
|
│ ├── validate_commit_msg.py # Conventional commit validation
|
||||||
│ ├── pr_review.py # Automated PR review + manual reviews (--event, --body, --checklist-confirmed)
|
│ ├── 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_pyproject_deps.py # Validate pyproject.toml deps have documentation comments
|
||||||
│ ├── check_test_coverage.py # Ensure changed files have corresponding tests (configurable rules)
|
│ ├── 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_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
|
│ ├── configure_repo.py # Branch protection and label setup
|
||||||
│ ├── generate_badges.py # Badge SVG generation
|
│ ├── generate_badges.py # Badge SVG generation
|
||||||
|
│ ├── generate_cliff_config.py # Generate git-cliff config (cliff.toml)
|
||||||
│ ├── create_task.py # Create Vikunja tasks
|
│ ├── create_task.py # Create Vikunja tasks
|
||||||
│ ├── create_pr.py # Create PRs with auto-derived title from Vikunja
|
│ ├── create_pr.py # Create PRs with auto-derived title from Vikunja
|
||||||
│ ├── pr_status.py # Check CI status for a PR/commit (--wait polls)
|
│ ├── pr_status.py # Check CI status for a PR/commit (--wait polls)
|
||||||
│ ├── pr_logs.py # Fetch logs for failed CI jobs
|
│ ├── 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)
|
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
|
||||||
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
||||||
├── discover_runners.py # Dynamic Gitea runner discovery
|
├── discover_runners.py # Dynamic Gitea runner discovery
|
||||||
├── distribute_molecule.py # Distribute molecule scenarios across runners (LPT scheduling, --roles-root for multi-role)
|
├── distribute_molecule.py # Distribute molecule scenarios across runners (LPT scheduling, --roles-root for multi-role)
|
||||||
├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast (--roles-root)
|
├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast (--roles-root)
|
||||||
├── molecule_all.py # Run all molecule scenarios locally
|
├── molecule_all.py # Run all molecule scenarios locally
|
||||||
|
├── start_docker.py # Ensure Docker daemon is running for molecule tests
|
||||||
└── platforms.py # Supported molecule platforms
|
└── 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_REPO_NAME` | **(none — must be set)** | Repository name (or `owner/repo`) |
|
||||||
| `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) |
|
| `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) |
|
||||||
| `DEVX_VIKUNJA_PROJECT_ID` | `6` | Vikunja project ID |
|
| `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 |
|
| `CI_GITEA_TOKEN` | (from .env) | Gitea API token |
|
||||||
| `VIKUNJA_TOKEN` | (from .env) | Vikunja API token |
|
| `VIKUNJA_TOKEN` | (from .env) | Vikunja API token |
|
||||||
|
|
||||||
@@ -516,3 +535,113 @@ create-task: devx-create-task
|
|||||||
- Line length: 120 chars
|
- Line length: 120 chars
|
||||||
- Secrets are passed via environment variables, never on the command line
|
- Secrets are passed via environment variables, never on the command line
|
||||||
- All user-facing strings wrapped in `_()` for i18n
|
- 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"`.
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,78 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
## [0.33.3] - 2026-07-06
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Make wiki sync resilient to API timeouts and stale page lists
|
||||||
|
|
||||||
|
## [0.33.2] - 2026-07-05
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Abort sync_wiki when list_wiki_pages fails
|
||||||
|
|
||||||
|
## [0.33.1] - 2026-07-05
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Build images after post-merge publish, not on push
|
||||||
|
|
||||||
|
## [0.33.0] - 2026-07-05
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Add check_api_identity_checks, setup_ssh_key, and api utils
|
||||||
|
|
||||||
|
## [0.32.1] - 2026-07-01
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Add missing i18n translations for new tools
|
||||||
|
|
||||||
|
## [0.32.0] - 2026-07-01
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Extract docker-login, tofu-ops, check-deps, install-tofu to Python tools
|
||||||
|
|
||||||
|
## [0.31.0] - 2026-07-01
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Centralize venv management in devx.mak
|
||||||
|
|
||||||
|
## [0.30.0] - 2026-07-01
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Add standard label creation to configure_repo
|
||||||
|
|
||||||
|
## [0.29.1] - 2026-07-01
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Strip task ID prefix from commit messages in extract_conventional_msg
|
||||||
|
|
||||||
|
## [0.29.0] - 2026-07-01
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Detect badge commits as automated CI commits
|
||||||
|
|
||||||
|
## [0.28.0] - 2026-07-01
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- 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
|
## [0.27.2] - 2026-06-29
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -6,6 +6,36 @@ BIN := $(VENV)/bin
|
|||||||
|
|
||||||
all: setup
|
all: setup
|
||||||
|
|
||||||
|
# --- devx.mak integration ----------------------------------------------------
|
||||||
|
# Include shared targets from the devx package itself (venv management,
|
||||||
|
# workflow-lint, notify-failure, checkmake, lint targets, quality checks, etc.)
|
||||||
|
# Since devx IS the package, we can include its own devx.mak.
|
||||||
|
DEVX_PYTHON := $(BIN)/python
|
||||||
|
DEVX_VENV := $(VENV)
|
||||||
|
DEVX_BIN := $(BIN)
|
||||||
|
DEVX_LINT_PATHS := src/ tests/
|
||||||
|
DEVX_COV_PKG := src/devx
|
||||||
|
DEVX_TEST_PATHS := tests/
|
||||||
|
|
||||||
|
DEVX_MAK := $(shell $(BIN)/python -c \
|
||||||
|
"from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
|
||||||
|
2>/dev/null)
|
||||||
|
# Fallback: when the venv doesn't exist yet (chicken-and-egg), use the
|
||||||
|
# source tree copy directly. devx IS the package, so src/devx/make/devx.mak
|
||||||
|
# is always available in this repo.
|
||||||
|
ifeq ($(strip $(DEVX_MAK)),)
|
||||||
|
DEVX_MAK := $(CURDIR)/src/devx/make/devx.mak
|
||||||
|
endif
|
||||||
|
-include $(DEVX_MAK)
|
||||||
|
|
||||||
|
# venv, .env, and activate-scripts are provided by devx.mak
|
||||||
|
# (devx-venv, devx-env, devx-activate-scripts, $(DEVX_VENV)/bin/activate rule)
|
||||||
|
# Aliases for convenience and backward compatibility:
|
||||||
|
.PHONY: venv activate-scripts
|
||||||
|
venv: devx-venv
|
||||||
|
.env: devx-env
|
||||||
|
activate-scripts: devx-activate-scripts
|
||||||
|
|
||||||
# Full setup for local development
|
# Full setup for local development
|
||||||
setup: $(VENV)/bin/activate .env activate-scripts install-tools
|
setup: $(VENV)/bin/activate .env activate-scripts install-tools
|
||||||
@$(BIN)/pip install -e '.[dev]' 2>/dev/null; \
|
@$(BIN)/pip install -e '.[dev]' 2>/dev/null; \
|
||||||
@@ -35,22 +65,9 @@ setup-release: $(VENV)/bin/activate .env
|
|||||||
# an older devx.mak that doesn't yet define devx-setup-image. Consumer repos
|
# 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.
|
# (grm, infra) can safely alias to devx-setup-image since they install devx from PyPI.
|
||||||
setup-image:
|
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
|
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:
|
install-hooks:
|
||||||
@cp hooks/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
|
@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
|
@cp hooks/pre-push .git/hooks/pre-push && chmod +x .git/hooks/pre-push
|
||||||
@@ -60,23 +77,13 @@ install-tools: $(VENV)/bin/activate
|
|||||||
@$(BIN)/pip install -e '.' 2>/dev/null; \
|
@$(BIN)/pip install -e '.' 2>/dev/null; \
|
||||||
$(BIN)/python -m devx.tools.install_tools
|
$(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
|
# 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-ruff: devx-lint-ruff
|
||||||
lint-format: devx-lint-format
|
lint-format: devx-lint-format
|
||||||
typecheck: devx-typecheck
|
typecheck: devx-typecheck
|
||||||
@@ -98,6 +105,8 @@ create-task: devx-create-task
|
|||||||
create-pr: devx-create-pr
|
create-pr: devx-create-pr
|
||||||
push-with-pr: devx-push-with-pr
|
push-with-pr: devx-push-with-pr
|
||||||
git-push: devx-push
|
git-push: devx-push
|
||||||
|
rebase: devx-rebase
|
||||||
|
pr-rebase: devx-pr-rebase
|
||||||
|
|
||||||
lint-all: lint workflow-lint lint-dockerfiles
|
lint-all: lint workflow-lint lint-dockerfiles
|
||||||
@echo "[lint-all] All linting checks passed."
|
@echo "[lint-all] All linting checks passed."
|
||||||
@@ -106,10 +115,7 @@ lint-all: lint workflow-lint lint-dockerfiles
|
|||||||
# devx's own CI images may have an older devx.mak. Consumer repos can safely alias.
|
# devx's own CI images may have an older devx.mak. Consumer repos can safely alias.
|
||||||
lint-dockerfiles:
|
lint-dockerfiles:
|
||||||
@echo "[lint-dockerfiles] Linting Dockerfiles with hadolint..."
|
@echo "[lint-dockerfiles] Linting Dockerfiles with hadolint..."
|
||||||
@if ! command -v hadolint >/dev/null 2>&1; then \
|
@command -v hadolint >/dev/null 2>&1 || { echo "hadolint not found" >&2; exit 1; }
|
||||||
echo "[lint-dockerfiles] ERROR: hadolint not found. Install from https://github.com/hadolint/hadolint/releases" >&2; \
|
|
||||||
exit 1; \
|
|
||||||
fi
|
|
||||||
@find docker -name 'Dockerfile*' -exec hadolint {} +
|
@find docker -name 'Dockerfile*' -exec hadolint {} +
|
||||||
@echo "[lint-dockerfiles] All Dockerfiles passed."
|
@echo "[lint-dockerfiles] All Dockerfiles passed."
|
||||||
|
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ quality badges.
|
|||||||
|
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Why devx?
|
## Why devx?
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.11.1",
|
"devx>=0.27.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pip]
|
[tool.pip]
|
||||||
@@ -101,8 +101,8 @@ pip install -e .
|
|||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** If your project requires a specific devx version, pin it in
|
> **Note:** If your project requires a specific devx version, pin it in
|
||||||
> `dependencies` (e.g., `"devx==0.11.1"`) or use a version constraint
|
> `dependencies` (e.g., `"devx==0.27.0"`) or use a version constraint
|
||||||
> (e.g., `"devx>=0.11.1,<0.12"`).
|
> (e.g., `"devx>=0.27.0,<0.28"`).
|
||||||
|
|
||||||
### Optional extras
|
### Optional extras
|
||||||
|
|
||||||
|
|||||||
+9
-9
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
|
|||||||
|
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||||
[](https://www.python.org/downloads/)
|
[](https://www.python.org/downloads/)
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.11.1",
|
"devx>=0.27.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pip]
|
[tool.pip]
|
||||||
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
|
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
|
||||||
```
|
```
|
||||||
|
|
||||||
Pin a specific version if needed: `"devx==0.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
|
### Optional extras
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ wiki sync details.
|
|||||||
devx provides a `devx` CLI with three command groups:
|
devx provides a `devx` CLI with three command groups:
|
||||||
|
|
||||||
- `devx ci <command>` — CI/CD automation (17 commands)
|
- `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)
|
- `devx molecule <command>` — Molecule testing (4 commands, optional)
|
||||||
|
|
||||||
See [CLI Commands](CLI-Commands) for full command documentation with examples.
|
See [CLI Commands](CLI-Commands) for full command documentation with examples.
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ Custom exception hierarchy:
|
|||||||
### `i18n.py`
|
### `i18n.py`
|
||||||
|
|
||||||
Simple i18n system using a JSON translations file (`translations.json`).
|
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.
|
wraps user-facing strings for translation.
|
||||||
|
|
||||||
Projects can extend translations by setting `DEVX_TRANSLATIONS_PATH` to a
|
Projects can extend translations by setting `DEVX_TRANSLATIONS_PATH` to a
|
||||||
|
|||||||
@@ -421,6 +421,35 @@ Options:
|
|||||||
- `--no-pre-commit` — skip pre-commit hook installation
|
- `--no-pre-commit` — skip pre-commit hook installation
|
||||||
- `--no-tea-login` — skip tea CLI login configuration
|
- `--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
|
||||||
|
|
||||||
Molecule commands require the `molecule` extra (`pip install devx[molecule]`).
|
Molecule commands require the `molecule` extra (`pip install devx[molecule]`).
|
||||||
|
|||||||
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
|
|||||||
```toml
|
```toml
|
||||||
[project]
|
[project]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"devx>=0.26.0",
|
"devx>=0.27.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
dev = [
|
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
|
vikunja_project_id = 6
|
||||||
|
|
||||||
[tool.devx.classify]
|
[tool.devx.classify]
|
||||||
# File patterns that are workflow-only (no release needed)
|
# File patterns that are infrastructure (no release needed)
|
||||||
workflow_only = [
|
infrastructure = [
|
||||||
".gitea/**",
|
".gitea/**",
|
||||||
"docs/**",
|
"docs/**",
|
||||||
"tests/**",
|
"tests/**",
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||||
|
|
||||||
__version__ = "0.27.2"
|
__version__ = "0.33.3"
|
||||||
|
|||||||
@@ -194,6 +194,19 @@ class GiteaClient:
|
|||||||
payload = {"Do": "squash", "MergeTitleField": merge_title}
|
payload = {"Do": "squash", "MergeTitleField": merge_title}
|
||||||
self._request("POST", f"/pulls/{pr_number}/merge", json=payload)
|
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]]:
|
def get_commit_status(self, sha: str) -> list[dict[str, Any]]:
|
||||||
"""Fetch all status check contexts reported for a commit.
|
"""Fetch all status check contexts reported for a commit.
|
||||||
|
|
||||||
|
|||||||
+38
-13
@@ -41,6 +41,9 @@ from devx.config import (
|
|||||||
from devx.exceptions import APIError
|
from devx.exceptions import APIError
|
||||||
from devx.i18n import _
|
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
|
TASKID_FILE = ".taskid" # Deprecated, kept for backward-compat warnings
|
||||||
PR_TITLE_RE = re.compile(rf"^{TASK_PREFIX}-\d+:\s+.+")
|
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):
|
for commit in reversed(commits):
|
||||||
commit_info = commit.get("commit", {})
|
commit_info = commit.get("commit", {})
|
||||||
message = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
|
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:
|
if m:
|
||||||
prefix = m.group(1).split("(")[0].strip() # e.g. "feat" from "feat(scope)"
|
prefix = m.group(1).split("(")[0].strip() # e.g. "feat" from "feat(scope)"
|
||||||
score = priority.get(prefix, 0)
|
score = priority.get(prefix, 0)
|
||||||
if score > best_score:
|
if score > best_score:
|
||||||
best_score = score
|
best_score = score
|
||||||
best_msg = message
|
best_msg = stripped
|
||||||
if best_msg:
|
if best_msg:
|
||||||
return 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:
|
if commits:
|
||||||
commit_info = commits[-1].get("commit", {})
|
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 ""
|
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)
|
client.merge_pr(pr_num, merge_title)
|
||||||
except APIError as e:
|
except APIError as e:
|
||||||
if e.status == 405 and "behind" in e.message.lower():
|
if e.status == 405 and "behind" in e.message.lower():
|
||||||
# Head branch is behind master — do NOT auto-rebase.
|
# Head branch is behind master. Auto-rebase via Gitea API.
|
||||||
# Auto-rebasing creates a feedback loop: the force-push triggers
|
# This triggers a new pull_request synchronize event → new CI run.
|
||||||
# a new pull_request synchronize event, which starts a new CI run,
|
# The next auto-merge attempt will find the branch up-to-date and
|
||||||
# which runs auto-merge again, which rebases again, etc.
|
# merge successfully. This is NOT an infinite loop: the rebase
|
||||||
raise click.ClickException(
|
# 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"
|
"Branch is behind master. Auto-rebasing via Gitea API...\n"
|
||||||
" git fetch origin master && git rebase origin/master && git push --force-with-lease\n"
|
"A new CI run will start automatically after the rebase.\n"
|
||||||
"Then re-add the ready-to-merge label.",
|
"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:
|
else:
|
||||||
raise click.ClickException(
|
raise click.ClickException(
|
||||||
_(
|
_(
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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``.
|
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
|
This script writes ``is-release=true`` or ``is-release=false`` to
|
||||||
``$GITHUB_OUTPUT`` for use in CI workflow conditionals.
|
``$GITHUB_OUTPUT`` for use in CI workflow conditionals.
|
||||||
|
|
||||||
@@ -21,6 +24,7 @@ from devx.ci._shared import write_github_output
|
|||||||
from devx.i18n import _
|
from devx.i18n import _
|
||||||
|
|
||||||
RELEASE_RE = re.compile(r"^release: v\d+\.\d+\.\d+")
|
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:
|
def get_commit_message() -> str:
|
||||||
@@ -41,15 +45,29 @@ def is_release_commit(message: str) -> bool:
|
|||||||
return bool(RELEASE_RE.match(message))
|
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()
|
@click.command()
|
||||||
def main() -> None:
|
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()
|
msg = get_commit_message()
|
||||||
click.echo(_("Commit message: {msg}", msg=msg))
|
click.echo(_("Commit message: {msg}", msg=msg))
|
||||||
is_release = is_release_commit(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-release", "true" if is_release else "false")
|
||||||
|
write_github_output("is-automated", "true" if is_automated else "false")
|
||||||
if is_release:
|
if is_release:
|
||||||
click.echo(_("Release commit — skipping all post-merge jobs."))
|
click.echo(_("Release commit — skipping all post-merge jobs."))
|
||||||
|
elif is_automated:
|
||||||
|
click.echo(_("Automated CI commit (badge) — skipping post-merge jobs."))
|
||||||
else:
|
else:
|
||||||
click.echo(_("Regular merge commit — running all post-merge jobs."))
|
click.echo(_("Regular merge commit — running all post-merge jobs."))
|
||||||
|
|
||||||
|
|||||||
+106
-18
@@ -21,11 +21,19 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import click
|
import click
|
||||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||||
|
from tenacity import (
|
||||||
|
before_sleep_log,
|
||||||
|
retry,
|
||||||
|
retry_if_exception_type,
|
||||||
|
stop_after_attempt,
|
||||||
|
wait_exponential,
|
||||||
|
)
|
||||||
|
|
||||||
from devx.api_clients import GiteaClient
|
from devx.api_clients import GiteaClient
|
||||||
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
|
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
|
||||||
@@ -86,11 +94,12 @@ def decode_content(content_b64: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def list_wiki_pages(client: GiteaClient) -> dict[str, str]:
|
def list_wiki_pages(client: GiteaClient) -> dict[str, str]:
|
||||||
"""List existing wiki pages, returning {title: sub_url}."""
|
"""List existing wiki pages, returning {title: sub_url}.
|
||||||
try:
|
|
||||||
pages = client._request("GET", "/wiki/pages").json()
|
Raises :class:`APIError` if the wiki API is unavailable — the caller
|
||||||
except APIError:
|
is responsible for retrying or handling the failure.
|
||||||
return {}
|
"""
|
||||||
|
pages = client._request("GET", "/wiki/pages").json()
|
||||||
return {page.get("title", ""): page.get("sub_url", page.get("title", "")) for page in pages}
|
return {page.get("title", ""): page.get("sub_url", page.get("title", "")) for page in pages}
|
||||||
|
|
||||||
|
|
||||||
@@ -113,6 +122,9 @@ def sync_page(
|
|||||||
"""Create or update a single wiki page.
|
"""Create or update a single wiki page.
|
||||||
|
|
||||||
Returns "created", "updated", or "skipped" (if dry-run).
|
Returns "created", "updated", or "skipped" (if dry-run).
|
||||||
|
|
||||||
|
If a create fails with HTTP 400 "already exists" (the page list was
|
||||||
|
stale), re-lists the wiki and falls back to an update.
|
||||||
"""
|
"""
|
||||||
if dry_run:
|
if dry_run:
|
||||||
click.echo(_("[dry-run] Would sync page: {title} ({chars} chars)", title=page_title, chars=len(content)))
|
click.echo(_("[dry-run] Would sync page: {title} ({chars} chars)", title=page_title, chars=len(content)))
|
||||||
@@ -135,16 +147,36 @@ def sync_page(
|
|||||||
return "updated"
|
return "updated"
|
||||||
|
|
||||||
# Create new page via POST /wiki/new
|
# Create new page via POST /wiki/new
|
||||||
client._request(
|
try:
|
||||||
"POST",
|
client._request(
|
||||||
"/wiki/new",
|
"POST",
|
||||||
json={
|
"/wiki/new",
|
||||||
"title": page_title,
|
json={
|
||||||
"content_base64": content_b64,
|
"title": page_title,
|
||||||
"message": f"Sync from docs/ — create {page_title}",
|
"content_base64": content_b64,
|
||||||
},
|
"message": f"Sync from docs/ — create {page_title}",
|
||||||
)
|
},
|
||||||
return "created"
|
)
|
||||||
|
return "created"
|
||||||
|
except APIError as e:
|
||||||
|
if e.status == 400 and "already exists" in e.message.lower():
|
||||||
|
# The page list was stale (e.g. after a timeout-retry returned
|
||||||
|
# incomplete data). Re-list and fall back to update.
|
||||||
|
click.echo(_(" Page '{title}' already exists (stale list). Re-listing and updating...", title=page_title))
|
||||||
|
fresh_pages = _list_wiki_pages_with_retry(client)
|
||||||
|
if page_title in fresh_pages:
|
||||||
|
sub_url = fresh_pages[page_title]
|
||||||
|
client._request(
|
||||||
|
"PATCH",
|
||||||
|
f"/wiki/page/{sub_url}",
|
||||||
|
json={
|
||||||
|
"title": page_title,
|
||||||
|
"content_base64": content_b64,
|
||||||
|
"message": f"Sync from docs/ — update {page_title} (create→update fallback)",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return "updated"
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def verify_wiki_page(
|
def verify_wiki_page(
|
||||||
@@ -161,6 +193,28 @@ def verify_wiki_page(
|
|||||||
return actual.strip() == expected_content.strip()
|
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 wiki API can be slow (it renders pages on each request)
|
||||||
|
and may time out. Uses 5 attempts with exponential backoff to handle
|
||||||
|
transient slowness.
|
||||||
|
"""
|
||||||
|
_logger = logging.getLogger("sync_wiki")
|
||||||
|
|
||||||
|
@retry(
|
||||||
|
stop=stop_after_attempt(5),
|
||||||
|
wait=wait_exponential(multiplier=2, min=2, max=16),
|
||||||
|
retry=retry_if_exception_type(APIError),
|
||||||
|
before_sleep=before_sleep_log(_logger, logging.WARNING),
|
||||||
|
reraise=True,
|
||||||
|
)
|
||||||
|
def _do_list() -> dict[str, str]:
|
||||||
|
return list_wiki_pages(client)
|
||||||
|
|
||||||
|
return _do_list()
|
||||||
|
|
||||||
|
|
||||||
def verify_wiki_integrity(
|
def verify_wiki_integrity(
|
||||||
client: GiteaClient,
|
client: GiteaClient,
|
||||||
mapping: dict[str, str],
|
mapping: dict[str, str],
|
||||||
@@ -176,9 +230,25 @@ def verify_wiki_integrity(
|
|||||||
5. Page count matches
|
5. Page count matches
|
||||||
|
|
||||||
Returns a list of failure messages (empty if all checks pass).
|
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] = []
|
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())
|
expected_titles = set(mapping.values())
|
||||||
|
|
||||||
# Check 1: Page count
|
# Check 1: Page count
|
||||||
@@ -243,7 +313,16 @@ def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
|
|||||||
|
|
||||||
click.echo(_("Syncing {count} documentation pages to wiki...", count=len(mapping)))
|
click.echo(_("Syncing {count} documentation pages to wiki...", count=len(mapping)))
|
||||||
|
|
||||||
existing_pages = list_wiki_pages(client)
|
try:
|
||||||
|
existing_pages = _list_wiki_pages_with_retry(client)
|
||||||
|
except APIError as e:
|
||||||
|
raise click.ClickException(
|
||||||
|
_(
|
||||||
|
"Failed to list existing wiki pages after retries: {error}. "
|
||||||
|
"Aborting to avoid creating duplicate pages.",
|
||||||
|
error=e,
|
||||||
|
)
|
||||||
|
) from e
|
||||||
if existing_pages:
|
if existing_pages:
|
||||||
click.echo(_("Found {count} existing wiki pages.", count=len(existing_pages)))
|
click.echo(_("Found {count} existing wiki pages.", count=len(existing_pages)))
|
||||||
|
|
||||||
@@ -302,7 +381,16 @@ def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
|
|||||||
else:
|
else:
|
||||||
click.echo(_("\nVerifying wiki pages have content..."))
|
click.echo(_("\nVerifying wiki pages have content..."))
|
||||||
# Re-fetch the page list to get updated sub_urls
|
# 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
|
failures = 0
|
||||||
for page_title, expected_content in sorted(synced.items()):
|
for page_title, expected_content in sorted(synced.items()):
|
||||||
ok = verify_wiki_page(client, page_title, expected_content, existing_pages)
|
ok = verify_wiki_page(client, page_title, expected_content, existing_pages)
|
||||||
|
|||||||
@@ -226,6 +226,20 @@ def tools_setup(args: tuple[str, ...]) -> None:
|
|||||||
_run_module("devx.tools.setup", list(args))
|
_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()
|
@cli.group()
|
||||||
def molecule() -> None:
|
def molecule() -> None:
|
||||||
"""Molecule testing commands (requires devx[molecule])."""
|
"""Molecule testing commands (requires devx[molecule])."""
|
||||||
|
|||||||
+68
-15
@@ -56,20 +56,61 @@ DEVX_DOCKERFILE_PATHS ?= docker
|
|||||||
# PIP_INSTALL — helper to run pip with Gitea private PyPI registry configured.
|
# PIP_INSTALL — helper to run pip with Gitea private PyPI registry configured.
|
||||||
# Usage: $(DEVX_PIP_INSTALL) install -e '.[ci,lint]'
|
# Usage: $(DEVX_PIP_INSTALL) install -e '.[ci,lint]'
|
||||||
# CI_GITEA_USERNAME can be set in .env, as an env var, or as a Make variable.
|
# CI_GITEA_USERNAME can be set in .env, as an env var, or as a Make variable.
|
||||||
|
# Projects can alias: PIP_INSTALL = $(DEVX_PIP_INSTALL)
|
||||||
DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
|
DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
|
||||||
CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \
|
CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \
|
||||||
_PYPI_USER="$${CI_GITEA_USERNAME:-emil}"; \
|
_PYPI_USER="$${CI_GITEA_USERNAME:-emil}"; \
|
||||||
if [ -n "$$CI_GITEA_TOKEN" ] && [ -n "$$_PYPI_USER" ]; then export PIP_EXTRA_INDEX_URL="https://$$_PYPI_USER:$$CI_GITEA_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \
|
if [ -n "$$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
|
$(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-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-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-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-lint-ruff devx-lint-format devx-typecheck devx-lint-bandit devx-lint-deps devx-lint
|
||||||
.PHONY: devx-clean devx-pre-push
|
.PHONY: devx-clean devx-pre-push
|
||||||
.PHONY: devx-check-mutable-globals devx-check-dep-docs devx-check-test-coverage devx-check-docs devx-check-test-speed
|
.PHONY: devx-check-mutable-globals devx-check-dep-docs devx-check-test-coverage devx-check-docs devx-check-test-speed
|
||||||
|
.PHONY: devx-check-api-identity-checks devx-setup-ssh-key
|
||||||
.PHONY: devx-test-unit devx-pytest-cov
|
.PHONY: devx-test-unit devx-pytest-cov
|
||||||
.PHONY: devx-setup-image devx-lint-dockerfiles
|
.PHONY: devx-setup-image devx-lint-dockerfiles
|
||||||
|
|
||||||
@@ -134,6 +175,20 @@ devx-pr-review:
|
|||||||
$(if $(BODY),--body "$(BODY)") \
|
$(if $(BODY),--body "$(BODY)") \
|
||||||
$(if $(CHECKLIST),--checklist-confirmed --checklist-categories $(CHECKLIST))
|
$(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 ─────────────────────────────────────────────────────────
|
# ── Environment setup ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Configure Gitea private PyPI registry so pip can find devx and other
|
# Configure Gitea private PyPI registry so pip can find devx and other
|
||||||
@@ -151,12 +206,6 @@ devx-env:
|
|||||||
echo "Created .env from .env.example — please edit it with your credentials."; \
|
echo "Created .env from .env.example — please edit it with your credentials."; \
|
||||||
fi
|
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
|
# Create activate scripts for shell/fish/zsh
|
||||||
devx-activate-scripts:
|
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)
|
@test -f activate.sh || (echo '#!/usr/bin/env bash' > activate.sh && echo 'source "$$(cd "$$(dirname "$${BASH_SOURCE[0]}")" && pwd)/.venv/bin/activate"' >> activate.sh && chmod +x activate.sh)
|
||||||
@@ -252,7 +301,7 @@ devx-lint: devx-lint-ruff devx-lint-format devx-typecheck devx-lint-bandit
|
|||||||
# ── Testing ───────────────────────────────────────────────────────────────────
|
# ── Testing ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
devx-test-unit:
|
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-pytest-cov:
|
||||||
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -v --cov=$(DEVX_COV_PKG) --cov-report=term-missing --cov-fail-under=100
|
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -v --cov=$(DEVX_COV_PKG) --cov-report=term-missing --cov-fail-under=100
|
||||||
@@ -279,6 +328,14 @@ devx-check-docs:
|
|||||||
devx-check-test-speed:
|
devx-check-test-speed:
|
||||||
@$(DEVX_PYTHON) -m devx.tools.check_test_speed
|
@$(DEVX_PYTHON) -m devx.tools.check_test_speed
|
||||||
|
|
||||||
|
# Scan integration tests for unsafe is True/is False identity checks
|
||||||
|
devx-check-api-identity-checks:
|
||||||
|
@$(DEVX_PYTHON) -m devx.tools.check_api_identity_checks
|
||||||
|
|
||||||
|
# Set up SSH private key from SSH_PRIVATE_KEY env var
|
||||||
|
devx-setup-ssh-key:
|
||||||
|
@$(DEVX_PYTHON) -m devx.tools.setup_ssh_key
|
||||||
|
|
||||||
# ── Pre-push validation ───────────────────────────────────────────────────────
|
# ── Pre-push validation ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
# Run lint + tests before push (projects can override with project-specific targets)
|
# Run lint + tests before push (projects can override with project-specific targets)
|
||||||
@@ -327,12 +384,8 @@ devx-lint-dockerfiles:
|
|||||||
# devx-setup-ci) — each project defines its own setup-ci target.
|
# devx-setup-ci) — each project defines its own setup-ci target.
|
||||||
|
|
||||||
devx-setup-image:
|
devx-setup-image:
|
||||||
@if [ -d /opt/venv ]; then ln -sf /opt/venv $(DEVX_VENV); . $(DEVX_BIN)/activate; \
|
@/opt/venv/bin/python -m devx.tools.setup_image --venv $(DEVX_VENV) --extras "$(EXTRAS)" \
|
||||||
_U="$${CI_GITEA_USERNAME:-emil}"; \
|
--gitea-host $(DEVX_GITEA_PYPI_HOST) --gitea-org $(DEVX_GITEA_PYPI_ORG)
|
||||||
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
|
|
||||||
|
|
||||||
# ── Docker image build / push / cleanup ───────────────────────────────────────
|
# ── Docker image build / push / cleanup ───────────────────────────────────────
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
import platform
|
import platform
|
||||||
|
import subprocess # nosec B404
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
|
||||||
@@ -22,3 +24,52 @@ def arch_string() -> str:
|
|||||||
if machine in {"aarch64", "arm64"}:
|
if machine in {"aarch64", "arm64"}:
|
||||||
return "arm64"
|
return "arm64"
|
||||||
raise click.ClickException(f"Unsupported architecture: {machine}")
|
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
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Scan integration tests for unsafe ``is True``/``is False`` identity checks.
|
||||||
|
|
||||||
|
Many APIs (e.g. Mattermost) return boolean values as strings (``"true"``,
|
||||||
|
``"false"``) rather than native JSON booleans. Using ``is True`` or
|
||||||
|
``is not False`` on such responses silently fails because ``"true" is True``
|
||||||
|
evaluates to ``False`` in Python.
|
||||||
|
|
||||||
|
This tool scans ``tests/integration/test_*.py`` files for identity checks
|
||||||
|
on API response values and reports them as errors.
|
||||||
|
|
||||||
|
Configuration (``[tool.devx.check_api_identity_checks]`` in pyproject.toml):
|
||||||
|
|
||||||
|
``scan_dirs`` — list of directories to scan (default: ``["tests/integration"]``)
|
||||||
|
``skip_patterns`` — list of filename patterns to skip (default: ``["test_*_helpers.py"]``)
|
||||||
|
``noqa_marker`` — comment to suppress individual lines (default: ``# noqa``)
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python3 -m devx.tools.check_api_identity_checks
|
||||||
|
python3 -m devx.tools.check_api_identity_checks --scan-dir tests/integration
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from devx.config import _load_pyproject_devx
|
||||||
|
from devx.i18n import _
|
||||||
|
|
||||||
|
DEFAULT_SCAN_DIRS = ["tests/integration"]
|
||||||
|
DEFAULT_SKIP_PATTERNS = ["test_*_helpers.py"]
|
||||||
|
DEFAULT_NOQA_MARKER = "# noqa"
|
||||||
|
|
||||||
|
# Matches: x is True, x is False, x is not True, x is not False
|
||||||
|
_IDENTITY_CHECK_RE = re.compile(r"\bis\s+(not\s+)?(True|False)\b")
|
||||||
|
|
||||||
|
|
||||||
|
def _load_config() -> tuple[list[str], list[str], str]:
|
||||||
|
"""Load configuration from pyproject.toml [tool.devx.check_api_identity_checks]."""
|
||||||
|
devx_cfg = _load_pyproject_devx()
|
||||||
|
cfg_raw = devx_cfg.get("check_api_identity_checks", {})
|
||||||
|
if not isinstance(cfg_raw, dict):
|
||||||
|
return DEFAULT_SCAN_DIRS, DEFAULT_SKIP_PATTERNS, DEFAULT_NOQA_MARKER
|
||||||
|
cfg: dict[str, object] = cfg_raw # type: ignore[assignment]
|
||||||
|
|
||||||
|
scan_dirs_raw = cfg.get("scan_dirs", DEFAULT_SCAN_DIRS)
|
||||||
|
scan_dirs: list[str] = [str(d) for d in scan_dirs_raw] if isinstance(scan_dirs_raw, list) else DEFAULT_SCAN_DIRS
|
||||||
|
|
||||||
|
skip_raw = cfg.get("skip_patterns", DEFAULT_SKIP_PATTERNS)
|
||||||
|
skip_patterns: list[str] = [str(p) for p in skip_raw] if isinstance(skip_raw, list) else DEFAULT_SKIP_PATTERNS
|
||||||
|
|
||||||
|
noqa_marker = str(cfg.get("noqa_marker", DEFAULT_NOQA_MARKER))
|
||||||
|
|
||||||
|
return scan_dirs, skip_patterns, noqa_marker
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_skip_pattern(path: Path, skip_patterns: list[str]) -> bool:
|
||||||
|
"""Check if a file path matches any skip pattern."""
|
||||||
|
name = path.name
|
||||||
|
return any(Path(name).match(pattern) for pattern in skip_patterns)
|
||||||
|
|
||||||
|
|
||||||
|
def find_identity_checks(
|
||||||
|
file_path: Path,
|
||||||
|
repo_root: Path,
|
||||||
|
noqa_marker: str,
|
||||||
|
) -> list[str]:
|
||||||
|
"""Return a list of issue strings for unsafe identity checks in *file_path*."""
|
||||||
|
issues: list[str] = []
|
||||||
|
try:
|
||||||
|
source = file_path.read_text(encoding="utf-8")
|
||||||
|
except (OSError, UnicodeDecodeError):
|
||||||
|
return issues
|
||||||
|
|
||||||
|
rel = str(file_path.relative_to(repo_root))
|
||||||
|
for lineno, line in enumerate(source.splitlines(), 1):
|
||||||
|
if noqa_marker in line:
|
||||||
|
continue
|
||||||
|
match = _IDENTITY_CHECK_RE.search(line)
|
||||||
|
if match:
|
||||||
|
issues.append(
|
||||||
|
f"{rel}:{lineno}: unsafe identity check '{match.group()}' "
|
||||||
|
f"— APIs may return string 'true'/'false'. "
|
||||||
|
f"Use string comparison or _is_truthy()/_is_falsy() helpers."
|
||||||
|
)
|
||||||
|
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option(
|
||||||
|
"--scan-dir",
|
||||||
|
multiple=True,
|
||||||
|
help=_("Directory to scan (default: tests/integration). Can be repeated."),
|
||||||
|
)
|
||||||
|
def cli(scan_dir: tuple[str, ...]) -> None:
|
||||||
|
"""Scan integration tests for unsafe ``is True``/``is False`` identity checks."""
|
||||||
|
repo_root = Path.cwd()
|
||||||
|
config_scan_dirs, skip_patterns, noqa_marker = _load_config()
|
||||||
|
|
||||||
|
scan_dirs = list(scan_dir) if scan_dir else config_scan_dirs
|
||||||
|
|
||||||
|
all_issues: list[str] = []
|
||||||
|
|
||||||
|
for scan_dir_name in scan_dirs:
|
||||||
|
scan_path = repo_root / scan_dir_name
|
||||||
|
if not scan_path.exists():
|
||||||
|
continue
|
||||||
|
for py_file in scan_path.rglob("test_*.py"):
|
||||||
|
if _matches_skip_pattern(py_file, skip_patterns):
|
||||||
|
continue
|
||||||
|
all_issues.extend(find_identity_checks(py_file, repo_root, noqa_marker))
|
||||||
|
|
||||||
|
if all_issues:
|
||||||
|
click.echo(
|
||||||
|
_("Found {count} unsafe identity check(s) in integration tests.", count=len(all_issues)),
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
for issue in all_issues:
|
||||||
|
click.echo(f" {issue}", err=True)
|
||||||
|
raise click.ClickException(
|
||||||
|
_(
|
||||||
|
"Use string comparison or _is_truthy()/_is_falsy() helpers instead. "
|
||||||
|
"Add '{marker}' to suppress individual lines.",
|
||||||
|
marker=noqa_marker,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
click.echo(_("[check-api-identity-checks] Passed: no unsafe identity checks found"))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
cli() # pragma: no cover
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Check that required development tools are present.
|
||||||
|
|
||||||
|
Verifies the availability of core tools (tofu, docker, checkmake, Python
|
||||||
|
3.12+ in the venv) and prints warnings or errors for missing ones.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python3 -m devx.tools.check_deps
|
||||||
|
python3 -m devx.tools.check_deps --venv .venv
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess # nosec B404
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from devx.i18n import _
|
||||||
|
|
||||||
|
REQUIRED_TOOLS = ["tofu", "docker"]
|
||||||
|
OPTIONAL_TOOLS = ["checkmake"]
|
||||||
|
PYTHON_MIN_VERSION = (3, 12)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_tool(name: str, *, optional: bool = False) -> bool:
|
||||||
|
"""Check if a tool is on PATH. Returns True if found."""
|
||||||
|
found = shutil.which(name) is not None
|
||||||
|
if found:
|
||||||
|
return True
|
||||||
|
level = "WARN" if optional else "ERROR"
|
||||||
|
click.echo(
|
||||||
|
_("{level}: {tool} not found.{hint}", level=level, tool=name, hint=""),
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _check_python_version(venv_bin: Path) -> None:
|
||||||
|
"""Check that the venv Python is >= 3.12."""
|
||||||
|
python_bin = venv_bin / "python"
|
||||||
|
if not python_bin.exists():
|
||||||
|
click.echo(
|
||||||
|
_("WARN: .venv not found. Run 'make setup-venv' to create it."),
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
result = subprocess.run( # nosec B603
|
||||||
|
[str(python_bin), "--version"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
click.echo(_("WARN: Could not determine Python version in .venv."), err=True)
|
||||||
|
return
|
||||||
|
version_str = result.stdout.strip().split()[-1] if result.stdout else ""
|
||||||
|
try:
|
||||||
|
major, minor = int(version_str.split(".")[0]), int(version_str.split(".")[1])
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
click.echo(_("WARN: Could not parse Python version '{version}'.", version=version_str), err=True)
|
||||||
|
return
|
||||||
|
if (major, minor) < PYTHON_MIN_VERSION:
|
||||||
|
click.echo(
|
||||||
|
_(
|
||||||
|
"WARN: .venv has Python {version}, but >={req} is required.",
|
||||||
|
version=version_str,
|
||||||
|
req=f"{PYTHON_MIN_VERSION[0]}.{PYTHON_MIN_VERSION[1]}",
|
||||||
|
),
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
click.echo(_("[check-deps] Virtualenv .venv ready (Python {version}).", version=version_str))
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option("--venv", default=".venv", show_default=True, help="Path to the virtual environment.")
|
||||||
|
@click.option("--checkmake-bin", default=None, help="Path to checkmake binary (fallback if not on PATH).")
|
||||||
|
def cli(venv: str, checkmake_bin: str | None) -> None:
|
||||||
|
"""Verify that required development tools are present."""
|
||||||
|
click.echo(_("[check-deps] Verifying tools..."))
|
||||||
|
|
||||||
|
all_required = True
|
||||||
|
for tool in REQUIRED_TOOLS:
|
||||||
|
if not _check_tool(tool):
|
||||||
|
all_required = False
|
||||||
|
|
||||||
|
for tool in OPTIONAL_TOOLS:
|
||||||
|
if not _check_tool(tool, optional=True):
|
||||||
|
if checkmake_bin and Path(checkmake_bin).exists():
|
||||||
|
click.echo(_(" {tool}: found at {path}", tool=tool, path=checkmake_bin))
|
||||||
|
else:
|
||||||
|
click.echo(_(" Run 'make install-checkmake' to install the Makefile linter."))
|
||||||
|
|
||||||
|
_check_python_version(Path(venv) / "bin")
|
||||||
|
|
||||||
|
if not all_required:
|
||||||
|
raise click.ClickException(_("Required tools missing."))
|
||||||
|
click.echo(_("[check-deps] All core tools present."))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
cli() # pragma: no cover
|
||||||
@@ -32,7 +32,10 @@ _TIMING_RE = re.compile(r"(\d+) passed.* in ([0-9.]+)s")
|
|||||||
|
|
||||||
# Matches per-test duration lines from --durations=0:
|
# Matches per-test duration lines from --durations=0:
|
||||||
# 0.51s call tests/test_foo.py::test_bar
|
# 0.51s call tests/test_foo.py::test_bar
|
||||||
_DURATION_LINE_RE = re.compile(r"^(\d+\.?\d*)s\s+(?:setup|call|teardown)\s+(.+)$")
|
# Only "call" duration is counted — "setup" includes import/collection
|
||||||
|
# overhead (coverage init, module imports) which is environment-dependent
|
||||||
|
# and not a test quality signal.
|
||||||
|
_DURATION_LINE_RE = re.compile(r"^(\d+\.?\d*)s\s+call\s+(.+)$")
|
||||||
|
|
||||||
|
|
||||||
def run_tests() -> tuple[str, str]:
|
def run_tests() -> tuple[str, str]:
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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.
|
Uses ``GiteaClient`` for branch protection, repo settings, and label
|
||||||
The ``tea`` CLI is used for label creation if available, with a
|
creation. Standard labels (bug, ready-to-merge, feedback, tooling,
|
||||||
fallback to ``GiteaClient`` if tea is not installed.
|
ci-improvement, doc-improvement, workflow-improvement) are created
|
||||||
|
idempotently via ``ensure_label``.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
CI_GITEA_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo
|
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:
|
def _handle_http_error(e: APIError) -> None:
|
||||||
"""Raise a user-friendly Click exception for HTTP errors."""
|
"""Raise a user-friendly Click exception for HTTP errors."""
|
||||||
if e.status == http.HTTPStatus.FORBIDDEN:
|
if e.status == http.HTTPStatus.FORBIDDEN:
|
||||||
@@ -138,6 +152,12 @@ def configure_repo(
|
|||||||
client.update_repo_settings(cast(dict[str, object], rs_config))
|
client.update_repo_settings(cast(dict[str, object], rs_config))
|
||||||
click.echo(_(" - Auto-delete branch after merge: yes"))
|
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("")
|
||||||
click.echo(_("Repository configuration complete."))
|
click.echo(_("Repository configuration complete."))
|
||||||
except APIError as e:
|
except APIError as e:
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Docker registry login helper.
|
||||||
|
|
||||||
|
Handles login to Docker registries (Gitea, Docker Hub) with credential
|
||||||
|
loading from environment variables. Supports required and optional modes.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python3 -m devx.tools.docker_login --registry git.oblachno.oblachno.fyi \\
|
||||||
|
--token-env CI_GITEA_TOKEN --username-env CI_GITEA_USERNAME \\
|
||||||
|
--default-username emil
|
||||||
|
|
||||||
|
python3 -m devx.tools.docker_login --registry docker.io \\
|
||||||
|
--token-env DOCKER_HUB_TOKEN --username-env DOCKER_HUB_USERNAME --optional
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess # nosec B404
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from devx.i18n import _
|
||||||
|
|
||||||
|
|
||||||
|
def docker_login(
|
||||||
|
registry: str,
|
||||||
|
username: str,
|
||||||
|
token: str,
|
||||||
|
*,
|
||||||
|
suppress_failure: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""Log in to a Docker registry.
|
||||||
|
|
||||||
|
Returns True on success, False on failure.
|
||||||
|
If ``suppress_failure`` is True, prints a warning instead of raising.
|
||||||
|
"""
|
||||||
|
cmd = ["docker", "login", registry, "-u", username, "-p", token]
|
||||||
|
result = subprocess.run( # nosec B603
|
||||||
|
cmd,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
if suppress_failure:
|
||||||
|
click.echo(
|
||||||
|
_("[docker-login] Login to {registry} failed (continuing).", registry=registry),
|
||||||
|
err=True,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
raise click.ClickException(
|
||||||
|
_("Login to {registry} failed: {error}", registry=registry, error=result.stderr.strip()),
|
||||||
|
)
|
||||||
|
click.echo(_("[docker-login] Logged in to {registry}.", registry=registry))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_credentials(
|
||||||
|
token_env: str,
|
||||||
|
username_env: str,
|
||||||
|
default_username: str | None,
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Resolve credentials from environment variables.
|
||||||
|
|
||||||
|
Returns (username, token) or (None, None) if token is not set.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
token = os.environ.get(token_env, "")
|
||||||
|
if not token:
|
||||||
|
return None, None
|
||||||
|
username = os.environ.get(username_env, "") or (default_username or "")
|
||||||
|
return username, token
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.option("--registry", required=True, help="Docker registry URL (e.g. docker.io, git.example.com).")
|
||||||
|
@click.option("--token-env", required=True, help="Environment variable name for the auth token.")
|
||||||
|
@click.option("--username-env", required=True, help="Environment variable name for the username.")
|
||||||
|
@click.option(
|
||||||
|
"--default-username",
|
||||||
|
default=None,
|
||||||
|
help="Default username if the env var is not set.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--optional",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Skip silently if token is not set instead of raising.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--suppress-failure",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Continue on login failure instead of raising (prints warning).",
|
||||||
|
)
|
||||||
|
def cli(
|
||||||
|
registry: str,
|
||||||
|
token_env: str,
|
||||||
|
username_env: str,
|
||||||
|
default_username: str | None,
|
||||||
|
optional: bool,
|
||||||
|
suppress_failure: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Log in to a Docker registry using credentials from environment variables."""
|
||||||
|
username, token = _resolve_credentials(token_env, username_env, default_username)
|
||||||
|
if token is None:
|
||||||
|
if optional:
|
||||||
|
click.echo(_("[docker-login] Skipping {registry} (token {env} not set).", registry=registry, env=token_env))
|
||||||
|
return
|
||||||
|
raise click.ClickException(
|
||||||
|
_("{env} is not set. Set it in your .env file or pass it as an environment variable.", env=token_env),
|
||||||
|
)
|
||||||
|
if not username:
|
||||||
|
raise click.ClickException(
|
||||||
|
_("{env} is not set. Set it in your .env file.", env=username_env),
|
||||||
|
)
|
||||||
|
docker_login(registry, username, token, suppress_failure=suppress_failure)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
cli() # pragma: no cover
|
||||||
@@ -42,6 +42,8 @@ TEA_VERSION = "0.14.1"
|
|||||||
|
|
||||||
HADOLINT_VERSION = "2.12.0"
|
HADOLINT_VERSION = "2.12.0"
|
||||||
|
|
||||||
|
TOFU_VERSION = "1.12.3"
|
||||||
|
|
||||||
|
|
||||||
def _arch() -> str:
|
def _arch() -> str:
|
||||||
"""Return the architecture string used by release assets (delegates to shared utility)."""
|
"""Return the architecture string used by release assets (delegates to shared utility)."""
|
||||||
@@ -174,7 +176,27 @@ def install_hadolint() -> bool:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea", "hadolint"]
|
def install_tofu() -> bool:
|
||||||
|
"""Install OpenTofu if not already present. Returns True if installed/skipped.
|
||||||
|
|
||||||
|
Downloads the official release tarball from GitHub and extracts the
|
||||||
|
``tofu`` binary to ``~/.local/bin``.
|
||||||
|
"""
|
||||||
|
if _is_installed("tofu"):
|
||||||
|
click.echo("tofu: already installed")
|
||||||
|
return True
|
||||||
|
arch = _arch()
|
||||||
|
os_name = platform.system().lower()
|
||||||
|
url = (
|
||||||
|
f"https://github.com/opentofu/opentofu/releases/download/"
|
||||||
|
f"v{TOFU_VERSION}/tofu_{TOFU_VERSION}_{os_name}_{arch}.tar.gz"
|
||||||
|
)
|
||||||
|
dest = _download_and_extract_tarball(url, "tofu")
|
||||||
|
click.echo(f"tofu: installed to {dest}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea", "hadolint", "tofu"]
|
||||||
|
|
||||||
|
|
||||||
def _install_tool(name: str) -> bool:
|
def _install_tool(name: str) -> bool:
|
||||||
@@ -189,6 +211,8 @@ def _install_tool(name: str) -> bool:
|
|||||||
return install_tea()
|
return install_tea()
|
||||||
if name == "hadolint":
|
if name == "hadolint":
|
||||||
return install_hadolint()
|
return install_hadolint()
|
||||||
|
if name == "tofu":
|
||||||
|
return install_tofu()
|
||||||
raise click.ClickException(f"Unknown tool: {name}")
|
raise click.ClickException(f"Unknown tool: {name}")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Set up SSH private key for CI jobs that need SSH access to remote hosts.
|
||||||
|
|
||||||
|
Writes the ``SSH_PRIVATE_KEY`` env var to ``~/.ssh/id_rsa``, starts
|
||||||
|
``ssh-agent``, and adds the key. Replaces the repeated inline shell
|
||||||
|
pattern in CI workflow files.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python3 -m devx.tools.setup_ssh_key
|
||||||
|
|
||||||
|
Reads ``SSH_PRIVATE_KEY`` from the environment. Exits 0 on success,
|
||||||
|
1 on missing key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import subprocess # nosec B404
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from devx.i18n import _
|
||||||
|
|
||||||
|
|
||||||
|
def setup_ssh_key(private_key: str | None = None) -> bool:
|
||||||
|
"""Set up SSH private key and start ssh-agent.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
private_key: The SSH private key content. If None, reads from
|
||||||
|
``SSH_PRIVATE_KEY`` environment variable.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if setup succeeded, False if key is missing.
|
||||||
|
"""
|
||||||
|
key = private_key or os.environ.get("SSH_PRIVATE_KEY", "")
|
||||||
|
if not key:
|
||||||
|
click.echo(_("SSH_PRIVATE_KEY not set — skipping SSH key setup"), err=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
ssh_dir = Path.home() / ".ssh"
|
||||||
|
ssh_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
key_path = ssh_dir / "id_rsa"
|
||||||
|
key_path.write_text(f"{key}\n", encoding="utf-8")
|
||||||
|
key_path.chmod(0o600)
|
||||||
|
|
||||||
|
# Start ssh-agent and add the key
|
||||||
|
agent_result = subprocess.run( # nosec B603, B607
|
||||||
|
["ssh-agent", "-s"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if agent_result.returncode != 0:
|
||||||
|
click.echo(_("Failed to start ssh-agent: {error}", error=agent_result.stderr), err=True)
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Parse ssh-agent output to set env vars
|
||||||
|
for raw_line in agent_result.stdout.splitlines():
|
||||||
|
stripped = raw_line.strip()
|
||||||
|
if "=" in stripped and ";" in stripped:
|
||||||
|
var, val = stripped.split("=", 1)
|
||||||
|
val = val.rstrip(";")
|
||||||
|
os.environ[var] = val
|
||||||
|
|
||||||
|
# Add the key (non-fatal if it fails — key may already be loaded)
|
||||||
|
subprocess.run( # nosec B603, B607
|
||||||
|
["ssh-add", str(key_path)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
def cli() -> None:
|
||||||
|
"""Set up SSH private key from SSH_PRIVATE_KEY env var."""
|
||||||
|
if setup_ssh_key():
|
||||||
|
click.echo(_("SSH key set up successfully"))
|
||||||
|
sys.exit(0)
|
||||||
|
click.echo(_("SSH key setup skipped (no key provided)"), err=True)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
cli() # pragma: no cover
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""OpenTofu operations: init and validate across directories.
|
||||||
|
|
||||||
|
Handles initialization and validation of OpenTofu configurations across
|
||||||
|
multiple directories (modules + environments). Supports CI mode with
|
||||||
|
``-backend=false`` to avoid state backend access.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
python3 -m devx.tools.tofu_ops init --env staging
|
||||||
|
python3 -m devx.tools.tofu_ops validate
|
||||||
|
python3 -m devx.tools.tofu_ops validate --ci
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess # nosec B404
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from devx.i18n import _
|
||||||
|
|
||||||
|
DEFAULT_ENV_DIRS = ["tofu/environments/{env}", "tofu/environments/dns"]
|
||||||
|
DEFAULT_VALIDATE_DIRS = [
|
||||||
|
"tofu/modules/hetzner-vm",
|
||||||
|
"tofu/modules/hetzner-network",
|
||||||
|
"tofu/environments/staging",
|
||||||
|
"tofu/environments/production",
|
||||||
|
"tofu/environments/dns",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _run_tofu(cmd: list[str], cwd: Path) -> None:
|
||||||
|
"""Run a tofu command in the given directory, raising on failure."""
|
||||||
|
click.echo(_(" -> {dir}", dir=cwd))
|
||||||
|
result = subprocess.run( # nosec B603, B607
|
||||||
|
cmd,
|
||||||
|
cwd=str(cwd),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise click.ClickException(
|
||||||
|
_("tofu command failed in {dir}: {error}", dir=cwd, error=result.stderr.strip()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def tofu_init(env: str, root: str = ".", extra_dirs: list[str] | None = None) -> None:
|
||||||
|
"""Run ``tofu init`` in the environment directory and DNS directory.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
env: Environment name (e.g. staging, production).
|
||||||
|
root: Repository root directory.
|
||||||
|
extra_dirs: Additional directory patterns to initialize.
|
||||||
|
"""
|
||||||
|
root_path = Path(root)
|
||||||
|
dirs = [d.format(env=env) for d in (extra_dirs or DEFAULT_ENV_DIRS)]
|
||||||
|
for dir_pattern in dirs:
|
||||||
|
dir_path = root_path / dir_pattern
|
||||||
|
if dir_path.is_dir():
|
||||||
|
click.echo(_("[tofu-init] Initializing {dir}...", dir=dir_path))
|
||||||
|
_run_tofu(["tofu", "init"], dir_path)
|
||||||
|
click.echo(_("[tofu-init] Done."))
|
||||||
|
|
||||||
|
|
||||||
|
def tofu_validate(
|
||||||
|
root: str = ".",
|
||||||
|
dirs: list[str] | None = None,
|
||||||
|
ci: bool = False,
|
||||||
|
) -> None:
|
||||||
|
"""Run ``tofu validate`` in all OpenTofu directories.
|
||||||
|
|
||||||
|
In CI mode, runs ``tofu init -backend=false`` before validate to avoid
|
||||||
|
state backend access.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
root: Repository root directory.
|
||||||
|
dirs: List of directory paths to validate (relative to root).
|
||||||
|
ci: If True, use CI mode with -backend=false.
|
||||||
|
"""
|
||||||
|
root_path = Path(root)
|
||||||
|
target_dirs = dirs or DEFAULT_VALIDATE_DIRS
|
||||||
|
mode = "ci" if ci else "validate"
|
||||||
|
click.echo(_("[tofu-{mode}] Validating OpenTofu configurations...", mode=mode))
|
||||||
|
for dir_rel in target_dirs:
|
||||||
|
dir_path = root_path / dir_rel
|
||||||
|
if not dir_path.is_dir():
|
||||||
|
continue
|
||||||
|
if ci:
|
||||||
|
_run_tofu(["tofu", "init", "-backend=false", "-input=false"], dir_path)
|
||||||
|
_run_tofu(["tofu", "validate"], dir_path)
|
||||||
|
click.echo(_("[tofu-{mode}] All configurations valid.", mode=mode))
|
||||||
|
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
def cli() -> None:
|
||||||
|
"""OpenTofu operations."""
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.option("--env", required=True, help="Environment name (staging, production).")
|
||||||
|
@click.option("--root", default=".", help="Repository root directory.")
|
||||||
|
def init(env: str, root: str) -> None:
|
||||||
|
"""Initialize OpenTofu in an environment."""
|
||||||
|
tofu_init(env, root)
|
||||||
|
|
||||||
|
|
||||||
|
@cli.command()
|
||||||
|
@click.option("--root", default=".", help="Repository root directory.")
|
||||||
|
@click.option("--ci", is_flag=True, default=False, help="CI mode: use -backend=false.")
|
||||||
|
def validate(root: str, ci: bool) -> None:
|
||||||
|
"""Validate OpenTofu configurations."""
|
||||||
|
tofu_validate(root, ci=ci)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__": # pragma: no cover
|
||||||
|
cli() # pragma: no cover
|
||||||
+456
-8
@@ -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 \"Заголовок задачи\"",
|
"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 \"任务标题\""
|
"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": {
|
"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",
|
"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",
|
"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.",
|
"ru": "Release commit — skipping all post-merge jobs.",
|
||||||
"zh": "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}": {
|
"Release creation failed: {error}": {
|
||||||
"bg": "Release creation failed: {error}",
|
"bg": "Release creation failed: {error}",
|
||||||
"de": "Release creation failed: {error}",
|
"de": "Release creation failed: {error}",
|
||||||
@@ -2527,6 +2527,22 @@
|
|||||||
"ru": "ВНИМАНИЕ: Файл .taskid ({file_id}) устарел и не совпадает с именем ветки ({branch_id}). Удалите .taskid из репозитория — имя ветки — единственный источник истины.",
|
"ru": "ВНИМАНИЕ: Файл .taskid ({file_id}) устарел и не совпадает с именем ветки ({branch_id}). Удалите .taskid из репозитория — имя ветки — единственный источник истины.",
|
||||||
"zh": "警告:.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.": {
|
"WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.": {
|
||||||
"bg": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не е зададен — пропускане на проверката за съществуване на задача. Задайте го в .env за пълна валидация.",
|
"bg": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не е зададен — пропускане на проверката за съществуване на задача. Задайте го в .env за пълна валидация.",
|
||||||
"de": "WARNUNG: VIKUNJA_TOKEN nicht gesetzt — Task-Existenzprüfung übersprungen. In .env setzen für volle Validierung.",
|
"de": "WARNUNG: VIKUNJA_TOKEN nicht gesetzt — Task-Existenzprüfung übersprungen. In .env setzen für volle Validierung.",
|
||||||
@@ -2910,5 +2926,437 @@
|
|||||||
"pl": "Rebase attempt {n}/3 failed: {err}",
|
"pl": "Rebase attempt {n}/3 failed: {err}",
|
||||||
"ru": "Rebase attempt {n}/3 failed: {err}",
|
"ru": "Rebase attempt {n}/3 failed: {err}",
|
||||||
"zh": "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"
|
||||||
|
},
|
||||||
|
"[check-deps] Virtualenv .venv ready (Python {version}).": {
|
||||||
|
"en": "[check-deps] Virtualenv .venv ready (Python {version}).",
|
||||||
|
"bg": "[check-deps] Виртуална среда .venv готова (Python {version}).",
|
||||||
|
"de": "[check-deps] Virtuelle Umgebung .venv bereit (Python {version}).",
|
||||||
|
"pl": "[check-deps] Środowisko wirtualne .venv gotowe (Python {version}).",
|
||||||
|
"ru": "[check-deps] Виртуальное окружение .venv готово (Python {version}).",
|
||||||
|
"zh": "[check-deps] 虚拟环境 .venv 已就绪 (Python {version})。"
|
||||||
|
},
|
||||||
|
"{level}: {tool} not found.{hint}": {
|
||||||
|
"en": "{level}: {tool} not found.{hint}",
|
||||||
|
"bg": "{level}: {tool} не е намерен.{hint}",
|
||||||
|
"de": "{level}: {tool} nicht gefunden.{hint}",
|
||||||
|
"pl": "{level}: {tool} nie znaleziono.{hint}",
|
||||||
|
"ru": "{level}: {tool} не найден.{hint}",
|
||||||
|
"zh": "{level}: 未找到 {tool}。{hint}"
|
||||||
|
},
|
||||||
|
"WARN: Could not determine Python version in .venv.": {
|
||||||
|
"en": "WARN: Could not determine Python version in .venv.",
|
||||||
|
"bg": "ПРЕДУПРЕЖДЕНИЕ: Не може да се определи версията на Python в .venv.",
|
||||||
|
"de": "WARNUNG: Python-Version in .venv konnte nicht bestimmt werden.",
|
||||||
|
"pl": "OSTRZEŻENIE: Nie można określić wersji Python w .venv.",
|
||||||
|
"ru": "ПРЕДУПРЕЖДЕНИЕ: Не удалось определить версию Python в .venv.",
|
||||||
|
"zh": "警告: 无法确定 .venv 中的 Python 版本。"
|
||||||
|
},
|
||||||
|
"WARN: Could not parse Python version '{version}'.": {
|
||||||
|
"en": "WARN: Could not parse Python version '{version}'.",
|
||||||
|
"bg": "ПРЕДУПРЕЖДЕНИЕ: Не може да се анализира версията на Python '{version}'.",
|
||||||
|
"de": "WARNUNG: Python-Version '{version}' konnte nicht analysiert werden.",
|
||||||
|
"pl": "OSTRZEŻENIE: Nie można przeanalizować wersji Python '{version}'.",
|
||||||
|
"ru": "ПРЕДУПРЕЖДЕНИЕ: Не удалось разобрать версию Python '{version}'.",
|
||||||
|
"zh": "警告: 无法解析 Python 版本 '{version}'。"
|
||||||
|
},
|
||||||
|
"WARN: .venv not found. Run 'make setup-venv' to create it.": {
|
||||||
|
"en": "WARN: .venv not found. Run 'make setup-venv' to create it.",
|
||||||
|
"bg": "ПРЕДУПРЕЖДЕНИЕ: .venv не е намерен. Изпълнете 'make setup-venv' за създаване.",
|
||||||
|
"de": "WARNUNG: .venv nicht gefunden. Führen Sie 'make setup-venv' aus, um es zu erstellen.",
|
||||||
|
"pl": "OSTRZEŻENIE: Nie znaleziono .venv. Uruchom 'make setup-venv', aby utworzyć.",
|
||||||
|
"ru": "ПРЕДУПРЕЖДЕНИЕ: .venv не найден. Выполните 'make setup-venv' для создания.",
|
||||||
|
"zh": "警告: 未找到 .venv。运行 'make setup-venv' 来创建。"
|
||||||
|
},
|
||||||
|
"[docker-login] Logged in to {registry}.": {
|
||||||
|
"en": "[docker-login] Logged in to {registry}.",
|
||||||
|
"bg": "[docker-login] Влязъл в {registry}.",
|
||||||
|
"de": "[docker-login] Angemeldet bei {registry}.",
|
||||||
|
"pl": "[docker-login] Zalogowano do {registry}.",
|
||||||
|
"ru": "[docker-login] Выполнен вход в {registry}.",
|
||||||
|
"zh": "[docker-login] 已登录到 {registry}。"
|
||||||
|
},
|
||||||
|
"[docker-login] Login to {registry} failed (continuing).": {
|
||||||
|
"en": "[docker-login] Login to {registry} failed (continuing).",
|
||||||
|
"bg": "[docker-login] Влизането в {registry} не успя (продължава).",
|
||||||
|
"de": "[docker-login] Anmeldung bei {registry} fehlgeschlagen (wird fortgesetzt).",
|
||||||
|
"pl": "[docker-login] Logowanie do {registry} nie powiodło się (kontynuowanie).",
|
||||||
|
"ru": "[docker-login] Ошибка входа в {registry} (продолжаем).",
|
||||||
|
"zh": "[docker-login] 登录 {registry} 失败(继续)。"
|
||||||
|
},
|
||||||
|
"[docker-login] Skipping {registry} (token {env} not set).": {
|
||||||
|
"en": "[docker-login] Skipping {registry} (token {env} not set).",
|
||||||
|
"bg": "[docker-login] Пропускане на {registry} (токен {env} не е зададен).",
|
||||||
|
"de": "[docker-login] {registry} übersprungen (Token {env} nicht gesetzt).",
|
||||||
|
"pl": "[docker-login] Pomijanie {registry} (token {env} nie ustawiony).",
|
||||||
|
"ru": "[docker-login] Пропуск {registry} (токен {env} не задан).",
|
||||||
|
"zh": "[docker-login] 跳过 {registry}(未设置令牌 {env})。"
|
||||||
|
},
|
||||||
|
"{env} is not set. Set it in your .env file.": {
|
||||||
|
"en": "{env} is not set. Set it in your .env file.",
|
||||||
|
"bg": "{env} не е зададен. Задайте го във вашия .env файл.",
|
||||||
|
"de": "{env} ist nicht gesetzt. Setzen Sie es in Ihrer .env-Datei.",
|
||||||
|
"pl": "{env} nie jest ustawiony. Ustaw go w pliku .env.",
|
||||||
|
"ru": "{env} не задан. Установите его в файле .env.",
|
||||||
|
"zh": "{env} 未设置。请在 .env 文件中设置。"
|
||||||
|
},
|
||||||
|
"{env} is not set. Set it in your .env file or pass it as an environment variable.": {
|
||||||
|
"en": "{env} is not set. Set it in your .env file or pass it as an environment variable.",
|
||||||
|
"bg": "{env} не е зададен. Задайте го във вашия .env файл или го подайте като променлива на средата.",
|
||||||
|
"de": "{env} ist nicht gesetzt. Setzen Sie es in Ihrer .env-Datei oder übergeben Sie es als Umgebungsvariable.",
|
||||||
|
"pl": "{env} nie jest ustawiony. Ustaw go w pliku .env lub przekaż jako zmienną środowiskową.",
|
||||||
|
"ru": "{env} не задан. Установите его в файле .env или передайте как переменную окружения.",
|
||||||
|
"zh": "{env} 未设置。请在 .env 文件中设置或作为环境变量传递。"
|
||||||
|
},
|
||||||
|
"Login to {registry} failed: {error}": {
|
||||||
|
"en": "Login to {registry} failed: {error}",
|
||||||
|
"bg": "Влизането в {registry} не успя: {error}",
|
||||||
|
"de": "Anmeldung bei {registry} fehlgeschlagen: {error}",
|
||||||
|
"pl": "Logowanie do {registry} nie powiodło się: {error}",
|
||||||
|
"ru": "Ошибка входа в {registry}: {error}",
|
||||||
|
"zh": "登录 {registry} 失败: {error}"
|
||||||
|
},
|
||||||
|
"tofu command failed in {dir}: {error}": {
|
||||||
|
"en": "tofu command failed in {dir}: {error}",
|
||||||
|
"bg": "командата tofu не успя в {dir}: {error}",
|
||||||
|
"de": "tofu-Befehl fehlgeschlagen in {dir}: {error}",
|
||||||
|
"pl": "polecenie tofu nie powiodło się w {dir}: {error}",
|
||||||
|
"ru": "команда tofu не удалась в {dir}: {error}",
|
||||||
|
"zh": "tofu 命令在 {dir} 中失败: {error}"
|
||||||
|
},
|
||||||
|
"WARN: .venv has Python {version}, but >={req} is required.": {
|
||||||
|
"en": "WARN: .venv has Python {version}, but >={req} is required.",
|
||||||
|
"bg": "ПРЕДУПРЕЖДЕНИЕ: .venv има Python {version}, но се изисква >={req}.",
|
||||||
|
"de": "WARNUNG: .venv hat Python {version}, aber >={req} ist erforderlich.",
|
||||||
|
"pl": "OSTRZEŻENIE: .venv ma Python {version}, ale wymagane jest >={req}.",
|
||||||
|
"ru": "ПРЕДУПРЕЖДЕНИЕ: в .venv установлен Python {version}, но требуется >={req}.",
|
||||||
|
"zh": "警告: .venv 的 Python 版本为 {version},但要求 >={req}。"
|
||||||
|
},
|
||||||
|
" -> {dir}": {
|
||||||
|
"en": " -> {dir}",
|
||||||
|
"bg": " -> {dir}",
|
||||||
|
"de": " -> {dir}",
|
||||||
|
"pl": " -> {dir}",
|
||||||
|
"ru": " -> {dir}",
|
||||||
|
"zh": " -> {dir}"
|
||||||
|
},
|
||||||
|
"[tofu-init] Initializing {dir}...": {
|
||||||
|
"en": "[tofu-init] Initializing {dir}...",
|
||||||
|
"bg": "[tofu-init] Инициализиране на {dir}...",
|
||||||
|
"de": "[tofu-init] Initialisiere {dir}...",
|
||||||
|
"pl": "[tofu-init] Inicjalizacja {dir}...",
|
||||||
|
"ru": "[tofu-init] Инициализация {dir}...",
|
||||||
|
"zh": "[tofu-init] 正在初始化 {dir}..."
|
||||||
|
},
|
||||||
|
"[tofu-init] Done.": {
|
||||||
|
"en": "[tofu-init] Done.",
|
||||||
|
"bg": "[tofu-init] Готово.",
|
||||||
|
"de": "[tofu-init] Fertig.",
|
||||||
|
"pl": "[tofu-init] Gotowe.",
|
||||||
|
"ru": "[tofu-init] Готово.",
|
||||||
|
"zh": "[tofu-init] 完成。"
|
||||||
|
},
|
||||||
|
"[tofu-{mode}] Validating OpenTofu configurations...": {
|
||||||
|
"en": "[tofu-{mode}] Validating OpenTofu configurations...",
|
||||||
|
"bg": "[tofu-{mode}] Проверка на OpenTofu конфигурациите...",
|
||||||
|
"de": "[tofu-{mode}] Validiere OpenTofu-Konfigurationen...",
|
||||||
|
"pl": "[tofu-{mode}] Sprawdzanie konfiguracji OpenTofu...",
|
||||||
|
"ru": "[tofu-{mode}] Проверка конфигураций OpenTofu...",
|
||||||
|
"zh": "[tofu-{mode}] 正在验证 OpenTofu 配置..."
|
||||||
|
},
|
||||||
|
"[tofu-{mode}] All configurations valid.": {
|
||||||
|
"en": "[tofu-{mode}] All configurations valid.",
|
||||||
|
"bg": "[tofu-{mode}] Всички конфигурации са валидни.",
|
||||||
|
"de": "[tofu-{mode}] Alle Konfigurationen gültig.",
|
||||||
|
"pl": "[tofu-{mode}] Wszystkie konfiguracje są poprawne.",
|
||||||
|
"ru": "[tofu-{mode}] Все конфигурации валидны.",
|
||||||
|
"zh": "[tofu-{mode}] 所有配置有效。"
|
||||||
|
},
|
||||||
|
"[check-deps] Verifying tools...": {
|
||||||
|
"en": "[check-deps] Verifying tools...",
|
||||||
|
"bg": "[check-deps] Проверка на инструментите...",
|
||||||
|
"de": "[check-deps] Werkzeuge werden überprüft...",
|
||||||
|
"pl": "[check-deps] Sprawdzanie narzędzi...",
|
||||||
|
"ru": "[check-deps] Проверка инструментов...",
|
||||||
|
"zh": "[check-deps] 正在验证工具..."
|
||||||
|
},
|
||||||
|
" {tool}: found at {path}": {
|
||||||
|
"en": " {tool}: found at {path}",
|
||||||
|
"bg": " {tool}: намерен на {path}",
|
||||||
|
"de": " {tool}: gefunden unter {path}",
|
||||||
|
"pl": " {tool}: znaleziono w {path}",
|
||||||
|
"ru": " {tool}: найден в {path}",
|
||||||
|
"zh": " {tool}: 在 {path} 找到"
|
||||||
|
},
|
||||||
|
" Run 'make install-checkmake' to install the Makefile linter.": {
|
||||||
|
"en": " Run 'make install-checkmake' to install the Makefile linter.",
|
||||||
|
"bg": " Изпълнете 'make install-checkmake' за инсталиране на Makefile линтера.",
|
||||||
|
"de": " Führen Sie 'make install-checkmake' aus, um den Makefile-Linter zu installieren.",
|
||||||
|
"pl": " Uruchom 'make install-checkmake', aby zainstalować linter Makefile.",
|
||||||
|
"ru": " Выполните 'make install-checkmake' для установки линтера Makefile.",
|
||||||
|
"zh": " 运行 'make install-checkmake' 来安装 Makefile 检查器。"
|
||||||
|
},
|
||||||
|
"Required tools missing.": {
|
||||||
|
"en": "Required tools missing.",
|
||||||
|
"bg": "Липсват задължителни инструменти.",
|
||||||
|
"de": "Erforderliche Werkzeuge fehlen.",
|
||||||
|
"pl": "Brak wymaganych narzędzi.",
|
||||||
|
"ru": "Отсутствуют обязательные инструменты.",
|
||||||
|
"zh": "缺少必需的工具。"
|
||||||
|
},
|
||||||
|
"[check-deps] All core tools present.": {
|
||||||
|
"en": "[check-deps] All core tools present.",
|
||||||
|
"bg": "[check-deps] Всички основни инструменти са налични.",
|
||||||
|
"de": "[check-deps] Alle Kernwerkzeuge vorhanden.",
|
||||||
|
"pl": "[check-deps] Wszystkie podstawowe narzędzia są dostępne.",
|
||||||
|
"ru": "[check-deps] Все основные инструменты доступны.",
|
||||||
|
"zh": "[check-deps] 所有核心工具均已就绪。"
|
||||||
|
},
|
||||||
|
"SSH_PRIVATE_KEY not set — skipping SSH key setup": {
|
||||||
|
"en": "SSH_PRIVATE_KEY not set — skipping SSH key setup",
|
||||||
|
"bg": "SSH_PRIVATE_KEY не е зададен — пропускане на SSH ключ настройката",
|
||||||
|
"de": "SSH_PRIVATE_KEY nicht gesetzt — SSH-Schlüssel-Setup übersprungen",
|
||||||
|
"pl": "SSH_PRIVATE_KEY nie ustawione — pomijanie konfiguracji klucza SSH",
|
||||||
|
"ru": "SSH_PRIVATE_KEY не задан — пропуск настройки SSH-ключа",
|
||||||
|
"zh": "SSH_PRIVATE_KEY 未设置 — 跳过 SSH 密钥设置"
|
||||||
|
},
|
||||||
|
"Failed to start ssh-agent: {error}": {
|
||||||
|
"en": "Failed to start ssh-agent: {error}",
|
||||||
|
"bg": "Неуспешно стартиране на ssh-agent: {error}",
|
||||||
|
"de": "Starten von ssh-agent fehlgeschlagen: {error}",
|
||||||
|
"pl": "Nie udało się uruchomić ssh-agent: {error}",
|
||||||
|
"ru": "Не удалось запустить ssh-agent: {error}",
|
||||||
|
"zh": "启动 ssh-agent 失败: {error}"
|
||||||
|
},
|
||||||
|
"SSH key set up successfully": {
|
||||||
|
"en": "SSH key set up successfully",
|
||||||
|
"bg": "SSH ключът е настроен успешно",
|
||||||
|
"de": "SSH-Schlüssel erfolgreich eingerichtet",
|
||||||
|
"pl": "Klucz SSH skonfigurowany pomyślnie",
|
||||||
|
"ru": "SSH-ключ успешно настроен",
|
||||||
|
"zh": "SSH 密钥设置成功"
|
||||||
|
},
|
||||||
|
"SSH key setup skipped (no key provided)": {
|
||||||
|
"en": "SSH key setup skipped (no key provided)",
|
||||||
|
"bg": "Настройката на SSH ключ е пропусната (не е предоставен ключ)",
|
||||||
|
"de": "SSH-Schlüssel-Setup übersprungen (kein Schlüssel bereitgestellt)",
|
||||||
|
"pl": "Pominięto konfigurację klucza SSH (brak klucza)",
|
||||||
|
"ru": "Настройка SSH-ключа пропущена (ключ не предоставлен)",
|
||||||
|
"zh": "SSH 密钥设置已跳过(未提供密钥)"
|
||||||
|
},
|
||||||
|
"Found {count} unsafe identity check(s) in integration tests.": {
|
||||||
|
"en": "Found {count} unsafe identity check(s) in integration tests.",
|
||||||
|
"bg": "Намерени са {count} небрежни проверки за идентичност в интеграционните тестове.",
|
||||||
|
"de": "{count} unsichere Identitätsprüfung(en) in Integrationstests gefunden.",
|
||||||
|
"pl": "Znaleziono {count} niebezpiecznych sprawdzeń tożsamości w testach integracyjnych.",
|
||||||
|
"ru": "Найдено {count} небезопасных проверок идентичности в интеграционных тестах.",
|
||||||
|
"zh": "在集成测试中发现 {count} 个不安全的身份检查。"
|
||||||
|
},
|
||||||
|
"Use string comparison or _is_truthy()/_is_falsy() helpers instead. Add '{marker}' to suppress individual lines.": {
|
||||||
|
"en": "Use string comparison or _is_truthy()/_is_falsy() helpers instead. Add '{marker}' to suppress individual lines.",
|
||||||
|
"bg": "Използвайте сравнение на низове или _is_truthy()/_is_falsy() помощници. Добавете '{marker}' за потискане на отделни редове.",
|
||||||
|
"de": "Verwenden Sie String-Vergleich oder _is_truthy()/_is_falsy() Hilfsfunktionen. Fügen Sie '{marker}' hinzu, um einzelne Zeilen zu unterdrücken.",
|
||||||
|
"pl": "Użyj porównania ciągów lub pomocników _is_truthy()/_is_falsy(). Dodaj '{marker}', aby pominąć pojedyncze linie.",
|
||||||
|
"ru": "Используйте строковое сравнение или помощники _is_truthy()/_is_falsy(). Добавьте '{marker}' для подавления отдельных строк.",
|
||||||
|
"zh": "使用字符串比较或 _is_truthy()/_is_falsy() 辅助函数。添加 '{marker}' 以抑制个别行。"
|
||||||
|
},
|
||||||
|
"[check-api-identity-checks] Passed: no unsafe identity checks found": {
|
||||||
|
"en": "[check-api-identity-checks] Passed: no unsafe identity checks found",
|
||||||
|
"bg": "[check-api-identity-checks] Мина: не са намерени небрежни проверки за идентичност",
|
||||||
|
"de": "[check-api-identity-checks] Bestanden: keine unsicheren Identitätsprüfungen gefunden",
|
||||||
|
"pl": "[check-api-identity-checks] Passed: nie znaleziono niebezpiecznych sprawdzeń tożsamości",
|
||||||
|
"ru": "[check-api-identity-checks] Пройдено: небезопасных проверок идентичности не найдено",
|
||||||
|
"zh": "[check-api-identity-checks] 通过:未发现不安全的身份检查"
|
||||||
|
},
|
||||||
|
"Directory to scan (default: tests/integration). Can be repeated.": {
|
||||||
|
"en": "Directory to scan (default: tests/integration). Can be repeated.",
|
||||||
|
"bg": "Директория за сканиране (по подразбиране: tests/integration). Може да се повтаря.",
|
||||||
|
"de": "Zu scannendes Verzeichnis (Standard: tests/integration). Kann wiederholt werden.",
|
||||||
|
"pl": "Katalog do skanowania (domyślnie: tests/integration). Można powtarzać.",
|
||||||
|
"ru": "Директория для сканирования (по умолчанию: tests/integration). Можно повторять.",
|
||||||
|
"zh": "要扫描的目录(默认:tests/integration)。可重复。"
|
||||||
|
},
|
||||||
|
"Failed to list existing wiki pages after retries: {error}. Aborting to avoid creating duplicate pages.": {
|
||||||
|
"bg": "Неуспешно извличане на съществуващи wiki страници след повторни опити: {error}. Прекратяване, за да се избегне създаване на дублирани страници.",
|
||||||
|
"de": "Abrufen bestehender Wiki-Seiten nach Wiederholungen fehlgeschlagen: {error}. Abbruch, um doppelte Seiten zu vermeiden.",
|
||||||
|
"en": "Failed to list existing wiki pages after retries: {error}. Aborting to avoid creating duplicate pages.",
|
||||||
|
"pl": "Nie udało się wylistować istniejących stron wiki po ponownych próbach: {error}. Przerywanie, aby uniknąć tworzenia zduplikowanych stron.",
|
||||||
|
"ru": "Не удалось получить список существующих wiki-страниц после повторных попыток: {error}. Прерывание, чтобы избежать создания дубликатов страниц.",
|
||||||
|
"zh": "重试后列出现有 wiki 页面失败:{error}。正在中止以避免创建重复页面。"
|
||||||
|
},
|
||||||
|
" Page '{title}' already exists (stale list). Re-listing and updating...": {
|
||||||
|
"bg": " Страницата '{title}' вече съществува (остарял списък). Пресписване и обновяване...",
|
||||||
|
"de": " Seite '{title}' existiert bereits (veraltete Liste). Neu auflisten und aktualisieren...",
|
||||||
|
"en": " Page '{title}' already exists (stale list). Re-listing and updating...",
|
||||||
|
"pl": " Strona '{title}' już istnieje (nieaktualna lista). Ponowne listowanie i aktualizacja...",
|
||||||
|
"ru": " Страница '{title}' уже существует (устаревший список). Повторное получение списка и обновление...",
|
||||||
|
"zh": " 页面 '{title}' 已存在(列表过期)。重新列出并更新..."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Shared utility functions for devx and consumer projects."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Utilities for handling API response values.
|
||||||
|
|
||||||
|
Many APIs return boolean values as strings (``"true"``, ``"false"``)
|
||||||
|
rather than native JSON booleans. The Mattermost ``/api/v4/config/client``
|
||||||
|
endpoint is a notable example. These helpers handle both string and
|
||||||
|
boolean responses safely.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
from devx.utils.api import is_truthy, is_falsy
|
||||||
|
|
||||||
|
if not is_truthy(config.get("EnableOpenServer")):
|
||||||
|
raise ValueError("EnableOpenServer not enabled")
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
def is_truthy(value: str | bool | None) -> bool:
|
||||||
|
"""Check if an API config value is truthy.
|
||||||
|
|
||||||
|
The API may return strings (``"true"``/``"false"``) or native
|
||||||
|
booleans. This helper handles both.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: The value to check (string, bool, or None).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the value represents a truthy boolean.
|
||||||
|
"""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return value
|
||||||
|
return str(value).lower() == "true"
|
||||||
|
|
||||||
|
|
||||||
|
def is_falsy(value: str | bool | None) -> bool:
|
||||||
|
"""Check if an API config value is falsy.
|
||||||
|
|
||||||
|
The API may return strings (``"true"``/``"false"``) or native
|
||||||
|
booleans. This helper handles both.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
value: The value to check (string, bool, or None).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the value represents a falsy boolean.
|
||||||
|
"""
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return not value
|
||||||
|
return str(value).lower() == "false"
|
||||||
@@ -233,6 +233,18 @@ class TestGiteaClient:
|
|||||||
json={"Do": "squash", "MergeTitleField": "fix: bug"},
|
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:
|
def test_get_pr_labels(self) -> None:
|
||||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||||
client._session.request = MagicMock(return_value=_mock_response([{"name": "ready-to-merge"}]))
|
client._session.request = MagicMock(return_value=_mock_response([{"name": "ready-to-merge"}]))
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""Unit tests for devx.utils.api."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from devx.utils.api import is_falsy, is_truthy
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsTruthy:
|
||||||
|
def test_string_true(self) -> None:
|
||||||
|
assert is_truthy("true") is True
|
||||||
|
|
||||||
|
def test_string_true_uppercase(self) -> None:
|
||||||
|
assert is_truthy("True") is True
|
||||||
|
|
||||||
|
def test_boolean_true(self) -> None:
|
||||||
|
assert is_truthy(True) is True
|
||||||
|
|
||||||
|
def test_string_false(self) -> None:
|
||||||
|
assert is_truthy("false") is False
|
||||||
|
|
||||||
|
def test_boolean_false(self) -> None:
|
||||||
|
assert is_truthy(False) is False
|
||||||
|
|
||||||
|
def test_none(self) -> None:
|
||||||
|
assert is_truthy(None) is False
|
||||||
|
|
||||||
|
def test_empty_string(self) -> None:
|
||||||
|
assert is_truthy("") is False
|
||||||
|
|
||||||
|
def test_random_string(self) -> None:
|
||||||
|
assert is_truthy("random") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsFalsy:
|
||||||
|
def test_string_false(self) -> None:
|
||||||
|
assert is_falsy("false") is True
|
||||||
|
|
||||||
|
def test_string_false_uppercase(self) -> None:
|
||||||
|
assert is_falsy("False") is True
|
||||||
|
|
||||||
|
def test_boolean_false(self) -> None:
|
||||||
|
assert is_falsy(False) is True
|
||||||
|
|
||||||
|
def test_string_true(self) -> None:
|
||||||
|
assert is_falsy("true") is False
|
||||||
|
|
||||||
|
def test_boolean_true(self) -> None:
|
||||||
|
assert is_falsy(True) is False
|
||||||
|
|
||||||
|
def test_none(self) -> None:
|
||||||
|
assert is_falsy(None) is False
|
||||||
|
|
||||||
|
def test_empty_string(self) -> None:
|
||||||
|
assert is_falsy("") is False
|
||||||
@@ -218,6 +218,20 @@ class TestExtractConventionalMsg:
|
|||||||
]
|
]
|
||||||
assert extract_conventional_msg(commits) == "feat(api): add endpoint"
|
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 --
|
# -- run_cmd --
|
||||||
|
|
||||||
@@ -292,14 +306,13 @@ class TestMain:
|
|||||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
|
@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.validate_pr_title_matches_vikunja")
|
||||||
@patch("devx.ci.auto_merge.GiteaClient")
|
@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
|
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
||||||
) -> None: # type: ignore[no-untyped-def]
|
) -> 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
|
The rebase triggers a new CI run. The next auto-merge attempt will
|
||||||
pull_request synchronize event, which starts a new CI run, which runs
|
find the branch up-to-date and merge successfully.
|
||||||
auto-merge again, which rebases again, etc.
|
|
||||||
"""
|
"""
|
||||||
monkeypatch.chdir(tmp_path)
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
@@ -315,12 +328,40 @@ class TestMain:
|
|||||||
main,
|
main,
|
||||||
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
|
["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 "behind master" in result.output.lower()
|
||||||
assert "rebase manually" in result.output.lower()
|
assert "auto-rebasing" in result.output.lower()
|
||||||
# Must NOT have called merge_pr twice (no retry after rebase)
|
# 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
|
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.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.validate_pr_title_matches_vikunja")
|
||||||
@patch("devx.ci.auto_merge.GiteaClient")
|
@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.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.validate_pr_title_matches_vikunja")
|
||||||
@patch("devx.ci.auto_merge.GiteaClient")
|
@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
|
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
||||||
) -> None: # type: ignore[no-untyped-def]
|
) -> 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)
|
monkeypatch.chdir(tmp_path)
|
||||||
|
|
||||||
mock_client = MagicMock()
|
mock_client = MagicMock()
|
||||||
@@ -405,8 +446,8 @@ class TestMain:
|
|||||||
main,
|
main,
|
||||||
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
|
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
|
||||||
)
|
)
|
||||||
assert result.exit_code != 0
|
assert result.exit_code == 0
|
||||||
# No git commands should be run (no rebase, no push)
|
# No local git commands should be run (rebase is via API)
|
||||||
mock_run.assert_not_called()
|
mock_run.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""Unit tests for devx.tools.check_api_identity_checks."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from devx.tools.check_api_identity_checks import (
|
||||||
|
DEFAULT_NOQA_MARKER,
|
||||||
|
DEFAULT_SCAN_DIRS,
|
||||||
|
DEFAULT_SKIP_PATTERNS,
|
||||||
|
_load_config,
|
||||||
|
_matches_skip_pattern,
|
||||||
|
cli,
|
||||||
|
find_identity_checks,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindIdentityChecks:
|
||||||
|
def test_detects_is_true(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "test_foo.py"
|
||||||
|
f.write_text("assert config.get('x') is True\n")
|
||||||
|
issues = find_identity_checks(f, tmp_path, DEFAULT_NOQA_MARKER)
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "is True" in issues[0]
|
||||||
|
|
||||||
|
def test_detects_is_false(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "test_foo.py"
|
||||||
|
f.write_text("if config.get('x') is False:\n pass\n")
|
||||||
|
issues = find_identity_checks(f, tmp_path, DEFAULT_NOQA_MARKER)
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "is False" in issues[0]
|
||||||
|
|
||||||
|
def test_detects_is_not_true(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "test_foo.py"
|
||||||
|
f.write_text("if config.get('x') is not True:\n fail()\n")
|
||||||
|
issues = find_identity_checks(f, tmp_path, DEFAULT_NOQA_MARKER)
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "is not True" in issues[0]
|
||||||
|
|
||||||
|
def test_detects_is_not_false(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "test_foo.py"
|
||||||
|
f.write_text("if config.get('x') is not False:\n fail()\n")
|
||||||
|
issues = find_identity_checks(f, tmp_path, DEFAULT_NOQA_MARKER)
|
||||||
|
assert len(issues) == 1
|
||||||
|
assert "is not False" in issues[0]
|
||||||
|
|
||||||
|
def test_noqa_suppresses(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "test_foo.py"
|
||||||
|
f.write_text("assert config.get('x') is True # noqa\n")
|
||||||
|
issues = find_identity_checks(f, tmp_path, DEFAULT_NOQA_MARKER)
|
||||||
|
assert len(issues) == 0
|
||||||
|
|
||||||
|
def test_no_false_positives(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "test_foo.py"
|
||||||
|
f.write_text("assert config.get('x') == 'true'\nassert config.get('y') == True\nx = True\nif x:\n pass\n")
|
||||||
|
issues = find_identity_checks(f, tmp_path, DEFAULT_NOQA_MARKER)
|
||||||
|
assert len(issues) == 0
|
||||||
|
|
||||||
|
def test_multiple_issues(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "test_foo.py"
|
||||||
|
f.write_text("if config.get('a') is True:\n pass\nif config.get('b') is not False:\n pass\n")
|
||||||
|
issues = find_identity_checks(f, tmp_path, DEFAULT_NOQA_MARKER)
|
||||||
|
assert len(issues) == 2
|
||||||
|
|
||||||
|
def test_file_not_found(self, tmp_path: Path) -> None:
|
||||||
|
f = tmp_path / "nonexistent.py"
|
||||||
|
issues = find_identity_checks(f, tmp_path, DEFAULT_NOQA_MARKER)
|
||||||
|
assert issues == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestMatchesSkipPattern:
|
||||||
|
def test_matches_helpers(self) -> None:
|
||||||
|
assert _matches_skip_pattern(Path("test_mattermost_helpers.py"), DEFAULT_SKIP_PATTERNS)
|
||||||
|
|
||||||
|
def test_does_not_match_regular(self) -> None:
|
||||||
|
assert not _matches_skip_pattern(Path("test_mattermost.py"), DEFAULT_SKIP_PATTERNS)
|
||||||
|
|
||||||
|
def test_empty_patterns(self) -> None:
|
||||||
|
assert not _matches_skip_pattern(Path("test_anything.py"), [])
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadConfig:
|
||||||
|
def test_defaults(self) -> None:
|
||||||
|
with patch("devx.tools.check_api_identity_checks._load_pyproject_devx") as mock:
|
||||||
|
mock.return_value = {}
|
||||||
|
scan_dirs, skip_patterns, noqa = _load_config()
|
||||||
|
assert scan_dirs == DEFAULT_SCAN_DIRS
|
||||||
|
assert skip_patterns == DEFAULT_SKIP_PATTERNS
|
||||||
|
assert noqa == DEFAULT_NOQA_MARKER
|
||||||
|
|
||||||
|
def test_custom_config(self) -> None:
|
||||||
|
with patch("devx.tools.check_api_identity_checks._load_pyproject_devx") as mock:
|
||||||
|
mock.return_value = {
|
||||||
|
"check_api_identity_checks": {
|
||||||
|
"scan_dirs": ["tests/api"],
|
||||||
|
"skip_patterns": ["test_*_unit.py"],
|
||||||
|
"noqa_marker": "# allow",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
scan_dirs, skip_patterns, noqa = _load_config()
|
||||||
|
assert scan_dirs == ["tests/api"]
|
||||||
|
assert skip_patterns == ["test_*_unit.py"]
|
||||||
|
assert noqa == "# allow"
|
||||||
|
|
||||||
|
def test_invalid_config_returns_defaults(self) -> None:
|
||||||
|
with patch("devx.tools.check_api_identity_checks._load_pyproject_devx") as mock:
|
||||||
|
mock.return_value = {"check_api_identity_checks": "not a dict"}
|
||||||
|
scan_dirs, _, _ = _load_config()
|
||||||
|
assert scan_dirs == DEFAULT_SCAN_DIRS
|
||||||
|
|
||||||
|
|
||||||
|
class TestCli:
|
||||||
|
def test_no_issues(self, tmp_path: Path) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
with (
|
||||||
|
patch("devx.tools.check_api_identity_checks._load_config") as mock_cfg,
|
||||||
|
patch("devx.tools.check_api_identity_checks.Path.cwd", return_value=tmp_path),
|
||||||
|
):
|
||||||
|
mock_cfg.return_value = (["tests/integration"], DEFAULT_SKIP_PATTERNS, DEFAULT_NOQA_MARKER)
|
||||||
|
(tmp_path / "tests" / "integration").mkdir(parents=True)
|
||||||
|
(tmp_path / "tests" / "integration" / "test_foo.py").write_text("assert config.get('x') == 'true'\n")
|
||||||
|
result = runner.invoke(cli, [])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Passed" in result.output
|
||||||
|
|
||||||
|
def test_with_issues(self, tmp_path: Path) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
with (
|
||||||
|
patch("devx.tools.check_api_identity_checks._load_config") as mock_cfg,
|
||||||
|
patch("devx.tools.check_api_identity_checks.Path.cwd", return_value=tmp_path),
|
||||||
|
):
|
||||||
|
mock_cfg.return_value = (["tests/integration"], DEFAULT_SKIP_PATTERNS, DEFAULT_NOQA_MARKER)
|
||||||
|
(tmp_path / "tests" / "integration").mkdir(parents=True)
|
||||||
|
(tmp_path / "tests" / "integration" / "test_foo.py").write_text(
|
||||||
|
"if config.get('x') is not True:\n fail()\n"
|
||||||
|
)
|
||||||
|
result = runner.invoke(cli, [])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "is not True" in result.output
|
||||||
|
|
||||||
|
def test_skips_helpers(self, tmp_path: Path) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
with (
|
||||||
|
patch("devx.tools.check_api_identity_checks._load_config") as mock_cfg,
|
||||||
|
patch("devx.tools.check_api_identity_checks.Path.cwd", return_value=tmp_path),
|
||||||
|
):
|
||||||
|
mock_cfg.return_value = (["tests/integration"], DEFAULT_SKIP_PATTERNS, DEFAULT_NOQA_MARKER)
|
||||||
|
(tmp_path / "tests" / "integration").mkdir(parents=True)
|
||||||
|
(tmp_path / "tests" / "integration" / "test_foo_helpers.py").write_text("assert x is True\n")
|
||||||
|
result = runner.invoke(cli, [])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_nonexistent_dir(self, tmp_path: Path) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
with (
|
||||||
|
patch("devx.tools.check_api_identity_checks._load_config") as mock_cfg,
|
||||||
|
patch("devx.tools.check_api_identity_checks.Path.cwd", return_value=tmp_path),
|
||||||
|
):
|
||||||
|
mock_cfg.return_value = (["nonexistent"], DEFAULT_SKIP_PATTERNS, DEFAULT_NOQA_MARKER)
|
||||||
|
result = runner.invoke(cli, [])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
def test_custom_scan_dir(self, tmp_path: Path) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
with (
|
||||||
|
patch("devx.tools.check_api_identity_checks._load_config") as mock_cfg,
|
||||||
|
patch("devx.tools.check_api_identity_checks.Path.cwd", return_value=tmp_path),
|
||||||
|
):
|
||||||
|
mock_cfg.return_value = (["other"], DEFAULT_SKIP_PATTERNS, DEFAULT_NOQA_MARKER)
|
||||||
|
(tmp_path / "custom").mkdir()
|
||||||
|
(tmp_path / "custom" / "test_foo.py").write_text("if x is True:\n pass\n")
|
||||||
|
result = runner.invoke(cli, ["--scan-dir", "custom"])
|
||||||
|
assert result.exit_code != 0
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""Unit tests for devx.tools.check_deps."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from devx.tools.check_deps import (
|
||||||
|
_check_python_version,
|
||||||
|
_check_tool,
|
||||||
|
cli,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckTool:
|
||||||
|
@patch("devx.tools.check_deps.shutil.which", return_value="/usr/bin/tofu")
|
||||||
|
def test_found(self, mock_which: MagicMock) -> None:
|
||||||
|
assert _check_tool("tofu") is True
|
||||||
|
|
||||||
|
@patch("devx.tools.check_deps.shutil.which", return_value=None)
|
||||||
|
def test_not_found_required(self, mock_which: MagicMock) -> None:
|
||||||
|
assert _check_tool("tofu") is False
|
||||||
|
|
||||||
|
@patch("devx.tools.check_deps.shutil.which", return_value=None)
|
||||||
|
def test_not_found_optional(self, mock_which: MagicMock) -> None:
|
||||||
|
assert _check_tool("checkmake", optional=True) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestCheckPythonVersion:
|
||||||
|
@patch("devx.tools.check_deps.subprocess.run")
|
||||||
|
def test_valid_version(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||||
|
venv_bin = tmp_path / "bin"
|
||||||
|
venv_bin.mkdir()
|
||||||
|
(venv_bin / "python").touch()
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="Python 3.12.3\n", stderr="")
|
||||||
|
_check_python_version(venv_bin)
|
||||||
|
|
||||||
|
@patch("devx.tools.check_deps.subprocess.run")
|
||||||
|
def test_old_version(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||||
|
venv_bin = tmp_path / "bin"
|
||||||
|
venv_bin.mkdir()
|
||||||
|
(venv_bin / "python").touch()
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="Python 3.11.0\n", stderr="")
|
||||||
|
_check_python_version(venv_bin)
|
||||||
|
|
||||||
|
def test_no_venv(self, tmp_path: Path) -> None:
|
||||||
|
venv_bin = tmp_path / "bin"
|
||||||
|
_check_python_version(venv_bin)
|
||||||
|
|
||||||
|
@patch("devx.tools.check_deps.subprocess.run")
|
||||||
|
def test_command_fails(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||||
|
venv_bin = tmp_path / "bin"
|
||||||
|
venv_bin.mkdir()
|
||||||
|
(venv_bin / "python").touch()
|
||||||
|
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
|
||||||
|
_check_python_version(venv_bin)
|
||||||
|
|
||||||
|
@patch("devx.tools.check_deps.subprocess.run")
|
||||||
|
def test_unparseable_version(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||||
|
venv_bin = tmp_path / "bin"
|
||||||
|
venv_bin.mkdir()
|
||||||
|
(venv_bin / "python").touch()
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="garbage\n", stderr="")
|
||||||
|
_check_python_version(venv_bin)
|
||||||
|
|
||||||
|
|
||||||
|
class TestCli:
|
||||||
|
@patch("devx.tools.check_deps._check_python_version")
|
||||||
|
@patch("devx.tools.check_deps._check_tool")
|
||||||
|
def test_all_present(self, mock_check: MagicMock, mock_py: MagicMock) -> None:
|
||||||
|
mock_check.return_value = True
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, [])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "All core tools present" in result.output
|
||||||
|
|
||||||
|
@patch("devx.tools.check_deps._check_python_version")
|
||||||
|
@patch("devx.tools.check_deps._check_tool")
|
||||||
|
def test_missing_required(self, mock_check: MagicMock, mock_py: MagicMock) -> None:
|
||||||
|
mock_check.side_effect = lambda name, optional=False: name != "tofu"
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, [])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
|
||||||
|
@patch("devx.tools.check_deps._check_python_version")
|
||||||
|
@patch("devx.tools.check_deps._check_tool")
|
||||||
|
def test_missing_optional_with_fallback(self, mock_check: MagicMock, mock_py: MagicMock, tmp_path: Path) -> None:
|
||||||
|
checkmake_bin = tmp_path / "checkmake"
|
||||||
|
checkmake_bin.touch()
|
||||||
|
|
||||||
|
def _side(name: str, optional: bool = False) -> bool:
|
||||||
|
return name != "checkmake"
|
||||||
|
|
||||||
|
mock_check.side_effect = _side
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["--checkmake-bin", str(checkmake_bin)])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
|
||||||
|
@patch("devx.tools.check_deps._check_python_version")
|
||||||
|
@patch("devx.tools.check_deps._check_tool")
|
||||||
|
def test_missing_optional_no_fallback(self, mock_check: MagicMock, mock_py: MagicMock) -> None:
|
||||||
|
def _side(name: str, optional: bool = False) -> bool:
|
||||||
|
return name != "checkmake"
|
||||||
|
|
||||||
|
mock_check.side_effect = _side
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, [])
|
||||||
|
assert result.exit_code == 0
|
||||||
@@ -69,16 +69,16 @@ class TestParsePerTestDurations:
|
|||||||
assert len(durations) == 1
|
assert len(durations) == 1
|
||||||
assert durations[0] == ("tests/test_foo.py::test_bar", 0.01)
|
assert durations[0] == ("tests/test_foo.py::test_bar", 0.01)
|
||||||
|
|
||||||
def test_parses_setup_and_teardown(self) -> None:
|
def test_ignores_setup_and_teardown(self) -> None:
|
||||||
|
"""Only 'call' durations are counted — setup includes import overhead."""
|
||||||
output = (
|
output = (
|
||||||
"0.02s setup tests/test_foo.py::test_bar\n"
|
"0.68s setup tests/test_foo.py::test_bar\n"
|
||||||
"0.01s call tests/test_foo.py::test_bar\n"
|
"0.01s call tests/test_foo.py::test_bar\n"
|
||||||
"0.00s teardown tests/test_foo.py::test_bar\n"
|
"0.00s teardown tests/test_foo.py::test_bar\n"
|
||||||
)
|
)
|
||||||
durations = parse_per_test_durations(output)
|
durations = parse_per_test_durations(output)
|
||||||
assert len(durations) == 3
|
assert len(durations) == 1
|
||||||
names = [d[0] for d in durations]
|
assert durations[0] == ("tests/test_foo.py::test_bar", 0.01)
|
||||||
assert "tests/test_foo.py::test_bar" in names
|
|
||||||
|
|
||||||
def test_sorted_slowest_first(self) -> None:
|
def test_sorted_slowest_first(self) -> None:
|
||||||
output = "0.01s call tests/test_a.py::test_slow\n0.50s call tests/test_b.py::test_fast\n"
|
output = "0.01s call tests/test_a.py::test_slow\n0.50s call tests/test_b.py::test_fast\n"
|
||||||
|
|||||||
@@ -201,6 +201,20 @@ class TestToolsCommands:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
mock_run.assert_called_once_with("devx.tools.setup", [])
|
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:
|
class TestMoleculeCommands:
|
||||||
@patch("devx.cli._run_module")
|
@patch("devx.cli._run_module")
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from click.testing import CliRunner
|
|||||||
|
|
||||||
from devx.exceptions import APIError
|
from devx.exceptions import APIError
|
||||||
from devx.tools.configure_repo import (
|
from devx.tools.configure_repo import (
|
||||||
|
_STANDARD_LABELS,
|
||||||
_default_branch_protection_config,
|
_default_branch_protection_config,
|
||||||
_default_repo_settings_config,
|
_default_repo_settings_config,
|
||||||
_handle_http_error,
|
_handle_http_error,
|
||||||
@@ -58,6 +59,7 @@ class TestConfigureRepo:
|
|||||||
|
|
||||||
mock_client.ensure_branch_protection.assert_called_once()
|
mock_client.ensure_branch_protection.assert_called_once()
|
||||||
mock_client.update_repo_settings.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.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||||
@patch("devx.tools.configure_repo.GiteaClient")
|
@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.ensure_branch_protection.assert_called_once_with("develop", custom_bp)
|
||||||
mock_client.update_repo_settings.assert_called_once_with(custom_rs)
|
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:
|
class TestMain:
|
||||||
@@ -114,6 +131,7 @@ class TestMain:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
mock_client.ensure_branch_protection.assert_called_once()
|
mock_client.ensure_branch_protection.assert_called_once()
|
||||||
mock_client.update_repo_settings.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.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||||
@patch("devx.tools.configure_repo.GiteaClient")
|
@patch("devx.tools.configure_repo.GiteaClient")
|
||||||
@@ -125,6 +143,7 @@ class TestMain:
|
|||||||
result = runner.invoke(main, ["--repo", "myrepo", "--owner", "myorg"])
|
result = runner.invoke(main, ["--repo", "myrepo", "--owner", "myorg"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
mock_client.ensure_branch_protection.assert_called_once()
|
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.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||||
@patch("devx.tools.configure_repo.GiteaClient")
|
@patch("devx.tools.configure_repo.GiteaClient")
|
||||||
|
|||||||
@@ -38,6 +38,31 @@ class TestIsReleaseCommit:
|
|||||||
assert detect_release_commit.is_release_commit("") is False
|
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:
|
class TestWriteGithubOutput:
|
||||||
def test_write(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
def test_write(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
gh_file = tmp_path / "output.txt"
|
gh_file = tmp_path / "output.txt"
|
||||||
@@ -62,7 +87,26 @@ class TestMain:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Release commit" in result.output
|
assert "Release commit" in result.output
|
||||||
with open(gh_file) as f:
|
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:
|
def test_regular_commit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
gh_file = tmp_path / "output.txt"
|
gh_file = tmp_path / "output.txt"
|
||||||
@@ -73,4 +117,6 @@ class TestMain:
|
|||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Regular merge commit" in result.output
|
assert "Regular merge commit" in result.output
|
||||||
with open(gh_file) as f:
|
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
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""Unit tests for devx.tools.docker_login."""
|
||||||
|
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from devx.tools.docker_login import (
|
||||||
|
_resolve_credentials,
|
||||||
|
cli,
|
||||||
|
docker_login,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDockerLogin:
|
||||||
|
@patch("devx.tools.docker_login.subprocess.run")
|
||||||
|
def test_success(self, mock_run: MagicMock) -> None:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
assert docker_login("registry.io", "user", "tok") is True
|
||||||
|
|
||||||
|
@patch("devx.tools.docker_login.subprocess.run")
|
||||||
|
def test_failure_raises(self, mock_run: MagicMock) -> None:
|
||||||
|
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="auth failed")
|
||||||
|
with pytest.raises(Exception, match="auth failed"):
|
||||||
|
docker_login("registry.io", "user", "tok")
|
||||||
|
|
||||||
|
@patch("devx.tools.docker_login.subprocess.run")
|
||||||
|
def test_failure_suppressed(self, mock_run: MagicMock) -> None:
|
||||||
|
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="auth failed")
|
||||||
|
assert docker_login("registry.io", "user", "tok", suppress_failure=True) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestResolveCredentials:
|
||||||
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "CI_GITEA_USERNAME": "emil"}, clear=True)
|
||||||
|
def test_both_set(self) -> None:
|
||||||
|
user, token = _resolve_credentials("CI_GITEA_TOKEN", "CI_GITEA_USERNAME", None)
|
||||||
|
assert user == "emil"
|
||||||
|
assert token == "tok"
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||||
|
def test_token_only_with_default(self) -> None:
|
||||||
|
user, token = _resolve_credentials("CI_GITEA_TOKEN", "CI_GITEA_USERNAME", "emil")
|
||||||
|
assert user == "emil"
|
||||||
|
assert token == "tok"
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {}, clear=True)
|
||||||
|
def test_no_token(self) -> None:
|
||||||
|
user, token = _resolve_credentials("CI_GITEA_TOKEN", "CI_GITEA_USERNAME", "emil")
|
||||||
|
assert user is None
|
||||||
|
assert token is None
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||||
|
def test_no_username_no_default(self) -> None:
|
||||||
|
user, token = _resolve_credentials("CI_GITEA_TOKEN", "CI_GITEA_USERNAME", None)
|
||||||
|
assert user == ""
|
||||||
|
assert token == "tok"
|
||||||
|
|
||||||
|
|
||||||
|
class TestCli:
|
||||||
|
@patch("devx.tools.docker_login.docker_login")
|
||||||
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "CI_GITEA_USERNAME": "emil"}, clear=True)
|
||||||
|
def test_required_login(self, mock_login: MagicMock) -> None:
|
||||||
|
mock_login.return_value = True
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
["--registry", "reg.io", "--token-env", "CI_GITEA_TOKEN", "--username-env", "CI_GITEA_USERNAME"],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_login.assert_called_once()
|
||||||
|
|
||||||
|
@patch("devx.tools.docker_login.docker_login")
|
||||||
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||||
|
def test_default_username(self, mock_login: MagicMock) -> None:
|
||||||
|
mock_login.return_value = True
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"--registry",
|
||||||
|
"reg.io",
|
||||||
|
"--token-env",
|
||||||
|
"CI_GITEA_TOKEN",
|
||||||
|
"--username-env",
|
||||||
|
"CI_GITEA_USERNAME",
|
||||||
|
"--default-username",
|
||||||
|
"emil",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_login.assert_called_once_with("reg.io", "emil", "tok", suppress_failure=False)
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {}, clear=True)
|
||||||
|
def test_required_no_token_raises(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
["--registry", "reg.io", "--token-env", "CI_GITEA_TOKEN", "--username-env", "CI_GITEA_USERNAME"],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {}, clear=True)
|
||||||
|
def test_optional_no_token_skips(self) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"--registry",
|
||||||
|
"reg.io",
|
||||||
|
"--token-env",
|
||||||
|
"CI_GITEA_TOKEN",
|
||||||
|
"--username-env",
|
||||||
|
"CI_GITEA_USERNAME",
|
||||||
|
"--optional",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Skipping" in result.output
|
||||||
|
|
||||||
|
@patch("devx.tools.docker_login.docker_login")
|
||||||
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||||
|
def test_no_username_raises(self, mock_login: MagicMock) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
["--registry", "reg.io", "--token-env", "CI_GITEA_TOKEN", "--username-env", "CI_GITEA_USERNAME"],
|
||||||
|
)
|
||||||
|
assert result.exit_code != 0
|
||||||
|
mock_login.assert_not_called()
|
||||||
|
|
||||||
|
@patch("devx.tools.docker_login.docker_login")
|
||||||
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "CI_GITEA_USERNAME": "emil"}, clear=True)
|
||||||
|
def test_suppress_failure(self, mock_login: MagicMock) -> None:
|
||||||
|
mock_login.return_value = False
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(
|
||||||
|
cli,
|
||||||
|
[
|
||||||
|
"--registry",
|
||||||
|
"reg.io",
|
||||||
|
"--token-env",
|
||||||
|
"CI_GITEA_TOKEN",
|
||||||
|
"--username-env",
|
||||||
|
"CI_GITEA_USERNAME",
|
||||||
|
"--suppress-failure",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_login.assert_called_once_with("reg.io", "emil", "tok", suppress_failure=True)
|
||||||
@@ -240,6 +240,35 @@ class TestInstallHadolint:
|
|||||||
assert (tmp_path / "hadolint").exists()
|
assert (tmp_path / "hadolint").exists()
|
||||||
|
|
||||||
|
|
||||||
|
class TestInstallTofu:
|
||||||
|
def test_already_installed(self) -> None:
|
||||||
|
with patch.object(install_tools, "_is_installed", return_value=True):
|
||||||
|
assert install_tools.install_tofu() is True
|
||||||
|
|
||||||
|
def test_install(self, tmp_path: Path) -> None:
|
||||||
|
import io
|
||||||
|
import tarfile
|
||||||
|
|
||||||
|
tarball_path = tmp_path / "archive.tar.gz"
|
||||||
|
binary_content = b"fake tofu"
|
||||||
|
with tarfile.open(tarball_path, "w:gz") as tar:
|
||||||
|
info = tarfile.TarInfo(name="tofu")
|
||||||
|
info.size = len(binary_content)
|
||||||
|
tar.addfile(info, io.BytesIO(binary_content))
|
||||||
|
|
||||||
|
with patch.object(install_tools, "_is_installed", return_value=False):
|
||||||
|
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||||
|
with patch.object(platform, "machine", return_value="x86_64"):
|
||||||
|
with patch.object(platform, "system", return_value="Linux"):
|
||||||
|
with patch.object(
|
||||||
|
install_tools,
|
||||||
|
"_download",
|
||||||
|
side_effect=lambda url, dest: Path(dest).write_bytes(tarball_path.read_bytes()),
|
||||||
|
):
|
||||||
|
assert install_tools.install_tofu() is True
|
||||||
|
assert (tmp_path / "tofu").exists()
|
||||||
|
|
||||||
|
|
||||||
class TestListTools:
|
class TestListTools:
|
||||||
def test_list(self, tmp_path: Path) -> None:
|
def test_list(self, tmp_path: Path) -> None:
|
||||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||||
@@ -274,6 +303,11 @@ class TestInstallTool:
|
|||||||
assert install_tools._install_tool("hadolint") is True
|
assert install_tools._install_tool("hadolint") is True
|
||||||
mock.assert_called_once()
|
mock.assert_called_once()
|
||||||
|
|
||||||
|
def test_tofu(self) -> None:
|
||||||
|
with patch.object(install_tools, "install_tofu", return_value=True) as mock:
|
||||||
|
assert install_tools._install_tool("tofu") is True
|
||||||
|
mock.assert_called_once()
|
||||||
|
|
||||||
def test_unknown_tool(self) -> None:
|
def test_unknown_tool(self) -> None:
|
||||||
with pytest.raises(ClickException, match="Unknown tool"):
|
with pytest.raises(ClickException, match="Unknown tool"):
|
||||||
install_tools._install_tool("unknown")
|
install_tools._install_tool("unknown")
|
||||||
@@ -292,7 +326,7 @@ class TestMain:
|
|||||||
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
|
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
|
||||||
result = runner.invoke(install_tools.main, [])
|
result = runner.invoke(install_tools.main, [])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert mock_install.call_count == 5
|
assert mock_install.call_count == 6
|
||||||
|
|
||||||
def test_install_specific_tool(self) -> None:
|
def test_install_specific_tool(self) -> None:
|
||||||
runner = CliRunner()
|
runner = CliRunner()
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
"""Unit tests for devx.tools.setup_ssh_key."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from devx.tools.setup_ssh_key import cli, setup_ssh_key
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetupSshKey:
|
||||||
|
def test_success(self, tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
monkeypatch.setenv("SSH_PRIVATE_KEY", "-----BEGIN KEY-----\nfake\n-----END KEY-----")
|
||||||
|
with (
|
||||||
|
patch("subprocess.run") as mock_run,
|
||||||
|
patch("pathlib.Path.chmod"),
|
||||||
|
):
|
||||||
|
agent_result = MagicMock()
|
||||||
|
agent_result.returncode = 0
|
||||||
|
agent_result.stdout = "SSH_AUTH_SOCK=/tmp/agent.sock;\nSSH_AGENT_PID=12345;\n"
|
||||||
|
agent_result.stderr = ""
|
||||||
|
add_result = MagicMock()
|
||||||
|
add_result.returncode = 0
|
||||||
|
add_result.stdout = ""
|
||||||
|
add_result.stderr = ""
|
||||||
|
mock_run.side_effect = [agent_result, add_result]
|
||||||
|
assert setup_ssh_key() is True
|
||||||
|
assert mock_run.call_count == 2
|
||||||
|
|
||||||
|
def test_missing_key(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.delenv("SSH_PRIVATE_KEY", raising=False)
|
||||||
|
assert setup_ssh_key() is False
|
||||||
|
|
||||||
|
def test_empty_key(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("SSH_PRIVATE_KEY", "")
|
||||||
|
assert setup_ssh_key() is False
|
||||||
|
|
||||||
|
def test_explicit_key_param(self, tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
monkeypatch.delenv("SSH_PRIVATE_KEY", raising=False)
|
||||||
|
with (
|
||||||
|
patch("subprocess.run") as mock_run,
|
||||||
|
patch("pathlib.Path.chmod"),
|
||||||
|
):
|
||||||
|
agent_result = MagicMock()
|
||||||
|
agent_result.returncode = 0
|
||||||
|
agent_result.stdout = "SSH_AUTH_SOCK=/tmp/agent.sock;\n"
|
||||||
|
agent_result.stderr = ""
|
||||||
|
add_result = MagicMock()
|
||||||
|
add_result.returncode = 0
|
||||||
|
add_result.stdout = ""
|
||||||
|
add_result.stderr = ""
|
||||||
|
mock_run.side_effect = [agent_result, add_result]
|
||||||
|
assert setup_ssh_key("-----BEGIN KEY-----\nfake\n-----END KEY-----") is True
|
||||||
|
|
||||||
|
def test_ssh_agent_failure(self, tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
monkeypatch.setenv("SSH_PRIVATE_KEY", "fake-key")
|
||||||
|
with (
|
||||||
|
patch("subprocess.run") as mock_run,
|
||||||
|
patch("pathlib.Path.chmod"),
|
||||||
|
):
|
||||||
|
agent_result = MagicMock()
|
||||||
|
agent_result.returncode = 1
|
||||||
|
agent_result.stdout = ""
|
||||||
|
agent_result.stderr = "ssh-agent failed"
|
||||||
|
mock_run.return_value = agent_result
|
||||||
|
assert setup_ssh_key() is False
|
||||||
|
|
||||||
|
def test_key_file_written(self, tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
monkeypatch.setenv("SSH_PRIVATE_KEY", "my-secret-key")
|
||||||
|
with (
|
||||||
|
patch("subprocess.run") as mock_run,
|
||||||
|
patch("pathlib.Path.chmod") as mock_chmod,
|
||||||
|
):
|
||||||
|
agent_result = MagicMock()
|
||||||
|
agent_result.returncode = 0
|
||||||
|
agent_result.stdout = "SSH_AUTH_SOCK=/tmp/agent.sock;\n"
|
||||||
|
agent_result.stderr = ""
|
||||||
|
add_result = MagicMock()
|
||||||
|
add_result.returncode = 0
|
||||||
|
add_result.stdout = ""
|
||||||
|
add_result.stderr = ""
|
||||||
|
mock_run.side_effect = [agent_result, add_result]
|
||||||
|
setup_ssh_key()
|
||||||
|
key_file = tmp_path / ".ssh" / "id_rsa"
|
||||||
|
assert key_file.exists()
|
||||||
|
assert "my-secret-key" in key_file.read_text()
|
||||||
|
mock_chmod.assert_called_with(0o600)
|
||||||
|
|
||||||
|
def test_env_vars_set_from_agent(self, tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
monkeypatch.setenv("SSH_PRIVATE_KEY", "fake-key")
|
||||||
|
with (
|
||||||
|
patch("subprocess.run") as mock_run,
|
||||||
|
patch("pathlib.Path.chmod"),
|
||||||
|
):
|
||||||
|
agent_result = MagicMock()
|
||||||
|
agent_result.returncode = 0
|
||||||
|
agent_result.stdout = "SSH_AUTH_SOCK=/tmp/agent.sock;\nSSH_AGENT_PID=999;\n"
|
||||||
|
agent_result.stderr = ""
|
||||||
|
add_result = MagicMock()
|
||||||
|
add_result.returncode = 0
|
||||||
|
add_result.stdout = ""
|
||||||
|
add_result.stderr = ""
|
||||||
|
mock_run.side_effect = [agent_result, add_result]
|
||||||
|
setup_ssh_key()
|
||||||
|
assert os.environ.get("SSH_AUTH_SOCK") == "/tmp/agent.sock"
|
||||||
|
assert os.environ.get("SSH_AGENT_PID") == "999"
|
||||||
|
|
||||||
|
def test_agent_output_without_env_vars(self, tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
monkeypatch.setenv("SSH_PRIVATE_KEY", "fake-key")
|
||||||
|
monkeypatch.delenv("SSH_AUTH_SOCK", raising=False)
|
||||||
|
with (
|
||||||
|
patch("subprocess.run") as mock_run,
|
||||||
|
patch("pathlib.Path.chmod"),
|
||||||
|
):
|
||||||
|
agent_result = MagicMock()
|
||||||
|
agent_result.returncode = 0
|
||||||
|
agent_result.stdout = "Agent started\nsome message without equals\n"
|
||||||
|
agent_result.stderr = ""
|
||||||
|
add_result = MagicMock()
|
||||||
|
add_result.returncode = 0
|
||||||
|
add_result.stdout = ""
|
||||||
|
add_result.stderr = ""
|
||||||
|
mock_run.side_effect = [agent_result, add_result]
|
||||||
|
assert setup_ssh_key() is True
|
||||||
|
assert os.environ.get("SSH_AUTH_SOCK") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestCli:
|
||||||
|
def test_success(self, tmp_path, monkeypatch) -> None:
|
||||||
|
monkeypatch.setenv("HOME", str(tmp_path))
|
||||||
|
monkeypatch.setenv("SSH_PRIVATE_KEY", "fake-key")
|
||||||
|
runner = CliRunner()
|
||||||
|
with patch("devx.tools.setup_ssh_key.setup_ssh_key") as mock_setup:
|
||||||
|
mock_setup.return_value = True
|
||||||
|
result = runner.invoke(cli, [])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "successfully" in result.output
|
||||||
|
|
||||||
|
def test_no_key(self, monkeypatch) -> None:
|
||||||
|
monkeypatch.delenv("SSH_PRIVATE_KEY", raising=False)
|
||||||
|
runner = CliRunner()
|
||||||
|
with patch("devx.tools.setup_ssh_key.setup_ssh_key") as mock_setup:
|
||||||
|
mock_setup.return_value = False
|
||||||
|
result = runner.invoke(cli, [])
|
||||||
|
assert result.exit_code == 1
|
||||||
@@ -21,6 +21,7 @@ from devx.ci.sync_wiki import (
|
|||||||
verify_wiki_integrity,
|
verify_wiki_integrity,
|
||||||
verify_wiki_page,
|
verify_wiki_page,
|
||||||
)
|
)
|
||||||
|
from devx.exceptions import APIError
|
||||||
|
|
||||||
|
|
||||||
class TestEncodeContent:
|
class TestEncodeContent:
|
||||||
@@ -97,13 +98,11 @@ class TestReadDocContent:
|
|||||||
|
|
||||||
|
|
||||||
class TestListWikiPages:
|
class TestListWikiPages:
|
||||||
def test_returns_empty_on_api_error(self) -> None:
|
def test_raises_on_api_error(self) -> None:
|
||||||
from devx.exceptions import APIError
|
|
||||||
|
|
||||||
client = MagicMock()
|
client = MagicMock()
|
||||||
client._request.side_effect = APIError(404, "not found")
|
client._request.side_effect = APIError(404, "not found")
|
||||||
result = list_wiki_pages(client)
|
with pytest.raises(APIError):
|
||||||
assert result == {}
|
list_wiki_pages(client)
|
||||||
|
|
||||||
def test_returns_page_dict(self) -> None:
|
def test_returns_page_dict(self) -> None:
|
||||||
client = MagicMock()
|
client = MagicMock()
|
||||||
@@ -172,6 +171,35 @@ class TestSyncPage:
|
|||||||
assert "content" not in payload
|
assert "content" not in payload
|
||||||
assert base64.b64decode(payload["content_base64"]).decode("utf-8") == "# Updated"
|
assert base64.b64decode(payload["content_base64"]).decode("utf-8") == "# Updated"
|
||||||
|
|
||||||
|
def test_create_falls_back_to_update_on_already_exists(self) -> None:
|
||||||
|
"""When create fails with 400 'already exists', re-list and update."""
|
||||||
|
client = MagicMock()
|
||||||
|
# First call: POST /wiki/new → 400 already exists
|
||||||
|
# Second call: PATCH /wiki/page/{sub_url} → success
|
||||||
|
create_error = APIError(400, "wiki page already exists [title: Test-Page]")
|
||||||
|
client._request.side_effect = [create_error, MagicMock()]
|
||||||
|
with patch("devx.ci.sync_wiki._list_wiki_pages_with_retry", return_value={"Test-Page": "Test-Page.-"}):
|
||||||
|
result = sync_page(client, "Test-Page", "# Content", {}, dry_run=False)
|
||||||
|
assert result == "updated"
|
||||||
|
# Verify PATCH was called (second call)
|
||||||
|
patch_call = client._request.call_args_list[1]
|
||||||
|
assert patch_call.args[0] == "PATCH"
|
||||||
|
assert "/wiki/page/Test-Page.-" in patch_call.args[1]
|
||||||
|
|
||||||
|
def test_create_raises_non_400_error(self) -> None:
|
||||||
|
"""Non-400 errors from create should propagate, not trigger fallback."""
|
||||||
|
client = MagicMock()
|
||||||
|
client._request.side_effect = APIError(500, "server error")
|
||||||
|
with pytest.raises(APIError):
|
||||||
|
sync_page(client, "Test-Page", "# Content", {}, dry_run=False)
|
||||||
|
|
||||||
|
def test_create_raises_400_not_already_exists(self) -> None:
|
||||||
|
"""400 errors that don't mention 'already exists' should propagate."""
|
||||||
|
client = MagicMock()
|
||||||
|
client._request.side_effect = APIError(400, "invalid title")
|
||||||
|
with pytest.raises(APIError):
|
||||||
|
sync_page(client, "Test-Page", "# Content", {}, dry_run=False)
|
||||||
|
|
||||||
|
|
||||||
class TestVerifyWikiPage:
|
class TestVerifyWikiPage:
|
||||||
def test_verifies_matching_content(self) -> None:
|
def test_verifies_matching_content(self) -> None:
|
||||||
@@ -284,6 +312,43 @@ class TestVerifyWikiIntegrity:
|
|||||||
failures = verify_wiki_integrity(client, mapping, synced)
|
failures = verify_wiki_integrity(client, mapping, synced)
|
||||||
assert len(failures) >= 3 # count mismatch, missing FAQ, stale Stale, empty Home
|
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:
|
class TestMain:
|
||||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||||
@@ -498,3 +563,40 @@ class TestMain:
|
|||||||
result = runner.invoke(main, ["--dry-run", "--strict", "--repo", "owner/repo"])
|
result = runner.invoke(main, ["--dry-run", "--strict", "--repo", "owner/repo"])
|
||||||
assert result.exit_code == 0
|
assert result.exit_code == 0
|
||||||
assert "Integrity check" not in result.output
|
assert "Integrity check" not in result.output
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||||
|
@patch("devx.ci.sync_wiki.GiteaClient")
|
||||||
|
def test_initial_list_api_error_aborts(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
"""When the initial page list fails after retries, sync aborts to avoid duplicate pages."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||||
|
mock_mapping.exists.return_value = True
|
||||||
|
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||||
|
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
||||||
|
with patch("devx.ci.sync_wiki._list_wiki_pages_with_retry", side_effect=APIError(0, "timeout")):
|
||||||
|
with patch("devx.ci.sync_wiki.sync_page", return_value="created"):
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--repo", "owner/repo"])
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "Failed to list existing wiki pages" in result.output
|
||||||
|
assert "Aborting" in result.output
|
||||||
|
|
||||||
|
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||||
|
@patch("devx.ci.sync_wiki.GiteaClient")
|
||||||
|
def test_verify_skips_when_refetch_fails(self, mock_client_cls: MagicMock) -> None:
|
||||||
|
"""When --verify re-fetch fails after retries, verification is skipped gracefully."""
|
||||||
|
mock_client = MagicMock()
|
||||||
|
mock_client_cls.return_value = mock_client
|
||||||
|
# Initial list succeeds, but verify re-fetch fails
|
||||||
|
list_side_effect = [{"Home": "Home"}, APIError(0, "timeout")]
|
||||||
|
with patch("devx.ci.sync_wiki.MAPPING_FILE") as mock_mapping:
|
||||||
|
mock_mapping.exists.return_value = True
|
||||||
|
with patch("devx.ci.sync_wiki.load_mapping", return_value={"index.md": "Home"}):
|
||||||
|
with patch("devx.ci.sync_wiki.read_doc_content", return_value="# Home"):
|
||||||
|
with patch("devx.ci.sync_wiki._list_wiki_pages_with_retry", side_effect=list_side_effect):
|
||||||
|
with patch("devx.ci.sync_wiki.sync_page", return_value="updated"):
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(main, ["--repo", "owner/repo", "--verify"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert "Skipping content verification" in result.output
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Unit tests for devx.tools.tofu_ops."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
from devx.tools.tofu_ops import (
|
||||||
|
_run_tofu,
|
||||||
|
cli,
|
||||||
|
tofu_init,
|
||||||
|
tofu_validate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunTofu:
|
||||||
|
@patch("devx.tools.tofu_ops.subprocess.run")
|
||||||
|
def test_success(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||||
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||||
|
_run_tofu(["tofu", "init"], tmp_path)
|
||||||
|
mock_run.assert_called_once()
|
||||||
|
|
||||||
|
@patch("devx.tools.tofu_ops.subprocess.run")
|
||||||
|
def test_failure_raises(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||||
|
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="error")
|
||||||
|
with pytest.raises(Exception, match="error"):
|
||||||
|
_run_tofu(["tofu", "validate"], tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTofuInit:
|
||||||
|
@patch("devx.tools.tofu_ops._run_tofu")
|
||||||
|
def test_init_existing_dirs(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "tofu/environments/staging").mkdir(parents=True)
|
||||||
|
(tmp_path / "tofu/environments/dns").mkdir(parents=True)
|
||||||
|
tofu_init("staging", root=str(tmp_path))
|
||||||
|
assert mock_run.call_count == 2
|
||||||
|
|
||||||
|
@patch("devx.tools.tofu_ops._run_tofu")
|
||||||
|
def test_init_skips_missing_dirs(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "tofu/environments/staging").mkdir(parents=True)
|
||||||
|
# dns dir doesn't exist
|
||||||
|
tofu_init("staging", root=str(tmp_path))
|
||||||
|
assert mock_run.call_count == 1
|
||||||
|
|
||||||
|
@patch("devx.tools.tofu_ops._run_tofu")
|
||||||
|
def test_init_no_dirs_exist(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||||
|
tofu_init("staging", root=str(tmp_path))
|
||||||
|
mock_run.assert_not_called()
|
||||||
|
|
||||||
|
@patch("devx.tools.tofu_ops._run_tofu")
|
||||||
|
def test_init_custom_dirs(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "custom/dir").mkdir(parents=True)
|
||||||
|
tofu_init("staging", root=str(tmp_path), extra_dirs=["custom/dir"])
|
||||||
|
assert mock_run.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestTofuValidate:
|
||||||
|
@patch("devx.tools.tofu_ops._run_tofu")
|
||||||
|
def test_validate_all_dirs(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||||
|
for d in [
|
||||||
|
"tofu/modules/hetzner-vm",
|
||||||
|
"tofu/modules/hetzner-network",
|
||||||
|
"tofu/environments/staging",
|
||||||
|
"tofu/environments/production",
|
||||||
|
"tofu/environments/dns",
|
||||||
|
]:
|
||||||
|
(tmp_path / d).mkdir(parents=True)
|
||||||
|
tofu_validate(root=str(tmp_path))
|
||||||
|
assert mock_run.call_count == 5
|
||||||
|
|
||||||
|
@patch("devx.tools.tofu_ops._run_tofu")
|
||||||
|
def test_validate_skips_missing(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "tofu/environments/staging").mkdir(parents=True)
|
||||||
|
tofu_validate(root=str(tmp_path))
|
||||||
|
assert mock_run.call_count == 1
|
||||||
|
|
||||||
|
@patch("devx.tools.tofu_ops._run_tofu")
|
||||||
|
def test_validate_ci_mode(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "tofu/environments/staging").mkdir(parents=True)
|
||||||
|
tofu_validate(root=str(tmp_path), ci=True)
|
||||||
|
# CI mode runs init + validate = 2 calls per dir
|
||||||
|
assert mock_run.call_count == 2
|
||||||
|
first_call = mock_run.call_args_list[0][0][0]
|
||||||
|
assert "init" in first_call
|
||||||
|
assert "-backend=false" in first_call
|
||||||
|
|
||||||
|
@patch("devx.tools.tofu_ops._run_tofu")
|
||||||
|
def test_validate_custom_dirs(self, mock_run: MagicMock, tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "custom").mkdir()
|
||||||
|
tofu_validate(root=str(tmp_path), dirs=["custom"])
|
||||||
|
assert mock_run.call_count == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestCli:
|
||||||
|
@patch("devx.tools.tofu_ops.tofu_init")
|
||||||
|
def test_init_command(self, mock_init: MagicMock) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["init", "--env", "staging"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_init.assert_called_once_with("staging", ".")
|
||||||
|
|
||||||
|
@patch("devx.tools.tofu_ops.tofu_validate")
|
||||||
|
def test_validate_command(self, mock_validate: MagicMock) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["validate"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_validate.assert_called_once_with(".", ci=False)
|
||||||
|
|
||||||
|
@patch("devx.tools.tofu_ops.tofu_validate")
|
||||||
|
def test_validate_ci_command(self, mock_validate: MagicMock) -> None:
|
||||||
|
runner = CliRunner()
|
||||||
|
result = runner.invoke(cli, ["validate", "--ci"])
|
||||||
|
assert result.exit_code == 0
|
||||||
|
mock_validate.assert_called_once_with(".", ci=True)
|
||||||
Reference in New Issue
Block a user