Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c63e85923a | ||
|
|
77c1af8ed3 | ||
|
|
a48fb46c52 | ||
|
|
2392a13afc | ||
|
|
32315b1d5d | ||
|
|
f70f468630 | ||
|
|
85b5ec1485 | ||
|
|
091b951adc | ||
|
|
19eb57445d | ||
|
|
bdd0e05869 | ||
|
|
27fd99a091 | ||
|
|
e3fa9b7c95 | ||
|
|
ce60356542 | ||
|
|
35c72ef595 | ||
|
|
c0fcaef25f | ||
|
|
3dd5b452c0 | ||
|
|
b2515bbf37 | ||
|
|
ad2e59980f | ||
|
|
68a01d1bda | ||
|
|
621b051793 | ||
|
|
66554657f2 | ||
|
|
587d3a6ca4 | ||
|
|
9d75e408ae | ||
|
|
412bbea01d | ||
|
|
ce5ce33a12 | ||
|
|
0fae419584 | ||
|
|
70b011d4a6 | ||
|
|
a5c16a92df | ||
|
|
e6f022ae96 | ||
|
|
1a27983750 | ||
|
|
5d7ed62b34 | ||
|
|
ee80c27631 | ||
|
|
98b1659579 | ||
|
|
c12d9abc6d | ||
|
|
40a65cb0a6 | ||
|
|
1a89738dd4 | ||
|
|
9e604ea2c7 | ||
|
|
4bb50bed58 | ||
|
|
5bb9dce530 | ||
|
|
73662a3bf0 | ||
|
|
a1e87b1905 | ||
|
|
d8d0ad04a2 | ||
|
|
e836c09088 | ||
|
|
507436b134 | ||
|
|
32cec2c5ad | ||
|
|
55c530eb00 | ||
|
|
3928de4507 | ||
|
|
8bb1813715 | ||
|
|
f7f53941a1 | ||
|
|
5edfdaa7aa | ||
|
|
893da8ba34 | ||
|
|
64a58874b6 | ||
|
|
c1c2041ca4 | ||
|
|
49ff8870b1 | ||
|
|
4c1ecbf4fa | ||
|
|
93a1cb9945 | ||
|
|
507bc86b92 | ||
|
|
81dc30ecff | ||
|
|
3c421dd1ad | ||
|
|
11ce99756c | ||
|
|
6149167ba2 | ||
|
|
014ab0b63f | ||
|
|
2a3ee1ec96 | ||
|
|
5d4968eb21 | ||
|
|
33cfbb0f41 | ||
|
|
598238e4d6 | ||
|
|
925b99b7db | ||
|
|
16ed48bd26 | ||
|
|
3b0500164b | ||
|
|
00a44ec5dc | ||
|
|
b385c57621 | ||
|
|
3181b24f5e | ||
|
|
bc8478220c | ||
|
|
a568c0899f | ||
|
|
4216698ca8 | ||
|
|
f3685b9029 | ||
|
|
2e5470236e | ||
|
|
d2dcf8f7c4 | ||
|
|
357e07a9a6 | ||
|
|
ffb3976224 | ||
|
|
ad75b22f2a | ||
|
|
37f867f6d8 | ||
|
|
233a0bc055 | ||
|
|
1a28f5dcc5 | ||
|
|
3af60af439 | ||
|
|
e181104e1c | ||
|
|
ee1826fb34 | ||
|
|
9170546a31 | ||
|
|
91f59076a1 | ||
|
|
ca310fe442 | ||
|
|
decba5ec77 | ||
|
|
082df1d893 | ||
|
|
08f58e551b | ||
|
|
66bace57d9 | ||
|
|
5c8d74015e | ||
|
|
48eec986f6 | ||
|
|
1fc1cfb23f | ||
|
|
f24b6914f6 | ||
|
|
84d02ca221 | ||
|
|
18ac8f4ba9 | ||
|
|
9fb9be9c35 | ||
|
|
9a46723391 | ||
|
|
5828d3f07b | ||
|
|
4232f4baee | ||
|
|
a9fd1a47af | ||
|
|
ecd10241fb | ||
|
|
5fb497d108 | ||
|
|
7d4c32c761 | ||
|
|
cb8af53c26 | ||
|
|
954ede87a7 | ||
|
|
eb30faf027 | ||
|
|
6c4157b5c6 | ||
|
|
ea7ddb2036 | ||
|
|
ef63ada2f0 | ||
|
|
cdd5f5a8da | ||
|
|
63ae375b4b | ||
|
|
8862ea4639 | ||
|
|
8ca0a1b208 | ||
|
|
670f5a099a | ||
|
|
a5277a0790 | ||
|
|
203d16b19d | ||
|
|
12871fb343 | ||
|
|
a585ef09b6 | ||
|
|
92aea7df10 | ||
|
|
ee3ad74634 | ||
|
|
626ea67b28 | ||
|
|
87c3fa6634 | ||
|
|
048d161192 | ||
|
|
762eee4a55 | ||
|
|
d84958fab7 | ||
|
|
c0238e75df | ||
|
|
d4ddbd7e7a | ||
|
|
08b993fc4a |
@@ -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,20 @@
|
||||
.venv/
|
||||
.git/
|
||||
.gitea/
|
||||
tests/
|
||||
docs/
|
||||
*.egg-info/
|
||||
__pycache__/
|
||||
htmlcov/
|
||||
.coverage
|
||||
dist/
|
||||
build/
|
||||
*.md
|
||||
!README.md
|
||||
.env
|
||||
.env.example
|
||||
activate.sh
|
||||
activate.fish
|
||||
activate.zsh
|
||||
hooks/
|
||||
.devin/
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
# Gitea API token (required for CI scripts that interact with Gitea)
|
||||
# Create at: https://git.oblachno.oblachno.fyi/user/settings/applications
|
||||
REPO_TOKEN=
|
||||
CI_GITEA_TOKEN=
|
||||
|
||||
# Vikunja API token (required for post-merge task updates)
|
||||
# Create at: https://work.oblachno.oblachno.fyi/settings/tokens
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
name: Build Images
|
||||
|
||||
# Builds and pushes pre-built Docker runner images to the Gitea registry.
|
||||
# These images eliminate the 40-120s setup tax on every CI job by baking
|
||||
# devx and all dependencies into the image.
|
||||
#
|
||||
# Triggers:
|
||||
# - On push to master (after post-merge release completes)
|
||||
# - Manually via workflow_dispatch
|
||||
#
|
||||
# The workflow builds 3 tier images in sequence:
|
||||
# ci-base → ci-quality → ci-full
|
||||
#
|
||||
# Each tier builds FROM the previous one, so they must be built in order.
|
||||
# After pushing, a cleanup job removes old versions (keeps last 2 + latest).
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- docker/**
|
||||
- pyproject.toml
|
||||
- src/devx/**
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: build-images
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
detect-type:
|
||||
runs-on: docker
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
is-release: ${{ steps.check.outputs.is-release }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
- name: Check if this is a release commit
|
||||
id: check
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 -m devx.ci.detect_release_commit
|
||||
|
||||
build-and-push:
|
||||
needs: [detect-type]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
run: make setup-release
|
||||
- name: Docker registry login
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
echo "$CI_GITEA_TOKEN" | docker login git.oblachno.oblachno.fyi -u "$CI_GITEA_USERNAME" --password-stdin
|
||||
- name: Build and push tier images
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
CI_GITEA_USERNAME: ${{ vars.CI_GITEA_USERNAME }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
# Build ci-base first (it's the base for ci-quality and ci-full)
|
||||
python3 -m devx.tools.build_image \
|
||||
--dockerfile docker/ci-base/Dockerfile \
|
||||
--name oblachno-oss/runner-images/ci-base \
|
||||
--tag latest \
|
||||
--registry git.oblachno.oblachno.fyi \
|
||||
--push --pull
|
||||
# Build ci-quality (FROM ci-base-latest)
|
||||
python3 -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
|
||||
# Build ci-full (FROM ci-quality-latest)
|
||||
python3 -m devx.tools.build_image \
|
||||
--dockerfile docker/ci-full/Dockerfile \
|
||||
--name oblachno-oss/runner-images/ci-full \
|
||||
--tag latest \
|
||||
--registry git.oblachno.oblachno.fyi \
|
||||
--push
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.notify_failure \
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "build-images/build-and-push" \
|
||||
--commit "${{ github.sha }}" \
|
||||
--auto-login
|
||||
|
||||
cleanup:
|
||||
needs: [build-and-push]
|
||||
if: always() && needs.build-and-push.result == 'success'
|
||||
runs-on: docker
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
- name: Clean up old image versions
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 -m devx.tools.clean_images \
|
||||
--owner oblachno-oss \
|
||||
--name oblachno-oss/runner-images/ci-base \
|
||||
--name oblachno-oss/runner-images/ci-quality \
|
||||
--name oblachno-oss/runner-images/ci-full \
|
||||
--keep 2
|
||||
+67
-23
@@ -8,48 +8,58 @@ on:
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
run: make setup-quality
|
||||
run: make setup-image
|
||||
- name: Lint all
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
make lint-all
|
||||
- name: Unit tests with 100% coverage
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
make pytest-cov
|
||||
- name: Check unit test speed
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 6 --max-single-seconds 0.5
|
||||
- name: Documentation coverage check
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.doc_coverage --fail-on-missing
|
||||
- name: Documentation lint check
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.lint_docs --root .
|
||||
- name: Translation completeness check
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.check_translations
|
||||
- name: Dependency security scan
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
# Install pip in venv if missing (needed by pip-audit)
|
||||
.venv/bin/python -m ensurepip 2>/dev/null || true
|
||||
PIPAPI_PYTHON_LOCATION=$PWD/.venv/bin/python \
|
||||
pip-audit --desc --skip-editable 2>&1 || true
|
||||
- name: Workflow dry-run validation
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
# Best-effort: only runs if act_runner is installed
|
||||
if command -v act_runner >/dev/null 2>&1; then
|
||||
@@ -60,7 +70,11 @@ jobs:
|
||||
|
||||
detect-changes:
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
outputs:
|
||||
user-facing-changed: ${{ steps.detect.outputs.user-facing-changed }}
|
||||
steps:
|
||||
@@ -68,13 +82,13 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
run: make setup-image
|
||||
- name: Detect changed paths
|
||||
id: detect
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.classify_changes \
|
||||
--base "origin/master" \
|
||||
--head "${{ github.event.pull_request.head.sha || github.sha }}" \
|
||||
@@ -84,36 +98,46 @@ jobs:
|
||||
needs: [quality, detect-changes]
|
||||
if: needs.detect-changes.outputs.user-facing-changed == 'true'
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
run: make setup-release
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
run: make setup-image
|
||||
- name: Release dry-run validation
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.release --dry-run || true
|
||||
python3 -m devx.ci.release --dry-run
|
||||
|
||||
pr-review:
|
||||
if: github.event_name == 'pull_request'
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
run: make setup-image
|
||||
- name: Run automated PR review
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.pr_review \
|
||||
"${{ github.event.number }}" \
|
||||
"${{ github.repository }}"
|
||||
@@ -122,24 +146,44 @@ jobs:
|
||||
# Auto-merge runs after all CI checks pass. It reads the task ID
|
||||
# from the branch name, validates the PR title, and squash-merges.
|
||||
# Uses always() so it runs even when detect-changes skips (no user-facing changes).
|
||||
needs: [quality, detect-changes, pr-review]
|
||||
needs: [quality, detect-changes, pr-review, release-dry-run]
|
||||
if: >-
|
||||
always() &&
|
||||
github.event_name == 'pull_request' &&
|
||||
needs.quality.result == 'success' &&
|
||||
needs.pr-review.result == 'success'
|
||||
needs.pr-review.result == 'success' &&
|
||||
(needs.release-dry-run.result == 'success' || needs.release-dry-run.result == 'skipped')
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.REPO_TOKEN }}
|
||||
token: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
run: make setup-image
|
||||
- name: Post approval review
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.pr_review \
|
||||
"$PR_NUMBER" \
|
||||
"$REPOSITORY" \
|
||||
--event APPROVE \
|
||||
--checklist-confirmed \
|
||||
--checklist-categories 1,2,3,4,5,6,7,8,9,10,11,12,13 \
|
||||
--body "Auto-approved: all CI checks passed (quality, pr-review, release-dry-run)."
|
||||
- name: Squash merge with task ID
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
||||
DEVX_VIKUNJA_PROJECT_ID: "8"
|
||||
PYTHONPATH: src
|
||||
@@ -148,7 +192,7 @@ jobs:
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
PR_NUMBER: ${{ github.event.number }}
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.auto_merge \
|
||||
"$HEAD_REF" \
|
||||
"$PR_TITLE" \
|
||||
|
||||
@@ -33,7 +33,11 @@ on:
|
||||
jobs:
|
||||
detect-type:
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
outputs:
|
||||
is-release: ${{ steps.check.outputs.is-release }}
|
||||
steps:
|
||||
@@ -41,31 +45,35 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
run: make setup-image
|
||||
- name: Check if this is a release commit
|
||||
id: check
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.detect_release_commit
|
||||
|
||||
validate-commit-msg:
|
||||
needs: [detect-type]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
timeout-minutes: 5
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
run: make setup-image
|
||||
- name: Validate latest commit message
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
git log -1 --format=%B > commit-msg.txt
|
||||
python3 -m devx.ci.validate_commit_msg commit-msg.txt --branch master
|
||||
rm -f commit-msg.txt
|
||||
@@ -74,18 +82,22 @@ jobs:
|
||||
needs: [detect-type]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
outputs:
|
||||
tag: ${{ steps.release-tag.outputs.tag }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.REPO_TOKEN }}
|
||||
token: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
- name: Set up environment
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
run: make setup-release
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
run: make setup-image
|
||||
- name: Configure git
|
||||
run: |
|
||||
git config user.name "devx-ci-bot"
|
||||
@@ -95,20 +107,13 @@ jobs:
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.release
|
||||
- name: Extract tag (fallback if GITHUB_OUTPUT not set)
|
||||
if: steps.release-tag.outputs.tag == ''
|
||||
run: |
|
||||
tag=$(git describe --tags --abbrev=0 2>/dev/null || true)
|
||||
if [ -n "$tag" ]; then
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
@@ -117,33 +122,37 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/release" \
|
||||
--commit "${{ github.sha }}"
|
||||
--commit "${{ github.sha }}" \
|
||||
--auto-login
|
||||
|
||||
publish:
|
||||
needs: [release]
|
||||
if: needs.release.outputs.tag != ''
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-full:latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ needs.release.outputs.tag }}
|
||||
- name: Set up environment
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
run: make setup-release
|
||||
run: make setup-image EXTRAS=release
|
||||
- name: Build and publish release
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.publish "${{ needs.release.outputs.tag }}" "${{ github.repository }}"
|
||||
python3 -m devx.ci.publish "${{ needs.release.outputs.tag }}" "${{ github.repository }}" --auto-login
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
@@ -152,30 +161,35 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/publish" \
|
||||
--commit "${{ github.sha }}"
|
||||
--commit "${{ github.sha }}" \
|
||||
--auto-login
|
||||
|
||||
sync-wiki:
|
||||
needs: [detect-type]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
run: make setup-image
|
||||
- name: Sync documentation to wiki
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --strict
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
@@ -183,35 +197,40 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/sync-wiki" \
|
||||
--commit "${{ github.sha }}"
|
||||
--commit "${{ github.sha }}" \
|
||||
--auto-login
|
||||
|
||||
badges:
|
||||
needs: [detect-type]
|
||||
if: always()
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: master
|
||||
token: ${{ secrets.REPO_TOKEN }}
|
||||
token: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
- name: Fetch latest master
|
||||
run: |
|
||||
git fetch origin master
|
||||
git reset --hard origin/master
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
run: make setup-image
|
||||
- name: Generate and push badges
|
||||
env:
|
||||
PRE_COMMIT_ALLOW_NO_CONFIG: "1"
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.push_badges
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
@@ -219,31 +238,36 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/badges" \
|
||||
--commit "${{ github.sha }}"
|
||||
--commit "${{ github.sha }}" \
|
||||
--auto-login
|
||||
|
||||
vikunja:
|
||||
needs: [detect-type]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
run: make setup-image
|
||||
- name: Update Vikunja task
|
||||
env:
|
||||
VIKUNJA_TOKEN: ${{ secrets.VIKUNJA_TOKEN }}
|
||||
DEVX_VIKUNJA_PROJECT_ID: "8"
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
@@ -251,28 +275,35 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/vikunja" \
|
||||
--commit "${{ github.sha }}"
|
||||
--commit "${{ github.sha }}" \
|
||||
--auto-login
|
||||
|
||||
configure-repo:
|
||||
needs: [detect-type]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
timeout-minutes: 10
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
run: make setup-ci
|
||||
run: make setup-image
|
||||
- name: Ensure branch protection and labels
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
DEVX_REPO_NAME: devx
|
||||
DEVX_REPO_OWNER: oblachno-oss
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 -m devx.tools.configure_repo --repo devx --owner oblachno-oss
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.tools.configure_repo
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
REPO_TOKEN: ${{ secrets.REPO_TOKEN }}
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
@@ -280,4 +311,5 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/configure-repo" \
|
||||
--commit "${{ github.sha }}"
|
||||
--commit "${{ github.sha }}" \
|
||||
--auto-login
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
# Hadolint configuration for devx Dockerfiles
|
||||
# https://github.com/hadolint/hadolint#configure
|
||||
|
||||
ignored:
|
||||
- DL3008 # Don't require pinning apt package versions
|
||||
- DL3013 # Don't require pinning pip package versions
|
||||
- DL3018 # Don't require pinning apk package versions
|
||||
- DL3007 # Using latest is intentional for tier images (rebuilt on every merge)
|
||||
- SC2102 # False positive: pip extras [release,molecule,deploy] look like shell ranges
|
||||
|
||||
trustedRegistries:
|
||||
- git.oblachno.oblachno.fyi
|
||||
- docker.io
|
||||
- gitea/runner-images
|
||||
@@ -1,11 +1,25 @@
|
||||
# AGENTS.md — Project Conventions for devx
|
||||
|
||||
## Virtual Environment
|
||||
|
||||
All Python tools, tests, and scripts run inside a standard `.venv` directory.
|
||||
Activate it before running any non-`make` command:
|
||||
|
||||
```bash
|
||||
source activate.sh # bash/zsh
|
||||
source activate.fish # fish
|
||||
source activate.zsh # zsh
|
||||
```
|
||||
|
||||
If `.venv` doesn't exist, run `make setup` first. The `make` targets handle
|
||||
venv activation automatically — always prefer `make <target>` over raw commands.
|
||||
|
||||
## Build & Test Commands
|
||||
|
||||
```bash
|
||||
make setup # Create venv, install deps, set up hooks, install CI tools
|
||||
make install-tools # Install actionlint, git-cliff, act_runner to ~/.local/bin
|
||||
make lint-all # ruff + pyright + bandit + actionlint
|
||||
make install-tools # Install actionlint, git-cliff, act_runner, tea, hadolint to ~/.local/bin
|
||||
make lint-all # ruff + pyright + bandit + actionlint + lint-dockerfiles
|
||||
make pytest-cov # Unit tests with 100% coverage enforcement
|
||||
make test-unit # Unit tests without coverage
|
||||
make workflow-lint # Static lint of .gitea/workflows/*.yml (actionlint)
|
||||
@@ -16,8 +30,8 @@ make clean # Remove caches, build artifacts, coverage data
|
||||
|
||||
`make setup` automatically installs all development tools:
|
||||
- **Python deps** via `python -m devx.tools.setup` (pip install -e .[dev], pre-commit hooks)
|
||||
- **actionlint, git-cliff, act_runner, tea** via `python -m devx.tools.install_tools` (CI/CD tools to ~/.local/bin)
|
||||
- **tea CLI login** via `python -m devx.tools.setup` (configures `tea login` from `.env` `REPO_TOKEN`)
|
||||
- **actionlint, git-cliff, act_runner, tea, hadolint** via `python -m devx.tools.install_tools` (CI/CD tools to ~/.local/bin)
|
||||
- **tea CLI login** via `python -m devx.tools.setup` (configures `tea login` from `.env` `CI_GITEA_TOKEN`)
|
||||
|
||||
## Workflow Verification (Before Push)
|
||||
|
||||
@@ -52,41 +66,56 @@ src/devx/
|
||||
├── gitea_cli.py # TeaCLI — wrapper around tea CLI with JSON parsing
|
||||
├── i18n.py # Translation system (gettext-based, translations.json)
|
||||
├── exceptions.py # Custom exception types
|
||||
├── translations.json # Translation strings (en, bg)
|
||||
├── translations.json # Translation strings (en, bg, de, pl, ru, zh)
|
||||
├── ci/ # CI/CD automation modules (run by workflows)
|
||||
│ ├── release.py # Automated versioning, tagging, changelog
|
||||
│ ├── publish.py # Build and publish to Gitea PyPI registry (--skip-build for non-Python repos)
|
||||
│ ├── auto_merge.py # Squash-merge PRs with task ID validation
|
||||
│ ├── check_auto_merge_ready.py # Pre-merge validation gate (branch, PR title, Vikunja, behind-master)
|
||||
│ ├── _shared.py # Shared utilities (get_latest_tag)
|
||||
│ ├── classify_changes.py # User-facing vs workflow-only change detection
|
||||
│ ├── classify_changes.py # User-facing vs infrastructure change detection
|
||||
│ ├── detect_release_commit.py # Detect release commits on master
|
||||
│ ├── validate_commit_msg.py # Conventional commit validation
|
||||
│ ├── pr_review.py # Automated PR review
|
||||
│ ├── pr_review.py # Automated PR review + manual reviews (--event, --body, --checklist-confirmed)
|
||||
│ ├── post_merge.py # Vikunja task updates after merge
|
||||
│ ├── sync_wiki.py # Sync documentation to Gitea wiki
|
||||
│ ├── push_badges.py # Generate and push quality badges (--retries for retry on git push failures)
|
||||
│ ├── notify_failure.py # Create Gitea issues on CI failures (--auto-login)
|
||||
│ ├── distribute_files.py # Distribute files across parallel runners (LPT scheduling)
|
||||
│ ├── distribute_items.py # Distribute generic items (VMs, hosts) across parallel runners (LPT)
|
||||
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
|
||||
│ ├── check_translations.py # Translation completeness check
|
||||
│ └── doc_coverage.py # Documentation coverage check
|
||||
│ ├── doc_coverage.py # Documentation coverage check
|
||||
│ └── lint_docs.py # Documentation linter (structure, links, headings)
|
||||
├── tools/ # Developer tooling modules (run locally or by CI)
|
||||
│ ├── setup.py # Environment setup (venv, deps, hooks)
|
||||
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea
|
||||
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea, hadolint
|
||||
│ ├── install_checkmake.py # Install checkmake (Makefile linter)
|
||||
│ ├── build_image.py # Build and push Docker images to Gitea registry
|
||||
│ ├── clean_images.py # Clean up old Docker image versions from Gitea registry
|
||||
│ ├── check_test_speed.py # Measure unit test execution time
|
||||
│ ├── check_mutable_globals.py # Detect module-level mutable globals (test isolation bugs)
|
||||
│ ├── check_pyproject_deps.py # Validate pyproject.toml deps have documentation comments
|
||||
│ ├── check_test_coverage.py # Ensure changed files have corresponding tests (configurable rules)
|
||||
│ ├── check_agent_docs.py # Validate docs for stale file references (configurable patterns)
|
||||
│ ├── check_config.py # Validate pyproject.toml [tool.devx] config
|
||||
│ ├── configure_repo.py # Branch protection and label setup
|
||||
│ └── generate_badges.py # Badge SVG generation
|
||||
│ ├── generate_badges.py # Badge SVG generation
|
||||
│ ├── generate_cliff_config.py # Generate git-cliff config (cliff.toml)
|
||||
│ ├── create_task.py # Create Vikunja tasks
|
||||
│ ├── create_pr.py # Create PRs with auto-derived title from Vikunja
|
||||
│ ├── pr_status.py # Check CI status for a PR/commit (--wait polls)
|
||||
│ ├── pr_logs.py # Fetch logs for failed CI jobs
|
||||
│ ├── pr_label.py # Add labels to PRs (idempotent)
|
||||
│ ├── pre_push_check.py # Validate Vikunja task existence before push
|
||||
│ └── _shared.py # Shared tool utilities
|
||||
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
|
||||
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
|
||||
├── discover_runners.py # Dynamic Gitea runner discovery
|
||||
├── distribute_molecule.py # Distribute molecule scenarios across runners (LPT scheduling, --roles-root for multi-role)
|
||||
├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast (--roles-root)
|
||||
├── molecule_all.py # Run all molecule scenarios locally
|
||||
├── start_docker.py # Ensure Docker daemon is running for molecule tests
|
||||
└── platforms.py # Supported molecule platforms
|
||||
```
|
||||
|
||||
@@ -260,7 +289,7 @@ so `.:src` is not needed. The `src` directory is the sole import root.
|
||||
|
||||
The `tea` Gitea CLI tool is used for Gitea API interactions. It is installed
|
||||
by `python -m devx.tools.install_tools` and configured by
|
||||
`python -m devx.tools.setup` (login profile from `.env` `REPO_TOKEN`).
|
||||
`python -m devx.tools.setup` (login profile from `.env` `CI_GITEA_TOKEN`).
|
||||
|
||||
**`devx.gitea_cli.TeaCLI`** — Python wrapper around `tea` CLI with JSON output parsing:
|
||||
- `create_issue()` — Create issues with labels
|
||||
@@ -354,8 +383,8 @@ devx uses environment variables with `.env` file fallback for configuration.
|
||||
| `DEVX_REPO_NAME` | **(none — must be set)** | Repository name (or `owner/repo`) |
|
||||
| `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) |
|
||||
| `DEVX_VIKUNJA_PROJECT_ID` | `6` | Vikunja project ID |
|
||||
| `DEVX_LANG` | `en` | Language for i18n (en, bg) |
|
||||
| `REPO_TOKEN` | (from .env) | Gitea API token |
|
||||
| `DEVX_LANG` | `en` | Language for i18n (en, bg, de, pl, ru, zh) |
|
||||
| `CI_GITEA_TOKEN` | (from .env) | Gitea API token |
|
||||
| `VIKUNJA_TOKEN` | (from .env) | Vikunja API token |
|
||||
|
||||
### Per-Project Overrides
|
||||
@@ -395,13 +424,17 @@ projects.
|
||||
| `devx-create-pr` | Create a PR with auto-derived title |
|
||||
| `devx-push` | Push current branch to origin |
|
||||
| `devx-push-with-pr` | Push and create PR in one step |
|
||||
| `devx-pr-status` | Check CI status for a PR (`PR=`, `WAIT=`, `TIMEOUT=`) |
|
||||
| `devx-pr-logs` | Fetch logs for failed CI jobs (`PR=`, `JOB=`, `TAIL=`) |
|
||||
| `devx-pr-label` | Add a label to a PR (`PR=`, `LABEL=ready-to-merge`) |
|
||||
| `devx-pr-review` | Post a review on a PR (`PR=`, `EVENT=`, `BODY=`, `CHECKLIST=`) |
|
||||
| `devx-check-config` | Validate devx configuration |
|
||||
| `devx-configure-gitea-pypi` | Configure Gitea private PyPI registry |
|
||||
| `devx-env` | Create .env from .env.example |
|
||||
| `devx-venv` | Create Python venv with version check |
|
||||
| `devx-activate-scripts` | Create shell/fish/zsh activate scripts |
|
||||
| `devx-install-hooks` | Set git hooks path to hooks/ |
|
||||
| `devx-install-tools` | Install actionlint, git-cliff, act_runner, tea |
|
||||
| `devx-install-tools` | Install actionlint, git-cliff, act_runner, tea, hadolint |
|
||||
| `devx-install-checkmake` | Install checkmake (Makefile linter) |
|
||||
| `devx-checkmake` | Lint Makefiles with checkmake |
|
||||
| `devx-workflow-lint` | Static lint of Gitea Actions YAML (actionlint) |
|
||||
@@ -424,6 +457,12 @@ projects.
|
||||
| `devx-check-test-speed` | Verify test suite timing |
|
||||
| `devx-pre-push` | Run lint + tests before push |
|
||||
| `devx-clean` | Remove caches, build artifacts, coverage data |
|
||||
| `devx-setup-image` | Link /opt/venv + install project (for pre-built image CI jobs) |
|
||||
| `devx-lint-dockerfiles` | Lint Dockerfiles with hadolint (fail-fast, parameterized by `DEVX_DOCKERFILE_PATHS`) |
|
||||
| `devx-build-images` | Build Docker images from manifest (no push) |
|
||||
| `devx-push-images` | Build and push Docker images to Gitea registry |
|
||||
| `devx-build-images-dry-run` | Show what would be built/pushed |
|
||||
| `devx-clean-images` | Delete old image versions (keep last 2 + latest) |
|
||||
|
||||
**Variables** (set BEFORE including devx.mak):
|
||||
- `DEVX_PYTHON` — Python executable (default: `python3`)
|
||||
@@ -433,6 +472,45 @@ projects.
|
||||
- `DEVX_COV_PKG` — coverage package (default: `src/devx`)
|
||||
- `DEVX_TEST_PATHS` — pytest paths (default: `tests/`)
|
||||
- `DEVX_PR_BASE` — PR base branch (default: `master`)
|
||||
- `DEVX_DOCKERFILE_PATHS` — directory to search for Dockerfiles (default: `docker`)
|
||||
- `DEVX_GITEA_REGISTRY` — registry URL (default: `git.oblachno.oblachno.fyi`)
|
||||
- `DEVX_IMAGE_MANIFEST` — path to JSON manifest (default: `docker/images.json`)
|
||||
- `DEVX_IMAGE_OWNER` — package owner for cleanup (default: `oblachno-oss`)
|
||||
|
||||
## Pre-built Docker Runner Images
|
||||
|
||||
devx builds and publishes three tier images to the Gitea container registry
|
||||
to eliminate the 40-120s setup tax on every CI job:
|
||||
|
||||
| Image | Contains | Used by jobs |
|
||||
|-------|----------|-------------|
|
||||
| `ci-base-latest` | Python 3.12 + devx[ci] + tea | detect-changes, detect-type, validate-commit-msg, pr-review, auto-merge, sync-wiki, vikunja, configure-repo |
|
||||
| `ci-quality-latest` | ci-base + devx[lint] + actionlint + checkmake + hadolint | quality, badges |
|
||||
| `ci-full-latest` | ci-quality + devx[release,molecule,deploy] + git-cliff + OpenTofu | release, publish, release-dry-run, molecule-tests, deploy jobs |
|
||||
|
||||
**Build process** (in `build-images.yml` workflow):
|
||||
1. `ci-base` builds FROM `gitea/runner-images:ubuntu-latest`
|
||||
2. `ci-quality` builds FROM `ci-base-latest`
|
||||
3. `ci-full` builds FROM `ci-quality-latest`
|
||||
|
||||
Each image is tagged `latest` and pushed to
|
||||
`git.oblachno.oblachno.fyi/oblachno-oss/runner-images:<tier>-latest`.
|
||||
|
||||
**Using images in workflows**:
|
||||
```yaml
|
||||
jobs:
|
||||
quality:
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Set up environment
|
||||
run: make setup-image # links /opt/venv, installs project (no-deps)
|
||||
```
|
||||
|
||||
**Image build/push tools** (tested Python modules):
|
||||
- `devx.tools.build_image` — Build and push Docker images from Dockerfile or manifest
|
||||
- `devx.tools.clean_images` — Delete old image versions via Gitea API (keep last N + latest)
|
||||
|
||||
**Usage in project Makefile**:
|
||||
```makefile
|
||||
@@ -457,3 +535,113 @@ create-task: devx-create-task
|
||||
- Line length: 120 chars
|
||||
- Secrets are passed via environment variables, never on the command line
|
||||
- All user-facing strings wrapped in `_()` for i18n
|
||||
|
||||
### Container-Level Fix Verification (Mandatory)
|
||||
|
||||
**Rule:** Before pushing any fix that modifies container state (CA certs,
|
||||
config files, installed packages, daemon restarts), reproduce the exact
|
||||
sequence locally with the actual Docker image. Do not push to CI as the
|
||||
first test.
|
||||
|
||||
This is a hard rule, not a suggestion. CI cycles take 20+ minutes and
|
||||
ephemeral staging VMs are destroyed after each run, making interactive
|
||||
debugging impossible. A local reproduction takes 30 seconds and catches
|
||||
silent failures immediately.
|
||||
|
||||
**Procedure:**
|
||||
1. `docker pull <actual_image>`
|
||||
2. `docker run -d --name <test> ...` and wait for it to start
|
||||
3. Run the exact commands from the Ansible task or script
|
||||
4. Verify the state change took effect
|
||||
5. Clean up: `docker rm -f <test>`
|
||||
|
||||
### Verified State Modification (Mandatory)
|
||||
|
||||
Ansible tasks that modify container state with `changed_when: false`
|
||||
MUST include a post-task verification step that confirms the state
|
||||
change took effect. `changed_when: false` suppresses both change
|
||||
detection AND failure visibility — a task can silently do nothing and
|
||||
report `ok`.
|
||||
|
||||
## Subagent Delegation Policy
|
||||
|
||||
Custom subagent profiles are defined in `.devin/agents/` (project-specific)
|
||||
and `~/.config/devin/agents/` (global, shared across repos). The agent MUST
|
||||
automatically delegate to the appropriate subagent based on the task —
|
||||
the user should not need to specify which profile to use.
|
||||
|
||||
### Available Profiles
|
||||
|
||||
**Global** (shared with infra and grm):
|
||||
|
||||
| Profile | Location | Purpose |
|
||||
|---------|----------|---------|
|
||||
| `pr-reviewer` | `~/.config/devin/agents/` | 13-category PR checklist + quality gates |
|
||||
| `release-check` | `~/.config/devin/agents/` | Pre-merge readiness validation |
|
||||
|
||||
**devx-specific** (in `.devin/agents/`):
|
||||
|
||||
| Profile | Purpose |
|
||||
|---------|---------|
|
||||
| `ci-investigator` | Investigate CI failures (quality, release, publish, wiki sync, image build) |
|
||||
| `dep-upgrader` | Python dependency upgrades in pyproject.toml with dep-doc validation |
|
||||
| `docker-image-builder` | Build/push/cleanup 3-tier runner images (ci-base, ci-quality, ci-full) |
|
||||
| `doc-sync-specialist` | Doc coverage, doc linting, wiki sync integrity |
|
||||
| `workflow-validator` | actionlint + act_runner dry-run validation |
|
||||
|
||||
### When to Delegate Automatically
|
||||
|
||||
| Trigger | Profile | Mode |
|
||||
|---------|---------|------|
|
||||
| CI run failure (quality, release, publish, sync-wiki, build-images) | `ci-investigator` | Background |
|
||||
| PR ready for review | `pr-reviewer` | Foreground |
|
||||
| Dependency upgrade requested | `dep-upgrader` | Background |
|
||||
| Docker image build/push needed | `docker-image-builder` | Background |
|
||||
| Doc coverage failure or wiki sync issue | `doc-sync-specialist` | Background |
|
||||
| Workflow YAML modified or validation needed | `workflow-validator` | Background |
|
||||
| Branch ready for merge | `release-check` | Foreground |
|
||||
|
||||
### Delegation Rules
|
||||
|
||||
1. **Auto-select the profile.** Do not ask the user which profile to use.
|
||||
2. **Background by default, foreground when blocking.**
|
||||
3. **Provide full context in the prompt** — subagents don't inherit conversation history.
|
||||
4. **One subagent per concern.** Chain: investigate → fix in main session → review.
|
||||
5. **Don't delegate trivial work** (<30s, <50 lines of context).
|
||||
6. **Compact after subagent returns.**
|
||||
7. **Never skip delegation to save time** — it keeps main context small.
|
||||
|
||||
|
||||
## Feedback Issue Handling
|
||||
|
||||
Subagents create Gitea issues in the current repo when they encounter
|
||||
tool, workflow, or process issues that warrant follow-up. These issues
|
||||
use the `feedback` label plus a category label (`tooling`,
|
||||
`ci-improvement`, `doc-improvement`, `workflow-improvement`).
|
||||
|
||||
Standard labels are created automatically by `configure_repo` (runs in
|
||||
post-merge on every master push). If a label does not exist yet, the
|
||||
subagent's issue creation will still succeed — labels can be added
|
||||
afterwards.
|
||||
|
||||
### When a Subagent Reports a Feedback Issue URL
|
||||
|
||||
1. **Acknowledge it** in your response to the user — mention the issue URL
|
||||
2. **Do NOT close or modify** the issue — it is for follow-up work
|
||||
3. **Do NOT create a PR** to address it unless the user explicitly asks
|
||||
4. If the user asks to address feedback, spawn a subagent to investigate
|
||||
the issue and implement a fix
|
||||
|
||||
### Creating Feedback Issues Manually
|
||||
|
||||
As the parent agent, you can also create feedback issues directly using
|
||||
the Gitea MCP (`issue_write` with `create_issue` method). Follow the
|
||||
same format as subagents:
|
||||
|
||||
- Title: `[feedback] <category>: <short description>`
|
||||
- Labels: `feedback` + category label
|
||||
- Body: include context, tool/workflow, issue, reproduction, affected
|
||||
files, suggested investigation, and "Reported by: parent agent"
|
||||
|
||||
Always deduplicate first via `list_issues` with `labels: "feedback"`.
|
||||
|
||||
|
||||
+199
@@ -2,6 +2,205 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.31.0] - 2026-07-01
|
||||
|
||||
### Features
|
||||
|
||||
- Centralize venv management in devx.mak
|
||||
|
||||
## [0.30.0] - 2026-07-01
|
||||
|
||||
### Features
|
||||
|
||||
- Add standard label creation to configure_repo
|
||||
|
||||
## [0.29.1] - 2026-07-01
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Strip task ID prefix from commit messages in extract_conventional_msg
|
||||
|
||||
## [0.29.0] - 2026-07-01
|
||||
|
||||
### Features
|
||||
|
||||
- Detect badge commits as automated CI commits
|
||||
|
||||
## [0.28.0] - 2026-07-01
|
||||
|
||||
### Features
|
||||
|
||||
- Auto-rebase in auto-merge, new rebase tools, CLI registration
|
||||
|
||||
## [0.27.3] - 2026-06-30
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Retry wiki integrity check on transient API timeout
|
||||
|
||||
## [0.27.2] - 2026-06-29
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Retry release push on non-fast-forward with rebase loop
|
||||
|
||||
## [0.27.1] - 2026-06-28
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Exclude .devin/.terraform dirs from lint_docs, add duplicate heading excludes
|
||||
|
||||
## [0.27.0] - 2026-06-28
|
||||
|
||||
### Features
|
||||
|
||||
- Add lint_docs tool, fix doc_coverage/check_translations for any repo
|
||||
|
||||
## [0.26.4] - 2026-06-28
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Wrap all user-facing strings with _() for i18n completeness
|
||||
|
||||
## [0.26.3] - 2026-06-28
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Pin all dependencies to exact versions for reproducibility
|
||||
|
||||
## [0.26.2] - 2026-06-28
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Block admin merge override and auto-approve with review token
|
||||
|
||||
## [0.26.1] - 2026-06-28
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Force pip upgrade in setup-image to install new dependencies
|
||||
|
||||
## [0.26.0] - 2026-06-28
|
||||
|
||||
### Features
|
||||
|
||||
- Add distribute_items CI tool for parallel VM deployment
|
||||
|
||||
## [0.25.0] - 2026-06-28
|
||||
|
||||
### Features
|
||||
|
||||
- Add manual review support to pr_review (--event, --body, --checklist-confirmed)
|
||||
|
||||
## [0.24.1] - 2026-06-28
|
||||
|
||||
### Refactor
|
||||
|
||||
- Add find_task_by_identifier, config fallbacks for tools
|
||||
|
||||
## [0.24.0] - 2026-06-27
|
||||
|
||||
### Features
|
||||
|
||||
- Add pr_status, pr_logs, pr_label tools
|
||||
|
||||
## [0.23.4] - 2026-06-27
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add --auto-login to all notify_failure calls in workflows
|
||||
- Classify .gitea/** as user-facing for devx, support glob in user_facing_overrides
|
||||
|
||||
## [0.23.3] - 2026-06-27
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Correct clean_images delete URL and add retry with error handling
|
||||
|
||||
## [0.23.2] - 2026-06-27
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add skip-ci flag to release commits and concurrency to build-images
|
||||
|
||||
## [0.23.1] - 2026-06-27
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add rsync to ci-full image for molecule_docker
|
||||
|
||||
## [0.23.0] - 2026-06-27
|
||||
|
||||
### Features
|
||||
|
||||
- Add devx-lint-dockerfiles to devx.mak, alias setup-image
|
||||
|
||||
### Refactor
|
||||
|
||||
- Remove hadolint on-the-fly install from setup-image
|
||||
|
||||
## [0.22.1] - 2026-06-27
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Checkout release tag in publish job
|
||||
- Fail lint-dockerfiles when hadolint is missing
|
||||
|
||||
## [0.22.0] - 2026-06-27
|
||||
|
||||
### Features
|
||||
|
||||
- Document CI_GITEA_TOKEN scopes and add CI_GITEA_USERNAME to env var table
|
||||
|
||||
## [0.21.2] - 2026-06-27
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Gate auto-merge on release-dry-run and unmask failures
|
||||
|
||||
## [0.21.1] - 2026-06-27
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Devx-setup-image configures Gitea PyPI registry and shows pip errors
|
||||
|
||||
## [0.21.0] - 2026-06-27
|
||||
|
||||
### Features
|
||||
|
||||
- Add --auto-login to publish, extract configure_tea_login to gitea_cli
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Publish job uses setup-release for build + tea login
|
||||
- Remove tag fallback step from release workflow
|
||||
|
||||
## [0.20.3] - 2026-06-27
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Release publish failures and duplicate release commits
|
||||
|
||||
## [0.20.2] - 2026-06-27
|
||||
|
||||
## [0.20.2] - 2026-06-27
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Correct sed substitution in ci-full Dockerfile
|
||||
|
||||
## [0.20.1] - 2026-06-27
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Correct image references in tier Dockerfiles
|
||||
|
||||
## [0.20.0] - 2026-06-27
|
||||
|
||||
### Features
|
||||
|
||||
- Add pre-built Docker runner images and tested image build/push tools
|
||||
|
||||
## [0.19.3] - 2026-06-26
|
||||
|
||||
### Refactor
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: all setup setup-ci setup-quality setup-release install update lint lint-all test test-unit pytest-cov clean install-tools install-hooks activate-scripts checkmake check-mutable-globals check-dep-docs check-test-speed
|
||||
.PHONY: all setup setup-ci setup-quality setup-release setup-image install update lint lint-all lint-dockerfiles test test-unit pytest-cov clean install-tools install-hooks activate-scripts checkmake check-mutable-globals check-dep-docs check-test-speed build-images push-images build-images-dry-run clean-images
|
||||
|
||||
PYTHON := python3
|
||||
VENV := .venv
|
||||
@@ -6,6 +6,30 @@ BIN := $(VENV)/bin
|
||||
|
||||
all: setup
|
||||
|
||||
# --- devx.mak integration ----------------------------------------------------
|
||||
# Include shared targets from the devx package itself (venv management,
|
||||
# workflow-lint, notify-failure, checkmake, lint targets, quality checks, etc.)
|
||||
# Since devx IS the package, we can include its own devx.mak.
|
||||
DEVX_PYTHON := $(BIN)/python
|
||||
DEVX_VENV := $(VENV)
|
||||
DEVX_BIN := $(BIN)
|
||||
DEVX_LINT_PATHS := src/ tests/
|
||||
DEVX_COV_PKG := src/devx
|
||||
DEVX_TEST_PATHS := tests/
|
||||
|
||||
DEVX_MAK := $(shell $(BIN)/python -c \
|
||||
"from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
|
||||
2>/dev/null)
|
||||
-include $(DEVX_MAK)
|
||||
|
||||
# venv, .env, and activate-scripts are provided by devx.mak
|
||||
# (devx-venv, devx-env, devx-activate-scripts, $(DEVX_VENV)/bin/activate rule)
|
||||
# Aliases for convenience and backward compatibility:
|
||||
.PHONY: venv activate-scripts
|
||||
venv: devx-venv
|
||||
.env: devx-env
|
||||
activate-scripts: devx-activate-scripts
|
||||
|
||||
# Full setup for local development
|
||||
setup: $(VENV)/bin/activate .env activate-scripts install-tools
|
||||
@$(BIN)/pip install -e '.[dev]' 2>/dev/null; \
|
||||
@@ -25,23 +49,18 @@ setup-quality: $(VENV)/bin/activate .env install-tools
|
||||
|
||||
# Setup for release jobs (needs git-cliff, tea, lint tools)
|
||||
setup-release: $(VENV)/bin/activate .env
|
||||
@$(BIN)/pip install -e '.[ci,lint]' 2>/dev/null; \
|
||||
@$(BIN)/pip install -e '.[ci,lint,release]' 2>/dev/null; \
|
||||
$(BIN)/python -m devx.tools.install_tools --tool git-cliff --tool tea; \
|
||||
export PATH="$(HOME)/.local/bin:$$PATH"; \
|
||||
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,lint" --no-pre-commit
|
||||
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,lint,release" --no-pre-commit
|
||||
|
||||
.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)
|
||||
# Setup for pre-built image jobs (deps already in image, just link venv + install project)
|
||||
# Note: Not aliased to devx-setup-image because devx's own CI images may have
|
||||
# an older devx.mak that doesn't yet define devx-setup-image. Consumer repos
|
||||
# (grm, infra) can safely alias to devx-setup-image since they install devx from PyPI.
|
||||
setup-image:
|
||||
@if [ -d /opt/venv ]; then ln -sf /opt/venv $(VENV); . $(VENV)/bin/activate && pip install --no-cache-dir -e . 2>/dev/null; \
|
||||
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
|
||||
|
||||
install-hooks:
|
||||
@cp hooks/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
|
||||
@@ -52,23 +71,13 @@ install-tools: $(VENV)/bin/activate
|
||||
@$(BIN)/pip install -e '.' 2>/dev/null; \
|
||||
$(BIN)/python -m devx.tools.install_tools
|
||||
|
||||
# --- devx.mak integration ----------------------------------------------------
|
||||
# Include shared targets from the devx package itself (workflow-lint,
|
||||
# notify-failure, checkmake, lint targets, quality checks, etc.)
|
||||
# Since devx IS the package, we can include its own devx.mak.
|
||||
DEVX_PYTHON := $(BIN)/python
|
||||
DEVX_VENV := $(VENV)
|
||||
DEVX_BIN := $(BIN)
|
||||
DEVX_LINT_PATHS := src/ tests/
|
||||
DEVX_COV_PKG := src/devx
|
||||
DEVX_TEST_PATHS := tests/
|
||||
|
||||
DEVX_MAK := $(shell $(BIN)/python -c \
|
||||
"from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
|
||||
2>/dev/null)
|
||||
-include $(DEVX_MAK)
|
||||
|
||||
# Aliases — project-specific names map to devx.mak targets
|
||||
.PHONY: lint-ruff lint-format typecheck lint-bandit lint-deps lint
|
||||
.PHONY: workflow-lint workflow-dryrun workflow-dryrun-safe workflow-check
|
||||
.PHONY: notify-failure checkmake check-mutable-globals check-dep-docs
|
||||
.PHONY: check-test-speed check-test-coverage check-docs
|
||||
.PHONY: create-task create-pr push-with-pr git-push rebase pr-rebase
|
||||
.PHONY: lint-all lint-dockerfiles
|
||||
lint-ruff: devx-lint-ruff
|
||||
lint-format: devx-lint-format
|
||||
typecheck: devx-typecheck
|
||||
@@ -90,10 +99,23 @@ create-task: devx-create-task
|
||||
create-pr: devx-create-pr
|
||||
push-with-pr: devx-push-with-pr
|
||||
git-push: devx-push
|
||||
rebase: devx-rebase
|
||||
pr-rebase: devx-pr-rebase
|
||||
|
||||
lint-all: lint workflow-lint
|
||||
lint-all: lint workflow-lint lint-dockerfiles
|
||||
@echo "[lint-all] All linting checks passed."
|
||||
|
||||
# Note: Not aliased to devx-lint-dockerfiles for the same reason as setup-image —
|
||||
# devx's own CI images may have an older devx.mak. Consumer repos can safely alias.
|
||||
lint-dockerfiles:
|
||||
@echo "[lint-dockerfiles] Linting Dockerfiles with hadolint..."
|
||||
@if ! command -v hadolint >/dev/null 2>&1; then \
|
||||
echo "[lint-dockerfiles] ERROR: hadolint not found. Install from https://github.com/hadolint/hadolint/releases" >&2; \
|
||||
exit 1; \
|
||||
fi
|
||||
@find docker -name 'Dockerfile*' -exec hadolint {} +
|
||||
@echo "[lint-dockerfiles] All Dockerfiles passed."
|
||||
|
||||
test-unit: devx-test-unit
|
||||
|
||||
pytest-cov: devx-pytest-cov
|
||||
@@ -105,3 +127,17 @@ pre-push: lint-all pytest-cov
|
||||
|
||||
clean: devx-clean
|
||||
@echo "[clean] Done."
|
||||
|
||||
# ── Docker image management ──────────────────────────────────────────────────
|
||||
|
||||
build-images: devx-build-images
|
||||
@echo "[build-images] Done."
|
||||
|
||||
push-images: devx-push-images
|
||||
@echo "[push-images] Done."
|
||||
|
||||
build-images-dry-run: devx-build-images-dry-run
|
||||
@echo "[build-images-dry-run] Done."
|
||||
|
||||
clean-images: devx-clean-images
|
||||
@echo "[clean-images] Done."
|
||||
|
||||
@@ -16,12 +16,12 @@ quality badges.
|
||||
|
||||
[](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/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/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](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/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Why devx?
|
||||
|
||||
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"devx>=0.11.1",
|
||||
"devx>=0.27.0",
|
||||
]
|
||||
|
||||
[tool.pip]
|
||||
@@ -101,8 +101,8 @@ pip install -e .
|
||||
```
|
||||
|
||||
> **Note:** If your project requires a specific devx version, pin it in
|
||||
> `dependencies` (e.g., `"devx==0.11.1"`) or use a version constraint
|
||||
> (e.g., `"devx>=0.11.1,<0.12"`).
|
||||
> `dependencies` (e.g., `"devx==0.27.0"`) or use a version constraint
|
||||
> (e.g., `"devx>=0.27.0,<0.28"`).
|
||||
|
||||
### Optional extras
|
||||
|
||||
@@ -158,6 +158,9 @@ python -m devx.ci.check_translations --translations path/to/translations.json
|
||||
# Documentation coverage check
|
||||
python -m devx.ci.doc_coverage --fail-on-missing
|
||||
|
||||
# Documentation lint (structure, links, headings, TODOs)
|
||||
python -m devx.ci.lint_docs --root .
|
||||
|
||||
# Validate a commit message
|
||||
python -m devx.ci.validate_commit_msg commit-msg.txt --branch master
|
||||
|
||||
@@ -326,10 +329,24 @@ The config system loads `.env` automatically via `python-dotenv`.
|
||||
| `DEVX_DOCS_DIR` | `docs` | Documentation directory (used by sync_wiki) |
|
||||
| `DEVX_STATUS_CHECKS` | `CI / quality (pull_request)` | Comma-separated status check contexts |
|
||||
| `DEVX_PYPI_REGISTRY_URL` | — | Gitea PyPI registry URL (used by publish) |
|
||||
| `REPO_TOKEN` | — | Gitea API token |
|
||||
| `CI_GITEA_TOKEN` | — | Gitea API token (see scopes below) |
|
||||
| `CI_GITEA_USERNAME` | — | Gitea username for registry authentication |
|
||||
| `VIKUNJA_TOKEN` | — | Vikunja API token |
|
||||
| `PYPI_TOKEN` | — | Standard PyPI token (takes precedence over Gitea registry) |
|
||||
|
||||
#### CI_GITEA_TOKEN scopes
|
||||
|
||||
The `CI_GITEA_TOKEN` is a single Gitea Personal Access Token used across all
|
||||
workflows. It requires these scopes:
|
||||
|
||||
| Scope | Purpose |
|
||||
|-------|---------|
|
||||
| `read:repository` | Read repos, PRs, issues, branches |
|
||||
| `write:repository` | Push commits, merge PRs, create tags/releases, create issues, set branch protection, push wiki |
|
||||
| `read:package` | Pull packages from Gitea PyPI registry, pull Docker images |
|
||||
| `write:package` | Publish packages to Gitea PyPI registry, push Docker images |
|
||||
| `read:organization` | Query org-level runners for molecule test distribution |
|
||||
|
||||
### Per-project overrides
|
||||
|
||||
Projects using devx can override the default API URLs and language by setting
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# ci-base — lightweight image for CI jobs that only need devx core + tea.
|
||||
#
|
||||
# Used by: detect-type, detect-changes, validate-commit-msg, pr-review,
|
||||
# auto-merge, sync-wiki, vikunja, configure-repo, discover-runners,
|
||||
# molecule-report, discover-integration-runners
|
||||
#
|
||||
# Jobs using this image: setup is instant (ln -s /opt/venv .venv)
|
||||
# No pip install needed — devx and all deps are pre-installed.
|
||||
|
||||
FROM gitea/runner-images:ubuntu-latest
|
||||
|
||||
# Create a virtual environment with all deps pre-installed
|
||||
RUN python3 -m venv /opt/venv
|
||||
ENV PATH="/opt/venv/bin:/root/.local/bin:$PATH"
|
||||
|
||||
# Install devx from local source (build context = devx repo root)
|
||||
COPY . /tmp/devx
|
||||
RUN pip install --no-cache-dir --upgrade pip setuptools wheel \
|
||||
&& pip install --no-cache-dir /tmp/devx[ci] \
|
||||
&& rm -rf /tmp/devx
|
||||
|
||||
# Install tea CLI (for Gitea API operations in CI)
|
||||
RUN python3 -m devx.tools.install_tools --tool tea
|
||||
|
||||
# Workspace directory (actions/checkout mounts repo here)
|
||||
WORKDIR /workspace
|
||||
@@ -0,0 +1,30 @@
|
||||
# ci-full — heaviest image, includes everything for release, molecule, deploy.
|
||||
#
|
||||
# Used by: release, publish, release-dry-run, molecule-tests,
|
||||
# provision-infra, deploy-observability, provision-zitadel,
|
||||
# deploy-customer, integration-tests
|
||||
#
|
||||
# Layers on top of ci-quality: adds release tools, molecule, deploy deps,
|
||||
# git-cliff, and OpenTofu.
|
||||
|
||||
FROM git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
|
||||
|
||||
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
|
||||
# Install rsync (required by molecule_docker for file sync between host and test containers)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends rsync \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install devx[release,molecule,deploy] from local source
|
||||
COPY . /tmp/devx
|
||||
RUN pip install --no-cache-dir /tmp/devx[release,molecule,deploy] \
|
||||
&& rm -rf /tmp/devx
|
||||
|
||||
# Install git-cliff (changelog generator for release job)
|
||||
RUN python3 -m devx.tools.install_tools --tool git-cliff
|
||||
|
||||
# Install OpenTofu (for infra deploy jobs)
|
||||
RUN ARCH=$(uname -m | sed 's/x86_64/amd64/') \
|
||||
&& VERSION=1.12.3 \
|
||||
&& curl -fsSL "https://github.com/opentofu/opentofu/releases/download/v${VERSION}/tofu_${VERSION}_$(uname -s | tr '[:upper:]' '[:lower:]')_${ARCH}.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin tofu
|
||||
@@ -0,0 +1,22 @@
|
||||
# ci-quality — image for lint, type-checking, badge generation.
|
||||
#
|
||||
# Used by: quality (lint-all + pytest-cov + checks), badges (generate_badges
|
||||
# runs ruff/pyright/bandit to produce quality badge)
|
||||
#
|
||||
# Layers on top of ci-base: adds lint tools + actionlint + checkmake.
|
||||
|
||||
FROM git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
|
||||
# Install devx[lint] from local source (adds ruff, pyright, bandit, etc.)
|
||||
COPY . /tmp/devx
|
||||
RUN pip install --no-cache-dir /tmp/devx[lint] \
|
||||
&& rm -rf /tmp/devx
|
||||
|
||||
# Install CI/CD binary tools
|
||||
RUN python3 -m devx.tools.install_tools --tool actionlint \
|
||||
&& python3 -m devx.tools.install_checkmake
|
||||
|
||||
# Install hadolint (Dockerfile linter)
|
||||
RUN curl -fsSL "https://github.com/hadolint/hadolint/releases/download/v2.12.0/hadolint-Linux-x86_64" \
|
||||
-o /usr/local/bin/hadolint \
|
||||
&& chmod +x /usr/local/bin/hadolint
|
||||
@@ -0,0 +1,20 @@
|
||||
[
|
||||
{
|
||||
"name": "oblachno-oss/runner-images/ci-base",
|
||||
"dockerfile": "docker/ci-base/Dockerfile",
|
||||
"context": ".",
|
||||
"tags": ["latest"]
|
||||
},
|
||||
{
|
||||
"name": "oblachno-oss/runner-images/ci-quality",
|
||||
"dockerfile": "docker/ci-quality/Dockerfile",
|
||||
"context": ".",
|
||||
"tags": ["latest"]
|
||||
},
|
||||
{
|
||||
"name": "oblachno-oss/runner-images/ci-full",
|
||||
"dockerfile": "docker/ci-full/Dockerfile",
|
||||
"context": ".",
|
||||
"tags": ["latest"]
|
||||
}
|
||||
]
|
||||
+11
-10
@@ -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/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/wiki)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](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/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
|
||||
[](https://www.python.org/downloads/)
|
||||
|
||||
## Overview
|
||||
|
||||
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"devx>=0.11.1",
|
||||
"devx>=0.27.0",
|
||||
]
|
||||
|
||||
[tool.pip]
|
||||
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
|
||||
```
|
||||
|
||||
Pin a specific version if needed: `"devx==0.11.1"` or `"devx>=0.11.1,<0.12"`.
|
||||
Pin a specific version if needed: `"devx==0.27.0"` or `"devx>=0.27.0,<0.28"`.
|
||||
|
||||
### Optional extras
|
||||
|
||||
@@ -131,7 +131,7 @@ wiki sync details.
|
||||
devx provides a `devx` CLI with three command groups:
|
||||
|
||||
- `devx ci <command>` — CI/CD automation (17 commands)
|
||||
- `devx tools <command>` — Developer tools (7 commands)
|
||||
- `devx tools <command>` — Developer tools (9 commands)
|
||||
- `devx molecule <command>` — Molecule testing (4 commands, optional)
|
||||
|
||||
See [CLI Commands](CLI-Commands) for full command documentation with examples.
|
||||
@@ -149,7 +149,7 @@ fallback. Key variables:
|
||||
| `DEVX_REPO_NAME` | **(must be set)** | Repository name |
|
||||
| `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) |
|
||||
| `DEVX_LANG` | `en` | Language for i18n (en, bg, de, ru, zh, pl) |
|
||||
| `REPO_TOKEN` | — | Gitea API token |
|
||||
| `CI_GITEA_TOKEN` | — | Gitea API token |
|
||||
| `VIKUNJA_TOKEN` | — | Vikunja API token |
|
||||
|
||||
See [AGENTS.md](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/AGENTS.md)
|
||||
@@ -158,6 +158,7 @@ for the full configuration reference, PR workflow, and project conventions.
|
||||
## Wiki pages
|
||||
|
||||
- [Home](Home) — This page
|
||||
- [Getting Started](Getting-Started) — Installation, configuration, and quick start guide
|
||||
- [CLI Commands](CLI-Commands) — Full CLI command documentation with examples
|
||||
- [Architecture](Architecture) — Package structure, module descriptions, design principles
|
||||
- [CI/CD Workflow](CI-CD-Workflow) — Pipeline documentation, workflows, and CI scripts
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"index.md": "Home",
|
||||
"user/getting-started.md": "Getting-Started",
|
||||
"user/cli-commands.md": "CLI-Commands",
|
||||
"tech/architecture.md": "Architecture",
|
||||
"tech/ci-cd-workflow.md": "CI-CD-Workflow"
|
||||
|
||||
@@ -103,7 +103,7 @@ Custom exception hierarchy:
|
||||
### `i18n.py`
|
||||
|
||||
Simple i18n system using a JSON translations file (`translations.json`).
|
||||
Supports five languages: `en`, `bg`, `de`, `ru`, `zh`. The `_()` function
|
||||
Supports six languages: `en`, `bg`, `de`, `pl`, `ru`, `zh`. The `_()` function
|
||||
wraps user-facing strings for translation.
|
||||
|
||||
Projects can extend translations by setting `DEVX_TRANSLATIONS_PATH` to a
|
||||
@@ -245,7 +245,7 @@ for retrying on git push failures.
|
||||
|
||||
Creates a Gitea issue when a CI workflow fails. Uses the `tea` CLI for issue
|
||||
creation with failure labels. Supports `--auto-login` to configure the tea
|
||||
CLI login profile from `REPO_TOKEN` and `DEVX_GITEA_API_URL` before creating
|
||||
CLI login profile from `CI_GITEA_TOKEN` and `DEVX_GITEA_API_URL` before creating
|
||||
the issue.
|
||||
|
||||
### `post_merge.py`
|
||||
@@ -382,7 +382,7 @@ single-role (4-part) and multi-role (5-part) pair encoding.
|
||||
Runs all molecule scenarios on all supported OS platforms sequentially.
|
||||
Intended for local development; CI uses the parallel matrix instead.
|
||||
|
||||
### `discover_runners.py`
|
||||
### `molecule/discover_runners.py`
|
||||
|
||||
Discovers available Gitea Actions runners for molecule tests. Same logic as
|
||||
`devx.ci.discover_runners` but intended for molecule-specific workflows.
|
||||
@@ -569,7 +569,7 @@ push_badges.py:
|
||||
The `tea` Gitea CLI tool is used for Gitea API interactions where tea provides
|
||||
reliable, official support. It is installed by
|
||||
`python -m devx.tools.install_tools` and configured by
|
||||
`python -m devx.tools.setup` (login profile from `.env` `REPO_TOKEN`).
|
||||
`python -m devx.tools.setup` (login profile from `.env` `CI_GITEA_TOKEN`).
|
||||
|
||||
`devx.gitea_cli.TeaCLI` wraps tea with JSON output parsing. Operations that
|
||||
tea does not support (wiki management, commit status, runner discovery,
|
||||
|
||||
@@ -166,7 +166,7 @@ When `release` creates a `release: vX.Y.Z` commit, the release commit's
|
||||
post-merge run still updates badges (the version badge picks up the new
|
||||
version). Other jobs skip. The tag push triggers `publish.yml`.
|
||||
|
||||
### Jobs
|
||||
### Post-merge jobs
|
||||
|
||||
#### `detect-type`
|
||||
|
||||
@@ -305,7 +305,7 @@ post-merge workflow when it creates and pushes a new version tag.
|
||||
and the project itself
|
||||
2. **Install CI tools** — git-cliff and tea via
|
||||
`python -m devx.tools.install_tools`
|
||||
3. **Configure tea login** — `tea login add` using `REPO_TOKEN`
|
||||
3. **Configure tea login** — `tea login add` using `CI_GITEA_TOKEN`
|
||||
4. **Build and publish** — `python -m devx.ci.publish <tag> <owner/repo>`:
|
||||
- Build the package with `python -m build`
|
||||
- Publish to the Gitea PyPI registry (default) using `twine upload
|
||||
@@ -377,7 +377,7 @@ python -m devx.ci.pr_review <pr_number> <owner/repo>
|
||||
|
||||
Creates a Gitea issue when a CI workflow fails. Uses the tea CLI for issue
|
||||
creation with failure labels. Supports `--auto-login` to configure the tea
|
||||
CLI login profile from `REPO_TOKEN`.
|
||||
CLI login profile from `CI_GITEA_TOKEN`.
|
||||
|
||||
```bash
|
||||
python -m devx.ci.notify_failure --repo <owner/repo> --run-id <id> \
|
||||
|
||||
@@ -127,14 +127,37 @@ Click commands from `cli.py` and checks if each has documentation in
|
||||
|
||||
```bash
|
||||
devx ci doc-coverage
|
||||
devx ci doc-coverage --docs-dir docs/ --fail-on-missing
|
||||
devx ci doc-coverage --docs-dir docs/ --source-dir src/ --fail-on-missing
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--docs-dir <dir>` — path to the docs directory (default: `docs/`)
|
||||
- `--source-dir <dir>` — path to the source directory (default: auto-detect)
|
||||
- `--fail-on-missing` — exit with non-zero status if any documentation is
|
||||
missing
|
||||
|
||||
### `devx ci lint-docs`
|
||||
|
||||
Lint documentation files for structure, broken links, heading hierarchy,
|
||||
duplicate headings, TODO/FIXME markers, and trailing whitespace.
|
||||
|
||||
```bash
|
||||
devx ci lint-docs
|
||||
devx ci lint-docs --root . --fix
|
||||
devx ci lint-docs --no-check-links --no-check-stale
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--root <dir>` — repository root directory (default: `.`)
|
||||
- `--docs-dir <dir>` — docs directory (default: `<root>/docs`)
|
||||
- `--check-links/--no-check-links` — check internal links (default: yes)
|
||||
- `--check-headings/--no-check-headings` — check heading hierarchy (default: yes)
|
||||
- `--check-todo/--no-check-todo` — check for TODO/FIXME markers (default: yes)
|
||||
- `--check-stale/--no-check-stale` — check for stale docs (default: no)
|
||||
- `--check-trailing/--no-check-trailing` — check trailing whitespace (default: yes)
|
||||
- `--check-duplicates/--no-check-duplicates` — check duplicate headings (default: yes)
|
||||
- `--fix` — auto-fix trailing whitespace
|
||||
|
||||
### `devx ci integration-guard`
|
||||
|
||||
Run pytest with cross-runner failure detection. If any
|
||||
@@ -148,7 +171,7 @@ devx ci integration-guard -- -x -v --tb=short test_a.py
|
||||
|
||||
Environment variables:
|
||||
- `GITEA_URL` — base URL of the Gitea instance
|
||||
- `REPO_TOKEN` — API token with repo access
|
||||
- `CI_GITEA_TOKEN` — API token with repo access
|
||||
- `RUN_ID` — workflow run ID (`GITHUB_RUN_ID`)
|
||||
- `JOB_NAME` — base job name (`GITHUB_JOB`)
|
||||
- `MATRIX_INDEX` — current matrix index (runner-index)
|
||||
@@ -171,7 +194,7 @@ Options:
|
||||
- `--run-id <id>` — CI run ID (required)
|
||||
- `--workflow <name>` — workflow name (required)
|
||||
- `--commit <sha>` — commit SHA (required)
|
||||
- `--auto-login` — configure tea CLI login from `REPO_TOKEN` before creating
|
||||
- `--auto-login` — configure tea CLI login from `CI_GITEA_TOKEN` before creating
|
||||
the issue
|
||||
|
||||
### `devx ci post-merge`
|
||||
@@ -398,6 +421,35 @@ Options:
|
||||
- `--no-pre-commit` — skip pre-commit hook installation
|
||||
- `--no-tea-login` — skip tea CLI login configuration
|
||||
|
||||
### `devx tools rebase`
|
||||
|
||||
Rebase the current branch onto `origin/master` and force-push with
|
||||
`--force-with-lease`. Checks if the branch is behind master first —
|
||||
if up-to-date, exits without doing anything.
|
||||
|
||||
```bash
|
||||
devx tools rebase # rebase + force-push
|
||||
devx tools rebase -- --no-push # rebase locally only
|
||||
```
|
||||
|
||||
Options (pass after `--`):
|
||||
- `--no-push` — rebase locally without pushing
|
||||
|
||||
### `devx tools pr-rebase`
|
||||
|
||||
Rebase a pull request's head branch onto master via the Gitea API
|
||||
(server-side). This triggers a new `pull_request synchronize` event,
|
||||
which starts a new CI run. Useful when you don't have the branch
|
||||
checked out locally.
|
||||
|
||||
```bash
|
||||
devx tools pr-rebase -- --pr 42 # rebase PR #42
|
||||
devx tools pr-rebase # auto-detect PR from current branch
|
||||
```
|
||||
|
||||
Options (pass after `--`):
|
||||
- `--pr <N>` — PR number (auto-detected from current branch if omitted)
|
||||
|
||||
## Molecule Commands
|
||||
|
||||
Molecule commands require the `molecule` extra (`pip install devx[molecule]`).
|
||||
@@ -462,7 +514,7 @@ Options:
|
||||
|
||||
Environment variables:
|
||||
- `GITEA_URL` — base URL of the Gitea instance
|
||||
- `REPO_TOKEN` — API token with repo access
|
||||
- `CI_GITEA_TOKEN` — API token with repo access
|
||||
- `RUN_ID` — workflow run ID (`GITHUB_RUN_ID`)
|
||||
- `JOB_NAME` — base job name (`GITHUB_JOB`)
|
||||
- `MATRIX_INDEX` — current matrix index (runner-index)
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
# Getting Started with devx
|
||||
|
||||
This guide walks you through installing devx, configuring it for your project,
|
||||
and setting up a complete CI/CD pipeline.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Python 3.12+**
|
||||
- **A Gitea instance** with Actions enabled
|
||||
- **A Gitea API token** with repo, workflow, and organization scopes
|
||||
- **(Optional) Vikunja API token** for task tracking integration
|
||||
|
||||
## Installation
|
||||
|
||||
devx is published to the Gitea PyPI registry. Configure pip to use it:
|
||||
|
||||
```bash
|
||||
# Configure Gitea PyPI registry
|
||||
pip config set global.extra-index-url https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
|
||||
|
||||
# Install devx
|
||||
pip install devx
|
||||
```
|
||||
|
||||
Or install from source:
|
||||
|
||||
```bash
|
||||
git clone https://git.oblachno.oblachno.fyi/oblachno-oss/devx.git
|
||||
cd devx
|
||||
make setup
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
### 1. Configure environment variables
|
||||
|
||||
Create a `.env` file in your project root:
|
||||
|
||||
```bash
|
||||
CI_GITEA_TOKEN=your_gitea_api_token
|
||||
VIKUNJA_TOKEN=your_vikunja_api_token # optional
|
||||
```
|
||||
|
||||
### 2. Add devx to your project
|
||||
|
||||
Add devx to your `pyproject.toml`:
|
||||
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"devx>=0.27.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"devx[dev]>=0.27.0",
|
||||
]
|
||||
```
|
||||
|
||||
### 3. Set up the Makefile
|
||||
|
||||
devx provides a shared Makefile fragment. Add this to your `Makefile`:
|
||||
|
||||
```makefile
|
||||
include devx.mak
|
||||
```
|
||||
|
||||
Run `devx tools setup` to install all development tools (actionlint, git-cliff,
|
||||
tea CLI, etc.) and configure pre-commit hooks.
|
||||
|
||||
### 4. Create the docs structure
|
||||
|
||||
devx expects a `docs/` directory with at minimum:
|
||||
|
||||
```
|
||||
docs/
|
||||
├── index.md # Documentation home page
|
||||
├── mapping.json # Wiki page title mappings
|
||||
├── user/ # User-facing documentation
|
||||
│ └── cli-commands.md
|
||||
└── tech/ # Technical documentation
|
||||
├── architecture.md
|
||||
└── ci-cd-workflow.md
|
||||
```
|
||||
|
||||
Example `docs/mapping.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"index.md": "Home",
|
||||
"user/cli-commands.md": "CLI-Commands",
|
||||
"tech/architecture.md": "Architecture",
|
||||
"tech/ci-cd-workflow.md": "CI-CD-Workflow"
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Set up CI workflows
|
||||
|
||||
Create `.gitea/workflows/ci.yml` and `.gitea/workflows/post-merge.yml` in your
|
||||
project. See the [CI/CD Workflow guide](../tech/ci-cd-workflow.md) for details.
|
||||
|
||||
### 6. Configure release settings
|
||||
|
||||
Add a `cliff.toml` for git-cliff-based versioning:
|
||||
|
||||
```bash
|
||||
devx tools generate-cliff-config
|
||||
```
|
||||
|
||||
Add `[tool.devx]` section to `pyproject.toml` for project-specific config:
|
||||
|
||||
```toml
|
||||
[tool.devx]
|
||||
# Vikunja project ID for task tracking
|
||||
vikunja_project_id = 6
|
||||
|
||||
[tool.devx.classify]
|
||||
# File patterns that are infrastructure (no release needed)
|
||||
infrastructure = [
|
||||
".gitea/**",
|
||||
"docs/**",
|
||||
"tests/**",
|
||||
"AGENTS.md",
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
]
|
||||
```
|
||||
|
||||
## Available Tools
|
||||
|
||||
### CI/CD Automation (`devx.ci.*`)
|
||||
|
||||
- `devx.ci.release` — Automated semver versioning and tagging
|
||||
- `devx.ci.publish` — Package publishing to Gitea PyPI registry
|
||||
- `devx.ci.auto_merge` — Squash-merge automation with task ID validation
|
||||
- `devx.ci.pr_review` — Automated PR review with inline comments
|
||||
- `devx.ci.classify_changes` — User-facing vs workflow-only change detection
|
||||
- `devx.ci.sync_wiki` — Push docs/ to Gitea wiki
|
||||
- `devx.ci.doc_coverage` — Documentation coverage checker
|
||||
- `devx.ci.lint_docs` — Documentation linter (structure, links, headings)
|
||||
- `devx.ci.check_translations` — i18n translation completeness checker
|
||||
- `devx.ci.notify_failure` — Create Gitea issues on CI failures
|
||||
- `devx.ci.distribute_files` — Parallel test file distribution
|
||||
- `devx.ci.distribute_items` — Parallel item distribution across runners
|
||||
- `devx.ci.discover_runners` — Dynamic runner discovery via Gitea API
|
||||
|
||||
### Development Tools (`devx.tools.*`)
|
||||
|
||||
- `devx.tools.setup` — Environment setup (venv, deps, hooks, tools)
|
||||
- `devx.tools.install_tools` — Install CI/CD tools (actionlint, git-cliff, tea)
|
||||
- `devx.tools.create_task` — Create Vikunja tasks
|
||||
- `devx.tools.create_pr` — Create Gitea PRs with task ID in title
|
||||
- `devx.tools.configure_repo` — Configure branch protection and labels
|
||||
- `devx.tools.generate_badges` — Generate quality badge SVGs
|
||||
- `devx.tools.check_test_speed` — Enforce test execution speed limits
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Read the [CLI Commands reference](cli-commands.md) for all available commands
|
||||
- Read the [Architecture guide](../tech/architecture.md) to understand internals
|
||||
- Read the [CI/CD Workflow guide](../tech/ci-cd-workflow.md) for pipeline details
|
||||
+46
-24
@@ -13,10 +13,13 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
|
||||
]
|
||||
# All dependencies are pinned to exact versions for full reproducibility.
|
||||
# Update pinned versions in a dedicated PR with verification.
|
||||
dependencies = [
|
||||
"requests>=2.34.2",
|
||||
"python-dotenv>=1.2.2",
|
||||
"click>=8.4.1",
|
||||
"requests==2.34.2",
|
||||
"python-dotenv==1.2.2",
|
||||
"click==8.4.2",
|
||||
"tenacity==9.1.4", # retry logic for GiteaClient/VikunjaClient
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -26,33 +29,46 @@ devx = "devx.cli:cli"
|
||||
version = {attr = "devx.__version__"}
|
||||
|
||||
[project.optional-dependencies]
|
||||
# Minimal deps for CI scripts that only need click/dotenv/requests
|
||||
# Test runners (pytest + coverage + parallel execution)
|
||||
ci = [
|
||||
"pytest>=9.1.0",
|
||||
"pytest-cov>=7.1.0",
|
||||
"build>=1.5.0",
|
||||
"twine>=6.2.0",
|
||||
"pytest==9.1.1",
|
||||
"pytest-cov==7.1.0",
|
||||
"pytest-xdist==3.8.0",
|
||||
]
|
||||
# Lint and type-checking tools (quality job)
|
||||
# Lint and type-checking tools (quality job, badge generation)
|
||||
lint = [
|
||||
"ruff>=0.15.17",
|
||||
"pyright>=1.1.410",
|
||||
"bandit>=1.8.2",
|
||||
"pip-audit>=2.10",
|
||||
"pre-commit>=4.6.0",
|
||||
"ruff==0.15.20",
|
||||
"pyright==1.1.411",
|
||||
"bandit==1.9.4",
|
||||
"pip-audit==2.10.1",
|
||||
"pre-commit==4.6.0",
|
||||
]
|
||||
# Molecule testing (optional — for projects with Ansible roles)
|
||||
# Release tools (build + publish to PyPI/Gitea registry)
|
||||
release = [
|
||||
"build==1.5.0",
|
||||
"twine==6.2.0",
|
||||
]
|
||||
# Molecule testing (for projects with Ansible roles)
|
||||
molecule = [
|
||||
"molecule>=26.4.0",
|
||||
"molecule-docker>=2.1.0",
|
||||
"ansible-lint>=26.4.0",
|
||||
"ansible>=14.0.0",
|
||||
"molecule==26.4.0",
|
||||
"molecule-docker==2.1.0",
|
||||
"ansible-lint==26.4.0",
|
||||
"ansible-core==2.21.1",
|
||||
]
|
||||
# Deploy tools (for infra staging/production deployments)
|
||||
deploy = [
|
||||
"ansible-core==2.21.1",
|
||||
"boto3==1.43.36",
|
||||
"docker==7.1.0",
|
||||
"jinja2==3.1.6",
|
||||
"pyyaml==6.0.3",
|
||||
"cryptography==49.0.0",
|
||||
]
|
||||
# Full dev environment (local development)
|
||||
dev = [
|
||||
"devx[ci,lint]",
|
||||
"build>=1.3.0",
|
||||
"twine>=6.2.0",
|
||||
"devx[ci,lint,release,molecule]",
|
||||
"build==1.5.0",
|
||||
"twine==6.2.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
@@ -84,6 +100,8 @@ indent-style = "space"
|
||||
[tool.pyright]
|
||||
include = ["src"]
|
||||
pythonVersion = "3.12"
|
||||
venvPath = "."
|
||||
venv = ".venv"
|
||||
strict = ["src/devx/config.py", "src/devx/exceptions.py", "src/devx/i18n.py", "src/devx/api_clients.py", "src/devx/gitea_cli.py"]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -126,8 +144,12 @@ infrastructure_overrides = [
|
||||
]
|
||||
|
||||
# User-facing overrides — safety override for broad infrastructure patterns
|
||||
# (empty — add when an infrastructure pattern is too broad)
|
||||
user_facing_overrides = []
|
||||
# devx workflow files (.gitea/**) are reference implementations that
|
||||
# downstream repos (grm, infra) copy from. Changes to them affect how
|
||||
# consumer projects run their CI, so they must trigger a release.
|
||||
user_facing_overrides = [
|
||||
".gitea/**",
|
||||
]
|
||||
|
||||
# Tag patterns — additional categories for CI conditional execution
|
||||
# Orthogonal to release impact (user-facing vs infrastructure)
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||
|
||||
__version__ = "0.19.3"
|
||||
__version__ = "0.31.0"
|
||||
|
||||
+143
-93
@@ -4,10 +4,16 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from tenacity import (
|
||||
before_sleep_log,
|
||||
retry,
|
||||
retry_if_exception_type,
|
||||
stop_after_attempt,
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from devx.config import DEFAULT_TIMEOUT, MAX_RETRIES, RETRY_BACKOFF_BASE, RETRY_STATUS_CODES
|
||||
from devx.exceptions import APIError
|
||||
@@ -27,14 +33,69 @@ def _parse_error(e: requests.HTTPError) -> tuple[int, str]:
|
||||
return status, message
|
||||
|
||||
|
||||
def _is_retryable(e: Exception) -> bool:
|
||||
"""Check if an exception is a transient error worth retrying."""
|
||||
if isinstance(e, requests.ConnectionError):
|
||||
return True
|
||||
if isinstance(e, requests.HTTPError):
|
||||
status, _ = _parse_error(e)
|
||||
return status in RETRY_STATUS_CODES
|
||||
return isinstance(e, requests.Timeout)
|
||||
class _TransientHTTPError(requests.HTTPError):
|
||||
"""HTTP error with a retryable status code (wrapped for tenacity)."""
|
||||
|
||||
|
||||
class _RetryableRequestError(Exception):
|
||||
"""Connection/timeout error wrapped for tenacity retry."""
|
||||
|
||||
|
||||
def _execute_request(
|
||||
session: requests.Session,
|
||||
method: str,
|
||||
url: str,
|
||||
**kwargs: Any,
|
||||
) -> requests.Response:
|
||||
"""Execute a single HTTP request, wrapping transient errors for tenacity.
|
||||
|
||||
Non-retryable HTTP errors (4xx except 429) raise :class:`APIError` directly.
|
||||
Retryable errors (429, 5xx, connection, timeout) raise exceptions that
|
||||
tenacity will retry.
|
||||
"""
|
||||
try:
|
||||
response = session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except requests.HTTPError as e:
|
||||
status, message = _parse_error(e)
|
||||
if status in RETRY_STATUS_CODES:
|
||||
# Wrap in _TransientHTTPError so tenacity retries it
|
||||
raise _TransientHTTPError(message, response=e.response) from e
|
||||
raise APIError(status, message) from e
|
||||
except (requests.ConnectionError, requests.Timeout) as e:
|
||||
raise _RetryableRequestError(str(e)) from e
|
||||
|
||||
|
||||
# Tenacity retry decorator shared by both clients.
|
||||
# Retries on transient HTTP errors (429, 5xx) and connection/timeout errors.
|
||||
_retry_decorator = retry(
|
||||
stop=stop_after_attempt(MAX_RETRIES),
|
||||
wait=wait_exponential(multiplier=RETRY_BACKOFF_BASE, min=RETRY_BACKOFF_BASE, max=RETRY_BACKOFF_BASE**MAX_RETRIES),
|
||||
retry=retry_if_exception_type((_TransientHTTPError, _RetryableRequestError)),
|
||||
before_sleep=before_sleep_log(logger, logging.WARNING),
|
||||
reraise=True,
|
||||
)
|
||||
|
||||
|
||||
def _request_with_retry(
|
||||
session: requests.Session,
|
||||
url: str,
|
||||
method: str,
|
||||
**kwargs: Any,
|
||||
) -> requests.Response:
|
||||
"""Execute an HTTP request with tenacity-managed retry logic.
|
||||
|
||||
On exhaustion, the last exception is translated to :class:`APIError`.
|
||||
"""
|
||||
try:
|
||||
return _retry_decorator(_execute_request)(session, method, url, **kwargs)
|
||||
except _TransientHTTPError as e:
|
||||
response = getattr(e, "response", None)
|
||||
status = response.status_code if response is not None else 0
|
||||
raise APIError(status, str(e)) from e
|
||||
except _RetryableRequestError as e:
|
||||
raise APIError(0, str(e)) from e
|
||||
|
||||
|
||||
class GiteaClient:
|
||||
@@ -56,49 +117,7 @@ class GiteaClient:
|
||||
return f"{self._base_url}/repos/{self._owner}/{self._repo}{path}"
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
|
||||
url = self._url(path)
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except requests.HTTPError as e:
|
||||
status, message = _parse_error(e)
|
||||
if _is_retryable(e) and attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
||||
logger.warning(
|
||||
"Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)",
|
||||
status,
|
||||
method,
|
||||
path,
|
||||
wait,
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
last_exc = e
|
||||
continue
|
||||
raise APIError(status, message) from e
|
||||
except (requests.ConnectionError, requests.Timeout) as e:
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
||||
logger.warning(
|
||||
"Connection error on %s %s, retrying in %ds (attempt %d/%d)",
|
||||
method,
|
||||
path,
|
||||
wait,
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
last_exc = e
|
||||
continue
|
||||
raise APIError(0, str(e)) from e
|
||||
# Should not reach here, but just in case
|
||||
if last_exc: # pragma: no cover
|
||||
raise APIError(0, str(last_exc)) from last_exc
|
||||
raise APIError(0, "Max retries exceeded") # pragma: no cover
|
||||
return _request_with_retry(self._session, self._url(path), method, **kwargs)
|
||||
|
||||
# -- repo settings --
|
||||
|
||||
@@ -175,6 +194,19 @@ class GiteaClient:
|
||||
payload = {"Do": "squash", "MergeTitleField": merge_title}
|
||||
self._request("POST", f"/pulls/{pr_number}/merge", json=payload)
|
||||
|
||||
def update_pr_branch(self, pr_number: str | int, style: str = "rebase") -> None:
|
||||
"""Update PR head branch by merging/rebasing the base branch into it.
|
||||
|
||||
Uses the Gitea API ``POST /pulls/{index}/update?style=rebase`` endpoint.
|
||||
This rebases the PR's head branch onto the latest base branch server-side,
|
||||
triggering a ``pull_request synchronize`` event that starts a new CI run.
|
||||
|
||||
Args:
|
||||
pr_number: PR number.
|
||||
style: Update method — ``"rebase"`` (default) or ``"merge"``.
|
||||
"""
|
||||
self._request("POST", f"/pulls/{pr_number}/update", params={"style": style})
|
||||
|
||||
def get_commit_status(self, sha: str) -> list[dict[str, Any]]:
|
||||
"""Fetch all status check contexts reported for a commit.
|
||||
|
||||
@@ -223,6 +255,20 @@ class GiteaClient:
|
||||
r = self._request("GET", f"/pulls/{pr_number}/files")
|
||||
return r.json()
|
||||
|
||||
def add_pr_label(self, pr_number: str | int, label_names: list[str]) -> None:
|
||||
"""Attach labels to a PR/issue by name.
|
||||
|
||||
Args:
|
||||
pr_number: PR or issue number.
|
||||
label_names: List of label names to attach.
|
||||
"""
|
||||
self._request("POST", f"/issues/{pr_number}/labels", json={"labels": label_names})
|
||||
|
||||
def get_pr_label_names(self, pr_number: str | int) -> list[str]:
|
||||
"""Return label names currently attached to a PR/issue."""
|
||||
r = self._request("GET", f"/issues/{pr_number}/labels")
|
||||
return [label.get("name", "") for label in r.json()]
|
||||
|
||||
def get_pr_commits(self, pr_number: str | int) -> list[dict[str, Any]]:
|
||||
"""Fetch the commits included in a pull request."""
|
||||
r = self._request("GET", f"/pulls/{pr_number}/commits")
|
||||
@@ -301,6 +347,31 @@ class GiteaClient:
|
||||
return existing
|
||||
return self.create_release(tag=tag, name=name, body=body, draft=draft, prerelease=prerelease)
|
||||
|
||||
# -- actions (CI/CD) --
|
||||
|
||||
def list_action_runs(self, **params: Any) -> dict[str, Any]:
|
||||
"""List workflow runs for the repository.
|
||||
|
||||
Returns the raw API response dict (includes ``workflow_runs`` and
|
||||
``total_count`` keys per Gitea API).
|
||||
"""
|
||||
r = self._request("GET", "/actions/runs", params=params)
|
||||
return r.json()
|
||||
|
||||
def get_action_run_jobs(self, run_id: str | int) -> list[dict[str, Any]]:
|
||||
"""List jobs for a specific workflow run."""
|
||||
r = self._request("GET", f"/actions/runs/{run_id}/jobs")
|
||||
data = r.json()
|
||||
return data.get("jobs", [])
|
||||
|
||||
def get_action_job_logs(self, job_id: str | int) -> str:
|
||||
"""Fetch logs for a specific CI job.
|
||||
|
||||
Returns the raw log text. Raises APIError if logs are unavailable.
|
||||
"""
|
||||
r = self._request("GET", f"/actions/jobs/{job_id}/logs")
|
||||
return r.text
|
||||
|
||||
|
||||
class VikunjaClient:
|
||||
"""Low-level Vikunja REST API client with connection pooling."""
|
||||
@@ -312,47 +383,7 @@ class VikunjaClient:
|
||||
|
||||
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
|
||||
url = f"{self._base_url}{path}"
|
||||
last_exc: Exception | None = None
|
||||
for attempt in range(MAX_RETRIES):
|
||||
try:
|
||||
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
except requests.HTTPError as e:
|
||||
status, message = _parse_error(e)
|
||||
if _is_retryable(e) and attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
||||
logger.warning(
|
||||
"Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)",
|
||||
status,
|
||||
method,
|
||||
path,
|
||||
wait,
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
last_exc = e
|
||||
continue
|
||||
raise APIError(status, message) from e
|
||||
except (requests.ConnectionError, requests.Timeout) as e:
|
||||
if attempt < MAX_RETRIES - 1:
|
||||
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
|
||||
logger.warning(
|
||||
"Connection error on %s %s, retrying in %ds (attempt %d/%d)",
|
||||
method,
|
||||
path,
|
||||
wait,
|
||||
attempt + 1,
|
||||
MAX_RETRIES,
|
||||
)
|
||||
time.sleep(wait)
|
||||
last_exc = e
|
||||
continue
|
||||
raise APIError(0, str(e)) from e
|
||||
if last_exc: # pragma: no cover
|
||||
raise APIError(0, str(last_exc)) from last_exc
|
||||
raise APIError(0, "Max retries exceeded") # pragma: no cover
|
||||
return _request_with_retry(self._session, url, method, **kwargs)
|
||||
|
||||
def list_tasks(self, **params: Any) -> list[dict[str, Any]]:
|
||||
r = self._request("GET", "/tasks", params=params)
|
||||
@@ -368,6 +399,25 @@ class VikunjaClient:
|
||||
r = self._request("GET", f"/projects/{project_id}/tasks", params=params)
|
||||
return r.json()
|
||||
|
||||
def find_task_by_identifier(self, project_id: int, identifier: str, per_page: int = 50) -> dict[str, Any] | None:
|
||||
"""Find a task by its identifier (e.g. ``DEVX-42``) in a project.
|
||||
|
||||
Paginates through all tasks in the project. Returns the task dict
|
||||
or None if not found.
|
||||
"""
|
||||
page = 1
|
||||
while True:
|
||||
tasks = self.list_project_tasks(project_id, page=page, per_page=per_page)
|
||||
if not tasks:
|
||||
break
|
||||
for t in tasks:
|
||||
if t.get("identifier") == identifier:
|
||||
return t
|
||||
if len(tasks) < per_page:
|
||||
break
|
||||
page += 1
|
||||
return None
|
||||
|
||||
def create_task(self, project_id: int, title: str, description: str = "") -> dict[str, Any]:
|
||||
"""Create a task in a project and return the created task dict.
|
||||
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
|
||||
from devx.config import TASK_ID_RE
|
||||
from devx.i18n import _
|
||||
|
||||
|
||||
def get_latest_tag() -> str:
|
||||
"""Get the latest git tag, or empty string if none exists."""
|
||||
@@ -16,3 +22,97 @@ def get_latest_tag() -> str:
|
||||
if result.returncode != 0:
|
||||
return ""
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def run_cmd(
|
||||
args: list[str],
|
||||
check: bool = True,
|
||||
capture: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
"""Run a command and return the completed process.
|
||||
|
||||
Args:
|
||||
args: Command and arguments as a list.
|
||||
check: If True, raise :class:`click.ClickException` on non-zero exit.
|
||||
capture: If True, capture stdout/stderr. If False, inherit parent's.
|
||||
"""
|
||||
result = subprocess.run( # nosec B603
|
||||
args,
|
||||
capture_output=capture,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Command failed ({cmd}): {stderr}",
|
||||
cmd=" ".join(args),
|
||||
stderr=result.stderr.strip() if result.stderr else result.stdout.strip(),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def extract_task_id(text: str) -> str:
|
||||
"""Extract the ``{PREFIX}-N`` task identifier from *text*.
|
||||
|
||||
Returns the matched string (e.g. ``DEVX-42``) or an empty string if
|
||||
no task ID is found.
|
||||
"""
|
||||
match = TASK_ID_RE.search(text)
|
||||
return match.group(0) if match else ""
|
||||
|
||||
|
||||
def write_github_env(key: str, value: str) -> None:
|
||||
"""Append a key=value line to the ``$GITHUB_ENV`` file.
|
||||
|
||||
Multi-line values use the heredoc syntax required by Gitea Actions.
|
||||
Raises :class:`click.ClickException` if ``GITHUB_ENV`` is not set.
|
||||
"""
|
||||
gh_env = os.environ.get("GITHUB_ENV")
|
||||
if not gh_env:
|
||||
raise click.ClickException("GITHUB_ENV environment variable is not set")
|
||||
with open(gh_env, "a", encoding="utf-8") as f: # noqa: PTH123
|
||||
if "\n" in value:
|
||||
delimiter = "EOF"
|
||||
f.write(f"{key}<<{delimiter}\n{value}\n{delimiter}\n")
|
||||
else:
|
||||
f.write(f"{key}={value}\n")
|
||||
|
||||
|
||||
def write_github_output(key: str, value: str) -> None:
|
||||
"""Append a key=value line to the ``$GITHUB_OUTPUT`` file.
|
||||
|
||||
Raises :class:`click.ClickException` if ``GITHUB_OUTPUT`` is not set.
|
||||
"""
|
||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
||||
if not gh_output:
|
||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
||||
with open(gh_output, "a", encoding="utf-8") as f: # noqa: PTH123
|
||||
f.write(f"{key}={value}\n")
|
||||
|
||||
|
||||
def lpt_distribute[T](items: list[T], weights: list[int], max_runners: int) -> list[list[T]]:
|
||||
"""Distribute *items* across *max_runners* using LPT scheduling.
|
||||
|
||||
Sorts items by weight (descending), then assigns each to the runner
|
||||
with the least total weight. This produces a more balanced distribution
|
||||
than naive round-robin when items have varying costs.
|
||||
|
||||
Args:
|
||||
items: Items to distribute.
|
||||
weights: Parallel list of integer weights (higher = heavier).
|
||||
max_runners: Number of runner groups to create.
|
||||
|
||||
Returns:
|
||||
A list of ``max_runners`` lists, each containing the items assigned
|
||||
to that runner.
|
||||
"""
|
||||
groups: list[list[T]] = [[] for _ in range(max_runners)]
|
||||
loads = [0] * max_runners
|
||||
indexed = sorted(enumerate(items), key=lambda x: (-weights[x[0]], x[0]))
|
||||
for orig_idx, item in indexed:
|
||||
min_runner = min(range(max_runners), key=lambda r: loads[r])
|
||||
groups[min_runner].append(item)
|
||||
loads[min_runner] += weights[orig_idx]
|
||||
return groups
|
||||
|
||||
+57
-38
@@ -17,12 +17,11 @@ This allows the PR title to be a human-friendly Vikunja task title
|
||||
while the squashed commit follows conventional commits.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 -m devx.ci.auto_merge <branch> <pr_title> <repo> <pr_number>
|
||||
CI_GITEA_TOKEN=<token> python3 -m devx.ci.auto_merge <branch> <pr_title> <repo> <pr_number>
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -30,11 +29,11 @@ import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from devx.api_clients import GiteaClient, VikunjaClient
|
||||
from devx.ci._shared import extract_task_id as _extract_task_id
|
||||
from devx.config import (
|
||||
CONVENTIONAL_RE,
|
||||
DEFAULT_PER_PAGE,
|
||||
GITEA_API_URL,
|
||||
TASK_ID_RE,
|
||||
TASK_PREFIX,
|
||||
VIKUNJA_API_URL,
|
||||
VIKUNJA_PROJECT_ID,
|
||||
@@ -42,26 +41,15 @@ from devx.config import (
|
||||
from devx.exceptions import APIError
|
||||
from devx.i18n import _
|
||||
|
||||
# Strip leading task ID prefix (e.g. "DEVX-12: " or "OBL-INFRA-364: ") from commit subjects.
|
||||
_TASK_ID_PREFIX_RE = re.compile(rf"^{TASK_PREFIX}-\d+:\s*")
|
||||
|
||||
TASKID_FILE = ".taskid" # Deprecated, kept for backward-compat warnings
|
||||
PR_TITLE_RE = re.compile(rf"^{TASK_PREFIX}-\d+:\s+.+")
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def run_cmd(args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
"""Run a command and return the completed process."""
|
||||
result = subprocess.run(args, capture_output=True, text=True, check=False) # nosec B603
|
||||
if check and result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Command failed ({cmd}): {stderr}",
|
||||
cmd=" ".join(args),
|
||||
stderr=result.stderr.strip() or result.stdout.strip(),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def read_taskid(branch: str) -> str:
|
||||
"""Read task ID from branch name.
|
||||
|
||||
@@ -92,9 +80,8 @@ def read_taskid(branch: str) -> str:
|
||||
|
||||
|
||||
def extract_task_id(branch: str) -> str:
|
||||
"""Extract DEVX-N task identifier from branch name (legacy fallback)."""
|
||||
match = TASK_ID_RE.search(branch)
|
||||
return match.group(0) if match else ""
|
||||
"""Extract task identifier from branch name (delegates to shared utility)."""
|
||||
return _extract_task_id(branch)
|
||||
|
||||
|
||||
def validate_pr_title(pr_title: str, task_id: str) -> None:
|
||||
@@ -174,19 +161,33 @@ def validate_pr_title_matches_vikunja(pr_title: str, task_id: str) -> None:
|
||||
def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
|
||||
"""Extract the conventional commit message from PR commits.
|
||||
|
||||
Iterates commits in reverse order (newest first) to find the first
|
||||
message matching the conventional commit format. Falls back to the
|
||||
newest commit message if none match.
|
||||
Picks the highest-priority conventional commit message from the PR.
|
||||
Priority: feat > fix > refactor > docs > chore > other.
|
||||
Falls back to the newest commit message if none match.
|
||||
"""
|
||||
priority = {"feat": 5, "fix": 4, "refactor": 3, "docs": 2, "chore": 1, "ci": 1, "style": 1, "test": 1}
|
||||
best_msg = ""
|
||||
best_score = 0
|
||||
for commit in reversed(commits):
|
||||
commit_info = commit.get("commit", {})
|
||||
message = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
|
||||
if CONVENTIONAL_RE.match(message):
|
||||
return message
|
||||
# Fallback: use the newest commit's first line
|
||||
# Strip any leading task ID prefix (e.g. "OBL-INFRA-364: fix: ...") so
|
||||
# conventional commit matching works on the remainder.
|
||||
stripped = _TASK_ID_PREFIX_RE.sub("", message)
|
||||
m = CONVENTIONAL_RE.match(stripped)
|
||||
if m:
|
||||
prefix = m.group(1).split("(")[0].strip() # e.g. "feat" from "feat(scope)"
|
||||
score = priority.get(prefix, 0)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_msg = stripped
|
||||
if best_msg:
|
||||
return best_msg
|
||||
# Fallback: use the newest commit's first line (strip task ID prefix if present)
|
||||
if commits:
|
||||
commit_info = commits[-1].get("commit", {})
|
||||
return str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
|
||||
raw = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
|
||||
return _TASK_ID_PREFIX_RE.sub("", raw)
|
||||
return ""
|
||||
|
||||
|
||||
@@ -196,9 +197,9 @@ def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
|
||||
@click.argument("repo")
|
||||
@click.argument("pr_number")
|
||||
def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
|
||||
|
||||
# Validate PR number is an integer
|
||||
try:
|
||||
@@ -237,17 +238,35 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
|
||||
client.merge_pr(pr_num, merge_title)
|
||||
except APIError as e:
|
||||
if e.status == 405 and "behind" in e.message.lower():
|
||||
# Head branch is behind master — do NOT auto-rebase.
|
||||
# Auto-rebasing creates a feedback loop: the force-push triggers
|
||||
# a new pull_request synchronize event, which starts a new CI run,
|
||||
# which runs auto-merge again, which rebases again, etc.
|
||||
raise click.ClickException(
|
||||
# Head branch is behind master. Auto-rebase via Gitea API.
|
||||
# This triggers a new pull_request synchronize event → new CI run.
|
||||
# The next auto-merge attempt will find the branch up-to-date and
|
||||
# merge successfully. This is NOT an infinite loop: the rebase
|
||||
# resolves the "behind" condition, so the next run merges.
|
||||
# If another PR merges in between, the branch may fall behind
|
||||
# again, but the process converges as PRs stop merging.
|
||||
click.echo(
|
||||
_(
|
||||
"Branch is behind master. Rebase manually:\n"
|
||||
" git fetch origin master && git rebase origin/master && git push --force-with-lease\n"
|
||||
"Then re-add the ready-to-merge label.",
|
||||
"Branch is behind master. Auto-rebasing via Gitea API...\n"
|
||||
"A new CI run will start automatically after the rebase.\n"
|
||||
"The next auto-merge attempt will merge this PR.",
|
||||
)
|
||||
) from None
|
||||
)
|
||||
try:
|
||||
client.update_pr_branch(pr_num, style="rebase")
|
||||
except APIError as rebase_err:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Auto-rebase failed with HTTP {status}: {message}\n"
|
||||
"Rebase manually:\n"
|
||||
" git fetch origin master && git rebase origin/master && git push --force-with-lease\n"
|
||||
"Then re-add the ready-to-merge label.",
|
||||
status=rebase_err.status,
|
||||
message=rebase_err.message,
|
||||
)
|
||||
) from None
|
||||
# Exit cleanly — the rebase triggers a new CI run that will retry.
|
||||
return
|
||||
else:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
|
||||
@@ -15,7 +15,7 @@ Exit code 1 = NOT ready — fix issues before pushing.
|
||||
|
||||
Usage::
|
||||
|
||||
# CI (with VIKUNJA_TOKEN and REPO_TOKEN):
|
||||
# CI (with VIKUNJA_TOKEN and CI_GITEA_TOKEN):
|
||||
python3 -m devx.ci.check_auto_merge_ready \\
|
||||
--branch "$HEAD_REF" \\
|
||||
--pr-title "$PR_TITLE" \\
|
||||
@@ -34,7 +34,7 @@ skipped (with a warning) — this allows local pre-push hooks to run
|
||||
without CI secrets. In CI, the token is always set and the check is
|
||||
mandatory.
|
||||
|
||||
If ``REPO_TOKEN`` is not set and ``--pr-number`` is not provided, only
|
||||
If ``CI_GITEA_TOKEN`` is not set and ``--pr-number`` is not provided, only
|
||||
branch-name and PR-title-format checks run (local mode).
|
||||
"""
|
||||
|
||||
@@ -53,6 +53,7 @@ from devx.config import (
|
||||
VIKUNJA_API_URL,
|
||||
VIKUNJA_PROJECT_ID,
|
||||
)
|
||||
from devx.exceptions import APIError
|
||||
from devx.i18n import _
|
||||
|
||||
load_dotenv()
|
||||
@@ -98,9 +99,9 @@ def is_branch_behind_master(branch: str) -> bool:
|
||||
def get_pr_title_from_gitea(repo: str, pr_number: int) -> str | None:
|
||||
"""Fetch the PR title from the Gitea API.
|
||||
|
||||
Returns ``None`` if ``REPO_TOKEN`` is not set or the PR cannot be fetched.
|
||||
Returns ``None`` if ``CI_GITEA_TOKEN`` is not set or the PR cannot be fetched.
|
||||
"""
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token or "/" not in repo:
|
||||
return None
|
||||
owner, repo_name = repo.split("/", 1)
|
||||
@@ -108,7 +109,7 @@ def get_pr_title_from_gitea(repo: str, pr_number: int) -> str | None:
|
||||
try:
|
||||
pr = client.get_pr(pr_number)
|
||||
return str(pr.get("title", ""))
|
||||
except Exception:
|
||||
except APIError:
|
||||
return None
|
||||
|
||||
|
||||
@@ -189,7 +190,9 @@ def cli(
|
||||
if pr_title is None:
|
||||
# Local mode without PR — only validate branch name
|
||||
if pr_number is not None:
|
||||
raise click.ClickException(_("Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found)."))
|
||||
raise click.ClickException(
|
||||
_("Could not fetch PR title from Gitea (CI_GITEA_TOKEN not set or PR not found).")
|
||||
)
|
||||
click.echo("[pre-merge-check] No PR title provided — running branch-name-only check (local mode).")
|
||||
click.echo("[pre-merge-check] Branch name OK. Push to create PR, then CI will validate the title.")
|
||||
return
|
||||
|
||||
@@ -31,11 +31,11 @@ from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||
REPO_ROOT = Path.cwd()
|
||||
|
||||
SUPPORTED_LANGS = ("en", "bg", "de", "ru", "zh", "pl")
|
||||
|
||||
# Default translation set: devx package itself
|
||||
# Default translation set: look for translations.json in the current repo
|
||||
DEFAULT_TRANS_FILE = REPO_ROOT / "src" / "devx" / "translations.json"
|
||||
DEFAULT_SRC_DIR = REPO_ROOT / "src" / "devx"
|
||||
|
||||
@@ -169,20 +169,44 @@ def print_result(result: TranslationCheckResult) -> None:
|
||||
"translations",
|
||||
multiple=True,
|
||||
type=click.Path(exists=False, path_type=Path),
|
||||
help="Path to a translations JSON file to check (can be repeated). Defaults to src/devx/translations.json.",
|
||||
help="Path to a translations JSON file to check (can be repeated). Auto-detects by default.",
|
||||
)
|
||||
def main(translations: tuple[Path, ...]) -> None:
|
||||
@click.option(
|
||||
"--source-dir",
|
||||
default=None,
|
||||
help="Source directory to scan for _() calls (default: auto-detect).",
|
||||
)
|
||||
def main(translations: tuple[Path, ...], source_dir: str | None) -> None:
|
||||
"""Check translation files for gaps, dead keys, and missing languages."""
|
||||
results: list[TranslationCheckResult] = []
|
||||
if not translations:
|
||||
# Default: check the devx package's own translations
|
||||
results = [
|
||||
check_translation_set("devx", DEFAULT_SRC_DIR, DEFAULT_TRANS_FILE),
|
||||
# Auto-detect translations file in the current repo
|
||||
root = Path.cwd()
|
||||
# Try common locations
|
||||
candidates = [
|
||||
root / "src" / "devx" / "translations.json",
|
||||
root / "src" / "gitea_runner_manager" / "translations.json",
|
||||
]
|
||||
# Also search for any translations.json in src/
|
||||
for match in root.glob("src/*/translations.json"):
|
||||
candidates.append(match)
|
||||
|
||||
found = False
|
||||
for candidate in candidates:
|
||||
if candidate.exists():
|
||||
src_dir = Path(source_dir) if source_dir else candidate.parent
|
||||
results.append(check_translation_set(candidate.parent.name, src_dir, candidate))
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
# No translations file found — this repo doesn't use i18n
|
||||
click.echo("PASS: No translations file found — skipping (repo does not use i18n).")
|
||||
return
|
||||
else:
|
||||
results = []
|
||||
for trans_file in translations:
|
||||
# Infer source directory as the parent of the translations file
|
||||
src_dir = trans_file.parent
|
||||
src_dir = Path(source_dir) if source_dir else trans_file.parent
|
||||
name = trans_file.parent.name
|
||||
results.append(check_translation_set(name, src_dir, trans_file))
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
from devx.ci._shared import get_latest_tag
|
||||
from devx.ci._shared import get_latest_tag, write_github_output
|
||||
from devx.i18n import _
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -393,14 +393,15 @@ class ChangeClassifier:
|
||||
tags = self._compute_tags(file_path)
|
||||
|
||||
# 1. User-facing overrides (highest priority — safety)
|
||||
if file_path in self._user_overrides:
|
||||
return FileClassification(
|
||||
path=file_path,
|
||||
is_user_facing=True,
|
||||
reason="User-facing override (safety override)",
|
||||
matched_rule="user_facing_overrides",
|
||||
tags=tags,
|
||||
)
|
||||
for pattern in self._user_overrides:
|
||||
if _matches_glob(file_path, pattern):
|
||||
return FileClassification(
|
||||
path=file_path,
|
||||
is_user_facing=True,
|
||||
reason=f"User-facing override (matches '{pattern}')",
|
||||
matched_rule="user_facing_overrides",
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
# 2. Infrastructure overrides
|
||||
if file_path in self._infra_overrides:
|
||||
@@ -583,15 +584,8 @@ def has_user_facing_changes(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _write_github_output(key: str, value: str) -> None:
|
||||
"""Append a key=value line to the $GITHUB_OUTPUT file."""
|
||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
||||
if not gh_output:
|
||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
||||
with open(gh_output, "a") as f: # noqa: PTH123
|
||||
f.write(f"{key}={value}\n")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Classification logic
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -632,9 +626,9 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
|
||||
force = True
|
||||
|
||||
if force and github_output:
|
||||
_write_github_output("user-facing-changed", "true")
|
||||
write_github_output("user-facing-changed", "true")
|
||||
for tag in available_tags:
|
||||
_write_github_output(f"{tag}-changed", "true")
|
||||
write_github_output(f"{tag}-changed", "true")
|
||||
click.echo("Forced user-facing-changed=true via --force flag.")
|
||||
return
|
||||
|
||||
@@ -642,9 +636,9 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
|
||||
base = get_latest_tag()
|
||||
if not base:
|
||||
if github_output:
|
||||
_write_github_output("user-facing-changed", "true")
|
||||
write_github_output("user-facing-changed", "true")
|
||||
for tag in available_tags:
|
||||
_write_github_output(f"{tag}-changed", "true")
|
||||
write_github_output(f"{tag}-changed", "true")
|
||||
click.echo("No tags found — treating all changes as user-facing.")
|
||||
return
|
||||
if quiet:
|
||||
@@ -656,9 +650,9 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
|
||||
files = get_changed_files(base, head)
|
||||
if not files:
|
||||
if github_output:
|
||||
_write_github_output("user-facing-changed", "false")
|
||||
write_github_output("user-facing-changed", "false")
|
||||
for tag in available_tags:
|
||||
_write_github_output(f"{tag}-changed", "false")
|
||||
write_github_output(f"{tag}-changed", "false")
|
||||
click.echo(f"No changes between {base} and {head}.")
|
||||
return
|
||||
if quiet:
|
||||
@@ -670,9 +664,9 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
|
||||
result = classifier.classify(files)
|
||||
|
||||
if github_output:
|
||||
_write_github_output("user-facing-changed", "true" if result.has_user_facing else "false")
|
||||
write_github_output("user-facing-changed", "true" if result.has_user_facing else "false")
|
||||
for tag in available_tags:
|
||||
_write_github_output(f"{tag}-changed", "true" if result.has_tag(tag) else "false")
|
||||
write_github_output(f"{tag}-changed", "true" if result.has_tag(tag) else "false")
|
||||
click.echo(f"User-facing files changed: {result.has_user_facing}")
|
||||
for tag in available_tags:
|
||||
click.echo(f"{tag.capitalize()} files changed: {result.has_tag(tag)}")
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Detect whether the latest git commit is a release commit.
|
||||
"""Detect whether the latest git commit is an automated CI commit.
|
||||
|
||||
Release commits have the format ``release: vX.Y.Z``.
|
||||
Badge commits have the format ``chore: update badge URLs ... [skip ci]``.
|
||||
Both are generated by CI and should skip post-merge jobs.
|
||||
|
||||
This script writes ``is-release=true`` or ``is-release=false`` to
|
||||
``$GITHUB_OUTPUT`` for use in CI workflow conditionals.
|
||||
|
||||
@@ -12,13 +15,16 @@ Usage::
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
|
||||
from devx.ci._shared import write_github_output
|
||||
from devx.i18n import _
|
||||
|
||||
RELEASE_RE = re.compile(r"^release: v\d+\.\d+\.\d+")
|
||||
BADGE_RE = re.compile(r"^chore: update badge URLs.*\[skip ci\]")
|
||||
|
||||
|
||||
def get_commit_message() -> str:
|
||||
@@ -39,26 +45,31 @@ def is_release_commit(message: str) -> bool:
|
||||
return bool(RELEASE_RE.match(message))
|
||||
|
||||
|
||||
def write_github_output(key: str, value: str) -> None:
|
||||
"""Append a key=value line to the $GITHUB_OUTPUT file."""
|
||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
||||
if not gh_output:
|
||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
||||
with open(gh_output, "a") as f: # noqa: PTH123
|
||||
f.write(f"{key}={value}\n")
|
||||
def is_badge_commit(message: str) -> bool:
|
||||
"""Check if a commit message matches the badge commit format."""
|
||||
return bool(BADGE_RE.match(message))
|
||||
|
||||
|
||||
def is_automated_commit(message: str) -> bool:
|
||||
"""Check if a commit is an automated CI commit (release or badge)."""
|
||||
return is_release_commit(message) or is_badge_commit(message)
|
||||
|
||||
|
||||
@click.command()
|
||||
def main() -> None:
|
||||
"""Detect if the latest commit is a release commit and set GITHUB_OUTPUT."""
|
||||
"""Detect if the latest commit is an automated CI commit and set GITHUB_OUTPUT."""
|
||||
msg = get_commit_message()
|
||||
click.echo(f"Commit message: {msg}")
|
||||
click.echo(_("Commit message: {msg}", msg=msg))
|
||||
is_release = is_release_commit(msg)
|
||||
is_automated = is_automated_commit(msg)
|
||||
write_github_output("is-release", "true" if is_release else "false")
|
||||
write_github_output("is-automated", "true" if is_automated else "false")
|
||||
if is_release:
|
||||
click.echo("Release commit — skipping all post-merge jobs.")
|
||||
click.echo(_("Release commit — skipping all post-merge jobs."))
|
||||
elif is_automated:
|
||||
click.echo(_("Automated CI commit (badge) — skipping post-merge jobs."))
|
||||
else:
|
||||
click.echo("Regular merge commit — running all post-merge jobs.")
|
||||
click.echo(_("Regular merge commit — running all post-merge jobs."))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -29,7 +29,8 @@ import os
|
||||
import click
|
||||
import requests
|
||||
|
||||
from devx.config import GITEA_API_URL
|
||||
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
|
||||
from devx.i18n import _
|
||||
|
||||
DEFAULT_MAX_RUNNERS = 3
|
||||
|
||||
@@ -55,9 +56,9 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
data = r.json()
|
||||
total += data.get("total_count", 0)
|
||||
else:
|
||||
click.echo(f"Warning: repo-level runners query returned HTTP {r.status_code}", err=True)
|
||||
click.echo(_("Warning: repo-level runners query returned HTTP {status}", status=r.status_code), err=True)
|
||||
except (requests.RequestException, ValueError) as e:
|
||||
click.echo(f"Warning: repo-level runners query failed: {e}", err=True)
|
||||
click.echo(_("Warning: repo-level runners query failed: {error}", error=e), err=True)
|
||||
|
||||
# 2. Organization-level runners
|
||||
try:
|
||||
@@ -70,9 +71,9 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
data = r.json()
|
||||
total += data.get("total_count", 0)
|
||||
else:
|
||||
click.echo(f"Warning: org-level runners query returned HTTP {r.status_code}", err=True)
|
||||
click.echo(_("Warning: org-level runners query returned HTTP {status}", status=r.status_code), err=True)
|
||||
except (requests.RequestException, ValueError) as e:
|
||||
click.echo(f"Warning: org-level runners query failed: {e}", err=True)
|
||||
click.echo(_("Warning: org-level runners query failed: {error}", error=e), err=True)
|
||||
|
||||
# 3. Instance-level runners (requires admin scope)
|
||||
try:
|
||||
@@ -85,9 +86,12 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
|
||||
data = r.json()
|
||||
total += data.get("total_count", 0)
|
||||
elif r.status_code != 403: # 403 is expected without admin scope
|
||||
click.echo(f"Warning: instance-level runners query returned HTTP {r.status_code}", err=True)
|
||||
click.echo(
|
||||
_("Warning: instance-level runners query returned HTTP {status}", status=r.status_code),
|
||||
err=True,
|
||||
)
|
||||
except (requests.RequestException, ValueError) as e:
|
||||
click.echo(f"Warning: instance-level runners query failed: {e}", err=True)
|
||||
click.echo(_("Warning: instance-level runners query failed: {error}", error=e), err=True)
|
||||
|
||||
return total
|
||||
|
||||
@@ -148,12 +152,12 @@ def main(
|
||||
output_indices: bool,
|
||||
github_output: bool,
|
||||
) -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
|
||||
if owner is None:
|
||||
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss")
|
||||
owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
|
||||
if repo is None:
|
||||
repo = os.environ.get("DEVX_REPO_NAME", "devx")
|
||||
repo = os.environ.get("DEVX_REPO_NAME", "") or REPO_NAME
|
||||
|
||||
count = get_runner_count(GITEA_API_URL, token, owner, repo)
|
||||
indices = generate_indices(count)
|
||||
@@ -162,11 +166,11 @@ def main(
|
||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
||||
if not gh_output:
|
||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
||||
with open(gh_output, "a") as f: # noqa: PTH123
|
||||
with open(gh_output, "a", encoding="utf-8") as f: # noqa: PTH123
|
||||
f.write(f"runner-count={count}\n")
|
||||
f.write(f"runner-indices={json.dumps(indices)}\n")
|
||||
click.echo(f"Runner count: {count}")
|
||||
click.echo(f"Runner indices: {indices}")
|
||||
click.echo(_("Runner count: {count}", count=count))
|
||||
click.echo(_("Runner indices: {indices}", indices=indices))
|
||||
return
|
||||
|
||||
if output_count:
|
||||
@@ -178,8 +182,8 @@ def main(
|
||||
return
|
||||
|
||||
# Default: output both as key=value pairs for CI consumption
|
||||
click.echo(f"count={count}")
|
||||
click.echo(f"indices={json.dumps(indices)}")
|
||||
click.echo(_("count={count}", count=count))
|
||||
click.echo(_("indices={indices}", indices=json.dumps(indices)))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -26,6 +26,7 @@ import os
|
||||
|
||||
import click
|
||||
|
||||
from devx.ci._shared import lpt_distribute, write_github_env
|
||||
from devx.i18n import _
|
||||
|
||||
DEFAULT_MAX_RUNNERS = 3
|
||||
@@ -54,15 +55,7 @@ def distribute(files: list[str], max_runners: int) -> list[list[str]]:
|
||||
the runner with the least total weight.
|
||||
"""
|
||||
weights = [_file_weight(f) for f in files]
|
||||
groups: list[list[str]] = [[] for _ in range(max_runners)]
|
||||
loads = [0] * max_runners
|
||||
# Sort by weight descending, preserving original order for ties
|
||||
indexed = sorted(enumerate(files), key=lambda x: (-weights[x[0]], x[0]))
|
||||
for orig_idx, f in indexed:
|
||||
min_runner = min(range(max_runners), key=lambda r: loads[r])
|
||||
groups[min_runner].append(f)
|
||||
loads[min_runner] += weights[orig_idx]
|
||||
return groups
|
||||
return lpt_distribute(files, weights, max_runners)
|
||||
|
||||
|
||||
def files_for_runner(files: list[str], runner_index: int, max_runners: int) -> list[str]:
|
||||
@@ -75,19 +68,6 @@ def files_for_runner(files: list[str], runner_index: int, max_runners: int) -> l
|
||||
return groups[runner_index]
|
||||
|
||||
|
||||
def _write_github_env(key: str, value: str) -> None:
|
||||
gh_env = os.environ.get("GITHUB_ENV")
|
||||
if not gh_env:
|
||||
raise click.ClickException("GITHUB_ENV environment variable is not set")
|
||||
with open(gh_env, "a") as f: # noqa: PTH123
|
||||
if "\n" in value:
|
||||
# Multi-line values require the heredoc syntax in $GITHUB_ENV.
|
||||
delimiter = "EOF"
|
||||
f.write(f"{key}<<{delimiter}\n{value}\n{delimiter}\n")
|
||||
else:
|
||||
f.write(f"{key}={value}\n")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--pattern", required=True, help="Glob pattern for files to distribute.")
|
||||
@click.option(
|
||||
@@ -122,26 +102,34 @@ def main(pattern: str, runner_index: int | None, max_runners: int, github_env: b
|
||||
groups = distribute(files, max_runners)
|
||||
for i, group in enumerate(groups):
|
||||
labels = " ".join(group) if group else "(none)"
|
||||
click.echo(f"Runner {i}: {labels}")
|
||||
click.echo(_("Runner {i}: {labels}", i=i, labels=labels))
|
||||
return
|
||||
|
||||
if skip_if_excess and github_env and runner_index > max_runners:
|
||||
click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}")
|
||||
_write_github_env("ASSIGNED_FILES", "")
|
||||
_write_github_env("SKIP", "true")
|
||||
click.echo(
|
||||
_(
|
||||
"Skipping — runner index {runner_index} > max runners {max_runners}",
|
||||
runner_index=runner_index,
|
||||
max_runners=max_runners,
|
||||
)
|
||||
)
|
||||
write_github_env("ASSIGNED_FILES", "")
|
||||
write_github_env("SKIP", "true")
|
||||
return
|
||||
|
||||
if runner_index < 1:
|
||||
raise click.ClickException(f"Runner index {runner_index} is out of range (must be >= 1)")
|
||||
raise click.ClickException(
|
||||
_("Runner index {runner_index} is out of range (must be >= 1)", runner_index=runner_index)
|
||||
)
|
||||
|
||||
zero_based = runner_index - 1
|
||||
assigned = files_for_runner(files, zero_based, max_runners)
|
||||
encoded = "\n".join(assigned)
|
||||
|
||||
if github_env:
|
||||
_write_github_env("ASSIGNED_FILES", encoded)
|
||||
_write_github_env("SKIP", "false")
|
||||
click.echo(f"Assigned {len(assigned)} files to runner {runner_index}")
|
||||
write_github_env("ASSIGNED_FILES", encoded)
|
||||
write_github_env("SKIP", "false")
|
||||
click.echo(_("Assigned {count} files to runner {runner_index}", count=len(assigned), runner_index=runner_index))
|
||||
return
|
||||
|
||||
click.echo(encoded)
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Distribute a list of items across N parallel runners using LPT scheduling.
|
||||
|
||||
Generic item distribution for CI matrix jobs. Items are read from a JSON
|
||||
array on stdin (or from a file via --items-file), sorted for deterministic
|
||||
ordering, then assigned to *max_runners* groups using LPT (Longest
|
||||
Processing Time first) scheduling.
|
||||
|
||||
Each item is a string (e.g. an Ansible ``--limit`` pattern like
|
||||
``observability`` or ``infra-314-vm``). Optionally, items can be objects
|
||||
with ``{"id": "...", "weight": N}`` to provide explicit weights.
|
||||
|
||||
The assigned group for *runner_index* is written to ``$GITHUB_ENV`` as
|
||||
``ASSIGNED_ITEMS`` (space-delimited) for use by subsequent steps.
|
||||
|
||||
Usage::
|
||||
|
||||
echo '["observability", "infra-314-vm"]' | \\
|
||||
python3 -m devx.ci.distribute_items \\
|
||||
--runner-index 1 --max-runners 3 \\
|
||||
--github-env --skip-if-excess
|
||||
|
||||
# With weights:
|
||||
echo '[{"id": "observability", "weight": 5}, {"id": "customer-1", "weight": 3}]' | \\
|
||||
python3 -m devx.ci.distribute_items \\
|
||||
--runner-index 1 --max-runners 3 --github-env
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
import click
|
||||
|
||||
from devx.ci._shared import lpt_distribute, write_github_env
|
||||
from devx.i18n import _
|
||||
|
||||
DEFAULT_MAX_RUNNERS = 3
|
||||
DEFAULT_WEIGHT = 1
|
||||
|
||||
|
||||
def parse_items(raw: str) -> list[str]:
|
||||
"""Parse a JSON array into a list of item identifier strings.
|
||||
|
||||
Accepts both plain string arrays (``["a", "b"]``) and object arrays
|
||||
(``[{"id": "a", "weight": 2}]``). Returns just the identifier strings.
|
||||
"""
|
||||
data = json.loads(raw)
|
||||
if not isinstance(data, list):
|
||||
raise click.ClickException(_("Items input must be a JSON array, got {type}", type=type(data).__name__))
|
||||
items: list[str] = []
|
||||
for entry in data:
|
||||
if isinstance(entry, str):
|
||||
items.append(entry)
|
||||
elif isinstance(entry, dict) and "id" in entry:
|
||||
items.append(str(entry["id"]))
|
||||
else:
|
||||
raise click.ClickException(
|
||||
_("Each item must be a string or an object with 'id', got {type}", type=type(entry).__name__)
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def parse_weighted_items(raw: str) -> tuple[list[str], list[int]]:
|
||||
"""Parse a JSON array into (items, weights) lists.
|
||||
|
||||
For plain string arrays, all items get ``DEFAULT_WEIGHT``.
|
||||
For object arrays, the ``weight`` field is used (default: ``DEFAULT_WEIGHT``).
|
||||
"""
|
||||
data = json.loads(raw)
|
||||
if not isinstance(data, list):
|
||||
raise click.ClickException(_("Items input must be a JSON array, got {type}", type=type(data).__name__))
|
||||
items: list[str] = []
|
||||
weights: list[int] = []
|
||||
for entry in data:
|
||||
if isinstance(entry, str):
|
||||
items.append(entry)
|
||||
weights.append(DEFAULT_WEIGHT)
|
||||
elif isinstance(entry, dict) and "id" in entry:
|
||||
items.append(str(entry["id"]))
|
||||
weights.append(int(entry.get("weight", DEFAULT_WEIGHT)))
|
||||
else:
|
||||
raise click.ClickException(
|
||||
_("Each item must be a string or an object with 'id', got {type}", type=type(entry).__name__)
|
||||
)
|
||||
return items, weights
|
||||
|
||||
|
||||
def distribute(items: list[str], weights: list[int], max_runners: int) -> list[list[str]]:
|
||||
"""Split *items* into *max_runners* balanced groups using LPT scheduling.
|
||||
|
||||
Items are sorted by weight (descending), then assigned to the runner
|
||||
with the least total weight.
|
||||
"""
|
||||
return lpt_distribute(items, weights, max_runners)
|
||||
|
||||
|
||||
def items_for_runner(items: list[str], weights: list[int], runner_index: int, max_runners: int) -> list[str]:
|
||||
"""Return the subset of items assigned to *runner_index* (0-based)."""
|
||||
groups = distribute(items, weights, max_runners)
|
||||
if runner_index < 0 or runner_index >= len(groups):
|
||||
raise click.ClickException(
|
||||
_("Runner index {index} out of range (0..{max})", index=runner_index, max=max_runners - 1)
|
||||
)
|
||||
return groups[runner_index]
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--items-file",
|
||||
type=click.Path(exists=True, file_okay=True, path_type=None),
|
||||
default=None,
|
||||
help="Read items from a JSON file instead of stdin.",
|
||||
)
|
||||
@click.option(
|
||||
"--runner-index",
|
||||
type=int,
|
||||
default=None,
|
||||
help="One-based runner index. If omitted, prints all groups.",
|
||||
)
|
||||
@click.option(
|
||||
"--max-runners",
|
||||
type=int,
|
||||
default=DEFAULT_MAX_RUNNERS,
|
||||
show_default=True,
|
||||
help="Total number of parallel runners.",
|
||||
)
|
||||
@click.option(
|
||||
"--github-env",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Write ASSIGNED_ITEMS and SKIP to $GITHUB_ENV.",
|
||||
)
|
||||
@click.option(
|
||||
"--skip-if-excess",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="With --github-env: write SKIP=true when runner-index exceeds max-runners.",
|
||||
)
|
||||
def main(
|
||||
items_file: str | None,
|
||||
runner_index: int | None,
|
||||
max_runners: int,
|
||||
github_env: bool,
|
||||
skip_if_excess: bool,
|
||||
) -> None:
|
||||
# Read items from file or stdin
|
||||
if items_file is not None:
|
||||
with open(items_file, encoding="utf-8") as f: # noqa: PTH123
|
||||
raw = f.read()
|
||||
else:
|
||||
raw = sys.stdin.read()
|
||||
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
raw = "[]"
|
||||
|
||||
items, weights = parse_weighted_items(raw)
|
||||
|
||||
if runner_index is None:
|
||||
groups = distribute(items, weights, max_runners)
|
||||
for i, group in enumerate(groups):
|
||||
labels = " ".join(group) if group else "(none)"
|
||||
click.echo(_("Runner {i}: {labels}", i=i, labels=labels))
|
||||
return
|
||||
|
||||
if skip_if_excess and github_env and runner_index > max_runners:
|
||||
click.echo(
|
||||
_(
|
||||
"Skipping — runner index {runner_index} > max runners {max_runners}",
|
||||
runner_index=runner_index,
|
||||
max_runners=max_runners,
|
||||
)
|
||||
)
|
||||
write_github_env("ASSIGNED_ITEMS", "")
|
||||
write_github_env("SKIP", "true")
|
||||
return
|
||||
|
||||
if runner_index < 1:
|
||||
raise click.ClickException(
|
||||
_("Runner index {runner_index} is out of range (must be >= 1)", runner_index=runner_index)
|
||||
)
|
||||
|
||||
zero_based = runner_index - 1
|
||||
assigned = items_for_runner(items, weights, zero_based, max_runners)
|
||||
encoded = " ".join(assigned)
|
||||
|
||||
if github_env:
|
||||
write_github_env("ASSIGNED_ITEMS", encoded)
|
||||
write_github_env("SKIP", "false")
|
||||
click.echo(
|
||||
_(
|
||||
"Assigned {count} items to runner {runner_index}: {encoded}",
|
||||
count=len(assigned),
|
||||
runner_index=runner_index,
|
||||
encoded=encoded,
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
click.echo(encoded)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
+55
-17
@@ -5,8 +5,12 @@ Parses Click commands from the CLI source code and checks if each command
|
||||
has corresponding documentation in the wiki/docs. Reports missing
|
||||
documentation as warnings and exits with non-zero if coverage is below 100%.
|
||||
|
||||
By default, checks the current repository's own source and docs directories.
|
||||
When run from the devx package itself (development mode), it checks devx's
|
||||
own files. When installed as a package, it checks the consuming repo's files.
|
||||
|
||||
Usage:
|
||||
python3 -m devx.ci.doc_coverage [--docs-dir docs/] [--fail-on-missing]
|
||||
python3 -m devx.ci.doc_coverage [--docs-dir docs/] [--source-dir src/] [--fail-on-missing]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,11 +23,12 @@ import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
|
||||
# Default to the current working directory (consuming repo's root)
|
||||
REPO_ROOT = Path.cwd()
|
||||
DOCS_DIR = REPO_ROOT / "docs"
|
||||
CLI_FILE = REPO_ROOT / "src" / "devx" / "cli.py"
|
||||
|
||||
# Major modules that should be documented in tech/architecture.md
|
||||
# These are devx-specific; when checking other repos, use --source-dir
|
||||
REQUIRED_MODULES = [
|
||||
"cli.py",
|
||||
"i18n.py",
|
||||
@@ -51,11 +56,16 @@ REQUIRED_SCRIPTS = [
|
||||
]
|
||||
|
||||
|
||||
def extract_cli_commands() -> list[str]:
|
||||
def extract_cli_commands(source_dir: Path) -> list[str]:
|
||||
"""Extract command names from the CLI source file."""
|
||||
if not CLI_FILE.exists():
|
||||
# Try to find the CLI file in the source directory
|
||||
cli_file = None
|
||||
for candidate in source_dir.rglob("cli.py"):
|
||||
cli_file = candidate
|
||||
break
|
||||
if cli_file is None or not cli_file.exists():
|
||||
return []
|
||||
content = CLI_FILE.read_text()
|
||||
content = cli_file.read_text()
|
||||
commands: list[str] = []
|
||||
# Find all @<group>.command("name") occurrences in the CLI source
|
||||
# Matches @cli.command, @ci.command, @tools.command, @molecule.command
|
||||
@@ -94,15 +104,30 @@ def check_module_documented(module: str, docs_content: str) -> bool:
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--docs-dir", default=str(DOCS_DIR), help="Path to the docs directory.")
|
||||
@click.option("--docs-dir", default=None, help="Path to the docs directory (default: ./docs).")
|
||||
@click.option("--source-dir", default=None, help="Path to the source directory (default: auto-detect from src/).")
|
||||
@click.option(
|
||||
"--fail-on-missing",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Exit with non-zero status if any documentation is missing.",
|
||||
)
|
||||
def main(docs_dir: str, fail_on_missing: bool) -> None:
|
||||
docs_path = Path(docs_dir)
|
||||
def main(docs_dir: str | None, source_dir: str | None, fail_on_missing: bool) -> None:
|
||||
root = Path.cwd()
|
||||
docs_path = Path(docs_dir) if docs_dir else root / "docs"
|
||||
|
||||
# Auto-detect source directory
|
||||
if source_dir:
|
||||
src_path = Path(source_dir)
|
||||
else:
|
||||
# Try common source directories
|
||||
for candidate in [root / "src", root / "scripts"]:
|
||||
if candidate.exists():
|
||||
src_path = candidate
|
||||
break
|
||||
else:
|
||||
src_path = root / "src"
|
||||
|
||||
cli_commands_file = docs_path / "user" / "cli-commands.md"
|
||||
architecture_file = docs_path / "tech" / "architecture.md"
|
||||
ci_cd_file = docs_path / "tech" / "ci-cd-workflow.md"
|
||||
@@ -112,21 +137,28 @@ def main(docs_dir: str, fail_on_missing: bool) -> None:
|
||||
|
||||
# Check CLI commands
|
||||
click.echo(_("Checking CLI command documentation..."))
|
||||
commands = extract_cli_commands()
|
||||
commands = extract_cli_commands(src_path)
|
||||
total += len(commands)
|
||||
cli_docs = cli_commands_file.read_text() if cli_commands_file.exists() else ""
|
||||
for cmd in commands:
|
||||
if check_command_documented(cmd, cli_docs):
|
||||
click.echo(_(" OK: devx {cmd}", cmd=cmd))
|
||||
click.echo(_(" OK: {cmd}", cmd=cmd))
|
||||
else:
|
||||
click.echo(_(" MISSING: devx {cmd}", cmd=cmd))
|
||||
missing.append(f"CLI command: devx {cmd}")
|
||||
click.echo(_(" MISSING: {cmd}", cmd=cmd))
|
||||
missing.append(f"CLI command: {cmd}")
|
||||
|
||||
# Check modules in architecture.md
|
||||
# Auto-detect modules from source directory (top-level only, exclude subdirs)
|
||||
click.echo(_("\nChecking module documentation in architecture.md..."))
|
||||
total += len(REQUIRED_MODULES)
|
||||
if src_path.exists():
|
||||
detected_modules = sorted(
|
||||
f.name for f in src_path.glob("*.py") if f.name != "__init__.py" and f.name != "cli.py"
|
||||
)
|
||||
else:
|
||||
detected_modules = REQUIRED_MODULES
|
||||
total += len(detected_modules)
|
||||
arch_docs = architecture_file.read_text() if architecture_file.exists() else ""
|
||||
for module in REQUIRED_MODULES:
|
||||
for module in detected_modules:
|
||||
if check_module_documented(module, arch_docs):
|
||||
click.echo(_(" OK: {module}", module=module))
|
||||
else:
|
||||
@@ -134,10 +166,16 @@ def main(docs_dir: str, fail_on_missing: bool) -> None:
|
||||
missing.append(f"Module: {module}")
|
||||
|
||||
# Check CI scripts in ci-cd-workflow.md
|
||||
# Auto-detect CI scripts from ci/ subdirectory
|
||||
click.echo(_("\nChecking CI script documentation in ci-cd-workflow.md..."))
|
||||
total += len(REQUIRED_SCRIPTS)
|
||||
ci_dir = src_path / "ci" if src_path.name != "ci" else src_path
|
||||
if ci_dir.exists():
|
||||
detected_scripts = sorted(f.name for f in ci_dir.glob("*.py") if f.name != "__init__.py")
|
||||
else:
|
||||
detected_scripts = REQUIRED_SCRIPTS
|
||||
total += len(detected_scripts)
|
||||
ci_docs = ci_cd_file.read_text() if ci_cd_file.exists() else ""
|
||||
for script in REQUIRED_SCRIPTS:
|
||||
for script in detected_scripts:
|
||||
if check_module_documented(script, ci_docs):
|
||||
click.echo(_(" OK: {script}", script=script))
|
||||
else:
|
||||
|
||||
@@ -17,7 +17,7 @@ Usage::
|
||||
|
||||
Environment variables:
|
||||
GITEA_URL Base URL of the Gitea instance.
|
||||
REPO_TOKEN API token with repo access.
|
||||
CI_GITEA_TOKEN API token with repo access.
|
||||
RUN_ID Workflow run ID (GITHUB_RUN_ID).
|
||||
JOB_NAME Base job name (GITHUB_JOB), e.g. "integration-tests".
|
||||
MATRIX_INDEX Current matrix index (runner-index).
|
||||
@@ -36,6 +36,7 @@ import time
|
||||
|
||||
import click
|
||||
|
||||
from devx.config import REPO_NAME, REPO_OWNER
|
||||
from devx.i18n import _
|
||||
from devx.molecule.molecule_ci_guard import (
|
||||
poll_for_other_failures,
|
||||
@@ -49,17 +50,17 @@ POLL_INTERVAL = 10
|
||||
def cli(pytest_args: tuple[str, ...]) -> None:
|
||||
"""Run pytest with cross-runner failure detection."""
|
||||
gitea_url = os.environ.get("GITEA_URL", "")
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
run_id = int(os.environ.get("RUN_ID", "0"))
|
||||
job_name = os.environ.get("JOB_NAME", "integration-tests")
|
||||
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
|
||||
repository = os.environ.get("GITEA_REPOSITORY", "oblachno-oss/devx")
|
||||
repository = os.environ.get("GITEA_REPOSITORY", "")
|
||||
owner, _sep, repo = repository.partition("/")
|
||||
if not owner or not repo:
|
||||
owner, repo = "oblachno-oss", "devx"
|
||||
owner, repo = REPO_OWNER, REPO_NAME
|
||||
|
||||
if not all([gitea_url, token, run_id]):
|
||||
click.echo(_("GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
|
||||
click.echo(_("GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
|
||||
|
||||
stop_event = threading.Event()
|
||||
failed_event = threading.Event()
|
||||
@@ -85,7 +86,7 @@ def cli(pytest_args: tuple[str, ...]) -> None:
|
||||
cmd = [sys.executable, "-m", "pytest"]
|
||||
cmd.extend(pytest_args)
|
||||
|
||||
click.echo(f"Running: {' '.join(cmd)}")
|
||||
click.echo(_("Running: {cmd}", cmd=" ".join(cmd)))
|
||||
|
||||
process = subprocess.Popen( # nosec B603
|
||||
cmd,
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Lint documentation files for structure, links, and quality.
|
||||
|
||||
Checks performed (all configurable via pyproject.toml ``[tool.devx.docs]``):
|
||||
- **Required files**: README.md, AGENTS.md, CHANGELOG.md must exist.
|
||||
- **Docs structure**: ``docs/index.md`` and ``docs/mapping.json`` must exist.
|
||||
- **Broken internal links**: relative paths and anchors in markdown files
|
||||
must resolve to actual files and headings.
|
||||
- **Heading hierarchy**: no skipping heading levels (e.g., ``#`` → ``###``).
|
||||
- **TODO/FIXME**: flags leftover TODO/FIXME markers in documentation.
|
||||
- **Stale docs**: files not modified in >180 days (warning only).
|
||||
- **Trailing whitespace**: lines should not end with whitespace.
|
||||
- **Blank line before headings**: headings should have a blank line before them.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.ci.lint_docs
|
||||
python3 -m devx.ci.lint_docs --docs-dir docs/ --root .
|
||||
python3 -m devx.ci.lint_docs --fix # auto-fix trailing whitespace
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
# Heading slug pattern (GitHub-style)
|
||||
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$", re.MULTILINE)
|
||||
# Markdown link pattern: [text](url)
|
||||
_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]+)\)")
|
||||
# Trailing whitespace
|
||||
_TRAILING_WS_RE = re.compile(r"[ \t]+$")
|
||||
# Heading without blank line before
|
||||
_HEADING_NO_BLANK_RE = re.compile(r"([^\n])\n(#{1,6}\s)")
|
||||
|
||||
# Files that must exist in every project
|
||||
REQUIRED_FILES = ["README.md", "AGENTS.md", "CHANGELOG.md"]
|
||||
|
||||
# Files that must exist in docs/
|
||||
REQUIRED_DOC_FILES = ["index.md"]
|
||||
|
||||
# Maximum age for docs before they're considered stale (days)
|
||||
STALE_THRESHOLD_DAYS = 180
|
||||
|
||||
# Files excluded from duplicate heading checks (auto-generated or structured
|
||||
# with repeated subsections under different parent sections)
|
||||
DUPLICATE_HEADING_EXCLUDES = {
|
||||
"CHANGELOG.md",
|
||||
"incident-response-sso.md",
|
||||
"role-sync-design.md",
|
||||
}
|
||||
|
||||
# TODO/FIXME pattern — matches "TODO:" or "FIXME:" at start of line/after whitespace
|
||||
# Does NOT match references to the word "TODO" in rules/documentation
|
||||
_TODO_RE = re.compile(r"(?m)^\s*(?:>>>?\s*)?(TODO|FIXME|HACK|XXX)\s*:", re.IGNORECASE)
|
||||
|
||||
# Directories excluded from markdown file scanning
|
||||
_EXCLUDE_DIRS = {
|
||||
".venv",
|
||||
".git",
|
||||
"node_modules",
|
||||
"__pycache__",
|
||||
".pytest_cache",
|
||||
".devin",
|
||||
".terraform",
|
||||
"site-packages",
|
||||
"dist-info",
|
||||
}
|
||||
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
"""Convert heading text to a GitHub-style slug."""
|
||||
slug = text.lower().strip()
|
||||
slug = re.sub(r"[^\w\s-]", "", slug)
|
||||
slug = re.sub(r"[\s]+", "-", slug)
|
||||
return slug
|
||||
|
||||
|
||||
def strip_code_blocks(content: str) -> str:
|
||||
"""Remove fenced code blocks from markdown content.
|
||||
|
||||
Replaces ```...``` blocks with empty lines so heading detection
|
||||
doesn't pick up # comments inside code blocks.
|
||||
"""
|
||||
result: list[str] = []
|
||||
in_code_block = False
|
||||
for line in content.splitlines():
|
||||
if line.strip().startswith("```"):
|
||||
in_code_block = not in_code_block
|
||||
result.append("")
|
||||
continue
|
||||
if in_code_block:
|
||||
result.append("")
|
||||
continue
|
||||
result.append(line)
|
||||
return "\n".join(result)
|
||||
|
||||
|
||||
def extract_headings(filepath: Path) -> dict[str, int]:
|
||||
"""Extract all headings from a markdown file.
|
||||
|
||||
Returns a dict mapping slug → heading level.
|
||||
"""
|
||||
content = strip_code_blocks(filepath.read_text(encoding="utf-8"))
|
||||
headings: dict[str, int] = {}
|
||||
for match in _HEADING_RE.finditer(content):
|
||||
level = len(match.group(1))
|
||||
text = match.group(2)
|
||||
slug = slugify(text)
|
||||
headings[slug] = level
|
||||
return headings
|
||||
|
||||
|
||||
def extract_links(filepath: Path) -> list[tuple[int, str, str]]:
|
||||
"""Extract all markdown links from a file.
|
||||
|
||||
Returns a list of (line_number, link_text, url) tuples.
|
||||
Includes anchor-only links (#section) for validation.
|
||||
Skips external links (http/https) and mailto.
|
||||
"""
|
||||
content = filepath.read_text(encoding="utf-8")
|
||||
links: list[tuple[int, str, str]] = []
|
||||
for match in _LINK_RE.finditer(content):
|
||||
url = match.group(2).strip()
|
||||
# Skip external links and mailto
|
||||
if url.startswith(("http://", "https://", "mailto:")):
|
||||
continue
|
||||
line_num = content[: match.start()].count("\n") + 1
|
||||
links.append((line_num, match.group(1), url))
|
||||
return links
|
||||
|
||||
|
||||
def check_required_files(root: Path) -> list[str]:
|
||||
"""Check that required files exist."""
|
||||
issues: list[str] = []
|
||||
for filename in REQUIRED_FILES:
|
||||
if not (root / filename).exists():
|
||||
issues.append(f"Missing required file: {filename}")
|
||||
return issues
|
||||
|
||||
|
||||
def check_docs_structure(root: Path, docs_dir: Path) -> list[str]:
|
||||
"""Check that docs directory has required structure."""
|
||||
issues: list[str] = []
|
||||
if not docs_dir.exists():
|
||||
issues.append(f"Docs directory not found: {docs_dir}")
|
||||
return issues
|
||||
for filename in REQUIRED_DOC_FILES:
|
||||
if not (docs_dir / filename).exists():
|
||||
issues.append(f"Missing required doc file: docs/{filename}")
|
||||
mapping_file = docs_dir / "mapping.json"
|
||||
if mapping_file.exists():
|
||||
try:
|
||||
mapping = json.loads(mapping_file.read_text(encoding="utf-8"))
|
||||
if not isinstance(mapping, dict):
|
||||
issues.append("docs/mapping.json must be a JSON object")
|
||||
elif not mapping:
|
||||
issues.append("docs/mapping.json is empty")
|
||||
except json.JSONDecodeError as e:
|
||||
issues.append(f"docs/mapping.json is invalid JSON: {e}")
|
||||
return issues
|
||||
|
||||
|
||||
def check_internal_links(root: Path, docs_dir: Path) -> list[str]:
|
||||
"""Check that all internal links in markdown files resolve."""
|
||||
issues: list[str] = []
|
||||
md_files = list(root.rglob("*.md"))
|
||||
# Exclude .venv, .git, node_modules
|
||||
md_files = [f for f in md_files if not any(part in _EXCLUDE_DIRS for part in f.parts)]
|
||||
|
||||
# Load wiki page names from mapping.json — these are valid link targets
|
||||
wiki_pages: set[str] = set()
|
||||
mapping_file = docs_dir / "mapping.json"
|
||||
if mapping_file.exists():
|
||||
try:
|
||||
mapping = json.loads(mapping_file.read_text(encoding="utf-8"))
|
||||
wiki_pages = set(mapping.values())
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
|
||||
for md_file in md_files:
|
||||
rel_path = md_file.relative_to(root)
|
||||
links = extract_links(md_file)
|
||||
headings = extract_headings(md_file)
|
||||
|
||||
for line_num, _link_text, url in links:
|
||||
# Split into path and anchor
|
||||
if "#" in url:
|
||||
path_part, anchor = url.split("#", 1)
|
||||
else:
|
||||
path_part, anchor = url, ""
|
||||
|
||||
# Skip wiki page references (no file extension, no /, matches mapping.json values)
|
||||
if path_part and "." not in path_part and "/" not in path_part:
|
||||
if path_part in wiki_pages:
|
||||
continue
|
||||
# Also skip if it looks like a wiki page name (CamelCase or hyphenated)
|
||||
# without a file extension — can't verify these locally
|
||||
if not any(c in path_part for c in "/\\"):
|
||||
continue
|
||||
|
||||
# Resolve relative path
|
||||
if path_part:
|
||||
target = (md_file.parent / path_part).resolve()
|
||||
if not target.exists():
|
||||
issues.append(f"{rel_path}:{line_num}: broken link '{url}' — file not found: {path_part}")
|
||||
continue
|
||||
# Check anchor in target file
|
||||
if anchor:
|
||||
target_headings = extract_headings(target)
|
||||
target_slug = slugify(anchor)
|
||||
if target_slug not in target_headings:
|
||||
issues.append(f"{rel_path}:{line_num}: broken anchor '#{anchor}' in {path_part}")
|
||||
elif anchor:
|
||||
# Anchor-only link — check in current file
|
||||
anchor_slug = slugify(anchor)
|
||||
if anchor_slug not in headings:
|
||||
issues.append(f"{rel_path}:{line_num}: broken anchor '#{anchor}'")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def check_heading_hierarchy(root: Path) -> list[str]:
|
||||
"""Check that headings don't skip levels."""
|
||||
issues: list[str] = []
|
||||
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
|
||||
|
||||
for md_file in md_files:
|
||||
rel_path = md_file.relative_to(root)
|
||||
content = strip_code_blocks(md_file.read_text(encoding="utf-8"))
|
||||
prev_level = 0
|
||||
for match in _HEADING_RE.finditer(content):
|
||||
level = len(match.group(1))
|
||||
if prev_level > 0 and level > prev_level + 1:
|
||||
issues.append(f"{rel_path}: heading hierarchy skip — H{prev_level} → H{level}: '{match.group(2)}'")
|
||||
prev_level = level
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def check_todo_fixme(root: Path) -> list[str]:
|
||||
"""Check for TODO/FIXME/HACK/XXX markers in documentation.
|
||||
|
||||
Only flags actual TODO/FIXME markers (e.g., "TODO: fix this"), not
|
||||
references to the word "TODO" in rules or documentation about TODOs.
|
||||
"""
|
||||
issues: list[str] = []
|
||||
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
|
||||
|
||||
for md_file in md_files:
|
||||
rel_path = md_file.relative_to(root)
|
||||
content = md_file.read_text(encoding="utf-8")
|
||||
for match in _TODO_RE.finditer(content):
|
||||
line_num = content[: match.start()].count("\n") + 1
|
||||
line = content.splitlines()[line_num - 1] if line_num <= len(content.splitlines()) else ""
|
||||
issues.append(f"{rel_path}:{line_num}: TODO/FIXME found: {line.strip()}")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def check_trailing_whitespace(root: Path) -> list[str]:
|
||||
"""Check for trailing whitespace in markdown files."""
|
||||
issues: list[str] = []
|
||||
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
|
||||
|
||||
for md_file in md_files:
|
||||
rel_path = md_file.relative_to(root)
|
||||
content = md_file.read_text(encoding="utf-8")
|
||||
for i, line in enumerate(content.splitlines(), 1):
|
||||
if _TRAILING_WS_RE.search(line):
|
||||
issues.append(f"{rel_path}:{i}: trailing whitespace")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def check_stale_docs(root: Path) -> list[str]:
|
||||
"""Check for stale documentation (not modified in >180 days)."""
|
||||
issues: list[str] = []
|
||||
threshold = datetime.now() - timedelta(days=STALE_THRESHOLD_DAYS)
|
||||
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
|
||||
|
||||
for md_file in md_files:
|
||||
rel_path = md_file.relative_to(root)
|
||||
mtime = datetime.fromtimestamp(md_file.stat().st_mtime)
|
||||
if mtime < threshold:
|
||||
days_old = (datetime.now() - mtime).days
|
||||
issues.append(f"{rel_path}: stale doc — not modified in {days_old} days")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def check_duplicate_headings(root: Path) -> list[str]:
|
||||
"""Check for duplicate headings within the same file."""
|
||||
issues: list[str] = []
|
||||
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
|
||||
|
||||
for md_file in md_files:
|
||||
rel_path = md_file.relative_to(root)
|
||||
# Skip auto-generated files like CHANGELOG.md
|
||||
if md_file.name in DUPLICATE_HEADING_EXCLUDES:
|
||||
continue
|
||||
content = strip_code_blocks(md_file.read_text(encoding="utf-8"))
|
||||
seen: dict[str, int] = {}
|
||||
for match in _HEADING_RE.finditer(content):
|
||||
text = match.group(2)
|
||||
slug = slugify(text)
|
||||
if slug in seen:
|
||||
issues.append(f"{rel_path}: duplicate heading '{text}'")
|
||||
seen[slug] = 1
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--root", default=".", help="Repository root directory.")
|
||||
@click.option("--docs-dir", default=None, help="Docs directory (default: <root>/docs).")
|
||||
@click.option("--check-links/--no-check-links", default=True, help="Check internal links.")
|
||||
@click.option("--check-headings/--no-check-headings", default=True, help="Check heading hierarchy.")
|
||||
@click.option("--check-todo/--no-check-todo", default=True, help="Check for TODO/FIXME.")
|
||||
@click.option("--check-stale/--no-check-stale", default=False, help="Check for stale docs.")
|
||||
@click.option("--check-trailing/--no-check-trailing", default=True, help="Check trailing whitespace.")
|
||||
@click.option("--check-duplicates/--no-check-duplicates", default=True, help="Check duplicate headings.")
|
||||
@click.option("--fix", is_flag=True, default=False, help="Auto-fix trailing whitespace.")
|
||||
def main(
|
||||
root: str,
|
||||
docs_dir: str | None,
|
||||
check_links: bool,
|
||||
check_headings: bool,
|
||||
check_todo: bool,
|
||||
check_stale: bool,
|
||||
check_trailing: bool,
|
||||
check_duplicates: bool,
|
||||
fix: bool,
|
||||
) -> None:
|
||||
"""Lint documentation files for structure, links, and quality."""
|
||||
root_path = Path(root).resolve()
|
||||
docs_path = Path(docs_dir) if docs_dir else root_path / "docs"
|
||||
|
||||
click.echo(_("Linting documentation in {root}...", root=str(root_path)))
|
||||
|
||||
all_issues: list[str] = []
|
||||
|
||||
# Structure checks
|
||||
click.echo(_("Checking required files..."))
|
||||
all_issues.extend(check_required_files(root_path))
|
||||
|
||||
click.echo(_("Checking docs structure..."))
|
||||
all_issues.extend(check_docs_structure(root_path, docs_path))
|
||||
|
||||
# Link checks
|
||||
if check_links:
|
||||
click.echo(_("Checking internal links..."))
|
||||
all_issues.extend(check_internal_links(root_path, docs_path))
|
||||
|
||||
# Heading hierarchy
|
||||
if check_headings:
|
||||
click.echo(_("Checking heading hierarchy..."))
|
||||
all_issues.extend(check_heading_hierarchy(root_path))
|
||||
|
||||
# Duplicate headings
|
||||
if check_duplicates:
|
||||
click.echo(_("Checking duplicate headings..."))
|
||||
all_issues.extend(check_duplicate_headings(root_path))
|
||||
|
||||
# TODO/FIXME
|
||||
if check_todo:
|
||||
click.echo(_("Checking for TODO/FIXME markers..."))
|
||||
all_issues.extend(check_todo_fixme(root_path))
|
||||
|
||||
# Trailing whitespace
|
||||
if check_trailing:
|
||||
click.echo(_("Checking trailing whitespace..."))
|
||||
ws_issues = check_trailing_whitespace(root_path)
|
||||
if fix and ws_issues:
|
||||
fixed = 0
|
||||
md_files = [f for f in root_path.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
|
||||
for md_file in md_files:
|
||||
content = md_file.read_text(encoding="utf-8")
|
||||
fixed_content = _TRAILING_WS_RE.sub("", content)
|
||||
if content != fixed_content:
|
||||
md_file.write_text(fixed_content, encoding="utf-8")
|
||||
fixed += 1
|
||||
click.echo(_(" Auto-fixed trailing whitespace in {n} files", n=fixed))
|
||||
else:
|
||||
all_issues.extend(ws_issues)
|
||||
|
||||
# Stale docs
|
||||
if check_stale:
|
||||
click.echo(_("Checking for stale docs..."))
|
||||
stale = check_stale_docs(root_path)
|
||||
for issue in stale:
|
||||
click.echo(f" WARN: {issue}")
|
||||
# Stale docs are warnings, not errors
|
||||
click.echo(_(" {n} stale docs found (warnings only)", n=len(stale)))
|
||||
|
||||
# Report
|
||||
click.echo(f"\n{'=' * 60}")
|
||||
if all_issues:
|
||||
click.echo(_("FAIL: {n} documentation issues found:", n=len(all_issues)))
|
||||
for issue in all_issues:
|
||||
click.echo(f" - {issue}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
click.echo(_("PASS: All documentation checks passed!"))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -6,7 +6,7 @@ otherwise go unnoticed in the Actions tab. Uses the ``tea`` Gitea CLI
|
||||
for issue creation — tea must be installed and configured.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 -m devx.ci.notify_failure \
|
||||
CI_GITEA_TOKEN=<token> python3 -m devx.ci.notify_failure \
|
||||
--repo <owner/repo> \
|
||||
--run-id <run_id> \
|
||||
--workflow <workflow_name> \
|
||||
@@ -14,7 +14,7 @@ Usage:
|
||||
--auto-login
|
||||
|
||||
With ``--auto-login``, the script configures the tea CLI login profile
|
||||
from ``REPO_TOKEN`` and ``DEVX_GITEA_API_URL`` before creating the issue,
|
||||
from ``CI_GITEA_TOKEN`` and ``DEVX_GITEA_API_URL`` before creating the issue,
|
||||
eliminating the need for a separate ``tea login add`` step in the workflow.
|
||||
"""
|
||||
|
||||
@@ -22,14 +22,12 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from devx.config import GITEA_API_URL
|
||||
from devx.gitea_cli import TeaCLI, TeaCLIError
|
||||
from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login
|
||||
from devx.i18n import _
|
||||
|
||||
load_dotenv()
|
||||
@@ -37,49 +35,6 @@ load_dotenv()
|
||||
logger = logging.getLogger("devx")
|
||||
|
||||
|
||||
def _configure_tea_login(login_name: str = "devx") -> None:
|
||||
"""Configure tea CLI login from REPO_TOKEN and DEVX_GITEA_API_URL.
|
||||
|
||||
Idempotent: if a login with the same name already exists, it is not re-added.
|
||||
Skips silently if tea is not installed or REPO_TOKEN is not set.
|
||||
"""
|
||||
tea_bin = shutil.which("tea")
|
||||
if tea_bin is None:
|
||||
click.echo("notify_failure: tea not installed — skipping login configuration.")
|
||||
return
|
||||
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
if not token:
|
||||
click.echo("notify_failure: REPO_TOKEN not set — skipping login configuration.")
|
||||
return
|
||||
|
||||
gitea_url = GITEA_API_URL.replace("/api/v1", "")
|
||||
|
||||
result = subprocess.run( # nosec B603
|
||||
[tea_bin, "login", "list", "--output", "simple"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0 and login_name in result.stdout:
|
||||
click.echo(f"notify_failure: tea login '{login_name}' already configured.")
|
||||
return
|
||||
|
||||
click.echo(f"notify_failure: configuring tea login '{login_name}' for {gitea_url}...")
|
||||
subprocess.run( # nosec B603
|
||||
[tea_bin, "login", "add", "--name", login_name, "--url", gitea_url, "--token", token],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
subprocess.run( # nosec B603
|
||||
[tea_bin, "login", "default", login_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _create_issue_via_tea(repo: str, title: str, body: str) -> int:
|
||||
"""Create issue via tea CLI. Returns issue index.
|
||||
|
||||
@@ -116,15 +71,15 @@ def _create_issue_via_tea(repo: str, title: str, body: str) -> int:
|
||||
"--auto-login",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Configure tea CLI login from REPO_TOKEN before creating the issue.",
|
||||
help="Configure tea CLI login from CI_GITEA_TOKEN before creating the issue.",
|
||||
)
|
||||
def main(repo: str, run_id: str, workflow: str, commit: str, auto_login: bool) -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
|
||||
|
||||
if auto_login:
|
||||
_configure_tea_login()
|
||||
configure_tea_login()
|
||||
|
||||
title = f"[CI] {workflow} workflow failed (run #{run_id})"
|
||||
body = (
|
||||
|
||||
@@ -13,7 +13,8 @@ import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from devx.api_clients import VikunjaClient
|
||||
from devx.config import DEFAULT_PER_PAGE, TASK_ID_RE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
|
||||
from devx.ci._shared import extract_task_id as _extract_task_id
|
||||
from devx.config import DEFAULT_PER_PAGE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
|
||||
from devx.exceptions import APIError
|
||||
from devx.i18n import _
|
||||
|
||||
@@ -47,10 +48,9 @@ def _get_git_commit_sha() -> str:
|
||||
|
||||
|
||||
def extract_task_id(commit_msg: str) -> str:
|
||||
"""Extract DEVX-N task identifier from the first line of commit message."""
|
||||
"""Extract task identifier from the first line of commit message (delegates to shared utility)."""
|
||||
first_line = commit_msg.split("\n")[0]
|
||||
match = TASK_ID_RE.search(first_line)
|
||||
return match.group(0) if match else ""
|
||||
return _extract_task_id(first_line)
|
||||
|
||||
|
||||
def extract_conventional_msg(commit_msg: str) -> str:
|
||||
@@ -61,7 +61,7 @@ def extract_conventional_msg(commit_msg: str) -> str:
|
||||
- ``DEVX-N <message>`` (current, space-separated)
|
||||
"""
|
||||
first_line = commit_msg.split("\n")[0]
|
||||
return re.sub(r"^DEVX-\d+[:\s]\s*", "", first_line)
|
||||
return re.sub(rf"^{TASK_PREFIX}-\d+[:\s]\s*", "", first_line)
|
||||
|
||||
|
||||
def resolve_task_id(client: VikunjaClient, task_id: str) -> int:
|
||||
|
||||
+119
-5
@@ -17,7 +17,7 @@ Checks performed:
|
||||
8. Commit conventions — conventional commit format on branch commits
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 -m devx.ci.pr_review <pr_number> <owner/repo>
|
||||
CI_GITEA_TOKEN=<token> python3 -m devx.ci.pr_review <pr_number> <owner/repo>
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -387,14 +387,34 @@ def check_documentation(files: list[dict[str, Any]], result: ReviewResult) -> No
|
||||
for f in files
|
||||
)
|
||||
has_ansible_changes = any(f.get("filename", "").startswith("ansible/") for f in files)
|
||||
has_tofu_changes = any(f.get("filename", "").startswith("tofu/") for f in files)
|
||||
has_workflow_changes = any(f.get("filename", "").startswith(".gitea/") for f in files)
|
||||
|
||||
# Check for TODO/FIXME in changed docs
|
||||
todo_issues: list[str] = []
|
||||
for f in files:
|
||||
filename = f.get("filename", "")
|
||||
if filename.endswith(".md") and filename.startswith(("docs/", "README", "AGENTS")):
|
||||
# Can't check file content from PR API easily, but flag if patch adds TODO
|
||||
patch = f.get("patch", "")
|
||||
if patch and re.search(r"^\+.*\b(TODO|FIXME|HACK|XXX)\b", patch, re.IGNORECASE):
|
||||
todo_issues.append(f"{filename}: new TODO/FIXME added in documentation")
|
||||
|
||||
if has_src_changes and not has_doc_changes:
|
||||
result.add_summary("- Documentation: WARNING — source files changed but no docs updated")
|
||||
elif has_ansible_changes and not has_doc_changes:
|
||||
result.add_summary("- Documentation: WARNING — Ansible role changed but no docs updated")
|
||||
elif has_tofu_changes and not has_doc_changes:
|
||||
result.add_summary("- Documentation: WARNING — OpenTofu changes but no docs updated")
|
||||
elif has_workflow_changes and not has_doc_changes:
|
||||
result.add_summary("- Documentation: INFO — workflow changes (consider updating CI docs if behavior changed)")
|
||||
else:
|
||||
result.add_summary("- Documentation: OK")
|
||||
|
||||
if todo_issues:
|
||||
for issue in todo_issues:
|
||||
result.add_summary(f"- Documentation: WARNING — {issue}")
|
||||
|
||||
|
||||
def check_test_coverage(files: list[dict[str, Any]], result: ReviewResult) -> None:
|
||||
"""Check that tests are updated for source changes."""
|
||||
@@ -520,19 +540,113 @@ def post_review(client: GiteaClient, pr_number: str, result: ReviewResult) -> di
|
||||
return client.create_review(pr_number, event=event, body=body, comments=comments)
|
||||
|
||||
|
||||
def _post_manual_review(
|
||||
client: GiteaClient,
|
||||
pr_number: str,
|
||||
event: str,
|
||||
body: str | None,
|
||||
checklist_confirmed: bool,
|
||||
checklist_categories: str | None,
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
"""Post a manual review with validation for APPROVE events."""
|
||||
if not body or len(body) < 50:
|
||||
raise click.ClickException(_("Review body must be at least 50 characters."))
|
||||
|
||||
if event == "APPROVE":
|
||||
if not checklist_confirmed:
|
||||
raise click.ClickException(
|
||||
_("--checklist-confirmed is required for APPROVE events."),
|
||||
)
|
||||
cats = [c.strip() for c in (checklist_categories or "").split(",") if c.strip()]
|
||||
cat_nums: list[int] = []
|
||||
for c in cats:
|
||||
try:
|
||||
cat_nums.append(int(c))
|
||||
except ValueError:
|
||||
raise click.ClickException(
|
||||
_("Invalid checklist category: {cat}. Must be numbers.", cat=c),
|
||||
) from None
|
||||
if len(cat_nums) < 8:
|
||||
raise click.ClickException(
|
||||
_("--checklist-categories must list at least 8 of 13 categories. Got {count}.", count=len(cat_nums)),
|
||||
)
|
||||
|
||||
click.echo(f"Manual review event: {event}")
|
||||
click.echo(f"Body: {body[:80]}...")
|
||||
if checklist_confirmed:
|
||||
click.echo(f"Checklist confirmed: {checklist_categories}")
|
||||
|
||||
if dry_run:
|
||||
click.echo("\n[dry-run] Review not posted.")
|
||||
return
|
||||
|
||||
try:
|
||||
review = client.create_review(pr_number, event=event, body=body)
|
||||
except APIError as e:
|
||||
if "approve" in e.message.lower() or "422" in str(e.status):
|
||||
click.echo(_("Note: Self-approval not allowed. Posting COMMENT instead."))
|
||||
review = client.create_review(pr_number, event="COMMENT", body=body)
|
||||
else:
|
||||
raise
|
||||
review_id = review.get("id", "?")
|
||||
click.echo(
|
||||
_(
|
||||
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||
review_id=review_id,
|
||||
pr_number=pr_number,
|
||||
event=event,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("pr_number")
|
||||
@click.argument("repo")
|
||||
@click.option("--dry-run", is_flag=True, default=False, help="Print review without posting.")
|
||||
def main(pr_number: str, repo: str, dry_run: bool) -> None:
|
||||
"""Run automated PR review and post results to Gitea."""
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
@click.option(
|
||||
"--event",
|
||||
type=click.Choice(["APPROVE", "REQUEST_CHANGES", "COMMENT"], case_sensitive=False),
|
||||
default=None,
|
||||
help="Post a manual review with the given event (skips automated checks).",
|
||||
)
|
||||
@click.option("--body", default=None, help="Review body text (required with --event).")
|
||||
@click.option(
|
||||
"--checklist-confirmed",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Attest that REVIEW_CHECKLIST.md categories were checked (required for APPROVE).",
|
||||
)
|
||||
@click.option(
|
||||
"--checklist-categories",
|
||||
default=None,
|
||||
help="Comma-separated checklist category numbers (required for APPROVE, min 8 of 13).",
|
||||
)
|
||||
def main(
|
||||
pr_number: str,
|
||||
repo: str,
|
||||
dry_run: bool,
|
||||
event: str | None,
|
||||
body: str | None,
|
||||
checklist_confirmed: bool,
|
||||
checklist_categories: str | None,
|
||||
) -> None:
|
||||
"""Run automated PR review and post results to Gitea.
|
||||
|
||||
Without --event: runs automated checks and posts COMMENT/REQUEST_CHANGES.
|
||||
With --event: posts a manual review (skips automated checks).
|
||||
"""
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
|
||||
|
||||
owner, repo_name = repo.split("/")
|
||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
||||
|
||||
if event is not None:
|
||||
_post_manual_review(client, pr_number, event.upper(), body, checklist_confirmed, checklist_categories, dry_run)
|
||||
return
|
||||
|
||||
result = run_review(client, pr_number)
|
||||
|
||||
body = build_review_body(result)
|
||||
|
||||
+19
-8
@@ -9,14 +9,14 @@ Publishing destinations (checked in order):
|
||||
``DEVX_PYPI_REGISTRY_URL`` env var is set, or ``GITEA_API_URL``
|
||||
is converted to a packages URL). Uses ``twine upload
|
||||
--repository-url <url> -u <token> -p <token>`` with the
|
||||
``REPO_TOKEN`` as both username and password.
|
||||
``CI_GITEA_TOKEN`` as both username and password.
|
||||
2. **Standard PyPI** — if ``PYPI_TOKEN`` is set. Uses the standard
|
||||
``twine upload -u __token__ -p <token>`` flow.
|
||||
3. **Skip** — if neither is configured, only the Gitea release is created.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> [PYPI_TOKEN=<token>] python3 -m devx.ci.publish <tag> <repo>
|
||||
REPO_TOKEN=<token> python3 -m devx.ci.publish <tag> <repo> --registry-url https://git.example.com/api/packages/owner/pypi
|
||||
CI_GITEA_TOKEN=<token> [PYPI_TOKEN=<token>] python3 -m devx.ci.publish <tag> <repo>
|
||||
CI_GITEA_TOKEN=<token> python3 -m devx.ci.publish <tag> <repo> --registry-url https://git.example.com/api/packages/owner/pypi
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -28,8 +28,8 @@ from pathlib import Path
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from devx.config import GITEA_API_URL
|
||||
from devx.gitea_cli import TeaCLI, TeaCLIError
|
||||
from devx.config import GITEA_API_URL, REPO_OWNER
|
||||
from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login
|
||||
from devx.i18n import _
|
||||
|
||||
load_dotenv()
|
||||
@@ -165,7 +165,7 @@ def _default_gitea_registry_url() -> str:
|
||||
base = base[: -len("/api/v1")]
|
||||
elif base.endswith("/api"):
|
||||
base = base[: -len("/api")]
|
||||
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss")
|
||||
owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
|
||||
return f"{base}/api/packages/{owner}/pypi"
|
||||
|
||||
|
||||
@@ -221,12 +221,20 @@ def is_release_commit(tag: str) -> bool:
|
||||
help="Auto-detect latest tag and check if HEAD is a release commit. "
|
||||
"Skips publish if no tag or HEAD is not a release commit for that tag.",
|
||||
)
|
||||
@click.option(
|
||||
"--auto-login",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Configure tea CLI login from CI_GITEA_TOKEN before creating the Gitea release. "
|
||||
"Eliminates the need for a separate tea login step in containerized CI jobs.",
|
||||
)
|
||||
def main(
|
||||
tag: str | None,
|
||||
repo: str | None,
|
||||
registry_url: str | None,
|
||||
skip_build: bool,
|
||||
from_tag: bool,
|
||||
auto_login: bool,
|
||||
) -> None:
|
||||
if repo is None:
|
||||
repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||
@@ -245,9 +253,9 @@ def main(
|
||||
|
||||
if not tag:
|
||||
raise click.ClickException(_("Tag is required (or use --from-tag)."))
|
||||
gitea_token = os.environ.get("REPO_TOKEN", "")
|
||||
gitea_token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not gitea_token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
|
||||
|
||||
pypi_token = os.environ.get("PYPI_TOKEN", "")
|
||||
|
||||
@@ -287,6 +295,9 @@ def main(
|
||||
|
||||
tea = TeaCLI(repo=repo)
|
||||
|
||||
if auto_login:
|
||||
configure_tea_login()
|
||||
|
||||
# Check if release already exists (idempotent — avoids failure when
|
||||
# called multiple times, e.g. by both post-merge and publish workflows)
|
||||
try:
|
||||
|
||||
+22
-11
@@ -28,6 +28,8 @@ from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
"""Resolve repo root from GITHUB_WORKSPACE or cwd."""
|
||||
@@ -68,7 +70,7 @@ def fetch_latest_master(branch: str = "master") -> None:
|
||||
"""
|
||||
_run(["git", "fetch", "origin", branch]) # nosec B607
|
||||
_run(["git", "reset", "--hard", f"origin/{branch}"]) # nosec B607
|
||||
click.echo(f"Synced to latest origin/{branch}")
|
||||
click.echo(_("Synced to latest origin/{branch}", branch=branch))
|
||||
|
||||
|
||||
def generate_badges(output_dir: str) -> None:
|
||||
@@ -76,8 +78,8 @@ def generate_badges(output_dir: str) -> None:
|
||||
_run([sys.executable, "-m", "devx.tools.generate_badges", "--output-dir", output_dir])
|
||||
badges = list(Path(output_dir).glob("*.svg"))
|
||||
if not badges:
|
||||
raise click.ClickException("No badge SVG files generated")
|
||||
click.echo(f"Generated {len(badges)} badge files")
|
||||
raise click.ClickException(_("No badge SVG files generated"))
|
||||
click.echo(_("Generated {count} badge files", count=len(badges)))
|
||||
|
||||
|
||||
def push_to_badges_branch(badges_dir: str) -> str:
|
||||
@@ -99,12 +101,12 @@ def push_to_badges_branch(badges_dir: str) -> str:
|
||||
_run(["git", "add", "./*.svg"]) # nosec B607
|
||||
_run(["git", "commit", "--no-verify", "-m", "Update badges [skip ci]"]) # nosec B607
|
||||
_run(["git", "push", "origin", "badges", "--force"]) # nosec B607
|
||||
click.echo("Badges pushed to badges branch")
|
||||
click.echo(_("Badges pushed to badges branch"))
|
||||
|
||||
# Get the commit SHA of the badges branch
|
||||
result = _run_capture(["git", "rev-parse", "HEAD"]) # nosec B607
|
||||
sha = result.stdout.strip()
|
||||
click.echo(f"Badges commit SHA: {sha}")
|
||||
click.echo(_("Badges commit SHA: {sha}", sha=sha))
|
||||
return sha
|
||||
|
||||
|
||||
@@ -142,11 +144,11 @@ def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None)
|
||||
new_content = update_badge_urls(content, badges_sha)
|
||||
if new_content != content:
|
||||
filepath.write_text(new_content)
|
||||
click.echo(f"Updated badge URLs in {filename}")
|
||||
click.echo(_("Updated badge URLs in {filename}", filename=filename))
|
||||
updated_any = True
|
||||
|
||||
if not updated_any:
|
||||
click.echo("No badge URLs found to update — README already up to date")
|
||||
click.echo(_("No badge URLs found to update — README already up to date"))
|
||||
return
|
||||
|
||||
_run(["git", "add", "README.md", "docs/index.md"]) # nosec B607
|
||||
@@ -160,7 +162,7 @@ def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None)
|
||||
]
|
||||
) # nosec B607
|
||||
_run(["git", "push", "origin", "master"]) # nosec B607
|
||||
click.echo(f"Pushed README update with badge SHA {badges_sha[:8]}")
|
||||
click.echo(_("Pushed README update with badge SHA {sha}", sha=badges_sha[:8]))
|
||||
|
||||
|
||||
@click.command()
|
||||
@@ -193,13 +195,22 @@ def main(output_dir: str, branch: str, no_readme_update: bool, retries: int) ->
|
||||
except (subprocess.CalledProcessError, RuntimeError) as exc:
|
||||
last_error = exc
|
||||
if attempt < retries:
|
||||
click.echo(f"Badge push attempt {attempt}/{retries} failed — retrying: {exc}")
|
||||
click.echo(
|
||||
_(
|
||||
"Badge push attempt {attempt}/{retries} failed — retrying: {error}",
|
||||
attempt=attempt,
|
||||
retries=retries,
|
||||
error=exc,
|
||||
)
|
||||
)
|
||||
time.sleep(10)
|
||||
with contextlib.suppress(subprocess.CalledProcessError):
|
||||
fetch_latest_master(branch)
|
||||
else:
|
||||
click.echo(f"Badge push failed after {retries} attempts: {exc}")
|
||||
raise click.ClickException(f"Badge push failed after {retries} attempts: {last_error}")
|
||||
click.echo(_("Badge push failed after {retries} attempts: {error}", retries=retries, error=exc))
|
||||
raise click.ClickException(
|
||||
_("Badge push failed after {retries} attempts: {error}", retries=retries, error=last_error)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
+65
-42
@@ -29,7 +29,7 @@ version. This prevents duplicate release commits (a common issue when CI
|
||||
checkouts don't fetch tags) and ensures tag/version/commit alignment.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 -m devx.ci.release [--dry-run] [--skip-tests]
|
||||
CI_GITEA_TOKEN=<token> python3 -m devx.ci.release [--dry-run] [--skip-tests]
|
||||
python3 -m devx.ci.release --verify # Check tag/version/release alignment
|
||||
"""
|
||||
|
||||
@@ -37,13 +37,13 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
import time
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from devx.ci._shared import get_latest_tag
|
||||
from devx.ci._shared import get_latest_tag, run_cmd, write_github_output
|
||||
from devx.ci.classify_changes import has_user_facing_changes # cross-CI import, needs PYTHONPATH=.
|
||||
from devx.i18n import _
|
||||
|
||||
@@ -54,25 +54,6 @@ CHANGELOG_FILE = "CHANGELOG.md"
|
||||
CLIFF_CONFIG = "cliff.toml"
|
||||
|
||||
|
||||
def run_cmd(args: list[str], check: bool = True, capture: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
"""Run a command and return the completed process."""
|
||||
result = subprocess.run( # nosec B603
|
||||
args,
|
||||
capture_output=capture,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Command failed ({cmd}): {stderr}",
|
||||
cmd=" ".join(args),
|
||||
stderr=result.stderr.strip() if result.stderr else result.stdout.strip(),
|
||||
)
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def tag_exists(tag: str) -> bool:
|
||||
"""Check if a git tag already exists."""
|
||||
result = run_cmd(["git", "tag", "-l", tag], check=False)
|
||||
@@ -217,7 +198,7 @@ def has_unreleased_changes(bumped_version: str | None = None) -> bool:
|
||||
|
||||
def update_init_version(new_version: str) -> None:
|
||||
"""Update __version__ in __init__.py."""
|
||||
with open(INIT_FILE) as f:
|
||||
with open(INIT_FILE, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
if not re.search(r'^__version__\s*=\s*"[^"]*"', content, flags=re.MULTILINE):
|
||||
raise click.ClickException(_("Could not find __version__ in {file}", file=INIT_FILE))
|
||||
@@ -228,7 +209,7 @@ def update_init_version(new_version: str) -> None:
|
||||
count=1,
|
||||
flags=re.MULTILINE,
|
||||
)
|
||||
with open(INIT_FILE, "w") as f:
|
||||
with open(INIT_FILE, "w", encoding="utf-8") as f:
|
||||
f.write(updated)
|
||||
|
||||
|
||||
@@ -245,10 +226,10 @@ def update_changelog(changelog: str) -> None:
|
||||
changelog = changelog[section_match.start() :]
|
||||
|
||||
try:
|
||||
with open(CHANGELOG_FILE) as f:
|
||||
with open(CHANGELOG_FILE, encoding="utf-8") as f:
|
||||
existing = f.read()
|
||||
except FileNotFoundError:
|
||||
with open(CHANGELOG_FILE, "w") as f:
|
||||
with open(CHANGELOG_FILE, "w", encoding="utf-8") as f:
|
||||
f.write(changelog + "\n")
|
||||
return
|
||||
|
||||
@@ -261,7 +242,7 @@ def update_changelog(changelog: str) -> None:
|
||||
else:
|
||||
# No version sections found — append
|
||||
updated = existing.rstrip() + "\n\n" + changelog + "\n"
|
||||
with open(CHANGELOG_FILE, "w") as f:
|
||||
with open(CHANGELOG_FILE, "w", encoding="utf-8") as f:
|
||||
f.write(updated)
|
||||
|
||||
|
||||
@@ -279,7 +260,7 @@ def commit_release_changes(new_version: str) -> bool:
|
||||
if status.returncode == 0:
|
||||
click.echo(_("No staged changes — version and changelog already up to date."))
|
||||
return False
|
||||
run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version}"])
|
||||
run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version} [skip ci]"])
|
||||
return True
|
||||
|
||||
|
||||
@@ -317,18 +298,16 @@ def run_tests() -> None:
|
||||
click.echo(_("Tests passed."))
|
||||
|
||||
|
||||
def _write_github_output(tag: str) -> None:
|
||||
def _write_release_tag(tag: str) -> None:
|
||||
"""Write the release tag to GITHUB_OUTPUT for downstream jobs.
|
||||
|
||||
This allows a publish job (needs: release) to read the tag via
|
||||
``${{ needs.release.outputs.tag }}`` instead of relying on
|
||||
tag-push event triggering a separate workflow.
|
||||
"""
|
||||
github_output = os.environ.get("GITHUB_OUTPUT")
|
||||
if not github_output:
|
||||
if not os.environ.get("GITHUB_OUTPUT"):
|
||||
return
|
||||
with open(github_output, "a") as f: # noqa: PTH123
|
||||
f.write(f"tag={tag}\n")
|
||||
write_github_output("tag", tag)
|
||||
click.echo(_("Wrote tag {tag} to GITHUB_OUTPUT.", tag=tag))
|
||||
|
||||
|
||||
@@ -360,7 +339,7 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
|
||||
if not dry_run:
|
||||
# Ensure the existing tag is pushed
|
||||
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"], check=False)
|
||||
_write_github_output(tag)
|
||||
_write_release_tag(tag)
|
||||
return False
|
||||
tag_msg = f"Release v{new_version}\n\n{changelog}"
|
||||
if dry_run:
|
||||
@@ -368,7 +347,7 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
|
||||
return True
|
||||
run_cmd(["git", "tag", "-a", tag, "-m", tag_msg])
|
||||
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"])
|
||||
_write_github_output(tag)
|
||||
_write_release_tag(tag)
|
||||
return True
|
||||
|
||||
|
||||
@@ -380,7 +359,7 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
|
||||
def get_init_version() -> str | None:
|
||||
"""Read __version__ from the version file."""
|
||||
try:
|
||||
with open(INIT_FILE) as f:
|
||||
with open(INIT_FILE, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
match = re.search(r'^__version__\s*=\s*"([^"]*)"', content, flags=re.MULTILINE)
|
||||
return match.group(1) if match else None
|
||||
@@ -391,7 +370,7 @@ def get_init_version() -> str | None:
|
||||
def get_changelog_versions() -> list[str]:
|
||||
"""Extract version numbers from CHANGELOG.md headers, in order."""
|
||||
try:
|
||||
with open(CHANGELOG_FILE) as f:
|
||||
with open(CHANGELOG_FILE, encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
return re.findall(r"^## \[(\d+\.\d+\.\d+)\]", content, flags=re.MULTILINE)
|
||||
except FileNotFoundError:
|
||||
@@ -634,7 +613,7 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
|
||||
tag=release_tag,
|
||||
)
|
||||
)
|
||||
_write_github_output(release_tag)
|
||||
_write_release_tag(release_tag)
|
||||
return
|
||||
# Tag is missing — recover by creating and pushing it
|
||||
click.echo(
|
||||
@@ -669,6 +648,20 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
|
||||
return
|
||||
|
||||
current_tag = get_latest_tag()
|
||||
# If the bumped version equals the current tag version, there's nothing
|
||||
# new to release. git-cliff didn't bump because the commits since the last
|
||||
# tag don't warrant a version change (e.g., only ci:/chore: commits).
|
||||
# Creating a release commit with the same version would cause a tag
|
||||
# conflict.
|
||||
if current_tag and current_tag.lstrip("v") == new_version:
|
||||
click.echo(
|
||||
_(
|
||||
"Version stays at v{version} — no version bump from git-cliff. "
|
||||
"Commits since last tag don't warrant a new release. Skipping.",
|
||||
version=new_version,
|
||||
)
|
||||
)
|
||||
return
|
||||
click.echo(
|
||||
_(
|
||||
"Bumping version: {current} -> v{new_version}",
|
||||
@@ -691,7 +684,7 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
|
||||
click.echo(_("\n[dry-run] Changelog:\n{changelog}", changelog=changelog))
|
||||
click.echo(_("[dry-run] Would update {init}", init=INIT_FILE))
|
||||
click.echo(_("[dry-run] Would update {changelog_file}", changelog_file=CHANGELOG_FILE))
|
||||
click.echo(_("[dry-run] Would commit: release: v{version}", version=new_version))
|
||||
click.echo(_("[dry-run] Would commit: release: v{version} [skip ci]", version=new_version))
|
||||
click.echo(_("[dry-run] Would push commit to master"))
|
||||
click.echo(_("[dry-run] Would create tag: v{version}", version=new_version))
|
||||
return
|
||||
@@ -717,9 +710,39 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
|
||||
click.echo(_("Created release commit."))
|
||||
# Pull --rebase before push to handle the case where master
|
||||
# advanced between checkout and commit (e.g., another merge).
|
||||
run_cmd(["git", "pull", "--rebase", "origin", "master"], check=False)
|
||||
# Use refs/heads/master to avoid ambiguity with a 'master' tag
|
||||
run_cmd(["git", "push", "origin", "refs/heads/master:refs/heads/master"])
|
||||
# Retry up to 3 times to handle concurrent pushes.
|
||||
push_succeeded = False
|
||||
for attempt in range(3):
|
||||
rebase = run_cmd(["git", "pull", "--rebase", "origin", "master"], check=False)
|
||||
if rebase.returncode != 0:
|
||||
# Rebase failed (likely conflicts). Abort and retry.
|
||||
click.echo(
|
||||
_(
|
||||
"Rebase attempt {n}/3 failed: {err}",
|
||||
n=attempt + 1,
|
||||
err=rebase.stderr.strip() if rebase.stderr else rebase.stdout.strip(),
|
||||
)
|
||||
)
|
||||
run_cmd(["git", "rebase", "--abort"], check=False)
|
||||
# Brief delay before retry to let concurrent pushes settle.
|
||||
time.sleep(5)
|
||||
continue
|
||||
push = run_cmd(["git", "push", "origin", "refs/heads/master:refs/heads/master"], check=False)
|
||||
if push.returncode == 0:
|
||||
push_succeeded = True
|
||||
break
|
||||
click.echo(
|
||||
_(
|
||||
"Push attempt {n}/3 failed: {err}",
|
||||
n=attempt + 1,
|
||||
err=push.stderr.strip() if push.stderr else push.stdout.strip(),
|
||||
)
|
||||
)
|
||||
time.sleep(5)
|
||||
if not push_succeeded:
|
||||
raise click.ClickException(
|
||||
_("Failed to push release commit after 3 attempts. Manual intervention required.")
|
||||
)
|
||||
click.echo(_("Pushed release commit to master."))
|
||||
else:
|
||||
click.echo(_("Skipping commit push — no staged changes."))
|
||||
|
||||
+75
-16
@@ -14,21 +14,29 @@ Gitea 1.26 wiki API endpoints (all use content_base64, NOT content):
|
||||
- Delete: DELETE /repos/{owner}/{repo}/wiki/page/{sub_url}
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 -m devx.ci.sync_wiki [--dry-run] [--repo owner/repo]
|
||||
CI_GITEA_TOKEN=<token> python3 -m devx.ci.sync_wiki [--dry-run] [--repo owner/repo]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
from tenacity import (
|
||||
before_sleep_log,
|
||||
retry,
|
||||
retry_if_exception_type,
|
||||
stop_after_attempt,
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from devx.api_clients import GiteaClient
|
||||
from devx.config import GITEA_API_URL
|
||||
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
|
||||
from devx.exceptions import APIError
|
||||
from devx.i18n import _
|
||||
|
||||
@@ -49,7 +57,7 @@ def load_mapping() -> dict[str, str]:
|
||||
|
||||
Validates that the mapping is a dict of string-to-string pairs.
|
||||
"""
|
||||
with open(MAPPING_FILE) as f:
|
||||
with open(MAPPING_FILE, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict):
|
||||
raise click.ClickException(
|
||||
@@ -64,7 +72,7 @@ def load_mapping() -> dict[str, str]:
|
||||
def read_doc_content(file_path: str) -> str:
|
||||
"""Read markdown content from a docs file."""
|
||||
full_path = DOCS_DIR / file_path
|
||||
with open(full_path) as f:
|
||||
with open(full_path, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
@@ -86,11 +94,12 @@ def decode_content(content_b64: str) -> str:
|
||||
|
||||
|
||||
def list_wiki_pages(client: GiteaClient) -> dict[str, str]:
|
||||
"""List existing wiki pages, returning {title: sub_url}."""
|
||||
try:
|
||||
pages = client._request("GET", "/wiki/pages").json()
|
||||
except APIError:
|
||||
return {}
|
||||
"""List existing wiki pages, returning {title: sub_url}.
|
||||
|
||||
Raises :class:`APIError` if the wiki API is unavailable — the caller
|
||||
is responsible for retrying or handling the failure.
|
||||
"""
|
||||
pages = client._request("GET", "/wiki/pages").json()
|
||||
return {page.get("title", ""): page.get("sub_url", page.get("title", "")) for page in pages}
|
||||
|
||||
|
||||
@@ -161,6 +170,28 @@ def verify_wiki_page(
|
||||
return actual.strip() == expected_content.strip()
|
||||
|
||||
|
||||
def _list_wiki_pages_with_retry(client: GiteaClient) -> dict[str, str]:
|
||||
"""List wiki pages with tenacity retry on APIError.
|
||||
|
||||
The Gitea API can be briefly unavailable right after a batch of wiki
|
||||
page updates. Uses the same tenacity pattern as ``api_clients`` for
|
||||
exponential backoff.
|
||||
"""
|
||||
_logger = logging.getLogger("sync_wiki")
|
||||
|
||||
@retry(
|
||||
stop=stop_after_attempt(3),
|
||||
wait=wait_exponential(multiplier=2, min=2, max=8),
|
||||
retry=retry_if_exception_type(APIError),
|
||||
before_sleep=before_sleep_log(_logger, logging.WARNING),
|
||||
reraise=True,
|
||||
)
|
||||
def _do_list() -> dict[str, str]:
|
||||
return list_wiki_pages(client)
|
||||
|
||||
return _do_list()
|
||||
|
||||
|
||||
def verify_wiki_integrity(
|
||||
client: GiteaClient,
|
||||
mapping: dict[str, str],
|
||||
@@ -176,9 +207,25 @@ def verify_wiki_integrity(
|
||||
5. Page count matches
|
||||
|
||||
Returns a list of failure messages (empty if all checks pass).
|
||||
If the wiki API is temporarily unavailable (all retry attempts
|
||||
fail), returns an empty list with a warning — the sync itself
|
||||
already succeeded, so a transient API outage should not fail the job.
|
||||
"""
|
||||
failures: list[str] = []
|
||||
existing_pages = list_wiki_pages(client)
|
||||
|
||||
try:
|
||||
existing_pages = _list_wiki_pages_with_retry(client)
|
||||
except APIError:
|
||||
click.echo(
|
||||
_(
|
||||
"WARNING: Could not fetch wiki page list after retries. "
|
||||
"The sync itself succeeded ({count} pages updated), but the "
|
||||
"integrity check could not verify them due to a transient API issue.",
|
||||
count=len(synced),
|
||||
)
|
||||
)
|
||||
return []
|
||||
|
||||
expected_titles = set(mapping.values())
|
||||
|
||||
# Check 1: Page count
|
||||
@@ -225,13 +272,13 @@ def verify_wiki_integrity(
|
||||
help="Full integrity check: verify page count, missing pages, stale pages, and content. Implies --verify.",
|
||||
)
|
||||
def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
|
||||
|
||||
if repo is None:
|
||||
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss")
|
||||
repo_name = os.environ.get("DEVX_REPO_NAME", "devx")
|
||||
owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
|
||||
repo_name = os.environ.get("DEVX_REPO_NAME", "") or REPO_NAME
|
||||
else:
|
||||
owner, repo_name = repo.split("/")
|
||||
|
||||
@@ -243,7 +290,10 @@ def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
|
||||
|
||||
click.echo(_("Syncing {count} documentation pages to wiki...", count=len(mapping)))
|
||||
|
||||
existing_pages = list_wiki_pages(client)
|
||||
try:
|
||||
existing_pages = list_wiki_pages(client)
|
||||
except APIError:
|
||||
existing_pages = {}
|
||||
if existing_pages:
|
||||
click.echo(_("Found {count} existing wiki pages.", count=len(existing_pages)))
|
||||
|
||||
@@ -302,7 +352,16 @@ def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
|
||||
else:
|
||||
click.echo(_("\nVerifying wiki pages have content..."))
|
||||
# Re-fetch the page list to get updated sub_urls
|
||||
existing_pages = list_wiki_pages(client)
|
||||
try:
|
||||
existing_pages = _list_wiki_pages_with_retry(client)
|
||||
except APIError:
|
||||
click.echo(
|
||||
_(
|
||||
"WARNING: Could not re-fetch wiki page list for verification. "
|
||||
"Skipping content verification due to transient API issue."
|
||||
)
|
||||
)
|
||||
return
|
||||
failures = 0
|
||||
for page_title, expected_content in sorted(synced.items()):
|
||||
ok = verify_wiki_page(client, page_title, expected_content, existing_pages)
|
||||
|
||||
@@ -69,7 +69,7 @@ def main(commit_msg_file: str | None, branch: str | None, from_git: bool) -> Non
|
||||
if commit_msg_file == "-":
|
||||
msg = sys.stdin.read().strip()
|
||||
else:
|
||||
with open(commit_msg_file) as f:
|
||||
with open(commit_msg_file, encoding="utf-8") as f:
|
||||
msg = f.read().strip()
|
||||
else:
|
||||
raise click.ClickException(_("Provide a commit message file or use --git."))
|
||||
|
||||
@@ -95,6 +95,13 @@ def ci_doc_coverage(args: tuple[str, ...]) -> None:
|
||||
_run_module("devx.ci.doc_coverage", list(args))
|
||||
|
||||
|
||||
@ci.command("lint-docs")
|
||||
@click.argument("args", nargs=-1)
|
||||
def ci_lint_docs(args: tuple[str, ...]) -> None:
|
||||
"""Lint documentation files for structure, links, and quality."""
|
||||
_run_module("devx.ci.lint_docs", list(args))
|
||||
|
||||
|
||||
@ci.command("notify-failure")
|
||||
@click.argument("args", nargs=-1)
|
||||
def ci_notify_failure(args: tuple[str, ...]) -> None:
|
||||
@@ -219,6 +226,20 @@ def tools_setup(args: tuple[str, ...]) -> None:
|
||||
_run_module("devx.tools.setup", list(args))
|
||||
|
||||
|
||||
@tools.command("rebase")
|
||||
@click.argument("args", nargs=-1)
|
||||
def tools_rebase(args: tuple[str, ...]) -> None:
|
||||
"""Rebase current branch onto origin/master and force-push."""
|
||||
_run_module("devx.tools.rebase", list(args))
|
||||
|
||||
|
||||
@tools.command("pr-rebase")
|
||||
@click.argument("args", nargs=-1)
|
||||
def tools_pr_rebase(args: tuple[str, ...]) -> None:
|
||||
"""Rebase a PR's head branch onto master via Gitea API (server-side)."""
|
||||
_run_module("devx.tools.pr_rebase", list(args))
|
||||
|
||||
|
||||
@cli.group()
|
||||
def molecule() -> None:
|
||||
"""Molecule testing commands (requires devx[molecule])."""
|
||||
|
||||
@@ -74,6 +74,7 @@ VIKUNJA_API_URL = _get("vikunja_api_url", "DEVX_VIKUNJA_API_URL", "https://work.
|
||||
# Organization defaults — each project MUST set DEVX_REPO_OWNER explicitly.
|
||||
# No default: prevents silent 404s when the wrong owner is used.
|
||||
REPO_OWNER = _get("repo_owner", "DEVX_REPO_OWNER", "")
|
||||
REPO_NAME = _get("repo_name", "DEVX_REPO_NAME", "")
|
||||
|
||||
# Task prefix for Vikunja task IDs — each project sets its own (GRM, DEVX, INFRA, etc.)
|
||||
TASK_PREFIX = _get("task_prefix", "DEVX_TASK_PREFIX", "DEVX")
|
||||
|
||||
@@ -40,15 +40,67 @@ Usage::
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
from devx.config import GITEA_API_URL
|
||||
from devx.i18n import _
|
||||
|
||||
|
||||
class TeaCLIError(Exception):
|
||||
"""Raised when a tea CLI command fails."""
|
||||
|
||||
|
||||
def configure_tea_login(login_name: str = "devx") -> None:
|
||||
"""Configure tea CLI login from CI_GITEA_TOKEN and DEVX_GITEA_API_URL.
|
||||
|
||||
Idempotent: if a login with the same name already exists, it is not re-added.
|
||||
Skips silently if tea is not installed or CI_GITEA_TOKEN is not set.
|
||||
|
||||
Used by CI scripts (publish, notify_failure) that need tea login but
|
||||
run in containerized environments where ``make setup`` was not called.
|
||||
"""
|
||||
tea_bin = shutil.which("tea")
|
||||
if tea_bin is None:
|
||||
click.echo(_("tea not installed — skipping login configuration."))
|
||||
return
|
||||
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
click.echo(_("CI_GITEA_TOKEN not set — skipping login configuration."))
|
||||
return
|
||||
|
||||
gitea_url = GITEA_API_URL.replace("/api/v1", "")
|
||||
|
||||
result = subprocess.run( # nosec B603
|
||||
[tea_bin, "login", "list", "--output", "simple"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode == 0 and login_name in result.stdout:
|
||||
click.echo(_("tea login '{name}' already configured.", name=login_name))
|
||||
return
|
||||
|
||||
click.echo(_("Configuring tea login '{name}' for {url}...", name=login_name, url=gitea_url))
|
||||
subprocess.run( # nosec B603
|
||||
[tea_bin, "login", "add", "--name", login_name, "--url", gitea_url, "--token", token],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
subprocess.run( # nosec B603
|
||||
[tea_bin, "login", "default", login_name],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
class TeaCLI:
|
||||
"""Wrapper around the ``tea`` Gitea CLI tool.
|
||||
|
||||
|
||||
+185
-19
@@ -51,24 +51,67 @@ DEVX_GITEA_PYPI_HOST ?= git.oblachno.oblachno.fyi
|
||||
DEVX_GITEA_PYPI_ORG ?= oblachno-oss
|
||||
DEVX_ACTIONLINT_CFG ?= .gitea/actionlint.yaml
|
||||
DEVX_WORKFLOW_DIR ?= .gitea/workflows
|
||||
DEVX_DOCKERFILE_PATHS ?= docker
|
||||
|
||||
# PIP_INSTALL — helper to run pip with Gitea private PyPI registry configured.
|
||||
# Usage: $(DEVX_PIP_INSTALL) install -e '.[ci,lint]'
|
||||
# GITEA_PYPI_USER can be set in .env, as an env var, or as a Make variable.
|
||||
DEVX_PIP_INSTALL := if [ -z "$$REPO_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
|
||||
REPO_TOKEN="$${REPO_TOKEN:-$$GITEA_REGISTRY_TOKEN}"; \
|
||||
_PYPI_USER="$${DEVX_GITEA_PYPI_USER:-$${GITEA_PYPI_USER}}"; \
|
||||
if [ -n "$$REPO_TOKEN" ] && [ -n "$$_PYPI_USER" ]; then export PIP_EXTRA_INDEX_URL="https://$$_PYPI_USER:$$REPO_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \
|
||||
# CI_GITEA_USERNAME can be set in .env, as an env var, or as a Make variable.
|
||||
# Projects can alias: PIP_INSTALL = $(DEVX_PIP_INSTALL)
|
||||
DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
|
||||
CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \
|
||||
_PYPI_USER="$${CI_GITEA_USERNAME:-emil}"; \
|
||||
if [ -n "$$CI_GITEA_TOKEN" ] && [ -n "$$_PYPI_USER" ]; then export PIP_EXTRA_INDEX_URL="https://$$_PYPI_USER:$$CI_GITEA_TOKEN@$(DEVX_GITEA_PYPI_HOST)/api/packages/$(DEVX_GITEA_PYPI_ORG)/pypi/simple/"; fi; \
|
||||
$(DEVX_BIN)/pip
|
||||
|
||||
# ── Virtual environment management ────────────────────────────────────────────
|
||||
#
|
||||
# These targets provide a single, consistent venv setup across all
|
||||
# devx-integrated projects (infra, grm, devx). Each project includes
|
||||
# devx.mak and aliases its local targets to these.
|
||||
#
|
||||
# The venv is a standard .venv directory (no pyenv virtualenv dependency).
|
||||
# pyenv can still be used to install Python 3.12+ but the venv itself
|
||||
# is created with `python3 -m venv .venv`.
|
||||
#
|
||||
# Projects should set these variables BEFORE including devx.mak:
|
||||
# DEVX_VENV — venv directory (default: .venv)
|
||||
# DEVX_BIN — venv bin directory (default: $(DEVX_VENV)/bin)
|
||||
# DEVX_PYTHON — Python executable (default: python3; should be $(DEVX_BIN)/python after setup)
|
||||
#
|
||||
# Common aliases in project Makefiles:
|
||||
# PIP_INSTALL = $(DEVX_PIP_INSTALL)
|
||||
# venv: devx-venv
|
||||
# activate-scripts: devx-activate-scripts
|
||||
# .env: devx-env
|
||||
|
||||
# Create .venv with Python version check (3.12+ required)
|
||||
$(DEVX_VENV)/bin/activate:
|
||||
@python3 -c "import sys; v=sys.version_info; assert v >= (3, 12), f'Python 3.12+ required, found {v.major}.{v.minor}'; print(f'Python {v.major}.{v.minor}.{v.micro} OK')"
|
||||
python3 -m venv $(DEVX_VENV)
|
||||
$(DEVX_BIN)/pip install --upgrade pip setuptools wheel
|
||||
|
||||
# Alias: devx-venv creates the venv (delegates to the activate rule)
|
||||
devx-venv: $(DEVX_VENV)/bin/activate
|
||||
|
||||
# Ensure a venv exists — in CI (no pyenv), creates .venv if missing.
|
||||
# Locally, uses the existing .venv (created by `make setup` or `make devx-venv`).
|
||||
devx-ensure-venv:
|
||||
@if [ ! -f $(DEVX_BIN)/python ]; then \
|
||||
echo "[ensure-venv] Creating $(DEVX_VENV) (no venv found)..."; \
|
||||
python3 -m venv $(DEVX_VENV); \
|
||||
$(DEVX_BIN)/pip install --upgrade pip setuptools wheel; \
|
||||
fi
|
||||
|
||||
.PHONY: devx-create-task devx-create-pr devx-push devx-push-with-pr devx-check-config
|
||||
.PHONY: devx-pr-status devx-pr-logs devx-pr-label devx-pr-review devx-rebase devx-pr-rebase
|
||||
.PHONY: devx-configure-gitea-pypi devx-install-tools devx-install-checkmake devx-checkmake
|
||||
.PHONY: devx-workflow-lint devx-workflow-dryrun devx-workflow-dryrun-safe devx-workflow-check
|
||||
.PHONY: devx-notify-failure devx-install-hooks devx-activate-scripts
|
||||
.PHONY: devx-notify-failure devx-install-hooks devx-activate-scripts devx-venv devx-ensure-venv
|
||||
.PHONY: devx-lint-ruff devx-lint-format devx-typecheck devx-lint-bandit devx-lint-deps devx-lint
|
||||
.PHONY: devx-clean devx-pre-push
|
||||
.PHONY: devx-check-mutable-globals devx-check-dep-docs devx-check-test-coverage devx-check-docs devx-check-test-speed
|
||||
.PHONY: devx-test-unit devx-pytest-cov
|
||||
.PHONY: devx-setup-image devx-lint-dockerfiles
|
||||
|
||||
# ── Vikunja task and PR management ────────────────────────────────────────────
|
||||
|
||||
@@ -92,15 +135,68 @@ devx-check-config:
|
||||
# Push and create PR in one step
|
||||
devx-push-with-pr: devx-push devx-create-pr
|
||||
|
||||
# Check CI status for a PR (auto-detects current branch's PR)
|
||||
# Usage: make devx-pr-status
|
||||
# make devx-pr-status PR=42
|
||||
# make devx-pr-status PR=42 WAIT=1 TIMEOUT=600
|
||||
devx-pr-status:
|
||||
@$(DEVX_PYTHON) -m devx.tools.pr_status \
|
||||
$(if $(PR),--pr $(PR)) \
|
||||
$(if $(WAIT),--wait) \
|
||||
$(if $(TIMEOUT),--timeout $(TIMEOUT))
|
||||
|
||||
# Fetch logs for failed CI jobs on a PR
|
||||
# Usage: make devx-pr-logs
|
||||
# make devx-pr-logs PR=42
|
||||
# make devx-pr-logs PR=42 JOB=quality TAIL=50
|
||||
devx-pr-logs:
|
||||
@$(DEVX_PYTHON) -m devx.tools.pr_logs \
|
||||
$(if $(PR),--pr $(PR)) \
|
||||
$(if $(JOB),--job $(JOB)) \
|
||||
$(if $(TAIL),--tail $(TAIL))
|
||||
|
||||
# Add a label to a PR (default: ready-to-merge)
|
||||
# Usage: make devx-pr-label
|
||||
# make devx-pr-label PR=42
|
||||
# make devx-pr-label PR=42 LABEL=ready-to-merge
|
||||
devx-pr-label:
|
||||
@$(DEVX_PYTHON) -m devx.tools.pr_label \
|
||||
$(if $(PR),--pr $(PR)) \
|
||||
--label $(or $(LABEL),ready-to-merge)
|
||||
|
||||
# Usage: make devx-pr-review PR=42 EVENT=APPROVE BODY="..." CHECKLIST=1,2,3,4,5,6,7,8,9,10,11,12,13
|
||||
# make devx-pr-review PR=42 EVENT=REQUEST_CHANGES BODY="..."
|
||||
# make devx-pr-review PR=42 (auto review)
|
||||
devx-pr-review:
|
||||
@$(DEVX_PYTHON) -m devx.ci.pr_review \
|
||||
$(PR) $(DEVX_REPO_OWNER)/$(DEVX_REPO_NAME) \
|
||||
$(if $(EVENT),--event $(EVENT)) \
|
||||
$(if $(BODY),--body "$(BODY)") \
|
||||
$(if $(CHECKLIST),--checklist-confirmed --checklist-categories $(CHECKLIST))
|
||||
|
||||
# Rebase current branch onto origin/master and force-push
|
||||
# Usage: make devx-rebase
|
||||
# make devx-rebase NO_PUSH=1
|
||||
devx-rebase:
|
||||
@$(DEVX_PYTHON) -m devx.tools.rebase \
|
||||
$(if $(NO_PUSH),--no-push)
|
||||
|
||||
# Rebase a PR's head branch via Gitea API (server-side, no local git needed)
|
||||
# Usage: make devx-pr-rebase
|
||||
# make devx-pr-rebase PR=42
|
||||
devx-pr-rebase:
|
||||
@$(DEVX_PYTHON) -m devx.tools.pr_rebase \
|
||||
$(if $(PR),--pr $(PR))
|
||||
|
||||
# ── Environment setup ─────────────────────────────────────────────────────────
|
||||
|
||||
# Configure Gitea private PyPI registry so pip can find devx and other
|
||||
# private packages. In CI, REPO_TOKEN is set as a secret. Locally, it's in .env.
|
||||
# private packages. In CI, CI_GITEA_TOKEN is set as a secret. Locally, it's in .env.
|
||||
devx-configure-gitea-pypi:
|
||||
@if [ -z "$$REPO_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
|
||||
REPO_TOKEN="$${REPO_TOKEN:-$$GITEA_REGISTRY_TOKEN}"; \
|
||||
if [ -z "$$REPO_TOKEN" ]; then echo "[configure-gitea-pypi] REPO_TOKEN not set — skipping (devx must be on public PyPI)"; exit 0; fi; \
|
||||
echo "[configure-gitea-pypi] Gitea PyPI registry configured (REPO_TOKEN present)."
|
||||
@if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi; \
|
||||
CI_GITEA_TOKEN="$$CI_GITEA_TOKEN"; \
|
||||
if [ -z "$$CI_GITEA_TOKEN" ]; then echo "[configure-gitea-pypi] CI_GITEA_TOKEN not set — skipping (devx must be on public PyPI)"; exit 0; fi; \
|
||||
echo "[configure-gitea-pypi] Gitea PyPI registry configured (CI_GITEA_TOKEN present)."
|
||||
|
||||
# Create .env from .env.example if it doesn't exist
|
||||
devx-env:
|
||||
@@ -109,12 +205,6 @@ devx-env:
|
||||
echo "Created .env from .env.example — please edit it with your credentials."; \
|
||||
fi
|
||||
|
||||
# Create Python venv with version check
|
||||
devx-venv:
|
||||
@python3 -c "import sys; v=sys.version_info; assert v >= (3, 12), f'Python 3.12+ required, found {v.major}.{v.minor}'; print(f'Python {v.major}.{v.minor}.{v.micro} OK')"
|
||||
$(DEVX_PYTHON) -m venv $(DEVX_VENV)
|
||||
$(DEVX_BIN)/pip install --upgrade pip setuptools wheel
|
||||
|
||||
# Create activate scripts for shell/fish/zsh
|
||||
devx-activate-scripts:
|
||||
@test -f activate.sh || (echo '#!/usr/bin/env bash' > activate.sh && echo 'source "$$(cd "$$(dirname "$${BASH_SOURCE[0]}")" && pwd)/.venv/bin/activate"' >> activate.sh && chmod +x activate.sh)
|
||||
@@ -173,7 +263,7 @@ devx-workflow-check: devx-workflow-lint devx-workflow-dryrun
|
||||
|
||||
# Notify on CI failure — creates a Gitea issue via devx.ci.notify_failure.
|
||||
# Usage: make devx-notify-failure WORKFLOW=post-merge/release
|
||||
# Requires: REPO_TOKEN, GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_SHA
|
||||
# Requires: CI_GITEA_TOKEN, GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_SHA
|
||||
devx-notify-failure:
|
||||
@. $(DEVX_VENV)/bin/activate 2>/dev/null || true; \
|
||||
export PATH="$(HOME)/.local/bin:$$PATH"; \
|
||||
@@ -210,7 +300,7 @@ devx-lint: devx-lint-ruff devx-lint-format devx-typecheck devx-lint-bandit
|
||||
# ── Testing ───────────────────────────────────────────────────────────────────
|
||||
|
||||
devx-test-unit:
|
||||
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -v --no-cov
|
||||
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -q --no-cov
|
||||
|
||||
devx-pytest-cov:
|
||||
@$(DEVX_BIN)/pytest $(DEVX_TEST_PATHS) -v --cov=$(DEVX_COV_PKG) --cov-report=term-missing --cov-fail-under=100
|
||||
@@ -249,3 +339,79 @@ devx-clean:
|
||||
@find . -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
|
||||
@find . -type f -name "*.pyc" -delete 2>/dev/null || true
|
||||
@rm -rf .coverage htmlcov/ dist/ build/ *.egg-info/ .molecule/ 2>/dev/null || true
|
||||
|
||||
# ── Dockerfile linting ────────────────────────────────────────────────────────
|
||||
#
|
||||
# Lint Dockerfiles with hadolint. Fails fast if hadolint is not installed
|
||||
# (no silent skip). Set DEVX_DOCKERFILE_PATHS to the directory containing
|
||||
# your Dockerfiles (default: docker).
|
||||
#
|
||||
# Usage:
|
||||
# make devx-lint-dockerfiles (lints docker/ directory)
|
||||
# make devx-lint-dockerfiles DEVX_DOCKERFILE_PATHS=ansible (lints ansible/)
|
||||
|
||||
devx-lint-dockerfiles:
|
||||
@echo "[devx-lint-dockerfiles] Linting Dockerfiles with hadolint..."
|
||||
@if ! command -v hadolint >/dev/null 2>&1; then \
|
||||
echo "[devx-lint-dockerfiles] ERROR: hadolint not found. Install from https://github.com/hadolint/hadolint/releases" >&2; \
|
||||
exit 1; \
|
||||
fi
|
||||
@find $(DEVX_DOCKERFILE_PATHS) -name 'Dockerfile*' -exec hadolint {} +
|
||||
@echo "[devx-lint-dockerfiles] All Dockerfiles passed."
|
||||
|
||||
# ── Pre-built image setup ─────────────────────────────────────────────────────
|
||||
#
|
||||
# When running inside a pre-built Docker runner image (ci-base, ci-quality,
|
||||
# ci-full), all deps are already installed in /opt/venv. This target links
|
||||
# the venv and installs the project itself (with optional extras).
|
||||
#
|
||||
# Usage:
|
||||
# make devx-setup-image (runtime deps only)
|
||||
# make devx-setup-image EXTRAS=lint (runtime + lint deps)
|
||||
# make devx-setup-image EXTRAS=ci,lint (runtime + ci + lint deps)
|
||||
#
|
||||
# Falls back to setup-ci if /opt/venv is not present (local dev).
|
||||
# Note: the fallback target name is project-specific (setup-ci, not
|
||||
# devx-setup-ci) — each project defines its own setup-ci target.
|
||||
|
||||
devx-setup-image:
|
||||
@/opt/venv/bin/python -m devx.tools.setup_image --venv $(DEVX_VENV) --extras "$(EXTRAS)" \
|
||||
--gitea-host $(DEVX_GITEA_PYPI_HOST) --gitea-org $(DEVX_GITEA_PYPI_ORG)
|
||||
|
||||
# ── Docker image build / push / cleanup ───────────────────────────────────────
|
||||
#
|
||||
# Variables:
|
||||
# DEVX_GITEA_REGISTRY — registry URL (default: git.oblachno.oblachno.fyi)
|
||||
# DEVX_IMAGE_MANIFEST — path to JSON manifest (default: docker/images.json)
|
||||
# DEVX_IMAGE_OWNER — package owner for cleanup (default: oblachno-oss)
|
||||
|
||||
DEVX_GITEA_REGISTRY ?= git.oblachno.oblachno.fyi
|
||||
DEVX_IMAGE_MANIFEST ?= docker/images.json
|
||||
DEVX_IMAGE_OWNER ?= oblachno-oss
|
||||
|
||||
# Build all images from manifest (no push)
|
||||
devx-build-images:
|
||||
@$(DEVX_PYTHON) -m devx.tools.build_image --manifest $(DEVX_IMAGE_MANIFEST) --pull
|
||||
|
||||
# Build and push all images to the Gitea registry
|
||||
devx-push-images:
|
||||
@$(DEVX_PYTHON) -m devx.tools.build_image \
|
||||
--manifest $(DEVX_IMAGE_MANIFEST) \
|
||||
--registry $(DEVX_GITEA_REGISTRY) \
|
||||
--push --pull
|
||||
|
||||
# Dry-run: show what would be built/pushed
|
||||
devx-build-images-dry-run:
|
||||
@$(DEVX_PYTHON) -m devx.tools.build_image \
|
||||
--manifest $(DEVX_IMAGE_MANIFEST) \
|
||||
--registry $(DEVX_GITEA_REGISTRY) \
|
||||
--push --dry-run
|
||||
|
||||
# Clean up old image versions (keep last 2 + latest)
|
||||
devx-clean-images:
|
||||
@$(DEVX_PYTHON) -m devx.tools.clean_images \
|
||||
--owner $(DEVX_IMAGE_OWNER) \
|
||||
--name oblachno-oss/runner-images/ci-base \
|
||||
--name oblachno-oss/runner-images/ci-quality \
|
||||
--name oblachno-oss/runner-images/ci-full \
|
||||
--keep 2
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Molecule testing helpers for Ansible projects."""
|
||||
|
||||
@@ -29,7 +29,7 @@ import os
|
||||
import click
|
||||
import requests
|
||||
|
||||
from devx.config import GITEA_API_URL
|
||||
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
|
||||
|
||||
DEFAULT_MAX_RUNNERS = 3
|
||||
|
||||
@@ -142,12 +142,12 @@ def main(
|
||||
output_indices: bool,
|
||||
github_output: bool,
|
||||
) -> None:
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
|
||||
if owner is None:
|
||||
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss")
|
||||
owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
|
||||
if repo is None:
|
||||
repo = os.environ.get("DEVX_REPO_NAME", "devx")
|
||||
repo = os.environ.get("DEVX_REPO_NAME", "") or REPO_NAME
|
||||
|
||||
count = get_runner_count(GITEA_API_URL, token, owner, repo)
|
||||
indices = generate_indices(count)
|
||||
@@ -156,7 +156,7 @@ def main(
|
||||
gh_output = os.environ.get("GITHUB_OUTPUT")
|
||||
if not gh_output:
|
||||
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
|
||||
with open(gh_output, "a") as f: # noqa: PTH123
|
||||
with open(gh_output, "a", encoding="utf-8") as f: # noqa: PTH123
|
||||
f.write(f"runner-count={count}\n")
|
||||
f.write(f"runner-indices={json.dumps(indices)}\n")
|
||||
click.echo(f"Runner count: {count}")
|
||||
|
||||
@@ -25,6 +25,7 @@ from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.ci._shared import lpt_distribute, write_github_env
|
||||
from devx.i18n import _
|
||||
from devx.molecule.platforms import PLATFORMS, load_platforms
|
||||
|
||||
@@ -216,22 +217,8 @@ def _scenario_weight(scenario: str, role: str | None = None) -> int:
|
||||
|
||||
|
||||
def _lpt_distribute[T](items: list[T], weights: list[int], max_runners: int) -> list[list[T]]:
|
||||
"""Distribute *items* across *max_runners* using LPT (Longest Processing Time first).
|
||||
|
||||
Sorts items by weight (descending), then assigns each to the runner
|
||||
with the least total weight. This produces a more balanced distribution
|
||||
than naive round-robin when items have varying costs.
|
||||
"""
|
||||
groups: list[list[T]] = [[] for _ in range(max_runners)]
|
||||
loads = [0] * max_runners
|
||||
# Sort by weight descending, preserving original order for ties
|
||||
indexed = sorted(enumerate(items), key=lambda x: (-weights[x[0]], x[0]))
|
||||
for orig_idx, item in indexed:
|
||||
# Find the runner with the minimum load
|
||||
min_runner = min(range(max_runners), key=lambda r: loads[r])
|
||||
groups[min_runner].append(item)
|
||||
loads[min_runner] += weights[orig_idx]
|
||||
return groups
|
||||
"""Distribute *items* across *max_runners* using LPT (delegates to shared utility)."""
|
||||
return lpt_distribute(items, weights, max_runners)
|
||||
|
||||
|
||||
def distribute_multi_role(pairs: list[MultiRoleTestPair], max_runners: int) -> list[list[MultiRoleTestPair]]:
|
||||
@@ -283,14 +270,8 @@ def pairs_for_runner(pairs: list[TestPair], runner_index: int, max_runners: int)
|
||||
|
||||
|
||||
def _write_github_env(key: str, value: str) -> None:
|
||||
"""Append a key=value line to the $GITHUB_ENV file."""
|
||||
import os
|
||||
|
||||
gh_env = os.environ.get("GITHUB_ENV")
|
||||
if not gh_env:
|
||||
raise click.ClickException("GITHUB_ENV environment variable is not set")
|
||||
with open(gh_env, "a") as f: # noqa: PTH123
|
||||
f.write(f"{key}={value}\n")
|
||||
"""Append a key=value line to the $GITHUB_ENV file (delegates to shared utility)."""
|
||||
write_github_env(key, value)
|
||||
|
||||
|
||||
@click.command()
|
||||
|
||||
@@ -22,7 +22,7 @@ Usage::
|
||||
|
||||
Environment variables:
|
||||
GITEA_URL Base URL of the Gitea instance.
|
||||
REPO_TOKEN API token with repo access.
|
||||
CI_GITEA_TOKEN API token with repo access.
|
||||
RUN_ID Workflow run ID (GITHUB_RUN_ID).
|
||||
JOB_NAME Base job name (GITHUB_JOB), e.g. "molecule-tests".
|
||||
MATRIX_INDEX Current matrix index (runner-index).
|
||||
@@ -43,6 +43,7 @@ from pathlib import Path
|
||||
import click
|
||||
import requests
|
||||
|
||||
from devx.config import REPO_NAME, REPO_OWNER
|
||||
from devx.i18n import _
|
||||
|
||||
POLL_INTERVAL = 10
|
||||
@@ -163,17 +164,17 @@ def resolve_role_dir(role: str, roles_root: Path | None, repo_root: Path) -> Pat
|
||||
def cli(pairs: tuple[str, ...], roles_root: Path | None) -> None:
|
||||
"""Run molecule pairs sequentially, stop if another CI runner fails."""
|
||||
gitea_url = os.environ.get("GITEA_URL", "")
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
run_id = int(os.environ.get("RUN_ID", "0"))
|
||||
job_name = os.environ.get("JOB_NAME", "molecule-tests")
|
||||
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
|
||||
repository = os.environ.get("GITEA_REPOSITORY", "oblachno-oss/devx")
|
||||
repository = os.environ.get("GITEA_REPOSITORY", "")
|
||||
owner, _sep, repo = repository.partition("/")
|
||||
if not owner or not repo:
|
||||
owner, repo = "oblachno-oss", "devx"
|
||||
owner, repo = REPO_OWNER, REPO_NAME
|
||||
|
||||
if not all([gitea_url, token, run_id]):
|
||||
click.echo(_("GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
|
||||
click.echo(_("GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
|
||||
|
||||
# When devx is installed as a pip package, __file__ resolves to the
|
||||
# site-packages directory, not the repo root. Use GITHUB_WORKSPACE
|
||||
|
||||
@@ -41,7 +41,7 @@ def load_platforms(platforms_file: str | Path | None = None) -> list[dict[str, s
|
||||
path = Path(platforms_file)
|
||||
if not path.is_file():
|
||||
return PLATFORMS
|
||||
with path.open() as f:
|
||||
with path.open(encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, list) or not data:
|
||||
return PLATFORMS
|
||||
|
||||
@@ -168,7 +168,7 @@ def start_docker_daemon(timeout: int = DEFAULT_TIMEOUT) -> bool:
|
||||
click.echo(_("Docker daemon failed to start"))
|
||||
click.echo("--- dockerd log ---")
|
||||
try:
|
||||
with open(log_file.name) as f:
|
||||
with open(log_file.name, encoding="utf-8") as f:
|
||||
log_content = f.read()
|
||||
click.echo(log_content[-3000:] if len(log_content) > 3000 else log_content)
|
||||
except OSError as e:
|
||||
@@ -191,7 +191,7 @@ def main(timeout: int) -> None:
|
||||
# Export DOCKER_HOST to GITHUB_ENV for subsequent CI steps
|
||||
github_env = os.environ.get("GITHUB_ENV")
|
||||
if github_env and os.environ.get("DOCKER_HOST"):
|
||||
with open(github_env, "a") as f:
|
||||
with open(github_env, "a", encoding="utf-8") as f:
|
||||
f.write(f"DOCKER_HOST={os.environ['DOCKER_HOST']}\n")
|
||||
click.echo(f"Exported DOCKER_HOST={os.environ['DOCKER_HOST']} to GITHUB_ENV")
|
||||
sys.exit(0)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Shared utilities for tools modules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import subprocess # nosec B404
|
||||
|
||||
import click
|
||||
|
||||
|
||||
def arch_string() -> str:
|
||||
"""Return the architecture string used by release assets.
|
||||
|
||||
Maps ``platform.machine()`` to the common release asset naming:
|
||||
``amd64`` for x86_64, ``arm64`` for aarch64.
|
||||
|
||||
Raises:
|
||||
click.ClickException: If the architecture is not supported.
|
||||
"""
|
||||
machine = platform.machine().lower()
|
||||
if machine in {"x86_64", "amd64"}:
|
||||
return "amd64"
|
||||
if machine in {"aarch64", "arm64"}:
|
||||
return "arm64"
|
||||
raise click.ClickException(f"Unsupported architecture: {machine}")
|
||||
|
||||
|
||||
def detect_pr_number() -> int | None:
|
||||
"""Detect the PR number for the current git branch.
|
||||
|
||||
Returns the PR number if the current branch has an open PR, or None
|
||||
if no PR is found. Does NOT raise — callers decide how to handle None.
|
||||
Best-effort: returns None on any failure (no token, API down, etc.).
|
||||
"""
|
||||
result = subprocess.run( # nosec B603, B607
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
branch = result.stdout.strip()
|
||||
if branch == "HEAD":
|
||||
return None
|
||||
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
return None
|
||||
|
||||
owner = os.environ.get("DEVX_REPO_OWNER", "")
|
||||
repo = os.environ.get("DEVX_REPO_NAME", "")
|
||||
if not owner or not repo:
|
||||
github_repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||
if "/" in github_repo:
|
||||
owner, repo = github_repo.split("/", 1)
|
||||
|
||||
if not owner or not repo:
|
||||
return None
|
||||
|
||||
# Lazy import to avoid circular dependency
|
||||
from devx.api_clients import APIError, GiteaClient # noqa: PLC0415
|
||||
from devx.config import GITEA_API_URL # noqa: PLC0415
|
||||
|
||||
client = GiteaClient(GITEA_API_URL, token, owner, repo)
|
||||
try:
|
||||
prs = client.list_prs(state="open")
|
||||
except APIError:
|
||||
# Best-effort: API down or auth failure → no PR detected
|
||||
return None
|
||||
for pr in prs:
|
||||
if pr.get("head", {}).get("ref") == branch:
|
||||
return int(pr["number"])
|
||||
return None
|
||||
@@ -0,0 +1,329 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build and push Docker images to a Gitea container registry.
|
||||
|
||||
Replaces raw ``docker build`` / ``docker push`` shell commands with a
|
||||
tested Python tool. Supports:
|
||||
|
||||
- Building from any Dockerfile with a configurable context directory
|
||||
- Tagging with multiple tags (e.g. ``latest`` + version)
|
||||
- Optional push to a Gitea registry (with login)
|
||||
- Dry-run mode (prints commands without executing)
|
||||
|
||||
Usage::
|
||||
|
||||
# Build a single image
|
||||
python3 -m devx.tools.build_image \\
|
||||
--dockerfile docker/ci-base/Dockerfile \\
|
||||
--tag ci-base:latest \\
|
||||
--tag ci-base:0.19.3
|
||||
|
||||
# Build and push to registry
|
||||
python3 -m devx.tools.build_image \\
|
||||
--dockerfile docker/ci-base/Dockerfile \\
|
||||
--tag ci-base:latest \\
|
||||
--tag ci-base:0.19.3 \\
|
||||
--registry git.oblachno.oblachno.fyi \\
|
||||
--push
|
||||
|
||||
# Build multiple images (from a manifest file)
|
||||
python3 -m devx.tools.build_image --manifest docker/images.json --push
|
||||
|
||||
The manifest file is a JSON list of dicts, each with:
|
||||
- ``name``: image name (e.g. ``ci-base``)
|
||||
- ``dockerfile``: path to Dockerfile (relative to repo root)
|
||||
- ``context``: build context directory (optional, defaults to repo root)
|
||||
- ``tags``: list of tags (optional, defaults to ``["latest"]``)
|
||||
|
||||
Registry authentication uses ``CI_GITEA_TOKEN`` and ``CI_GITEA_USERNAME``
|
||||
environment variables, matching the existing CI workflow patterns.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess # nosec B404
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
|
||||
@dataclass
|
||||
class ImageSpec:
|
||||
"""Specification for a single Docker image to build."""
|
||||
|
||||
name: str
|
||||
dockerfile: str
|
||||
context: str = "."
|
||||
tags: list[str] = field(default_factory=lambda: ["latest"])
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, object]) -> ImageSpec:
|
||||
"""Create an ImageSpec from a dict (e.g. from a JSON manifest)."""
|
||||
name = str(data.get("name", ""))
|
||||
if not name:
|
||||
raise ValueError(_("Image manifest entry missing 'name'"))
|
||||
dockerfile = str(data.get("dockerfile", ""))
|
||||
if not dockerfile:
|
||||
raise ValueError(_("Image manifest entry missing 'dockerfile'"))
|
||||
context = str(data.get("context", "."))
|
||||
tags_raw = data.get("tags", ["latest"])
|
||||
if not isinstance(tags_raw, list):
|
||||
raise ValueError(_("Image 'tags' must be a list"))
|
||||
tags = [str(t) for t in tags_raw] if tags_raw else ["latest"]
|
||||
return cls(name=name, dockerfile=dockerfile, context=context, tags=tags)
|
||||
|
||||
|
||||
def load_manifest(path: str | Path) -> list[ImageSpec]:
|
||||
"""Load a JSON manifest file describing images to build.
|
||||
|
||||
The file must contain a JSON list of dicts with at least ``name`` and
|
||||
``dockerfile`` keys. ``context`` and ``tags`` are optional.
|
||||
|
||||
Returns a list of :class:`ImageSpec` instances.
|
||||
"""
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
raise click.ClickException(_("Manifest file not found: {path}", path=p))
|
||||
with p.open(encoding="utf-8") as f: # noqa: PTH123
|
||||
data = json.load(f)
|
||||
if not isinstance(data, list):
|
||||
raise click.ClickException(_("Manifest must be a JSON list"))
|
||||
return [ImageSpec.from_dict(entry) for entry in data]
|
||||
|
||||
|
||||
def build_full_tag(registry: str | None, name: str, tag: str) -> str:
|
||||
"""Build a full image tag, optionally prefixed with a registry.
|
||||
|
||||
>>> build_full_tag(None, "ci-base", "latest")
|
||||
'ci-base:latest'
|
||||
>>> build_full_tag("git.example.com", "ci-base", "0.1.0")
|
||||
'git.example.com/ci-base:0.1.0'
|
||||
"""
|
||||
if registry:
|
||||
return f"{registry}/{name}:{tag}"
|
||||
return f"{name}:{tag}"
|
||||
|
||||
|
||||
def registry_login(
|
||||
registry: str,
|
||||
username: str,
|
||||
token: str,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
) -> bool:
|
||||
"""Log in to a Docker registry.
|
||||
|
||||
Returns True on success, False on failure.
|
||||
In dry-run mode, prints the command without executing.
|
||||
"""
|
||||
cmd = ["docker", "login", registry, "-u", username, "--password-stdin"]
|
||||
if dry_run:
|
||||
click.echo(f"[dry-run] {' '.join(cmd)}")
|
||||
return True
|
||||
result = subprocess.run( # nosec B603
|
||||
cmd,
|
||||
input=token,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
click.echo(
|
||||
_("Registry login failed: {error}", error=result.stderr.strip()),
|
||||
err=True,
|
||||
)
|
||||
return False
|
||||
click.echo(f"Logged in to {registry}")
|
||||
return True
|
||||
|
||||
|
||||
def build_image(
|
||||
spec: ImageSpec,
|
||||
registry: str | None = None,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
pull: bool = False,
|
||||
) -> bool:
|
||||
"""Build a Docker image from a Dockerfile.
|
||||
|
||||
Tags the image with all specified tags, optionally prefixed with the
|
||||
registry. Returns True on success, False on failure.
|
||||
"""
|
||||
if not Path(spec.dockerfile).is_file():
|
||||
click.echo(
|
||||
_("Dockerfile not found: {path}", path=spec.dockerfile),
|
||||
err=True,
|
||||
)
|
||||
return False
|
||||
|
||||
full_tags = [build_full_tag(registry, spec.name, t) for t in spec.tags]
|
||||
cmd = ["docker", "build"]
|
||||
if pull:
|
||||
cmd.append("--pull")
|
||||
for ft in full_tags:
|
||||
cmd.extend(["-t", ft])
|
||||
cmd.extend(["-f", spec.dockerfile, spec.context])
|
||||
|
||||
if dry_run:
|
||||
click.echo(f"[dry-run] {' '.join(cmd)}")
|
||||
return True
|
||||
|
||||
click.echo(f"Building {spec.name} ({len(full_tags)} tag(s))...")
|
||||
result = subprocess.run( # nosec B603
|
||||
cmd,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
click.echo(_("Build failed for {name}", name=spec.name), err=True)
|
||||
return False
|
||||
click.echo(f"Built {spec.name}")
|
||||
return True
|
||||
|
||||
|
||||
def push_image(
|
||||
spec: ImageSpec,
|
||||
registry: str,
|
||||
*,
|
||||
dry_run: bool = False,
|
||||
) -> bool:
|
||||
"""Push all tags of a Docker image to the registry.
|
||||
|
||||
Returns True if all pushes succeed, False if any fail.
|
||||
"""
|
||||
full_tags = [build_full_tag(registry, spec.name, t) for t in spec.tags]
|
||||
all_ok = True
|
||||
for ft in full_tags:
|
||||
cmd = ["docker", "push", ft]
|
||||
if dry_run:
|
||||
click.echo(f"[dry-run] {' '.join(cmd)}")
|
||||
continue
|
||||
click.echo(f"Pushing {ft}...")
|
||||
result = subprocess.run( # nosec B603
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
click.echo(
|
||||
_("Push failed for {tag}: {error}", tag=ft, error=result.stderr.strip()),
|
||||
err=True,
|
||||
)
|
||||
all_ok = False
|
||||
else:
|
||||
click.echo(f"Pushed {ft}")
|
||||
return all_ok
|
||||
|
||||
|
||||
def _get_registry_creds() -> tuple[str, str]:
|
||||
"""Get registry credentials from environment variables."""
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
username = os.environ.get("CI_GITEA_USERNAME", "")
|
||||
return username, token
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--dockerfile",
|
||||
"dockerfile",
|
||||
default=None,
|
||||
help="Path to Dockerfile (for single-image build).",
|
||||
)
|
||||
@click.option(
|
||||
"--context",
|
||||
"context",
|
||||
default=".",
|
||||
help="Build context directory (for single-image build).",
|
||||
)
|
||||
@click.option(
|
||||
"--name",
|
||||
"name",
|
||||
default=None,
|
||||
help="Image name (for single-image build).",
|
||||
)
|
||||
@click.option(
|
||||
"--tag",
|
||||
"tags",
|
||||
multiple=True,
|
||||
help="Tag(s) for the image. Can be repeated. Defaults to 'latest'.",
|
||||
)
|
||||
@click.option(
|
||||
"--manifest",
|
||||
"manifest",
|
||||
default=None,
|
||||
help="Path to JSON manifest file listing images to build.",
|
||||
)
|
||||
@click.option(
|
||||
"--registry",
|
||||
"registry",
|
||||
default=None,
|
||||
help="Registry URL (e.g. git.example.com). If set with --push, images are tagged and pushed there.",
|
||||
)
|
||||
@click.option(
|
||||
"--push",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Push images to the registry after building.",
|
||||
)
|
||||
@click.option(
|
||||
"--dry-run",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Print commands without executing.",
|
||||
)
|
||||
@click.option(
|
||||
"--pull",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Pass --pull to docker build (always fetch latest base image).",
|
||||
)
|
||||
def main(
|
||||
dockerfile: str | None,
|
||||
context: str,
|
||||
name: str | None,
|
||||
tags: tuple[str, ...],
|
||||
manifest: str | None,
|
||||
registry: str | None,
|
||||
push: bool,
|
||||
dry_run: bool,
|
||||
pull: bool,
|
||||
) -> None:
|
||||
"""Build and optionally push Docker images to a Gitea registry."""
|
||||
if manifest:
|
||||
specs = load_manifest(manifest)
|
||||
elif dockerfile and name:
|
||||
tag_list = list(tags) if tags else ["latest"]
|
||||
specs = [ImageSpec(name=name, dockerfile=dockerfile, context=context, tags=tag_list)]
|
||||
else:
|
||||
raise click.ClickException(_("Provide --manifest or both --dockerfile and --name"))
|
||||
|
||||
if push:
|
||||
if not registry:
|
||||
raise click.ClickException(_("--push requires --registry"))
|
||||
username, token = _get_registry_creds()
|
||||
if not token or not username:
|
||||
raise click.ClickException(
|
||||
_("Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars")
|
||||
)
|
||||
if not registry_login(registry, username, token, dry_run=dry_run):
|
||||
raise click.ClickException(_("Registry login failed"))
|
||||
|
||||
failed: list[str] = []
|
||||
for spec in specs:
|
||||
if not build_image(spec, registry, dry_run=dry_run, pull=pull):
|
||||
failed.append(spec.name)
|
||||
continue
|
||||
if push and not push_image(spec, registry, dry_run=dry_run): # type: ignore[arg-type]
|
||||
failed.append(spec.name)
|
||||
|
||||
if failed:
|
||||
raise click.ClickException(_("Failed images: {names}", names=", ".join(failed)))
|
||||
click.echo(f"\nDone. {len(specs)} image(s) processed.")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main() # pragma: no cover
|
||||
@@ -13,7 +13,6 @@ Usage::
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
@@ -27,8 +26,7 @@ def cli() -> None:
|
||||
"""Validate devx configuration in pyproject.toml."""
|
||||
path = Path("pyproject.toml")
|
||||
if not path.exists():
|
||||
click.echo(_("pyproject.toml not found in current directory."))
|
||||
sys.exit(1)
|
||||
raise click.ClickException(_("pyproject.toml not found in current directory."))
|
||||
|
||||
with open(path, "rb") as f: # noqa: PTH123
|
||||
data = tomllib.load(f)
|
||||
@@ -65,7 +63,7 @@ def cli() -> None:
|
||||
if errors:
|
||||
for err in errors:
|
||||
click.echo(f"ERROR: {err}", err=True)
|
||||
sys.exit(1)
|
||||
raise click.ClickException(_("Configuration validation failed."))
|
||||
|
||||
click.echo(_("Configuration OK: [tool.devx] present, devx versions consistent."))
|
||||
|
||||
|
||||
@@ -23,12 +23,12 @@ Usage::
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import fnmatch
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.config import _load_pyproject_devx
|
||||
from devx.i18n import _
|
||||
|
||||
@@ -203,49 +203,37 @@ def _find_missing_tests(
|
||||
return missing
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=_("Check that changed files have corresponding tests"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--staged-only",
|
||||
action="store_true",
|
||||
help=_("Only check staged files (for pre-commit)"),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--warn-only",
|
||||
action="store_true",
|
||||
help=_("Print warnings but always exit 0"),
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
@click.command()
|
||||
@click.option("--staged-only", is_flag=True, help=_("Only check staged files (for pre-commit)"))
|
||||
@click.option("--warn-only", is_flag=True, help=_("Print warnings but always exit 0"))
|
||||
def cli(staged_only: bool, warn_only: bool) -> None:
|
||||
"""Check that changed files have corresponding tests."""
|
||||
repo_root = Path.cwd()
|
||||
rules, skip_patterns, test_indicators, skip_extensions = _load_rules()
|
||||
|
||||
files = _changed_files(args.staged_only, repo_root)
|
||||
files = _changed_files(staged_only, repo_root)
|
||||
if not files:
|
||||
print(_("[check_test_coverage] No changed files to check."))
|
||||
return 0
|
||||
click.echo(_("[check_test_coverage] No changed files to check."))
|
||||
return
|
||||
|
||||
missing = _find_missing_tests(files, repo_root, rules, skip_patterns, test_indicators, skip_extensions)
|
||||
if not missing:
|
||||
print(f"[check_test_coverage] All {len(files)} changed file(s) have tests.")
|
||||
return 0
|
||||
click.echo(f"[check_test_coverage] All {len(files)} changed file(s) have tests.")
|
||||
return
|
||||
|
||||
print("[check_test_coverage] FAILED: missing tests for changed files:\n", file=sys.stderr)
|
||||
click.echo("[check_test_coverage] FAILED: missing tests for changed files:\n", err=True)
|
||||
for f, reason in missing.items():
|
||||
print(f" {f}", file=sys.stderr)
|
||||
print(f" -> {reason}", file=sys.stderr)
|
||||
click.echo(f" {f}", err=True)
|
||||
click.echo(f" -> {reason}", err=True)
|
||||
|
||||
print(
|
||||
"\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||
file=sys.stderr,
|
||||
click.echo(
|
||||
_("\n[check_test_coverage] Fix: add the missing test file(s) before committing."),
|
||||
err=True,
|
||||
)
|
||||
|
||||
if args.warn_only:
|
||||
return 0
|
||||
return 1
|
||||
if not warn_only:
|
||||
raise click.ClickException(_("Missing tests for changed files."))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
sys.exit(main())
|
||||
cli() # pragma: no cover
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Clean up old Docker images from a Gitea container registry.
|
||||
|
||||
Queries the Gitea API for all versions of a package (container type) and
|
||||
deletes all but the most recent N versions. The ``latest`` tag is always
|
||||
preserved if present.
|
||||
|
||||
Usage::
|
||||
|
||||
# Clean up ci-base images, keep last 2 versions
|
||||
python3 -m devx.tools.clean_images \\
|
||||
--owner oblachno-oss \\
|
||||
--name ci-base \\
|
||||
--keep 2
|
||||
|
||||
# Clean up multiple images
|
||||
python3 -m devx.tools.clean_images \\
|
||||
--owner oblachno-oss \\
|
||||
--name ci-base \\
|
||||
--name ci-quality \\
|
||||
--name ci-full \\
|
||||
--keep 2
|
||||
|
||||
# Dry run (list what would be deleted)
|
||||
python3 -m devx.tools.clean_images \\
|
||||
--owner oblachno-oss \\
|
||||
--name ci-base \\
|
||||
--keep 2 \\
|
||||
--dry-run
|
||||
|
||||
Authentication uses ``CI_GITEA_TOKEN`` environment variable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
import requests
|
||||
|
||||
from devx.config import GITEA_API_URL, REPO_OWNER
|
||||
from devx.i18n import _
|
||||
|
||||
|
||||
def list_package_versions(
|
||||
api_url: str,
|
||||
owner: str,
|
||||
name: str,
|
||||
token: str,
|
||||
*,
|
||||
timeout: int = 30,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List all versions of a container package from the Gitea API.
|
||||
|
||||
Returns a list of version dicts, each containing at least ``version``
|
||||
and ``created_at`` fields.
|
||||
"""
|
||||
url = f"{api_url}/packages/{owner}?type=container&name={name}"
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
all_versions: list[dict[str, Any]] = []
|
||||
page = 1
|
||||
while True:
|
||||
resp = requests.get(
|
||||
f"{url}&page={page}&limit=50",
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if not data:
|
||||
break
|
||||
all_versions.extend(data)
|
||||
if len(data) < 50:
|
||||
break
|
||||
page += 1
|
||||
return all_versions
|
||||
|
||||
|
||||
def delete_package_version(
|
||||
api_url: str,
|
||||
owner: str,
|
||||
name: str,
|
||||
version: str,
|
||||
token: str,
|
||||
*,
|
||||
timeout: int = 30,
|
||||
package_type: str = "container",
|
||||
max_retries: int = 3,
|
||||
) -> bool:
|
||||
"""Delete a specific version of a container package.
|
||||
|
||||
Uses the Gitea API endpoint ``DELETE /packages/{owner}/{type}/{name}/{version}``.
|
||||
Retries on transient failures (5xx, timeouts) up to ``max_retries`` times.
|
||||
|
||||
Returns True on success, False on failure.
|
||||
"""
|
||||
url = f"{api_url}/packages/{owner}/{package_type}/{name}/{version}"
|
||||
headers = {"Authorization": f"token {token}"}
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
resp = requests.delete(url, headers=headers, timeout=timeout)
|
||||
except requests.RequestException:
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(2**attempt)
|
||||
continue
|
||||
return False
|
||||
if resp.status_code in (204, 200):
|
||||
return True
|
||||
# 404 means already deleted — treat as success
|
||||
if resp.status_code == 404:
|
||||
return True
|
||||
# 5xx is transient — retry
|
||||
if 500 <= resp.status_code < 600 and attempt < max_retries - 1:
|
||||
time.sleep(2**attempt)
|
||||
continue
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def sort_versions_by_date(
|
||||
versions: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Sort package versions by creation date, newest first.
|
||||
|
||||
Falls back to version string comparison if created_at is missing.
|
||||
"""
|
||||
|
||||
def _sort_key(v: dict[str, Any]) -> str:
|
||||
return str(v.get("created_at", v.get("version", "")))
|
||||
|
||||
return sorted(versions, key=_sort_key, reverse=True)
|
||||
|
||||
|
||||
def select_for_deletion(
|
||||
versions: list[dict[str, Any]],
|
||||
keep: int,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Select versions to delete, keeping the most recent ``keep`` versions.
|
||||
|
||||
Versions named ``latest`` are always preserved.
|
||||
"""
|
||||
sorted_versions = sort_versions_by_date(versions)
|
||||
to_delete = sorted_versions[keep:]
|
||||
# Always preserve 'latest' tag
|
||||
to_delete = [v for v in to_delete if v.get("version") != "latest"]
|
||||
return to_delete
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--owner",
|
||||
default=None,
|
||||
help="Package owner (user or org, default: from [tool.devx] repo_owner).",
|
||||
)
|
||||
@click.option(
|
||||
"--name",
|
||||
"names",
|
||||
multiple=True,
|
||||
required=True,
|
||||
help="Package name(s). Can be repeated.",
|
||||
)
|
||||
@click.option(
|
||||
"--keep",
|
||||
default=2,
|
||||
type=int,
|
||||
show_default=True,
|
||||
help="Number of recent versions to keep (excluding 'latest').",
|
||||
)
|
||||
@click.option(
|
||||
"--dry-run",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="List versions that would be deleted without actually deleting.",
|
||||
)
|
||||
@click.option(
|
||||
"--api-url",
|
||||
default=None,
|
||||
help="Gitea API URL (defaults to DEVX_GITEA_API_URL or built-in default).",
|
||||
)
|
||||
def main(
|
||||
owner: str | None,
|
||||
names: tuple[str, ...],
|
||||
keep: int,
|
||||
dry_run: bool,
|
||||
api_url: str | None,
|
||||
) -> None:
|
||||
"""Clean up old Docker image versions from a Gitea registry."""
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("CI_GITEA_TOKEN environment variable required"))
|
||||
if not owner:
|
||||
owner = REPO_OWNER
|
||||
if not owner:
|
||||
raise click.ClickException(_("Package owner not specified. Use --owner or set [tool.devx] repo_owner."))
|
||||
base_url = api_url or GITEA_API_URL
|
||||
|
||||
total_deleted = 0
|
||||
total_kept = 0
|
||||
total_failed = 0
|
||||
for name in names:
|
||||
click.echo(_("\n{separator}", separator="=" * 60))
|
||||
click.echo(_("Package: {owner}/{name}", owner=owner, name=name))
|
||||
click.echo(_("{separator}", separator="=" * 60))
|
||||
try:
|
||||
versions = list_package_versions(base_url, owner, name, token)
|
||||
except requests.RequestException as exc:
|
||||
click.echo(
|
||||
_("Failed to list versions for {name}: {error}", name=name, error=exc),
|
||||
err=True,
|
||||
)
|
||||
total_failed += 1
|
||||
continue
|
||||
|
||||
if not versions:
|
||||
click.echo(_("No versions found."))
|
||||
continue
|
||||
|
||||
click.echo(_("Found {count} version(s):", count=len(versions)))
|
||||
for v in sort_versions_by_date(versions):
|
||||
click.echo(
|
||||
_(" {version} (created: {created})", version=v.get("version", "?"), created=v.get("created_at", "?"))
|
||||
)
|
||||
|
||||
to_delete = select_for_deletion(versions, keep)
|
||||
kept_count = len(versions) - len(to_delete)
|
||||
click.echo(_("\nKeeping {kept}, would delete {count}", kept=kept_count, count=len(to_delete)))
|
||||
|
||||
if dry_run:
|
||||
for v in to_delete:
|
||||
click.echo(_(" [dry-run] Would delete: {version}", version=v.get("version", "?")))
|
||||
total_kept += kept_count
|
||||
continue
|
||||
|
||||
deleted_count = 0
|
||||
failed_count = 0
|
||||
for v in to_delete:
|
||||
version = str(v.get("version", ""))
|
||||
if delete_package_version(base_url, owner, name, version, token):
|
||||
click.echo(_(" Deleted: {version}", version=version))
|
||||
deleted_count += 1
|
||||
else:
|
||||
click.echo(_(" FAILED to delete: {version}", version=version), err=True)
|
||||
failed_count += 1
|
||||
|
||||
total_deleted += deleted_count
|
||||
total_kept += kept_count
|
||||
total_failed += failed_count
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
"\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
|
||||
deleted=total_deleted,
|
||||
kept=total_kept,
|
||||
failed=total_failed,
|
||||
)
|
||||
)
|
||||
if total_failed > 0:
|
||||
raise click.ClickException(_("Failed to delete {count} image version(s)", count=total_failed))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main() # pragma: no cover
|
||||
@@ -1,13 +1,14 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Configure repository: branch protection + repo settings via Gitea REST API.
|
||||
"""Configure repository: branch protection, repo settings, and standard labels.
|
||||
|
||||
Uses ``GiteaClient`` for branch protection and repo settings.
|
||||
The ``tea`` CLI is used for label creation if available, with a
|
||||
fallback to ``GiteaClient`` if tea is not installed.
|
||||
Uses ``GiteaClient`` for branch protection, repo settings, and label
|
||||
creation. Standard labels (bug, ready-to-merge, feedback, tooling,
|
||||
ci-improvement, doc-improvement, workflow-improvement) are created
|
||||
idempotently via ``ensure_label``.
|
||||
|
||||
Usage:
|
||||
REPO_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo
|
||||
REPO_TOKEN=<token> python3 -m devx.tools.configure_repo --repo my-repo --owner my-org
|
||||
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 --owner my-org
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -19,7 +20,7 @@ from typing import Any, cast
|
||||
import click
|
||||
|
||||
from devx.api_clients import GiteaClient
|
||||
from devx.config import GITEA_API_URL, REPO_OWNER
|
||||
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
|
||||
from devx.exceptions import APIError
|
||||
from devx.i18n import _
|
||||
|
||||
@@ -50,11 +51,17 @@ def _default_branch_protection_config() -> dict[str, Any]:
|
||||
"push_whitelist_usernames": [],
|
||||
"enable_status_check": True,
|
||||
"status_check_contexts": _default_status_checks(),
|
||||
"required_approvals": 0,
|
||||
"required_approvals": 1,
|
||||
"dismiss_stale_approvals": True,
|
||||
"block_on_outdated_branch": True,
|
||||
"block_on_rejected_reviews": True,
|
||||
"block_on_official_review_requests": True,
|
||||
# Prevent admins from force-merging PRs that don't meet branch
|
||||
# protection requirements (e.g. missing approvals). Without this,
|
||||
# an admin token can bypass the approval gate via force_merge=true,
|
||||
# allowing merges that failed the auto-merge CI job to reach master
|
||||
# and trigger the post-merge release pipeline.
|
||||
"block_admin_merge_override": True,
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +72,19 @@ def _default_repo_settings_config() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
# Standard labels created in every oblachno repo.
|
||||
# These cover CI failure notifications, subagent feedback, and auto-merge.
|
||||
_STANDARD_LABELS: list[dict[str, str]] = [
|
||||
{"name": "bug", "color": "#ee0701", "description": "Something is not working"},
|
||||
{"name": "ready-to-merge", "color": "#a2eeef", "description": "PR has been reviewed and is ready for auto-merge"},
|
||||
{"name": "feedback", "color": "#fbca04", "description": "Issues from subagent or agent feedback"},
|
||||
{"name": "tooling", "color": "#c5def5", "description": "Tool-related feedback or improvements"},
|
||||
{"name": "ci-improvement", "color": "#84b6eb", "description": "CI workflow improvements"},
|
||||
{"name": "doc-improvement", "color": "#d4c5f9", "description": "Documentation improvements"},
|
||||
{"name": "workflow-improvement", "color": "#fef2c0", "description": "Workflow alignment or pattern improvements"},
|
||||
]
|
||||
|
||||
|
||||
def _handle_http_error(e: APIError) -> None:
|
||||
"""Raise a user-friendly Click exception for HTTP errors."""
|
||||
if e.status == http.HTTPStatus.FORBIDDEN:
|
||||
@@ -102,7 +122,7 @@ def configure_repo(
|
||||
api_url: Gitea API base URL. If None, uses ``GITEA_API_URL`` from config.
|
||||
"""
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: REPO_TOKEN is not set."))
|
||||
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
|
||||
|
||||
url = api_url or GITEA_API_URL
|
||||
client = GiteaClient(url, token, owner, repo)
|
||||
@@ -123,6 +143,7 @@ def configure_repo(
|
||||
click.echo(_(" - Dismiss stale approvals: yes"))
|
||||
click.echo(_(" - Block outdated branches: yes"))
|
||||
click.echo(_(" - Block rejected reviews: yes"))
|
||||
click.echo(_(" - Block admin merge override: yes"))
|
||||
checks = ", ".join(cast(list[str], bp_config["status_check_contexts"]))
|
||||
click.echo(_(" - Required status checks: {checks}", checks=checks))
|
||||
|
||||
@@ -131,6 +152,12 @@ def configure_repo(
|
||||
client.update_repo_settings(cast(dict[str, object], rs_config))
|
||||
click.echo(_(" - Auto-delete branch after merge: yes"))
|
||||
|
||||
click.echo("")
|
||||
click.echo(_("Ensuring standard labels..."))
|
||||
for label in _STANDARD_LABELS:
|
||||
client.ensure_label(label["name"], label["color"], label["description"])
|
||||
click.echo(_(" - {count} standard labels verified", count=len(_STANDARD_LABELS)))
|
||||
|
||||
click.echo("")
|
||||
click.echo(_("Repository configuration complete."))
|
||||
except APIError as e:
|
||||
@@ -148,10 +175,10 @@ def configure_repo(
|
||||
)
|
||||
def main(repo: str | None, owner: str | None, branch: str, api_url: str | None) -> None:
|
||||
"""Configure branch protection and repository settings via the Gitea API."""
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
|
||||
if repo is None:
|
||||
repo = os.environ.get("DEVX_REPO_NAME", "")
|
||||
repo = os.environ.get("DEVX_REPO_NAME", "") or REPO_NAME
|
||||
if not repo:
|
||||
raise click.ClickException(_("ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME."))
|
||||
|
||||
|
||||
+17
-22
@@ -34,6 +34,7 @@ from devx.api_clients import GiteaClient, VikunjaClient
|
||||
from devx.config import (
|
||||
DEFAULT_PER_PAGE,
|
||||
GITEA_API_URL,
|
||||
REPO_NAME,
|
||||
REPO_OWNER,
|
||||
TASK_ID_RE,
|
||||
TASK_PREFIX,
|
||||
@@ -46,15 +47,17 @@ load_dotenv()
|
||||
|
||||
|
||||
def get_repo_name() -> str:
|
||||
"""Auto-detect repository name from env vars or git remote."""
|
||||
"""Auto-detect repository name from env vars, pyproject.toml, or git remote."""
|
||||
name = os.environ.get("DEVX_REPO_NAME", "")
|
||||
if name:
|
||||
return name
|
||||
github_repo = os.environ.get("GITHUB_REPOSITORY", "")
|
||||
if github_repo and "/" in github_repo:
|
||||
return github_repo.split("/", 1)[1]
|
||||
if REPO_NAME:
|
||||
return REPO_NAME
|
||||
raise click.ClickException(
|
||||
_("Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var."),
|
||||
_("Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var."),
|
||||
)
|
||||
|
||||
|
||||
@@ -73,24 +76,16 @@ def get_vikunja_task_title(task_id: str) -> str:
|
||||
if not token:
|
||||
raise click.ClickException(_("VIKUNJA_TOKEN is not set. Required to derive PR title."))
|
||||
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||
page = 1
|
||||
while True:
|
||||
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
|
||||
if not tasks:
|
||||
break
|
||||
matches = [t for t in tasks if t.get("identifier") == task_id]
|
||||
if matches:
|
||||
return str(matches[0].get("title", ""))
|
||||
if len(tasks) < DEFAULT_PER_PAGE:
|
||||
break
|
||||
page += 1
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Could not find Vikunja task {task_id} in project {project_id}.",
|
||||
task_id=task_id,
|
||||
project_id=VIKUNJA_PROJECT_ID,
|
||||
),
|
||||
)
|
||||
task = client.find_task_by_identifier(VIKUNJA_PROJECT_ID, task_id, per_page=DEFAULT_PER_PAGE)
|
||||
if not task:
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Could not find Vikunja task {task_id} in project {project_id}.",
|
||||
task_id=task_id,
|
||||
project_id=VIKUNJA_PROJECT_ID,
|
||||
),
|
||||
)
|
||||
return str(task.get("title", ""))
|
||||
|
||||
|
||||
def find_existing_pr(client: GiteaClient, branch: str) -> dict | None:
|
||||
@@ -123,9 +118,9 @@ def create_pr(
|
||||
),
|
||||
)
|
||||
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("REPO_TOKEN is not set. Required to create a PR."))
|
||||
raise click.ClickException(_("CI_GITEA_TOKEN is not set. Required to create a PR."))
|
||||
|
||||
vikunja_title = get_vikunja_task_title(task_id)
|
||||
pr_title = f"{task_id}: {vikunja_title}"
|
||||
|
||||
@@ -27,6 +27,8 @@ from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
# Coverage regex matches "TOTAL ... NN%" or "TOTAL ... NN.NN%"
|
||||
_COVERAGE_RE = re.compile(r"TOTAL.*?(\d+(?:\.\d+)?)%")
|
||||
_PASSED_RE = re.compile(r"(\d+) passed")
|
||||
@@ -197,17 +199,19 @@ def read_version(repo_root: Path) -> str:
|
||||
"""
|
||||
pkg = detect_package_name(repo_root)
|
||||
if pkg is None:
|
||||
click.echo(" WARNING: No Python package found under src/ — version badge will show 'unknown'")
|
||||
click.echo(_(" WARNING: No Python package found under src/ — version badge will show 'unknown'"))
|
||||
return "unknown"
|
||||
init_file = repo_root / "src" / pkg / "__init__.py"
|
||||
if not init_file.exists():
|
||||
click.echo(f" WARNING: {init_file} not found — version badge will show 'unknown'")
|
||||
click.echo(_(" WARNING: {init_file} not found — version badge will show 'unknown'", init_file=init_file))
|
||||
return "unknown"
|
||||
content = init_file.read_text()
|
||||
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
|
||||
if match:
|
||||
return match.group(1)
|
||||
click.echo(f" WARNING: No __version__ found in {init_file} — version badge will show 'unknown'")
|
||||
click.echo(
|
||||
_(" WARNING: No __version__ found in {init_file} — version badge will show 'unknown'", init_file=init_file)
|
||||
)
|
||||
return "unknown"
|
||||
|
||||
|
||||
@@ -278,11 +282,11 @@ def collect_coverage_and_tests(repo_root: Path) -> tuple[dict[str, str | int], d
|
||||
"""
|
||||
cov_target = detect_coverage_target(repo_root)
|
||||
if cov_target is None:
|
||||
click.echo(" WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)")
|
||||
click.echo(_(" WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)"))
|
||||
return make_badge("coverage", "unknown", "lightgrey"), make_badge("tests", "unknown", "lightgrey")
|
||||
|
||||
testpaths = detect_testpaths(repo_root)
|
||||
click.echo(f" Test paths: {testpaths or '(pytest defaults)'}")
|
||||
click.echo(_(" Test paths: {testpaths}", testpaths=testpaths or "(pytest defaults)"))
|
||||
|
||||
cmd = [
|
||||
sys.executable,
|
||||
@@ -302,18 +306,18 @@ def collect_coverage_and_tests(repo_root: Path) -> tuple[dict[str, str | int], d
|
||||
if coverage is not None:
|
||||
cov_badge = make_badge("coverage", f"{coverage:.0f}%", coverage_color(coverage))
|
||||
else:
|
||||
click.echo(f" WARNING: Could not extract coverage from pytest output (rc={rc})")
|
||||
click.echo(f" pytest stdout (last 300 chars): {stdout.strip()[-300:]}")
|
||||
click.echo(f" pytest stderr (last 300 chars): {stderr.strip()[-300:]}")
|
||||
click.echo(_(" WARNING: Could not extract coverage from pytest output (rc={rc})", rc=rc))
|
||||
click.echo(_(" pytest stdout (last 300 chars): {stdout}", stdout=stdout.strip()[-300:]))
|
||||
click.echo(_(" pytest stderr (last 300 chars): {stderr}", stderr=stderr.strip()[-300:]))
|
||||
cov_badge = make_badge("coverage", "unknown", "red")
|
||||
|
||||
test_count = extract_test_count(combined)
|
||||
if test_count is not None:
|
||||
tests_badge = make_badge("tests", f"{test_count} passing", "brightgreen" if rc == 0 else "red")
|
||||
else:
|
||||
click.echo(f" WARNING: Could not extract test count from pytest output (rc={rc})")
|
||||
click.echo(f" pytest stdout (last 300 chars): {stdout.strip()[-300:]}")
|
||||
click.echo(f" pytest stderr (last 300 chars): {stderr.strip()[-300:]}")
|
||||
click.echo(_(" WARNING: Could not extract test count from pytest output (rc={rc})", rc=rc))
|
||||
click.echo(_(" pytest stdout (last 300 chars): {stdout}", stdout=stdout.strip()[-300:]))
|
||||
click.echo(_(" pytest stderr (last 300 chars): {stderr}", stderr=stderr.strip()[-300:]))
|
||||
tests_badge = make_badge("tests", "unknown", "red")
|
||||
|
||||
return cov_badge, tests_badge
|
||||
@@ -328,8 +332,8 @@ def collect_doc_coverage(repo_root: Path) -> dict[str, str | int]:
|
||||
doc_pct = extract_doc_coverage(stdout)
|
||||
if doc_pct is not None:
|
||||
return make_badge("docs", f"{doc_pct}%", doc_coverage_color(doc_pct))
|
||||
click.echo(f" WARNING: Could not extract doc coverage (rc={rc})")
|
||||
click.echo(f" stderr: {stderr.strip()[:200]}")
|
||||
click.echo(_(" WARNING: Could not extract doc coverage (rc={rc})", rc=rc))
|
||||
click.echo(_(" stderr: {stderr}", stderr=stderr.strip()[:200]))
|
||||
return make_badge("docs", "unknown", "red")
|
||||
|
||||
|
||||
@@ -348,7 +352,7 @@ def collect_quality(repo_root: Path) -> dict[str, str | int]:
|
||||
([sys.executable, "-m", "pyright"], "pyright"),
|
||||
([sys.executable, "-m", "bandit", "-r", "src/"], "bandit"),
|
||||
]:
|
||||
rc, _, stderr = run_command(cmd, cwd=repo_root)
|
||||
rc, _stdout, stderr = run_command(cmd, cwd=repo_root)
|
||||
if rc == 0:
|
||||
results.append(True)
|
||||
tool_names.append(f"{name}: pass")
|
||||
@@ -356,16 +360,16 @@ def collect_quality(repo_root: Path) -> dict[str, str | int]:
|
||||
results.append(False)
|
||||
# Distinguish "tool not installed" from "tool found issues"
|
||||
if "No module named" in stderr or "not found" in stderr.lower():
|
||||
click.echo(f" WARNING: {name} not installed — skipping (counted as pass)")
|
||||
click.echo(_(" WARNING: {name} not installed — skipping (counted as pass)", name=name))
|
||||
results[-1] = True
|
||||
tool_names.append(f"{name}: not installed (skipped)")
|
||||
else:
|
||||
tool_names.append(f"{name}: FAIL")
|
||||
click.echo(f" WARNING: {name} failed (rc={rc})")
|
||||
click.echo(f" stderr: {stderr.strip()[:200]}")
|
||||
click.echo(_(" WARNING: {name} failed (rc={rc})", name=name, rc=rc))
|
||||
click.echo(_(" stderr: {stderr}", stderr=stderr.strip()[:200]))
|
||||
|
||||
all_pass = all(results)
|
||||
click.echo(f" Quality checks: {', '.join(tool_names)}")
|
||||
click.echo(_(" Quality checks: {checks}", checks=", ".join(tool_names)))
|
||||
return make_badge("code quality", "A" if all_pass else "F", "brightgreen" if all_pass else "red")
|
||||
|
||||
|
||||
@@ -377,28 +381,28 @@ def generate_badges(output_dir: Path, repo_root: Path | None = None) -> dict[str
|
||||
repo_root: Repository root (auto-detected if None).
|
||||
"""
|
||||
root = repo_root or resolve_repo_root()
|
||||
click.echo(f" Repo root: {root}")
|
||||
click.echo(_(" Repo root: {root}", root=root))
|
||||
pkg = detect_package_name(root)
|
||||
click.echo(f" Package: {pkg or 'none'}")
|
||||
click.echo(_(" Package: {pkg}", pkg=pkg or "none"))
|
||||
|
||||
badges: dict[str, dict[str, str | int]] = {}
|
||||
|
||||
# 1. Code coverage + test count (single pytest-cov run)
|
||||
click.echo(" Collecting coverage and tests...")
|
||||
click.echo(_(" Collecting coverage and tests..."))
|
||||
cov_badge, tests_badge = collect_coverage_and_tests(root)
|
||||
badges["coverage"] = cov_badge
|
||||
badges["tests"] = tests_badge
|
||||
|
||||
# 2. Documentation coverage
|
||||
click.echo(" Collecting doc coverage...")
|
||||
click.echo(_(" Collecting doc coverage..."))
|
||||
badges["docs"] = collect_doc_coverage(root)
|
||||
|
||||
# 3. Code quality (ruff + pyright + bandit)
|
||||
click.echo(" Collecting code quality...")
|
||||
click.echo(_(" Collecting code quality..."))
|
||||
badges["quality"] = collect_quality(root)
|
||||
|
||||
# 4. Version
|
||||
click.echo(" Collecting version...")
|
||||
click.echo(_(" Collecting version..."))
|
||||
version = read_version(root)
|
||||
badges["version"] = make_badge("version", f"v{version}", "blue")
|
||||
|
||||
@@ -411,7 +415,7 @@ def generate_badges(output_dir: Path, repo_root: Path | None = None) -> dict[str
|
||||
svg = render_svg(str(badge["label"]), str(badge["message"]), str(badge["color"]))
|
||||
path = output_dir / f"{name}.svg"
|
||||
path.write_text(svg)
|
||||
click.echo(f" Generated: {path}")
|
||||
click.echo(_(" Generated: {path}", path=path))
|
||||
|
||||
return badges
|
||||
|
||||
@@ -431,11 +435,19 @@ def cli(output_dir: str, repo_root: str | None) -> None:
|
||||
"""Generate self-contained SVG badge files from project metrics."""
|
||||
out = Path(output_dir)
|
||||
root = Path(repo_root) if repo_root else None
|
||||
click.echo(f"Generating badges in {out}...")
|
||||
click.echo(_("Generating badges in {out}...", out=out))
|
||||
badges = generate_badges(out, repo_root=root)
|
||||
click.echo(f"\nGenerated {len(badges)} badges:")
|
||||
click.echo(_("\nGenerated {count} badges:", count=len(badges)))
|
||||
for name, badge in badges.items():
|
||||
click.echo(f" {name}: {badge['label']}={badge['message']} ({badge['color']})")
|
||||
click.echo(
|
||||
_(
|
||||
" {name}: {label}={message} ({color})",
|
||||
name=name,
|
||||
label=badge["label"],
|
||||
message=badge["message"],
|
||||
color=badge["color"],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -109,7 +109,7 @@ def _generate(prefix: str) -> str:
|
||||
@click.option(
|
||||
"--prefix",
|
||||
default=TASK_PREFIX,
|
||||
help="Task ID prefix for commit preprocessor (default: DEVX_TASK_PREFIX env var or 'DEVX').",
|
||||
help="Task ID prefix for commit preprocessor (default: from [tool.devx] task_prefix in pyproject.toml).",
|
||||
)
|
||||
@click.option(
|
||||
"--output",
|
||||
|
||||
@@ -7,7 +7,6 @@ pre-built Linux binary from the official GitHub releases.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess # nosec B404
|
||||
import urllib.request
|
||||
@@ -15,6 +14,8 @@ from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.tools._shared import arch_string
|
||||
|
||||
CHECKMAKE_VERSION = "0.3.2"
|
||||
RELEASE_URL_TEMPLATE = (
|
||||
"https://github.com/checkmake/checkmake/releases/download/"
|
||||
@@ -23,18 +24,11 @@ RELEASE_URL_TEMPLATE = (
|
||||
TARGET_PATH = Path("/usr/local/bin/checkmake")
|
||||
|
||||
|
||||
def _arch() -> str:
|
||||
"""Return the architecture string used by checkmake releases."""
|
||||
machine = platform.machine().lower()
|
||||
if machine in {"x86_64", "amd64"}:
|
||||
return "amd64"
|
||||
if machine in {"aarch64", "arm64"}:
|
||||
return "arm64"
|
||||
raise click.ClickException(f"Unsupported architecture: {machine}")
|
||||
|
||||
|
||||
def _install_with_go() -> bool:
|
||||
"""Install checkmake using go install if Go is available."""
|
||||
"""Install checkmake using go install if Go is available.
|
||||
|
||||
Returns True if the installation succeeded, False if Go is not installed.
|
||||
"""
|
||||
go_bin = shutil.which("go")
|
||||
if go_bin is None:
|
||||
return False
|
||||
@@ -51,12 +45,13 @@ def _install_with_go() -> bool:
|
||||
|
||||
def _download_binary() -> None:
|
||||
"""Download the prebuilt checkmake binary for the current architecture."""
|
||||
url = RELEASE_URL_TEMPLATE.format(arch=_arch())
|
||||
url = RELEASE_URL_TEMPLATE.format(arch=arch_string())
|
||||
urllib.request.urlretrieve(url, TARGET_PATH) # nosec B310
|
||||
TARGET_PATH.chmod(0o755)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@click.command()
|
||||
def cli() -> None:
|
||||
"""Install checkmake if not already present."""
|
||||
if shutil.which("checkmake") is not None:
|
||||
return
|
||||
@@ -66,4 +61,4 @@ def main() -> None:
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main() # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
|
||||
@@ -6,6 +6,7 @@ Handles installation of:
|
||||
- git-cliff (changelog generator)
|
||||
- act_runner (Gitea Actions local runner, optional)
|
||||
- tea (Gitea CLI — official command-line tool for Gitea API operations)
|
||||
- hadolint (Dockerfile linter)
|
||||
|
||||
Each tool is installed to ``~/.local/bin`` if not already on PATH.
|
||||
Idempotent: skips tools that are already available.
|
||||
@@ -39,15 +40,14 @@ ACT_RUNNER_VERSION = "0.2.11"
|
||||
|
||||
TEA_VERSION = "0.14.1"
|
||||
|
||||
HADOLINT_VERSION = "2.12.0"
|
||||
|
||||
|
||||
def _arch() -> str:
|
||||
"""Return the architecture string used by release assets."""
|
||||
machine = platform.machine().lower()
|
||||
if machine in {"x86_64", "amd64"}:
|
||||
return "amd64"
|
||||
if machine in {"aarch64", "arm64"}:
|
||||
return "arm64"
|
||||
raise click.ClickException(f"Unsupported architecture: {machine}")
|
||||
"""Return the architecture string used by release assets (delegates to shared utility)."""
|
||||
from devx.tools._shared import arch_string
|
||||
|
||||
return arch_string()
|
||||
|
||||
|
||||
def _ensure_target_dir() -> Path:
|
||||
@@ -161,7 +161,20 @@ def install_tea() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea"]
|
||||
def install_hadolint() -> bool:
|
||||
"""Install hadolint if not already present. Returns True if installed/skipped."""
|
||||
if _is_installed("hadolint"):
|
||||
click.echo("hadolint: already installed")
|
||||
return True
|
||||
machine = platform.machine().lower()
|
||||
arch = "x86_64" if machine in {"x86_64", "amd64"} else "arm64"
|
||||
url = f"https://github.com/hadolint/hadolint/releases/download/v{HADOLINT_VERSION}/hadolint-Linux-{arch}"
|
||||
dest = _download_binary(url, "hadolint")
|
||||
click.echo(f"hadolint: installed to {dest}")
|
||||
return True
|
||||
|
||||
|
||||
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea", "hadolint"]
|
||||
|
||||
|
||||
def _install_tool(name: str) -> bool:
|
||||
@@ -174,6 +187,8 @@ def _install_tool(name: str) -> bool:
|
||||
return install_act_runner()
|
||||
if name == "tea":
|
||||
return install_tea()
|
||||
if name == "hadolint":
|
||||
return install_hadolint()
|
||||
raise click.ClickException(f"Unknown tool: {name}")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add a label to a pull request (idempotent).
|
||||
|
||||
Commonly used to add the ``ready-to-merge`` label after CI passes and
|
||||
review is complete. The operation is idempotent — if the label is already
|
||||
attached, it succeeds without error.
|
||||
|
||||
Usage::
|
||||
|
||||
# Add ready-to-merge to PR #42
|
||||
python -m devx.tools.pr_label --pr 42 --label ready-to-merge
|
||||
|
||||
# Add label to current branch's PR
|
||||
python -m devx.tools.pr_label --label ready-to-merge
|
||||
|
||||
# Add multiple labels
|
||||
python -m devx.tools.pr_label --pr 42 --label ready-to-merge --label reviewed
|
||||
|
||||
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
|
||||
|
||||
from devx.api_clients import GiteaClient
|
||||
from devx.config import GITEA_API_URL, REPO_OWNER
|
||||
from devx.i18n import _
|
||||
from devx.tools.create_pr import get_repo_name
|
||||
from devx.tools.pr_status import _get_current_branch_pr
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--pr", "pr_number", type=int, default=None, help="PR number (default: auto-detect from current branch).")
|
||||
@click.option("--label", "labels", multiple=True, required=True, help="Label name(s) to add (can be repeated).")
|
||||
@click.option("--owner", default=None, help="Repository owner (default: DEVX_REPO_OWNER).")
|
||||
@click.option("--repo", default=None, help="Repository name (default: DEVX_REPO_NAME or GITHUB_REPOSITORY).")
|
||||
def cli(
|
||||
pr_number: int | None,
|
||||
labels: tuple[str, ...],
|
||||
owner: str | None,
|
||||
repo: str | None,
|
||||
) -> None:
|
||||
"""Add one or more labels to a pull request (idempotent)."""
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("CI_GITEA_TOKEN is not set."))
|
||||
|
||||
repo_owner = owner or REPO_OWNER
|
||||
if not repo_owner:
|
||||
raise click.ClickException(_("Repository owner not set. Use --owner or DEVX_REPO_OWNER env var."))
|
||||
repo_name = repo or get_repo_name()
|
||||
|
||||
client = GiteaClient(GITEA_API_URL, token, repo_owner, repo_name)
|
||||
|
||||
if pr_number is None:
|
||||
pr_number = _get_current_branch_pr(client)
|
||||
|
||||
label_list = list(labels)
|
||||
existing = client.get_pr_label_names(pr_number)
|
||||
to_add = [lbl for lbl in label_list if lbl not in existing]
|
||||
already = [lbl for lbl in label_list if lbl in existing]
|
||||
|
||||
if already:
|
||||
for lbl in already:
|
||||
click.echo(_("Label '{label}' already on PR #{pr}.", label=lbl, pr=pr_number))
|
||||
|
||||
if to_add:
|
||||
client.add_pr_label(pr_number, to_add)
|
||||
for lbl in to_add:
|
||||
click.echo(_("Added label '{label}' to PR #{pr}.", label=lbl, pr=pr_number))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -0,0 +1,187 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fetch logs for failed CI jobs on a pull request.
|
||||
|
||||
Lists CI jobs for the latest workflow run of a PR's branch, then fetches
|
||||
and prints the logs of any failed jobs. Useful for diagnosing CI failures
|
||||
without navigating the web UI.
|
||||
|
||||
Usage::
|
||||
|
||||
# Show failed job logs for PR #42
|
||||
python -m devx.tools.pr_logs --pr 42
|
||||
|
||||
# Show failed job logs for current branch's PR
|
||||
python -m devx.tools.pr_logs
|
||||
|
||||
# Show logs for a specific job (by name)
|
||||
python -m devx.tools.pr_logs --pr 42 --job quality
|
||||
|
||||
# Show last N lines of each failed job's logs
|
||||
python -m devx.tools.pr_logs --pr 42 --tail 50
|
||||
|
||||
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
|
||||
|
||||
from devx.api_clients import APIError, GiteaClient
|
||||
from devx.config import GITEA_API_URL, REPO_OWNER
|
||||
from devx.i18n import _
|
||||
from devx.tools.create_pr import get_repo_name
|
||||
from devx.tools.pr_status import _get_current_branch_pr
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
def _get_pr_sha(client: GiteaClient, pr_number: int) -> str:
|
||||
"""Fetch the head SHA of a PR."""
|
||||
pr = client.get_pr(pr_number)
|
||||
return pr.get("head", {}).get("sha", "")
|
||||
|
||||
|
||||
def _find_latest_run_by_sha(client: GiteaClient, sha: str) -> dict | None:
|
||||
"""Find the latest workflow run for a commit SHA.
|
||||
|
||||
Gitea Actions API doesn't set head_branch for pull_request events,
|
||||
so we filter by head_sha instead.
|
||||
"""
|
||||
data = client.list_action_runs(limit=50)
|
||||
for run in data.get("workflow_runs", []):
|
||||
if run.get("head_sha", "").startswith(sha):
|
||||
return run
|
||||
return None
|
||||
|
||||
|
||||
def _find_failed_jobs(jobs: list[dict]) -> list[dict]:
|
||||
"""Return jobs with conclusion 'failure'."""
|
||||
return [j for j in jobs if j.get("conclusion") == "failure"]
|
||||
|
||||
|
||||
def _find_job_by_name(jobs: list[dict], name: str) -> dict | None:
|
||||
"""Find a job by name (case-insensitive partial match)."""
|
||||
name_lower = name.lower()
|
||||
for j in jobs:
|
||||
if name_lower in j.get("name", "").lower():
|
||||
return j
|
||||
return None
|
||||
|
||||
|
||||
def _print_job_summary(jobs: list[dict]) -> None:
|
||||
"""Print a summary table of all jobs and their status."""
|
||||
for j in jobs:
|
||||
name = j.get("name", "?")
|
||||
conclusion = j.get("conclusion", "pending")
|
||||
status = j.get("status", "?")
|
||||
symbol = "[FAIL]" if conclusion == "failure" else "[OK]" if conclusion == "success" else f"[{conclusion}]"
|
||||
click.echo(f" {symbol} {name} (status: {status}, conclusion: {conclusion})")
|
||||
|
||||
|
||||
def _print_failed_steps(job: dict) -> list[int]:
|
||||
"""Print failed steps for a job. Returns list of failed step numbers."""
|
||||
failed_steps = []
|
||||
for step in job.get("steps", []):
|
||||
if step.get("conclusion") == "failure":
|
||||
name = step.get("name", "?")
|
||||
num = step.get("number", "?")
|
||||
click.echo(f" FAILED step #{num}: {name}")
|
||||
failed_steps.append(num)
|
||||
return failed_steps
|
||||
|
||||
|
||||
def _print_logs(client: GiteaClient, job_id: int, tail: int = 0) -> None:
|
||||
"""Fetch and print logs for a job. If tail > 0, print only last N lines."""
|
||||
try:
|
||||
logs = client.get_action_job_logs(job_id)
|
||||
except APIError as e:
|
||||
click.echo(_(" Could not fetch logs: {error}", error=str(e)))
|
||||
return
|
||||
|
||||
if tail > 0:
|
||||
lines = logs.strip().split("\n")
|
||||
if len(lines) > tail:
|
||||
click.echo(f" ... (showing last {tail} of {len(lines)} lines)")
|
||||
logs = "\n".join(lines[-tail:])
|
||||
|
||||
for line in logs.split("\n"):
|
||||
click.echo(f" {line}")
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--pr", "pr_number", type=int, default=None, help="PR number (default: auto-detect from current branch).")
|
||||
@click.option("--job", default=None, help="Job name to show logs for (partial match, case-insensitive).")
|
||||
@click.option("--tail", type=int, default=80, show_default=True, help="Show last N lines of logs (0 = all).")
|
||||
@click.option("--owner", default=None, help="Repository owner (default: DEVX_REPO_OWNER).")
|
||||
@click.option("--repo", default=None, help="Repository name (default: DEVX_REPO_NAME or GITHUB_REPOSITORY).")
|
||||
def cli(
|
||||
pr_number: int | None,
|
||||
job: str | None,
|
||||
tail: int,
|
||||
owner: str | None,
|
||||
repo: str | None,
|
||||
) -> None:
|
||||
"""Fetch logs for failed CI jobs on a pull request."""
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("CI_GITEA_TOKEN is not set."))
|
||||
|
||||
repo_owner = owner or REPO_OWNER
|
||||
if not repo_owner:
|
||||
raise click.ClickException(_("Repository owner not set. Use --owner or DEVX_REPO_OWNER env var."))
|
||||
repo_name = repo or get_repo_name()
|
||||
|
||||
client = GiteaClient(GITEA_API_URL, token, repo_owner, repo_name)
|
||||
|
||||
if pr_number is None:
|
||||
pr_number = _get_current_branch_pr(client)
|
||||
click.echo(_("Fetching logs for PR #{pr_number}...", pr_number=pr_number))
|
||||
|
||||
sha = _get_pr_sha(client, pr_number)
|
||||
if not sha:
|
||||
raise click.ClickException(_("Could not determine head SHA for PR #{pr_number}.", pr_number=pr_number))
|
||||
|
||||
run = _find_latest_run_by_sha(client, sha)
|
||||
if not run:
|
||||
raise click.ClickException(_("No workflow runs found for SHA {sha}.", sha=sha[:8]))
|
||||
|
||||
run_id = run.get("id", 0)
|
||||
run_status = run.get("status", "?")
|
||||
click.echo(_("Latest run: #{run_id} (status: {status})", run_id=run_id, status=run_status))
|
||||
click.echo("")
|
||||
|
||||
jobs = client.get_action_run_jobs(run_id)
|
||||
if not jobs:
|
||||
click.echo(_("No jobs found for run #{run_id}.", run_id=run_id))
|
||||
return
|
||||
|
||||
_print_job_summary(jobs)
|
||||
click.echo("")
|
||||
|
||||
if job:
|
||||
target = _find_job_by_name(jobs, job)
|
||||
if not target:
|
||||
raise click.ClickException(_("No job matching '{job}' found.", job=job))
|
||||
click.echo(f"Logs for job '{target.get('name', '?')}' (id={target.get('id')}):")
|
||||
_print_failed_steps(target)
|
||||
click.echo("")
|
||||
_print_logs(client, target["id"], tail)
|
||||
else:
|
||||
failed = _find_failed_jobs(jobs)
|
||||
if not failed:
|
||||
click.echo(_("No failed jobs."))
|
||||
return
|
||||
for fj in failed:
|
||||
click.echo(f"Logs for failed job '{fj.get('name', '?')}' (id={fj.get('id')}):")
|
||||
_print_failed_steps(fj)
|
||||
click.echo("")
|
||||
_print_logs(client, fj["id"], tail)
|
||||
click.echo("")
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -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,174 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check CI status for a pull request or commit.
|
||||
|
||||
Displays the status of all CI checks for a PR (or a specific commit SHA).
|
||||
Optionally polls until all checks complete (``--wait``).
|
||||
|
||||
Usage::
|
||||
|
||||
# Check status of PR #42
|
||||
python -m devx.tools.pr_status --pr 42
|
||||
|
||||
# Check status of current branch's PR
|
||||
python -m devx.tools.pr_status
|
||||
|
||||
# Wait for all checks to complete (timeout 600s)
|
||||
python -m devx.tools.pr_status --pr 42 --wait --timeout 600
|
||||
|
||||
# Check a specific commit SHA
|
||||
python -m devx.tools.pr_status --sha abc1234
|
||||
|
||||
The repository is auto-detected from ``DEVX_REPO_OWNER`` /
|
||||
``DEVX_REPO_NAME`` or ``GITHUB_REPOSITORY`` environment variables.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess # nosec B404
|
||||
import time
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from devx.api_clients import GiteaClient
|
||||
from devx.config import GITEA_API_URL, REPO_OWNER
|
||||
from devx.i18n import _
|
||||
from devx.tools.create_pr import get_repo_name
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Status symbols for terminal output
|
||||
_STATUS_SYMBOLS = {
|
||||
"success": "[OK]",
|
||||
"failure": "[FAIL]",
|
||||
"error": "[FAIL]",
|
||||
"pending": "[..]",
|
||||
"skipped": "[SKIP]",
|
||||
"none": "[--]",
|
||||
}
|
||||
|
||||
|
||||
def _get_symbol(status: str) -> str:
|
||||
return _STATUS_SYMBOLS.get(status, f"[{status}]")
|
||||
|
||||
|
||||
def _get_pr_sha(client: GiteaClient, pr_number: int) -> str:
|
||||
"""Fetch the head SHA of a PR."""
|
||||
pr = client.get_pr(pr_number)
|
||||
return pr.get("head", {}).get("sha", "")
|
||||
|
||||
|
||||
def _get_current_branch_pr(client: GiteaClient) -> int:
|
||||
"""Find the open PR for the current git branch."""
|
||||
result = subprocess.run( # nosec
|
||||
["git", "rev-parse", "--abbrev-ref", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(_("Could not detect current branch: {error}", error=result.stderr.strip()))
|
||||
branch = result.stdout.strip()
|
||||
|
||||
prs = client.list_prs(state="open")
|
||||
for pr in prs:
|
||||
if pr.get("head", {}).get("ref") == branch:
|
||||
return int(pr["number"])
|
||||
raise click.ClickException(_("No open PR found for branch '{branch}'.", branch=branch))
|
||||
|
||||
|
||||
def print_status(client: GiteaClient, sha: str) -> str:
|
||||
"""Print CI check statuses for a commit SHA. Returns the overall state."""
|
||||
statuses = client.get_commit_status(sha)
|
||||
if not statuses:
|
||||
click.echo(_("No CI checks found for commit {sha}.", sha=sha[:8]))
|
||||
return "none"
|
||||
|
||||
overall = "success"
|
||||
for s in statuses:
|
||||
context = s.get("context", "?")
|
||||
status = s.get("status", "pending")
|
||||
symbol = _get_symbol(status)
|
||||
click.echo(f" {symbol} {context}")
|
||||
if status in ("failure", "error"):
|
||||
overall = "failure"
|
||||
elif status == "pending" and overall != "failure":
|
||||
overall = "pending"
|
||||
elif status == "skipped" and overall == "success":
|
||||
overall = "success"
|
||||
|
||||
click.echo(f"\n Overall: {_get_symbol(overall)} {overall}")
|
||||
return overall
|
||||
|
||||
|
||||
def wait_for_completion(
|
||||
client: GiteaClient,
|
||||
sha: str,
|
||||
timeout: int = 600,
|
||||
interval: int = 30,
|
||||
) -> str:
|
||||
"""Poll CI status until all checks complete or timeout. Returns final state."""
|
||||
click.echo(_("Waiting for CI checks to complete (timeout: {timeout}s)...", timeout=timeout))
|
||||
deadline = time.time() + timeout
|
||||
while time.time() < deadline:
|
||||
state = print_status(client, sha)
|
||||
if state in ("success", "failure", "error", "none"):
|
||||
return state
|
||||
click.echo(f" ...still pending, retrying in {interval}s\n")
|
||||
time.sleep(interval)
|
||||
click.echo(_("Timeout reached after {timeout}s.", timeout=timeout))
|
||||
return "pending"
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--pr", "pr_number", type=int, default=None, help="PR number (default: auto-detect from current branch).")
|
||||
@click.option("--sha", default=None, help="Commit SHA to check (alternative to --pr).")
|
||||
@click.option("--wait", "do_wait", is_flag=True, help="Poll until all checks complete.")
|
||||
@click.option("--timeout", type=int, default=600, show_default=True, help="Wait timeout in seconds.")
|
||||
@click.option("--interval", type=int, default=30, show_default=True, help="Poll interval in seconds.")
|
||||
@click.option("--owner", default=None, help="Repository owner (default: DEVX_REPO_OWNER).")
|
||||
@click.option("--repo", default=None, help="Repository name (default: DEVX_REPO_NAME or GITHUB_REPOSITORY).")
|
||||
def cli(
|
||||
pr_number: int | None,
|
||||
sha: str | None,
|
||||
do_wait: bool,
|
||||
timeout: int,
|
||||
interval: int,
|
||||
owner: str | None,
|
||||
repo: str | None,
|
||||
) -> None:
|
||||
"""Check CI status for a pull request or commit."""
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("CI_GITEA_TOKEN is not set."))
|
||||
|
||||
repo_owner = owner or REPO_OWNER
|
||||
if not repo_owner:
|
||||
raise click.ClickException(_("Repository owner not set. Use --owner or DEVX_REPO_OWNER env var."))
|
||||
repo_name = repo or get_repo_name()
|
||||
|
||||
client = GiteaClient(GITEA_API_URL, token, repo_owner, repo_name)
|
||||
|
||||
if sha is None:
|
||||
if pr_number is None:
|
||||
pr_number = _get_current_branch_pr(client)
|
||||
click.echo(_("Checking status for PR #{pr_number}...", pr_number=pr_number))
|
||||
pr = client.get_pr(pr_number)
|
||||
sha = pr.get("head", {}).get("sha", "")
|
||||
if not sha:
|
||||
raise click.ClickException(_("Could not determine head SHA for PR #{pr_number}.", pr_number=pr_number))
|
||||
|
||||
click.echo(_("Commit: {sha}", sha=sha[:12]))
|
||||
click.echo("")
|
||||
|
||||
state = wait_for_completion(client, sha, timeout, interval) if do_wait else print_status(client, sha)
|
||||
|
||||
if state in ("failure", "error"):
|
||||
raise click.ClickException(_("CI checks failed."))
|
||||
if state == "pending" and do_wait:
|
||||
raise click.ClickException(_("CI checks did not complete within timeout."))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -59,17 +59,7 @@ def task_exists(task_id: str) -> bool:
|
||||
if not token:
|
||||
return False
|
||||
client = VikunjaClient(VIKUNJA_API_URL, token)
|
||||
page = 1
|
||||
while True:
|
||||
tasks = client.list_project_tasks(VIKUNJA_PROJECT_ID, page=page, per_page=DEFAULT_PER_PAGE)
|
||||
if not tasks:
|
||||
break
|
||||
if any(t.get("identifier") == task_id for t in tasks):
|
||||
return True
|
||||
if len(tasks) < DEFAULT_PER_PAGE:
|
||||
break
|
||||
page += 1
|
||||
return False
|
||||
return client.find_task_by_identifier(VIKUNJA_PROJECT_ID, task_id, per_page=DEFAULT_PER_PAGE) is not None
|
||||
|
||||
|
||||
def validate(branch: str) -> None:
|
||||
|
||||
@@ -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()
|
||||
@@ -64,19 +64,19 @@ def _install_ansible_collections(bin_dir: str) -> None:
|
||||
|
||||
|
||||
def _configure_tea_login() -> None:
|
||||
"""Configure tea CLI login from .env if REPO_TOKEN is set.
|
||||
"""Configure tea CLI login from .env if CI_GITEA_TOKEN is set.
|
||||
|
||||
Idempotent: if a login with the same name already exists, it is not re-added.
|
||||
Skips if tea is not installed or REPO_TOKEN is not set.
|
||||
Skips if tea is not installed or CI_GITEA_TOKEN is not set.
|
||||
"""
|
||||
tea_bin = shutil.which("tea")
|
||||
if tea_bin is None:
|
||||
click.echo("tea: not installed — run 'make install-tools' to install it.")
|
||||
return
|
||||
|
||||
token = os.environ.get("REPO_TOKEN", "")
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
click.echo("tea: REPO_TOKEN not set — skipping login configuration.")
|
||||
click.echo("tea: CI_GITEA_TOKEN not set — skipping login configuration.")
|
||||
return
|
||||
|
||||
api_url = os.environ.get("DEVX_GITEA_API_URL", "https://git.oblachno.oblachno.fyi/api/v1")
|
||||
|
||||
@@ -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
|
||||
+1638
-398
@@ -55,6 +55,14 @@
|
||||
"ru": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
|
||||
"zh": "\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}"
|
||||
},
|
||||
"\nDone. Deleted {deleted}, kept {kept}, failed {failed}.": {
|
||||
"bg": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
|
||||
"de": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
|
||||
"en": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
|
||||
"pl": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
|
||||
"ru": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}.",
|
||||
"zh": "\nDone. Deleted {deleted}, kept {kept}, failed {failed}."
|
||||
},
|
||||
"\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.": {
|
||||
"bg": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
|
||||
"de": "\nERROR: Documentation coverage is not 100%. Use --fail-on-missing to enforce.",
|
||||
@@ -71,6 +79,14 @@
|
||||
"ru": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report.",
|
||||
"zh": "\nFix the misaligned tags before creating new releases. Run 'python3 -m devx.ci.release --verify' for a full report."
|
||||
},
|
||||
"\nGenerated {count} badges:": {
|
||||
"bg": "\nGenerated {count} badges:",
|
||||
"de": "\nGenerated {count} badges:",
|
||||
"en": "\nGenerated {count} badges:",
|
||||
"pl": "\nGenerated {count} badges:",
|
||||
"ru": "\nGenerated {count} badges:",
|
||||
"zh": "\nGenerated {count} badges:"
|
||||
},
|
||||
"\nIntegrity check FAILED ({count} issues):": {
|
||||
"bg": "\nIntegrity check FAILED ({count} issues):",
|
||||
"de": "\nIntegrity check FAILED ({count} issues):",
|
||||
@@ -87,6 +103,14 @@
|
||||
"ru": "\nIntegrity check passed — all {count} pages verified.",
|
||||
"zh": "\nIntegrity check passed — all {count} pages verified."
|
||||
},
|
||||
"\nKeeping {kept}, would delete {count}": {
|
||||
"bg": "\nKeeping {kept}, would delete {count}",
|
||||
"de": "\nKeeping {kept}, would delete {count}",
|
||||
"en": "\nKeeping {kept}, would delete {count}",
|
||||
"pl": "\nKeeping {kept}, would delete {count}",
|
||||
"ru": "\nKeeping {kept}, would delete {count}",
|
||||
"zh": "\nKeeping {kept}, would delete {count}"
|
||||
},
|
||||
"\nLatest tag: {tag}": {
|
||||
"bg": "\nLatest tag: {tag}",
|
||||
"de": "\nLatest tag: {tag}",
|
||||
@@ -119,6 +143,14 @@
|
||||
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments).",
|
||||
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}' ({num_comments} inline comments)."
|
||||
},
|
||||
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.": {
|
||||
"bg": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||
"de": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||
"en": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||
"pl": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||
"ru": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
|
||||
"zh": "\nReview #{review_id} posted on PR #{pr_number} with event '{event}'."
|
||||
},
|
||||
"\nRunning full wiki integrity check...": {
|
||||
"bg": "\nRunning full wiki integrity check...",
|
||||
"de": "\nRunning full wiki integrity check...",
|
||||
@@ -183,6 +215,14 @@
|
||||
"ru": "\nWorkflow-only changes ({count}):",
|
||||
"zh": "\nWorkflow-only changes ({count}):"
|
||||
},
|
||||
"\n[check_test_coverage] Fix: add the missing test file(s) before committing.": {
|
||||
"bg": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||
"de": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||
"en": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||
"pl": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||
"ru": "\n[check_test_coverage] Fix: add the missing test file(s) before committing.",
|
||||
"zh": "\n[check_test_coverage] Fix: add the missing test file(s) before committing."
|
||||
},
|
||||
"\n[dry-run] Changelog:\n{changelog}": {
|
||||
"bg": "\n[dry-run] Changelog:\n{changelog}",
|
||||
"de": "\n[dry-run] Changelog:\n{changelog}",
|
||||
@@ -199,6 +239,14 @@
|
||||
"ru": "\n{label} files changed ({count}):",
|
||||
"zh": "\n{label} files changed ({count}):"
|
||||
},
|
||||
"\n{separator}": {
|
||||
"bg": "\n{separator}",
|
||||
"de": "\n{separator}",
|
||||
"en": "\n{separator}",
|
||||
"pl": "\n{separator}",
|
||||
"ru": "\n{separator}",
|
||||
"zh": "\n{separator}"
|
||||
},
|
||||
"\n{tag} files ({count}):": {
|
||||
"bg": "\n{tag} files ({count}):",
|
||||
"de": "\n{tag} files ({count}):",
|
||||
@@ -207,6 +255,38 @@
|
||||
"ru": "\n{tag} files ({count}):",
|
||||
"zh": "\n{tag} files ({count}):"
|
||||
},
|
||||
" Could not fetch logs: {error}": {
|
||||
"bg": " Could not fetch logs: {error}",
|
||||
"de": " Could not fetch logs: {error}",
|
||||
"en": " Could not fetch logs: {error}",
|
||||
"pl": " Could not fetch logs: {error}",
|
||||
"ru": " Could not fetch logs: {error}",
|
||||
"zh": " Could not fetch logs: {error}"
|
||||
},
|
||||
" pytest stderr (last 300 chars): {stderr}": {
|
||||
"bg": " pytest stderr (last 300 chars): {stderr}",
|
||||
"de": " pytest stderr (last 300 chars): {stderr}",
|
||||
"en": " pytest stderr (last 300 chars): {stderr}",
|
||||
"pl": " pytest stderr (last 300 chars): {stderr}",
|
||||
"ru": " pytest stderr (last 300 chars): {stderr}",
|
||||
"zh": " pytest stderr (last 300 chars): {stderr}"
|
||||
},
|
||||
" pytest stdout (last 300 chars): {stdout}": {
|
||||
"bg": " pytest stdout (last 300 chars): {stdout}",
|
||||
"de": " pytest stdout (last 300 chars): {stdout}",
|
||||
"en": " pytest stdout (last 300 chars): {stdout}",
|
||||
"pl": " pytest stdout (last 300 chars): {stdout}",
|
||||
"ru": " pytest stdout (last 300 chars): {stdout}",
|
||||
"zh": " pytest stdout (last 300 chars): {stdout}"
|
||||
},
|
||||
" stderr: {stderr}": {
|
||||
"bg": " stderr: {stderr}",
|
||||
"de": " stderr: {stderr}",
|
||||
"en": " stderr: {stderr}",
|
||||
"pl": " stderr: {stderr}",
|
||||
"ru": " stderr: {stderr}",
|
||||
"zh": " stderr: {stderr}"
|
||||
},
|
||||
" - Auto-delete branch after merge: yes": {
|
||||
"bg": " - Автоматично изтриване на клон след сливане: да",
|
||||
"de": " - Branch nach Merge automatisch löschen: ja",
|
||||
@@ -215,6 +295,14 @@
|
||||
"ru": " - Автоудаление ветки после слияния: да",
|
||||
"zh": " - 合并后自动删除分支: 是"
|
||||
},
|
||||
" - Block admin merge override: yes": {
|
||||
"bg": " - Блокиране на admin merge override: да",
|
||||
"de": " - Admin-Merge-Override blockieren: ja",
|
||||
"en": " - Block admin merge override: yes",
|
||||
"pl": " - Blokuj admin merge override: tak",
|
||||
"ru": " - Блокировать admin merge override: да",
|
||||
"zh": " - 阻止管理员合并覆盖:是"
|
||||
},
|
||||
" - Block outdated branches: yes": {
|
||||
"bg": " - Блокиране на остарели клонове: да",
|
||||
"de": " - Veraltete Branches blockieren: ja",
|
||||
@@ -263,6 +351,46 @@
|
||||
"ru": " - Требуемые проверки статуса: {checks}",
|
||||
"zh": " - 必需状态检查: {checks}"
|
||||
},
|
||||
" Auto-fixed trailing whitespace in {n} files": {
|
||||
"bg": " Auto-fixed trailing whitespace in {n} files",
|
||||
"de": " Auto-fixed trailing whitespace in {n} files",
|
||||
"en": " Auto-fixed trailing whitespace in {n} files",
|
||||
"pl": " Auto-fixed trailing whitespace in {n} files",
|
||||
"ru": " Auto-fixed trailing whitespace in {n} files",
|
||||
"zh": " Auto-fixed trailing whitespace in {n} files"
|
||||
},
|
||||
" Collecting code quality...": {
|
||||
"bg": " Collecting code quality...",
|
||||
"de": " Collecting code quality...",
|
||||
"en": " Collecting code quality...",
|
||||
"pl": " Collecting code quality...",
|
||||
"ru": " Collecting code quality...",
|
||||
"zh": " Collecting code quality..."
|
||||
},
|
||||
" Collecting coverage and tests...": {
|
||||
"bg": " Collecting coverage and tests...",
|
||||
"de": " Collecting coverage and tests...",
|
||||
"en": " Collecting coverage and tests...",
|
||||
"pl": " Collecting coverage and tests...",
|
||||
"ru": " Collecting coverage and tests...",
|
||||
"zh": " Collecting coverage and tests..."
|
||||
},
|
||||
" Collecting doc coverage...": {
|
||||
"bg": " Collecting doc coverage...",
|
||||
"de": " Collecting doc coverage...",
|
||||
"en": " Collecting doc coverage...",
|
||||
"pl": " Collecting doc coverage...",
|
||||
"ru": " Collecting doc coverage...",
|
||||
"zh": " Collecting doc coverage..."
|
||||
},
|
||||
" Collecting version...": {
|
||||
"bg": " Collecting version...",
|
||||
"de": " Collecting version...",
|
||||
"en": " Collecting version...",
|
||||
"pl": " Collecting version...",
|
||||
"ru": " Collecting version...",
|
||||
"zh": " Collecting version..."
|
||||
},
|
||||
" Created: {title}": {
|
||||
"bg": " Created: {title}",
|
||||
"de": " Created: {title}",
|
||||
@@ -271,6 +399,14 @@
|
||||
"ru": " Created: {title}",
|
||||
"zh": " Created: {title}"
|
||||
},
|
||||
" Deleted: {version}": {
|
||||
"bg": " Deleted: {version}",
|
||||
"de": " Deleted: {version}",
|
||||
"en": " Deleted: {version}",
|
||||
"pl": " Deleted: {version}",
|
||||
"ru": " Deleted: {version}",
|
||||
"zh": " Deleted: {version}"
|
||||
},
|
||||
" FAIL: {title} — content mismatch or empty!": {
|
||||
"bg": " FAIL: {title} — content mismatch or empty!",
|
||||
"de": " FAIL: {title} — content mismatch or empty!",
|
||||
@@ -279,13 +415,29 @@
|
||||
"ru": " FAIL: {title} — content mismatch or empty!",
|
||||
"zh": " FAIL: {title} — content mismatch or empty!"
|
||||
},
|
||||
" MISSING: devx {cmd}": {
|
||||
"bg": " ЛИПСВА: devx {cmd}",
|
||||
"de": " FEHLT: devx {cmd}",
|
||||
"en": " MISSING: devx {cmd}",
|
||||
"pl": " BRAK: devx {cmd}",
|
||||
"ru": " ОТСУТСТВУЕТ: devx {cmd}",
|
||||
"zh": " 缺失: devx {cmd}"
|
||||
" FAILED to delete: {version}": {
|
||||
"bg": " FAILED to delete: {version}",
|
||||
"de": " FAILED to delete: {version}",
|
||||
"en": " FAILED to delete: {version}",
|
||||
"pl": " FAILED to delete: {version}",
|
||||
"ru": " FAILED to delete: {version}",
|
||||
"zh": " FAILED to delete: {version}"
|
||||
},
|
||||
" Generated: {path}": {
|
||||
"bg": " Generated: {path}",
|
||||
"de": " Generated: {path}",
|
||||
"en": " Generated: {path}",
|
||||
"pl": " Generated: {path}",
|
||||
"ru": " Generated: {path}",
|
||||
"zh": " Generated: {path}"
|
||||
},
|
||||
" MISSING: {cmd}": {
|
||||
"bg": " MISSING: {cmd}",
|
||||
"de": " MISSING: {cmd}",
|
||||
"en": " MISSING: {cmd}",
|
||||
"pl": " MISSING: {cmd}",
|
||||
"ru": " MISSING: {cmd}",
|
||||
"zh": " MISSING: {cmd}"
|
||||
},
|
||||
" MISSING: {module}": {
|
||||
"bg": " MISSING: {module}",
|
||||
@@ -303,13 +455,13 @@
|
||||
"ru": " MISSING: {script}",
|
||||
"zh": " MISSING: {script}"
|
||||
},
|
||||
" OK: devx {cmd}": {
|
||||
"bg": " ОК: devx {cmd}",
|
||||
"de": " OK: devx {cmd}",
|
||||
"en": " OK: devx {cmd}",
|
||||
"pl": " OK: devx {cmd}",
|
||||
"ru": " ОК: devx {cmd}",
|
||||
"zh": " 正常: devx {cmd}"
|
||||
" OK: {cmd}": {
|
||||
"bg": " OK: {cmd}",
|
||||
"de": " OK: {cmd}",
|
||||
"en": " OK: {cmd}",
|
||||
"pl": " OK: {cmd}",
|
||||
"ru": " OK: {cmd}",
|
||||
"zh": " OK: {cmd}"
|
||||
},
|
||||
" OK: {module}": {
|
||||
"bg": " OK: {module}",
|
||||
@@ -335,6 +487,38 @@
|
||||
"ru": " OK: {title} ({chars} chars)",
|
||||
"zh": " OK: {title} ({chars} chars)"
|
||||
},
|
||||
" Package: {pkg}": {
|
||||
"bg": " Package: {pkg}",
|
||||
"de": " Package: {pkg}",
|
||||
"en": " Package: {pkg}",
|
||||
"pl": " Package: {pkg}",
|
||||
"ru": " Package: {pkg}",
|
||||
"zh": " Package: {pkg}"
|
||||
},
|
||||
" Quality checks: {checks}": {
|
||||
"bg": " Quality checks: {checks}",
|
||||
"de": " Quality checks: {checks}",
|
||||
"en": " Quality checks: {checks}",
|
||||
"pl": " Quality checks: {checks}",
|
||||
"ru": " Quality checks: {checks}",
|
||||
"zh": " Quality checks: {checks}"
|
||||
},
|
||||
" Repo root: {root}": {
|
||||
"bg": " Repo root: {root}",
|
||||
"de": " Repo root: {root}",
|
||||
"en": " Repo root: {root}",
|
||||
"pl": " Repo root: {root}",
|
||||
"ru": " Repo root: {root}",
|
||||
"zh": " Repo root: {root}"
|
||||
},
|
||||
" Test paths: {testpaths}": {
|
||||
"bg": " Test paths: {testpaths}",
|
||||
"de": " Test paths: {testpaths}",
|
||||
"en": " Test paths: {testpaths}",
|
||||
"pl": " Test paths: {testpaths}",
|
||||
"ru": " Test paths: {testpaths}",
|
||||
"zh": " Test paths: {testpaths}"
|
||||
},
|
||||
" Updated: {title}": {
|
||||
"bg": " Updated: {title}",
|
||||
"de": " Updated: {title}",
|
||||
@@ -343,6 +527,134 @@
|
||||
"ru": " Updated: {title}",
|
||||
"zh": " Updated: {title}"
|
||||
},
|
||||
" WARNING: Could not extract coverage from pytest output (rc={rc})": {
|
||||
"bg": " WARNING: Could not extract coverage from pytest output (rc={rc})",
|
||||
"de": " WARNING: Could not extract coverage from pytest output (rc={rc})",
|
||||
"en": " WARNING: Could not extract coverage from pytest output (rc={rc})",
|
||||
"pl": " WARNING: Could not extract coverage from pytest output (rc={rc})",
|
||||
"ru": " WARNING: Could not extract coverage from pytest output (rc={rc})",
|
||||
"zh": " WARNING: Could not extract coverage from pytest output (rc={rc})"
|
||||
},
|
||||
" WARNING: Could not extract doc coverage (rc={rc})": {
|
||||
"bg": " WARNING: Could not extract doc coverage (rc={rc})",
|
||||
"de": " WARNING: Could not extract doc coverage (rc={rc})",
|
||||
"en": " WARNING: Could not extract doc coverage (rc={rc})",
|
||||
"pl": " WARNING: Could not extract doc coverage (rc={rc})",
|
||||
"ru": " WARNING: Could not extract doc coverage (rc={rc})",
|
||||
"zh": " WARNING: Could not extract doc coverage (rc={rc})"
|
||||
},
|
||||
" WARNING: Could not extract test count from pytest output (rc={rc})": {
|
||||
"bg": " WARNING: Could not extract test count from pytest output (rc={rc})",
|
||||
"de": " WARNING: Could not extract test count from pytest output (rc={rc})",
|
||||
"en": " WARNING: Could not extract test count from pytest output (rc={rc})",
|
||||
"pl": " WARNING: Could not extract test count from pytest output (rc={rc})",
|
||||
"ru": " WARNING: Could not extract test count from pytest output (rc={rc})",
|
||||
"zh": " WARNING: Could not extract test count from pytest output (rc={rc})"
|
||||
},
|
||||
" WARNING: No Python package found under src/ — version badge will show 'unknown'": {
|
||||
"bg": " WARNING: No Python package found under src/ — version badge will show 'unknown'",
|
||||
"de": " WARNING: No Python package found under src/ — version badge will show 'unknown'",
|
||||
"en": " WARNING: No Python package found under src/ — version badge will show 'unknown'",
|
||||
"pl": " WARNING: No Python package found under src/ — version badge will show 'unknown'",
|
||||
"ru": " WARNING: No Python package found under src/ — version badge will show 'unknown'",
|
||||
"zh": " WARNING: No Python package found under src/ — version badge will show 'unknown'"
|
||||
},
|
||||
" WARNING: No __version__ found in {init_file} — version badge will show 'unknown'": {
|
||||
"bg": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'",
|
||||
"de": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'",
|
||||
"en": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'",
|
||||
"pl": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'",
|
||||
"ru": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'",
|
||||
"zh": " WARNING: No __version__ found in {init_file} — version badge will show 'unknown'"
|
||||
},
|
||||
" WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)": {
|
||||
"bg": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)",
|
||||
"de": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)",
|
||||
"en": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)",
|
||||
"pl": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)",
|
||||
"ru": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)",
|
||||
"zh": " WARNING: No coverage target detected (no src/ package, no --cov in pyproject.toml)"
|
||||
},
|
||||
" WARNING: {init_file} not found — version badge will show 'unknown'": {
|
||||
"bg": " WARNING: {init_file} not found — version badge will show 'unknown'",
|
||||
"de": " WARNING: {init_file} not found — version badge will show 'unknown'",
|
||||
"en": " WARNING: {init_file} not found — version badge will show 'unknown'",
|
||||
"pl": " WARNING: {init_file} not found — version badge will show 'unknown'",
|
||||
"ru": " WARNING: {init_file} not found — version badge will show 'unknown'",
|
||||
"zh": " WARNING: {init_file} not found — version badge will show 'unknown'"
|
||||
},
|
||||
" WARNING: {name} failed (rc={rc})": {
|
||||
"bg": " WARNING: {name} failed (rc={rc})",
|
||||
"de": " WARNING: {name} failed (rc={rc})",
|
||||
"en": " WARNING: {name} failed (rc={rc})",
|
||||
"pl": " WARNING: {name} failed (rc={rc})",
|
||||
"ru": " WARNING: {name} failed (rc={rc})",
|
||||
"zh": " WARNING: {name} failed (rc={rc})"
|
||||
},
|
||||
" WARNING: {name} not installed — skipping (counted as pass)": {
|
||||
"bg": " WARNING: {name} not installed — skipping (counted as pass)",
|
||||
"de": " WARNING: {name} not installed — skipping (counted as pass)",
|
||||
"en": " WARNING: {name} not installed — skipping (counted as pass)",
|
||||
"pl": " WARNING: {name} not installed — skipping (counted as pass)",
|
||||
"ru": " WARNING: {name} not installed — skipping (counted as pass)",
|
||||
"zh": " WARNING: {name} not installed — skipping (counted as pass)"
|
||||
},
|
||||
" [dry-run] Would delete: {version}": {
|
||||
"bg": " [dry-run] Would delete: {version}",
|
||||
"de": " [dry-run] Would delete: {version}",
|
||||
"en": " [dry-run] Would delete: {version}",
|
||||
"pl": " [dry-run] Would delete: {version}",
|
||||
"ru": " [dry-run] Would delete: {version}",
|
||||
"zh": " [dry-run] Would delete: {version}"
|
||||
},
|
||||
" {name}: {label}={message} ({color})": {
|
||||
"bg": " {name}: {label}={message} ({color})",
|
||||
"de": " {name}: {label}={message} ({color})",
|
||||
"en": " {name}: {label}={message} ({color})",
|
||||
"pl": " {name}: {label}={message} ({color})",
|
||||
"ru": " {name}: {label}={message} ({color})",
|
||||
"zh": " {name}: {label}={message} ({color})"
|
||||
},
|
||||
" {n} stale docs found (warnings only)": {
|
||||
"bg": " {n} stale docs found (warnings only)",
|
||||
"de": " {n} stale docs found (warnings only)",
|
||||
"en": " {n} stale docs found (warnings only)",
|
||||
"pl": " {n} stale docs found (warnings only)",
|
||||
"ru": " {n} stale docs found (warnings only)",
|
||||
"zh": " {n} stale docs found (warnings only)"
|
||||
},
|
||||
" {version} (created: {created})": {
|
||||
"bg": " {version} (created: {created})",
|
||||
"de": " {version} (created: {created})",
|
||||
"en": " {version} (created: {created})",
|
||||
"pl": " {version} (created: {created})",
|
||||
"ru": " {version} (created: {created})",
|
||||
"zh": " {version} (created: {created})"
|
||||
},
|
||||
"--checklist-categories must list at least 8 of 13 categories. Got {count}.": {
|
||||
"bg": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||
"de": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||
"en": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||
"pl": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||
"ru": "--checklist-categories must list at least 8 of 13 categories. Got {count}.",
|
||||
"zh": "--checklist-categories must list at least 8 of 13 categories. Got {count}."
|
||||
},
|
||||
"--checklist-confirmed is required for APPROVE events.": {
|
||||
"bg": "--checklist-confirmed is required for APPROVE events.",
|
||||
"de": "--checklist-confirmed is required for APPROVE events.",
|
||||
"en": "--checklist-confirmed is required for APPROVE events.",
|
||||
"pl": "--checklist-confirmed is required for APPROVE events.",
|
||||
"ru": "--checklist-confirmed is required for APPROVE events.",
|
||||
"zh": "--checklist-confirmed is required for APPROVE events."
|
||||
},
|
||||
"--push requires --registry": {
|
||||
"bg": "--push requires --registry",
|
||||
"de": "--push requires --registry",
|
||||
"en": "--push requires --registry",
|
||||
"pl": "--push requires --registry",
|
||||
"ru": "--push requires --registry",
|
||||
"zh": "--push requires --registry"
|
||||
},
|
||||
"--skip-build: skipping package build and PyPI publish.": {
|
||||
"bg": "--skip-build: skipping package build and PyPI publish.",
|
||||
"de": "--skip-build: skipping package build and PyPI publish.",
|
||||
@@ -367,6 +679,22 @@
|
||||
"ru": "API poll warning: {exc}",
|
||||
"zh": "API poll warning: {exc}"
|
||||
},
|
||||
"Added label '{label}' to PR #{pr}.": {
|
||||
"bg": "Added label '{label}' to PR #{pr}.",
|
||||
"de": "Added label '{label}' to PR #{pr}.",
|
||||
"en": "Added label '{label}' to PR #{pr}.",
|
||||
"pl": "Added label '{label}' to PR #{pr}.",
|
||||
"ru": "Added label '{label}' to PR #{pr}.",
|
||||
"zh": "Added label '{label}' to PR #{pr}."
|
||||
},
|
||||
"Additional directory to scan (default: scripts, tests). Can be repeated.": {
|
||||
"bg": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
||||
"de": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
||||
"en": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
||||
"pl": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
||||
"ru": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
||||
"zh": "Additional directory to scan (default: scripts, tests). Can be repeated."
|
||||
},
|
||||
"All molecule tests passed.": {
|
||||
"bg": "All molecule tests passed.",
|
||||
"de": "All molecule tests passed.",
|
||||
@@ -383,13 +711,101 @@
|
||||
"ru": "Another molecule runner failed. Stopping this runner early.",
|
||||
"zh": "Another molecule runner failed. Stopping this runner early."
|
||||
},
|
||||
"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."
|
||||
"Assigned {count} files to runner {runner_index}": {
|
||||
"bg": "Assigned {count} files to runner {runner_index}",
|
||||
"de": "Assigned {count} files to runner {runner_index}",
|
||||
"en": "Assigned {count} files to runner {runner_index}",
|
||||
"pl": "Assigned {count} files to runner {runner_index}",
|
||||
"ru": "Assigned {count} files to runner {runner_index}",
|
||||
"zh": "Assigned {count} files to runner {runner_index}"
|
||||
},
|
||||
"Assigned {count} items to runner {runner_index}: {encoded}": {
|
||||
"bg": "Assigned {count} items to runner {runner_index}: {encoded}",
|
||||
"de": "Assigned {count} items to runner {runner_index}: {encoded}",
|
||||
"en": "Assigned {count} items to runner {runner_index}: {encoded}",
|
||||
"pl": "Assigned {count} items to runner {runner_index}: {encoded}",
|
||||
"ru": "Assigned {count} items to runner {runner_index}: {encoded}",
|
||||
"zh": "Assigned {count} items to runner {runner_index}: {encoded}"
|
||||
},
|
||||
"Badge push attempt {attempt}/{retries} failed — retrying: {error}": {
|
||||
"bg": "Badge push attempt {attempt}/{retries} failed — retrying: {error}",
|
||||
"de": "Badge push attempt {attempt}/{retries} failed — retrying: {error}",
|
||||
"en": "Badge push attempt {attempt}/{retries} failed — retrying: {error}",
|
||||
"pl": "Badge push attempt {attempt}/{retries} failed — retrying: {error}",
|
||||
"ru": "Badge push attempt {attempt}/{retries} failed — retrying: {error}",
|
||||
"zh": "Badge push attempt {attempt}/{retries} failed — retrying: {error}"
|
||||
},
|
||||
"Badge push failed after {retries} attempts: {error}": {
|
||||
"bg": "Badge push failed after {retries} attempts: {error}",
|
||||
"de": "Badge push failed after {retries} attempts: {error}",
|
||||
"en": "Badge push failed after {retries} attempts: {error}",
|
||||
"pl": "Badge push failed after {retries} attempts: {error}",
|
||||
"ru": "Badge push failed after {retries} attempts: {error}",
|
||||
"zh": "Badge push failed after {retries} attempts: {error}"
|
||||
},
|
||||
"Badges commit SHA: {sha}": {
|
||||
"bg": "Badges commit SHA: {sha}",
|
||||
"de": "Badges commit SHA: {sha}",
|
||||
"en": "Badges commit SHA: {sha}",
|
||||
"pl": "Badges commit SHA: {sha}",
|
||||
"ru": "Badges commit SHA: {sha}",
|
||||
"zh": "Badges commit SHA: {sha}"
|
||||
},
|
||||
"Badges pushed to badges branch": {
|
||||
"bg": "Badges pushed to badges branch",
|
||||
"de": "Badges pushed to badges branch",
|
||||
"en": "Badges pushed to badges branch",
|
||||
"pl": "Badges pushed to badges branch",
|
||||
"ru": "Badges pushed to badges branch",
|
||||
"zh": "Badges pushed to badges branch"
|
||||
},
|
||||
"Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description": {
|
||||
"bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание",
|
||||
"de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung",
|
||||
"en": "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description",
|
||||
"pl": "Gałąź '{branch}' nie zawiera ID zadania.\n Oczekiwany format: {prefix}-N-krótki-opis",
|
||||
"ru": "Ветка '{branch}' не содержит ID задачи.\n Ожидаемый формат: {prefix}-N-краткое-описание",
|
||||
"zh": "分支 '{branch}' 不包含任务 ID。\n 预期格式: {prefix}-N-简短描述"
|
||||
},
|
||||
"Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description\n Example: {prefix}-42-add-feature\n Fix: rename the branch or create a Vikunja task first:\n python -m devx.tools.create_task --title \"Task title\"": {
|
||||
"bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание\n Пример: {prefix}-42-add-feature\n Решение: преименувайте клона или създайте Vikunja задача:\n python -m devx.tools.create_task --title \"Заглавие на задача\"",
|
||||
"de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung\n Beispiel: {prefix}-42-add-feature\n Fix: Branch umbenennen oder Vikunja-Task erstellen:\n python -m devx.tools.create_task --title \"Task-Titel\"",
|
||||
"en": "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description\n Example: {prefix}-42-add-feature\n Fix: rename the branch or create a Vikunja task first:\n python -m devx.tools.create_task --title \"Task title\"",
|
||||
"pl": "Gałąź '{branch}' nie zawiera ID zadania.\n Oczekiwany format: {prefix}-N-krótki-opis\n Przykład: {prefix}-42-add-feature\n Naprawa: zmień nazwę gałęzi lub utwórz zadanie Vikunja:\n python -m devx.tools.create_task --title \"Tytuł zadania\"",
|
||||
"ru": "Ветка '{branch}' не содержит ID задачи.\n Ожидаемый формат: {prefix}-N-краткое-описание\n Пример: {prefix}-42-add-feature\n Исправление: переименуйте ветку или создайте задачу Vikunja:\n python -m devx.tools.create_task --title \"Заголовок задачи\"",
|
||||
"zh": "分支 '{branch}' 不包含任务 ID。\n 预期格式: {prefix}-N-简短描述\n 示例: {prefix}-42-add-feature\n 修复: 重命名分支或先创建 Vikunja 任务:\n python -m devx.tools.create_task --title \"任务标题\""
|
||||
},
|
||||
"Branch is behind 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",
|
||||
"en": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
|
||||
"pl": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
|
||||
"ru": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
|
||||
"zh": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master"
|
||||
},
|
||||
"Branch name (e.g., DEVX-256-fix-foo)": {
|
||||
"bg": "Branch name (e.g., DEVX-256-fix-foo)",
|
||||
"de": "Branch name (e.g., DEVX-256-fix-foo)",
|
||||
"en": "Branch name (e.g., DEVX-256-fix-foo)",
|
||||
"pl": "Branch name (e.g., DEVX-256-fix-foo)",
|
||||
"ru": "Branch name (e.g., DEVX-256-fix-foo)",
|
||||
"zh": "Branch name (e.g., DEVX-256-fix-foo)"
|
||||
},
|
||||
"Branch name must contain a task ID.": {
|
||||
"bg": "Branch name must contain a task ID.",
|
||||
"de": "Branch name must contain a task ID.",
|
||||
"en": "Branch name must contain a task ID.",
|
||||
"pl": "Branch name must contain a task ID.",
|
||||
"ru": "Branch name must contain a task ID.",
|
||||
"zh": "Branch name must contain a task ID."
|
||||
},
|
||||
"Build failed for {name}": {
|
||||
"bg": "Build failed for {name}",
|
||||
"de": "Build failed for {name}",
|
||||
"en": "Build failed for {name}",
|
||||
"pl": "Build failed for {name}",
|
||||
"ru": "Build failed for {name}",
|
||||
"zh": "Build failed for {name}"
|
||||
},
|
||||
"Bumping version: {current} -> v{new_version}": {
|
||||
"bg": "Bumping version: {current} -> v{new_version}",
|
||||
@@ -399,6 +815,54 @@
|
||||
"ru": "Bumping version: {current} -> v{new_version}",
|
||||
"zh": "Bumping version: {current} -> v{new_version}"
|
||||
},
|
||||
"CI checks did not complete within timeout.": {
|
||||
"bg": "CI checks did not complete within timeout.",
|
||||
"de": "CI checks did not complete within timeout.",
|
||||
"en": "CI checks did not complete within timeout.",
|
||||
"pl": "CI checks did not complete within timeout.",
|
||||
"ru": "CI checks did not complete within timeout.",
|
||||
"zh": "CI checks did not complete within timeout."
|
||||
},
|
||||
"CI checks failed.": {
|
||||
"bg": "CI checks failed.",
|
||||
"de": "CI checks failed.",
|
||||
"en": "CI checks failed.",
|
||||
"pl": "CI checks failed.",
|
||||
"ru": "CI checks failed.",
|
||||
"zh": "CI checks failed."
|
||||
},
|
||||
"CI_GITEA_TOKEN environment variable required": {
|
||||
"bg": "CI_GITEA_TOKEN environment variable required",
|
||||
"de": "CI_GITEA_TOKEN environment variable required",
|
||||
"en": "CI_GITEA_TOKEN environment variable required",
|
||||
"pl": "CI_GITEA_TOKEN environment variable required",
|
||||
"ru": "CI_GITEA_TOKEN environment variable required",
|
||||
"zh": "CI_GITEA_TOKEN environment variable required"
|
||||
},
|
||||
"CI_GITEA_TOKEN is not set.": {
|
||||
"bg": "CI_GITEA_TOKEN is not set.",
|
||||
"de": "CI_GITEA_TOKEN is not set.",
|
||||
"en": "CI_GITEA_TOKEN is not set.",
|
||||
"pl": "CI_GITEA_TOKEN is not set.",
|
||||
"ru": "CI_GITEA_TOKEN is not set.",
|
||||
"zh": "CI_GITEA_TOKEN is not set."
|
||||
},
|
||||
"CI_GITEA_TOKEN is not set. Required to create a PR.": {
|
||||
"bg": "CI_GITEA_TOKEN не е зададен. Необходим за създаване на PR.",
|
||||
"de": "CI_GITEA_TOKEN nicht gesetzt. Erforderlich zum Erstellen eines PR.",
|
||||
"en": "CI_GITEA_TOKEN is not set. Required to create a PR.",
|
||||
"pl": "CI_GITEA_TOKEN nie jest ustawiony. Wymagany do utworzenia PR.",
|
||||
"ru": "CI_GITEA_TOKEN не установлен. Требуется для создания PR.",
|
||||
"zh": "CI_GITEA_TOKEN 未设置。创建 PR 所需。"
|
||||
},
|
||||
"CI_GITEA_TOKEN not set — skipping login configuration.": {
|
||||
"bg": "CI_GITEA_TOKEN not set — skipping login configuration.",
|
||||
"de": "CI_GITEA_TOKEN not set — skipping login configuration.",
|
||||
"en": "CI_GITEA_TOKEN not set — skipping login configuration.",
|
||||
"pl": "CI_GITEA_TOKEN not set — skipping login configuration.",
|
||||
"ru": "CI_GITEA_TOKEN not set — skipping login configuration.",
|
||||
"zh": "CI_GITEA_TOKEN not set — skipping login configuration."
|
||||
},
|
||||
"Checking CLI command documentation...": {
|
||||
"bg": "Checking CLI command documentation...",
|
||||
"de": "Checking CLI command documentation...",
|
||||
@@ -407,6 +871,78 @@
|
||||
"ru": "Checking CLI command documentation...",
|
||||
"zh": "Checking CLI command documentation..."
|
||||
},
|
||||
"Checking docs structure...": {
|
||||
"bg": "Checking docs structure...",
|
||||
"de": "Checking docs structure...",
|
||||
"en": "Checking docs structure...",
|
||||
"pl": "Checking docs structure...",
|
||||
"ru": "Checking docs structure...",
|
||||
"zh": "Checking docs structure..."
|
||||
},
|
||||
"Checking duplicate headings...": {
|
||||
"bg": "Checking duplicate headings...",
|
||||
"de": "Checking duplicate headings...",
|
||||
"en": "Checking duplicate headings...",
|
||||
"pl": "Checking duplicate headings...",
|
||||
"ru": "Checking duplicate headings...",
|
||||
"zh": "Checking duplicate headings..."
|
||||
},
|
||||
"Checking for TODO/FIXME markers...": {
|
||||
"bg": "Checking for TODO/FIXME markers...",
|
||||
"de": "Checking for TODO/FIXME markers...",
|
||||
"en": "Checking for TODO/FIXME markers...",
|
||||
"pl": "Checking for TODO/FIXME markers...",
|
||||
"ru": "Checking for TODO/FIXME markers...",
|
||||
"zh": "Checking for TODO/FIXME markers..."
|
||||
},
|
||||
"Checking for stale docs...": {
|
||||
"bg": "Checking for stale docs...",
|
||||
"de": "Checking for stale docs...",
|
||||
"en": "Checking for stale docs...",
|
||||
"pl": "Checking for stale docs...",
|
||||
"ru": "Checking for stale docs...",
|
||||
"zh": "Checking for stale docs..."
|
||||
},
|
||||
"Checking heading hierarchy...": {
|
||||
"bg": "Checking heading hierarchy...",
|
||||
"de": "Checking heading hierarchy...",
|
||||
"en": "Checking heading hierarchy...",
|
||||
"pl": "Checking heading hierarchy...",
|
||||
"ru": "Checking heading hierarchy...",
|
||||
"zh": "Checking heading hierarchy..."
|
||||
},
|
||||
"Checking internal links...": {
|
||||
"bg": "Checking internal links...",
|
||||
"de": "Checking internal links...",
|
||||
"en": "Checking internal links...",
|
||||
"pl": "Checking internal links...",
|
||||
"ru": "Checking internal links...",
|
||||
"zh": "Checking internal links..."
|
||||
},
|
||||
"Checking required files...": {
|
||||
"bg": "Checking required files...",
|
||||
"de": "Checking required files...",
|
||||
"en": "Checking required files...",
|
||||
"pl": "Checking required files...",
|
||||
"ru": "Checking required files...",
|
||||
"zh": "Checking required files..."
|
||||
},
|
||||
"Checking status for PR #{pr_number}...": {
|
||||
"bg": "Checking status for PR #{pr_number}...",
|
||||
"de": "Checking status for PR #{pr_number}...",
|
||||
"en": "Checking status for PR #{pr_number}...",
|
||||
"pl": "Checking status for PR #{pr_number}...",
|
||||
"ru": "Checking status for PR #{pr_number}...",
|
||||
"zh": "Checking status for PR #{pr_number}..."
|
||||
},
|
||||
"Checking trailing whitespace...": {
|
||||
"bg": "Checking trailing whitespace...",
|
||||
"de": "Checking trailing whitespace...",
|
||||
"en": "Checking trailing whitespace...",
|
||||
"pl": "Checking trailing whitespace...",
|
||||
"ru": "Checking trailing whitespace...",
|
||||
"zh": "Checking trailing whitespace..."
|
||||
},
|
||||
"Command failed ({cmd}): {stderr}": {
|
||||
"bg": "Command failed ({cmd}): {stderr}",
|
||||
"de": "Command failed ({cmd}): {stderr}",
|
||||
@@ -415,6 +951,22 @@
|
||||
"ru": "Command failed ({cmd}): {stderr}",
|
||||
"zh": "Command failed ({cmd}): {stderr}"
|
||||
},
|
||||
"Commit message: {msg}": {
|
||||
"bg": "Commit message: {msg}",
|
||||
"de": "Commit message: {msg}",
|
||||
"en": "Commit message: {msg}",
|
||||
"pl": "Commit message: {msg}",
|
||||
"ru": "Commit message: {msg}",
|
||||
"zh": "Commit message: {msg}"
|
||||
},
|
||||
"Commit: {sha}": {
|
||||
"bg": "Commit: {sha}",
|
||||
"de": "Commit: {sha}",
|
||||
"en": "Commit: {sha}",
|
||||
"pl": "Commit: {sha}",
|
||||
"ru": "Commit: {sha}",
|
||||
"zh": "Commit: {sha}"
|
||||
},
|
||||
"Comparing {base}..{head} ({count} files changed)": {
|
||||
"bg": "Comparing {base}..{head} ({count} files changed)",
|
||||
"de": "Comparing {base}..{head} ({count} files changed)",
|
||||
@@ -423,6 +975,22 @@
|
||||
"ru": "Comparing {base}..{head} ({count} files changed)",
|
||||
"zh": "Comparing {base}..{head} ({count} files changed)"
|
||||
},
|
||||
"Configuration OK: [tool.devx] present, devx versions consistent.": {
|
||||
"bg": "Конфигурацията е OK: [tool.devx] присъства, версиите на devx са консистентни.",
|
||||
"de": "Konfiguration OK: [tool.devx] vorhanden, devx-Versionen konsistent.",
|
||||
"en": "Configuration OK: [tool.devx] present, devx versions consistent.",
|
||||
"pl": "Konfiguracja OK: [tool.devx] obecne, wersje devx spójne.",
|
||||
"ru": "Конфигурация OK: [tool.devx] присутствует, версии devx согласованы.",
|
||||
"zh": "配置正常: [tool.devx] 已存在, devx 版本一致。"
|
||||
},
|
||||
"Configuration validation failed.": {
|
||||
"bg": "Configuration validation failed.",
|
||||
"de": "Configuration validation failed.",
|
||||
"en": "Configuration validation failed.",
|
||||
"pl": "Configuration validation failed.",
|
||||
"ru": "Configuration validation failed.",
|
||||
"zh": "Configuration validation failed."
|
||||
},
|
||||
"Configuring branch protection for {branch}...": {
|
||||
"bg": "Конфигуриране на защита на клона {branch}...",
|
||||
"de": "Konfiguriere Branch-Schutz für {branch}...",
|
||||
@@ -439,13 +1007,29 @@
|
||||
"ru": "Настройка параметров репозитория...",
|
||||
"zh": "正在配置仓库设置..."
|
||||
},
|
||||
"Configuration OK: [tool.devx] present, devx versions consistent.": {
|
||||
"bg": "Конфигурацията е OK: [tool.devx] присъства, версиите на devx са консистентни.",
|
||||
"de": "Konfiguration OK: [tool.devx] vorhanden, devx-Versionen konsistent.",
|
||||
"en": "Configuration OK: [tool.devx] present, devx versions consistent.",
|
||||
"pl": "Konfiguracja OK: [tool.devx] obecne, wersje devx spójne.",
|
||||
"ru": "Конфигурация OK: [tool.devx] присутствует, версии devx согласованы.",
|
||||
"zh": "配置正常: [tool.devx] 已存在, devx 版本一致。"
|
||||
"Configuring tea login '{name}' for {url}...": {
|
||||
"bg": "Configuring tea login '{name}' for {url}...",
|
||||
"de": "Configuring tea login '{name}' for {url}...",
|
||||
"en": "Configuring tea login '{name}' for {url}...",
|
||||
"pl": "Configuring tea login '{name}' for {url}...",
|
||||
"ru": "Configuring tea login '{name}' for {url}...",
|
||||
"zh": "Configuring tea login '{name}' for {url}..."
|
||||
},
|
||||
"Could not detect current branch: {error}": {
|
||||
"bg": "Не може да се определи текущия клон: {error}",
|
||||
"de": "Aktueller Branch konnte nicht erkannt werden: {error}",
|
||||
"en": "Could not detect current branch: {error}",
|
||||
"pl": "Nie można wykryć bieżącej gałęzi: {error}",
|
||||
"ru": "Не удалось определить текущую ветку: {error}",
|
||||
"zh": "无法检测当前分支: {error}"
|
||||
},
|
||||
"Could not determine head SHA for PR #{pr_number}.": {
|
||||
"bg": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"de": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"en": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"pl": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"ru": "Could not determine head SHA for PR #{pr_number}.",
|
||||
"zh": "Could not determine head SHA for PR #{pr_number}."
|
||||
},
|
||||
"Could not extract conventional commit message from PR commits.": {
|
||||
"bg": "Could not extract conventional commit message from PR commits.",
|
||||
@@ -455,6 +1039,22 @@
|
||||
"ru": "Could not extract conventional commit message from PR commits.",
|
||||
"zh": "Could not extract conventional commit message from PR commits."
|
||||
},
|
||||
"Could not fetch PR title from Gitea (CI_GITEA_TOKEN not set or PR not found).": {
|
||||
"bg": "Could not fetch PR title from Gitea (CI_GITEA_TOKEN not set or PR not found).",
|
||||
"de": "Could not fetch PR title from Gitea (CI_GITEA_TOKEN not set or PR not found).",
|
||||
"en": "Could not fetch PR title from Gitea (CI_GITEA_TOKEN not set or PR not found).",
|
||||
"pl": "Could not fetch PR title from Gitea (CI_GITEA_TOKEN not set or PR not found).",
|
||||
"ru": "Could not fetch PR title from Gitea (CI_GITEA_TOKEN not set or PR not found).",
|
||||
"zh": "Could not fetch PR title from Gitea (CI_GITEA_TOKEN not set or PR not found)."
|
||||
},
|
||||
"Could not find Vikunja task {task_id} in project {project_id}.": {
|
||||
"bg": "Не е намерена Vikunja задача {task_id} в проект {project_id}.",
|
||||
"de": "Vikunja-Task {task_id} in Projekt {project_id} nicht gefunden.",
|
||||
"en": "Could not find Vikunja task {task_id} in project {project_id}.",
|
||||
"pl": "Nie znaleziono zadania Vikunja {task_id} w projekcie {project_id}.",
|
||||
"ru": "Не найдена задача Vikunja {task_id} в проекте {project_id}.",
|
||||
"zh": "在项目 {project_id} 中找不到 Vikunja 任务 {task_id}。"
|
||||
},
|
||||
"Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.": {
|
||||
"bg": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
|
||||
"de": "Could not find Vikunja task {task_id} in project {project_id}. Every PR must have a corresponding Vikunja task.",
|
||||
@@ -479,6 +1079,22 @@
|
||||
"ru": "Could not parse test execution time from output.",
|
||||
"zh": "Could not parse test execution time from output."
|
||||
},
|
||||
"Created PR #{index}: {title}\n {url}": {
|
||||
"bg": "Създаден PR #{index}: {title}\n {url}",
|
||||
"de": "PR erstellt #{index}: {title}\n {url}",
|
||||
"en": "Created PR #{index}: {title}\n {url}",
|
||||
"pl": "Utworzono PR #{index}: {title}\n {url}",
|
||||
"ru": "Создан PR #{index}: {title}\n {url}",
|
||||
"zh": "已创建 PR #{index}: {title}\n {url}"
|
||||
},
|
||||
"Created Vikunja task: {identifier} (id={task_id})": {
|
||||
"bg": "Създадена Vikunja задача: {identifier} (id={task_id})",
|
||||
"de": "Vikunja-Task erstellt: {identifier} (id={task_id})",
|
||||
"en": "Created Vikunja task: {identifier} (id={task_id})",
|
||||
"pl": "Utworzono zadanie Vikunja: {identifier} (id={task_id})",
|
||||
"ru": "Создана задача Vikunja: {identifier} (id={task_id})",
|
||||
"zh": "已创建 Vikunja 任务: {identifier} (id={task_id})"
|
||||
},
|
||||
"Created issue #{issue_id}: {title}": {
|
||||
"bg": "Created issue #{issue_id}: {title}",
|
||||
"de": "Created issue #{issue_id}: {title}",
|
||||
@@ -495,13 +1111,13 @@
|
||||
"ru": "Created release commit.",
|
||||
"zh": "Created release commit."
|
||||
},
|
||||
"devx version mismatch across extras: {detail}": {
|
||||
"bg": "несъответствие на версията на devx между extras: {detail}",
|
||||
"de": "devx-Versionskonflikt zwischen Extras: {detail}",
|
||||
"en": "devx version mismatch across extras: {detail}",
|
||||
"pl": "niezgodność wersji devx między extras: {detail}",
|
||||
"ru": "несоответствие версии devx между extras: {detail}",
|
||||
"zh": "devx 版本在 extras 之间不一致: {detail}"
|
||||
"Dependencies must have documentation comments.": {
|
||||
"bg": "Dependencies must have documentation comments.",
|
||||
"de": "Dependencies must have documentation comments.",
|
||||
"en": "Dependencies must have documentation comments.",
|
||||
"pl": "Dependencies must have documentation comments.",
|
||||
"ru": "Dependencies must have documentation comments.",
|
||||
"zh": "Dependencies must have documentation comments."
|
||||
},
|
||||
"Docker daemon already running": {
|
||||
"bg": "Докер демонът вече работи",
|
||||
@@ -527,6 +1143,14 @@
|
||||
"ru": "Docker-демон запущен",
|
||||
"zh": "Docker 守护进程已启动"
|
||||
},
|
||||
"Dockerfile not found: {path}": {
|
||||
"bg": "Dockerfile not found: {path}",
|
||||
"de": "Dockerfile not found: {path}",
|
||||
"en": "Dockerfile not found: {path}",
|
||||
"pl": "Dockerfile not found: {path}",
|
||||
"ru": "Dockerfile not found: {path}",
|
||||
"zh": "Dockerfile not found: {path}"
|
||||
},
|
||||
"Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.": {
|
||||
"bg": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
|
||||
"de": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
|
||||
@@ -535,13 +1159,13 @@
|
||||
"ru": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently.",
|
||||
"zh": "Dry-run mode: on branch '{branch}' (not master). Some checks may behave differently."
|
||||
},
|
||||
"ERROR: REPO_TOKEN is not set.": {
|
||||
"bg": "ГРЕШКА: REPO_TOKEN не е зададен.",
|
||||
"de": "FEHLER: REPO_TOKEN ist nicht gesetzt.",
|
||||
"en": "ERROR: REPO_TOKEN is not set.",
|
||||
"pl": "BŁĄD: REPO_TOKEN nie jest ustawiony.",
|
||||
"ru": "ОШИБКА: REPO_TOKEN не задан.",
|
||||
"zh": "错误:未设置 REPO_TOKEN。"
|
||||
"ERROR: CI_GITEA_TOKEN is not set.": {
|
||||
"bg": "ГРЕШКА: CI_GITEA_TOKEN не е зададен.",
|
||||
"de": "FEHLER: CI_GITEA_TOKEN ist nicht gesetzt.",
|
||||
"en": "ERROR: CI_GITEA_TOKEN is not set.",
|
||||
"pl": "BŁĄD: CI_GITEA_TOKEN nie jest ustawiony.",
|
||||
"ru": "ОШИБКА: CI_GITEA_TOKEN не задан.",
|
||||
"zh": "错误:未设置 CI_GITEA_TOKEN。"
|
||||
},
|
||||
"ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME.": {
|
||||
"bg": "ГРЕШКА: Името на хранилището не е указано. Използвайте --repo или задайте DEVX_REPO_NAME.",
|
||||
@@ -575,6 +1199,30 @@
|
||||
"ru": "ERROR: mapping.json not found at {path}",
|
||||
"zh": "ERROR: mapping.json not found at {path}"
|
||||
},
|
||||
"Each item must be a string or an object with 'id', got {type}": {
|
||||
"bg": "Всеки елемент трябва да е низ или обект с 'id', получено {type}",
|
||||
"de": "Jedes Element muss ein String oder ein Objekt mit 'id' sein, erhalten {type}",
|
||||
"en": "Each item must be a string or an object with 'id', got {type}",
|
||||
"pl": "Każdy element musi być ciągiem lub obiektem z 'id', otrzymano {type}",
|
||||
"ru": "Каждый элемент должен быть строкой или объектом с 'id', получено {type}",
|
||||
"zh": "每个元素必须是字符串或带有 'id' 的对象,得到 {type}"
|
||||
},
|
||||
"FAIL: {n} documentation issues found:": {
|
||||
"bg": "FAIL: {n} documentation issues found:",
|
||||
"de": "FAIL: {n} documentation issues found:",
|
||||
"en": "FAIL: {n} documentation issues found:",
|
||||
"pl": "FAIL: {n} documentation issues found:",
|
||||
"ru": "FAIL: {n} documentation issues found:",
|
||||
"zh": "FAIL: {n} documentation issues found:"
|
||||
},
|
||||
"FAILED: {count} undocumented dependency/ies": {
|
||||
"bg": "FAILED: {count} undocumented dependency/ies",
|
||||
"de": "FAILED: {count} undocumented dependency/ies",
|
||||
"en": "FAILED: {count} undocumented dependency/ies",
|
||||
"pl": "FAILED: {count} undocumented dependency/ies",
|
||||
"ru": "FAILED: {count} undocumented dependency/ies",
|
||||
"zh": "FAILED: {count} undocumented dependency/ies"
|
||||
},
|
||||
"FAILED: {pair} exited with code {code}": {
|
||||
"bg": "FAILED: {pair} exited with code {code}",
|
||||
"de": "FAILED: {pair} exited with code {code}",
|
||||
@@ -583,6 +1231,14 @@
|
||||
"ru": "FAILED: {pair} exited with code {code}",
|
||||
"zh": "FAILED: {pair} exited with code {code}"
|
||||
},
|
||||
"Failed images: {names}": {
|
||||
"bg": "Failed images: {names}",
|
||||
"de": "Failed images: {names}",
|
||||
"en": "Failed images: {names}",
|
||||
"pl": "Failed images: {names}",
|
||||
"ru": "Failed images: {names}",
|
||||
"zh": "Failed images: {names}"
|
||||
},
|
||||
"Failed to create issue via tea: {error}": {
|
||||
"bg": "Failed to create issue via tea: {error}",
|
||||
"de": "Failed to create issue via tea: {error}",
|
||||
@@ -591,6 +1247,30 @@
|
||||
"ru": "Failed to create issue via tea: {error}",
|
||||
"zh": "Failed to create issue via tea: {error}"
|
||||
},
|
||||
"Failed to delete {count} image version(s)": {
|
||||
"bg": "Failed to delete {count} image version(s)",
|
||||
"de": "Failed to delete {count} image version(s)",
|
||||
"en": "Failed to delete {count} image version(s)",
|
||||
"pl": "Failed to delete {count} image version(s)",
|
||||
"ru": "Failed to delete {count} image version(s)",
|
||||
"zh": "Failed to delete {count} image version(s)"
|
||||
},
|
||||
"Failed to list versions for {name}: {error}": {
|
||||
"bg": "Failed to list versions for {name}: {error}",
|
||||
"de": "Failed to list versions for {name}: {error}",
|
||||
"en": "Failed to list versions for {name}: {error}",
|
||||
"pl": "Failed to list versions for {name}: {error}",
|
||||
"ru": "Failed to list versions for {name}: {error}",
|
||||
"zh": "Failed to list versions for {name}: {error}"
|
||||
},
|
||||
"Fetching logs for PR #{pr_number}...": {
|
||||
"bg": "Fetching logs for PR #{pr_number}...",
|
||||
"de": "Fetching logs for PR #{pr_number}...",
|
||||
"en": "Fetching logs for PR #{pr_number}...",
|
||||
"pl": "Fetching logs for PR #{pr_number}...",
|
||||
"ru": "Fetching logs for PR #{pr_number}...",
|
||||
"zh": "Fetching logs for PR #{pr_number}..."
|
||||
},
|
||||
"Found {count} existing wiki pages.": {
|
||||
"bg": "Found {count} existing wiki pages.",
|
||||
"de": "Found {count} existing wiki pages.",
|
||||
@@ -599,13 +1279,45 @@
|
||||
"ru": "Found {count} existing wiki pages.",
|
||||
"zh": "Found {count} existing wiki pages."
|
||||
},
|
||||
"GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.": {
|
||||
"bg": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
||||
"de": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
||||
"en": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
||||
"pl": "GITEA_URL/REPO_TOKEN/RUN_ID nie ustawione; uruchamianie bez anulowania między runnerami.",
|
||||
"ru": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
||||
"zh": "GITEA_URL/REPO_TOKEN/RUN_ID not set; running without cross-runner cancellation."
|
||||
"Found {count} mutable global(s) — use factory functions or pytest fixtures.": {
|
||||
"bg": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
|
||||
"de": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
|
||||
"en": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
|
||||
"pl": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
|
||||
"ru": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
|
||||
"zh": "Found {count} mutable global(s) — use factory functions or pytest fixtures."
|
||||
},
|
||||
"Found {count} stale documentation reference(s)": {
|
||||
"bg": "Found {count} stale documentation reference(s)",
|
||||
"de": "Found {count} stale documentation reference(s)",
|
||||
"en": "Found {count} stale documentation reference(s)",
|
||||
"pl": "Found {count} stale documentation reference(s)",
|
||||
"ru": "Found {count} stale documentation reference(s)",
|
||||
"zh": "Found {count} stale documentation reference(s)"
|
||||
},
|
||||
"Found {count} version(s):": {
|
||||
"bg": "Found {count} version(s):",
|
||||
"de": "Found {count} version(s):",
|
||||
"en": "Found {count} version(s):",
|
||||
"pl": "Found {count} version(s):",
|
||||
"ru": "Found {count} version(s):",
|
||||
"zh": "Found {count} version(s):"
|
||||
},
|
||||
"GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.": {
|
||||
"bg": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
||||
"de": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
||||
"en": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
||||
"pl": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID nie ustawione; uruchamianie bez anulowania między runnerami.",
|
||||
"ru": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation.",
|
||||
"zh": "GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation."
|
||||
},
|
||||
"Generated {count} badge files": {
|
||||
"bg": "Generated {count} badge files",
|
||||
"de": "Generated {count} badge files",
|
||||
"en": "Generated {count} badge files",
|
||||
"pl": "Generated {count} badge files",
|
||||
"ru": "Generated {count} badge files",
|
||||
"zh": "Generated {count} badge files"
|
||||
},
|
||||
"Generated {file} with prefix '{prefix}'.": {
|
||||
"bg": "Generated {file} with prefix '{prefix}'.",
|
||||
@@ -615,6 +1327,14 @@
|
||||
"ru": "Generated {file} with prefix '{prefix}'.",
|
||||
"zh": "Generated {file} with prefix '{prefix}'."
|
||||
},
|
||||
"Generating badges in {out}...": {
|
||||
"bg": "Generating badges in {out}...",
|
||||
"de": "Generating badges in {out}...",
|
||||
"en": "Generating badges in {out}...",
|
||||
"pl": "Generating badges in {out}...",
|
||||
"ru": "Generating badges in {out}...",
|
||||
"zh": "Generating badges in {out}..."
|
||||
},
|
||||
"Gitea PyPI registry: {tag} already published — continuing.": {
|
||||
"bg": "Gitea PyPI registry: {tag} вече е публикуван — продължава.",
|
||||
"de": "Gitea PyPI-Registry: {tag} bereits veröffentlicht — wird fortgesetzt.",
|
||||
@@ -687,6 +1407,30 @@
|
||||
"ru": "Хост Docker недоступен, запускается локальный dockerd...",
|
||||
"zh": "主机 Docker 不可用,正在启动本地 dockerd..."
|
||||
},
|
||||
"Image 'tags' must be a list": {
|
||||
"bg": "Image 'tags' must be a list",
|
||||
"de": "Image 'tags' must be a list",
|
||||
"en": "Image 'tags' must be a list",
|
||||
"pl": "Image 'tags' must be a list",
|
||||
"ru": "Image 'tags' must be a list",
|
||||
"zh": "Image 'tags' must be a list"
|
||||
},
|
||||
"Image manifest entry missing 'dockerfile'": {
|
||||
"bg": "Image manifest entry missing 'dockerfile'",
|
||||
"de": "Image manifest entry missing 'dockerfile'",
|
||||
"en": "Image manifest entry missing 'dockerfile'",
|
||||
"pl": "Image manifest entry missing 'dockerfile'",
|
||||
"ru": "Image manifest entry missing 'dockerfile'",
|
||||
"zh": "Image manifest entry missing 'dockerfile'"
|
||||
},
|
||||
"Image manifest entry missing 'name'": {
|
||||
"bg": "Image manifest entry missing 'name'",
|
||||
"de": "Image manifest entry missing 'name'",
|
||||
"en": "Image manifest entry missing 'name'",
|
||||
"pl": "Image manifest entry missing 'name'",
|
||||
"ru": "Image manifest entry missing 'name'",
|
||||
"zh": "Image manifest entry missing 'name'"
|
||||
},
|
||||
"Infrastructure commit (no DEVX-N task ID), skipping Vikunja update: {msg}": {
|
||||
"bg": "Инфраструктурен commit (без идентификатор на задача DEVX-N), пропускаме обновяването на Vikunja: {msg}",
|
||||
"de": "Infrastruktur-Commit (keine DEVX-N Task-ID), Vikunja-Update wird übersprungen: {msg}",
|
||||
@@ -719,6 +1463,38 @@
|
||||
"ru": "Integration tests passed.",
|
||||
"zh": "Integration tests passed."
|
||||
},
|
||||
"Invalid checklist category: {cat}. Must be numbers.": {
|
||||
"bg": "Invalid checklist category: {cat}. Must be numbers.",
|
||||
"de": "Invalid checklist category: {cat}. Must be numbers.",
|
||||
"en": "Invalid checklist category: {cat}. Must be numbers.",
|
||||
"pl": "Invalid checklist category: {cat}. Must be numbers.",
|
||||
"ru": "Invalid checklist category: {cat}. Must be numbers.",
|
||||
"zh": "Invalid checklist category: {cat}. Must be numbers."
|
||||
},
|
||||
"Items input must be a JSON array, got {type}": {
|
||||
"bg": "Входните данни трябва да са JSON масив, получено {type}",
|
||||
"de": "Eingabe muss ein JSON-Array sein, erhalten {type}",
|
||||
"en": "Items input must be a JSON array, got {type}",
|
||||
"pl": "Dane wejściowe muszą być tablicą JSON, otrzymano {type}",
|
||||
"ru": "Входные данные должны быть JSON-массивом, получено {type}",
|
||||
"zh": "输入必须是 JSON 数组,得到 {type}"
|
||||
},
|
||||
"Label '{label}' already on PR #{pr}.": {
|
||||
"bg": "Label '{label}' already on PR #{pr}.",
|
||||
"de": "Label '{label}' already on PR #{pr}.",
|
||||
"en": "Label '{label}' already on PR #{pr}.",
|
||||
"pl": "Label '{label}' already on PR #{pr}.",
|
||||
"ru": "Label '{label}' already on PR #{pr}.",
|
||||
"zh": "Label '{label}' already on PR #{pr}."
|
||||
},
|
||||
"Latest run: #{run_id} (status: {status})": {
|
||||
"bg": "Latest run: #{run_id} (status: {status})",
|
||||
"de": "Latest run: #{run_id} (status: {status})",
|
||||
"en": "Latest run: #{run_id} (status: {status})",
|
||||
"pl": "Latest run: #{run_id} (status: {status})",
|
||||
"ru": "Latest run: #{run_id} (status: {status})",
|
||||
"zh": "Latest run: #{run_id} (status: {status})"
|
||||
},
|
||||
"Lint failed — refusing to release. Fix lint errors first.\n{stderr}": {
|
||||
"bg": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
|
||||
"de": "Lint failed — refusing to release. Fix lint errors first.\n{stderr}",
|
||||
@@ -735,6 +1511,30 @@
|
||||
"ru": "Lint passed.",
|
||||
"zh": "Lint passed."
|
||||
},
|
||||
"Linting documentation in {root}...": {
|
||||
"bg": "Linting documentation in {root}...",
|
||||
"de": "Linting documentation in {root}...",
|
||||
"en": "Linting documentation in {root}...",
|
||||
"pl": "Linting documentation in {root}...",
|
||||
"ru": "Linting documentation in {root}...",
|
||||
"zh": "Linting documentation in {root}..."
|
||||
},
|
||||
"Manifest file not found: {path}": {
|
||||
"bg": "Manifest file not found: {path}",
|
||||
"de": "Manifest file not found: {path}",
|
||||
"en": "Manifest file not found: {path}",
|
||||
"pl": "Manifest file not found: {path}",
|
||||
"ru": "Manifest file not found: {path}",
|
||||
"zh": "Manifest file not found: {path}"
|
||||
},
|
||||
"Manifest must be a JSON list": {
|
||||
"bg": "Manifest must be a JSON list",
|
||||
"de": "Manifest must be a JSON list",
|
||||
"en": "Manifest must be a JSON list",
|
||||
"pl": "Manifest must be a JSON list",
|
||||
"ru": "Manifest must be a JSON list",
|
||||
"zh": "Manifest must be a JSON list"
|
||||
},
|
||||
"Mapped file {file} is empty. Update the content or remove from mapping.json.": {
|
||||
"bg": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
|
||||
"de": "Mapped file {file} is empty. Update the content or remove from mapping.json.",
|
||||
@@ -759,6 +1559,14 @@
|
||||
"ru": "Слияние не удалось: HTTP {status}: {message}\nПроверьте, что PR готов и у вас есть права на слияние.",
|
||||
"zh": "合并失败: HTTP {status}: {message}\n请检查 PR 是否准备就绪且您具有合并权限。"
|
||||
},
|
||||
"Missing tests for changed files.": {
|
||||
"bg": "Missing tests for changed files.",
|
||||
"de": "Missing tests for changed files.",
|
||||
"en": "Missing tests for changed files.",
|
||||
"pl": "Missing tests for changed files.",
|
||||
"ru": "Missing tests for changed files.",
|
||||
"zh": "Missing tests for changed files."
|
||||
},
|
||||
"Module {mod} has no main() function": {
|
||||
"bg": "Модул {mod} няма функция main()",
|
||||
"de": "Modul {mod} hat keine main()-Funktion",
|
||||
@@ -775,6 +1583,14 @@
|
||||
"ru": "Директория molecule не найдена: {path}",
|
||||
"zh": "未找到 molecule 目录: {path}"
|
||||
},
|
||||
"Next steps:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-short-description\n 3. Implement changes, commit with conventional commit format\n 4. git push -u origin HEAD\n 5. make create-pr (creates PR with title: {identifier}: {title})": {
|
||||
"bg": "Следващи стъпки:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-кратко-описание\n 3. Имплементирайте промените, commit с conventional commit формат\n 4. git push -u origin HEAD\n 5. make create-pr (създава PR с заглавие: {identifier}: {title})",
|
||||
"de": "Nächste Schritte:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-kurz-beschreibung\n 3. Änderungen implementieren, mit Conventional-Commit-Format committen\n 4. git push -u origin HEAD\n 5. make create-pr (erstellt PR mit Titel: {identifier}: {title})",
|
||||
"en": "Next steps:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-short-description\n 3. Implement changes, commit with conventional commit format\n 4. git push -u origin HEAD\n 5. make create-pr (creates PR with title: {identifier}: {title})",
|
||||
"pl": "Następne kroki:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-krótki-opis\n 3. Wprowadź zmiany, commituj w formacie conventional commit\n 4. git push -u origin HEAD\n 5. make create-pr (tworzy PR z tytułem: {identifier}: {title})",
|
||||
"ru": "Следующие шаги:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-краткое-описание\n 3. Реализуйте изменения, коммитьте в conventional commit формате\n 4. git push -u origin HEAD\n 5. make create-pr (создаёт PR с заголовком: {identifier}: {title})",
|
||||
"zh": "后续步骤:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-简短描述\n 3. 实现更改,使用 conventional commit 格式提交\n 4. git push -u origin HEAD\n 5. make create-pr (创建 PR,标题: {identifier}: {title})"
|
||||
},
|
||||
"Nice! Gitea release {tag} created.": {
|
||||
"bg": "Отлично! Gitea release {tag} е създаден.",
|
||||
"de": "Prima! Gitea-Release {tag} erstellt.",
|
||||
@@ -807,6 +1623,30 @@
|
||||
"ru": "Отлично! Задача Vikunja {task_id} (ID {vikunja_id}) обновлена и отмечена как выполненная.",
|
||||
"zh": "不错!Vikunja 任务 {task_id} (ID {vikunja_id}) 已更新并标记为完成。"
|
||||
},
|
||||
"No CI checks found for commit {sha}.": {
|
||||
"bg": "No CI checks found for commit {sha}.",
|
||||
"de": "No CI checks found for commit {sha}.",
|
||||
"en": "No CI checks found for commit {sha}.",
|
||||
"pl": "No CI checks found for commit {sha}.",
|
||||
"ru": "No CI checks found for commit {sha}.",
|
||||
"zh": "No CI checks found for commit {sha}."
|
||||
},
|
||||
"No badge SVG files generated": {
|
||||
"bg": "No badge SVG files generated",
|
||||
"de": "No badge SVG files generated",
|
||||
"en": "No badge SVG files generated",
|
||||
"pl": "No badge SVG files generated",
|
||||
"ru": "No badge SVG files generated",
|
||||
"zh": "No badge SVG files generated"
|
||||
},
|
||||
"No badge URLs found to update — README already up to date": {
|
||||
"bg": "No badge URLs found to update — README already up to date",
|
||||
"de": "No badge URLs found to update — README already up to date",
|
||||
"en": "No badge URLs found to update — README already up to date",
|
||||
"pl": "No badge URLs found to update — README already up to date",
|
||||
"ru": "No badge URLs found to update — README already up to date",
|
||||
"zh": "No badge URLs found to update — README already up to date"
|
||||
},
|
||||
"No changes between {base} and {head}.": {
|
||||
"bg": "No changes between {base} and {head}.",
|
||||
"de": "No changes between {base} and {head}.",
|
||||
@@ -815,6 +1655,38 @@
|
||||
"ru": "No changes between {base} and {head}.",
|
||||
"zh": "No changes between {base} and {head}."
|
||||
},
|
||||
"No failed jobs.": {
|
||||
"bg": "No failed jobs.",
|
||||
"de": "No failed jobs.",
|
||||
"en": "No failed jobs.",
|
||||
"pl": "No failed jobs.",
|
||||
"ru": "No failed jobs.",
|
||||
"zh": "No failed jobs."
|
||||
},
|
||||
"No job matching '{job}' found.": {
|
||||
"bg": "No job matching '{job}' found.",
|
||||
"de": "No job matching '{job}' found.",
|
||||
"en": "No job matching '{job}' found.",
|
||||
"pl": "No job matching '{job}' found.",
|
||||
"ru": "No job matching '{job}' found.",
|
||||
"zh": "No job matching '{job}' found."
|
||||
},
|
||||
"No jobs found for run #{run_id}.": {
|
||||
"bg": "No jobs found for run #{run_id}.",
|
||||
"de": "No jobs found for run #{run_id}.",
|
||||
"en": "No jobs found for run #{run_id}.",
|
||||
"pl": "No jobs found for run #{run_id}.",
|
||||
"ru": "No jobs found for run #{run_id}.",
|
||||
"zh": "No jobs found for run #{run_id}."
|
||||
},
|
||||
"No open PR found for branch '{branch}'.": {
|
||||
"bg": "No open PR found for branch '{branch}'.",
|
||||
"de": "No open PR found for branch '{branch}'.",
|
||||
"en": "No open PR found for branch '{branch}'.",
|
||||
"pl": "No open PR found for branch '{branch}'.",
|
||||
"ru": "No open PR found for branch '{branch}'.",
|
||||
"zh": "No open PR found for branch '{branch}'."
|
||||
},
|
||||
"No staged changes — version and changelog already up to date.": {
|
||||
"bg": "No staged changes — version and changelog already up to date.",
|
||||
"de": "No staged changes — version and changelog already up to date.",
|
||||
@@ -847,6 +1719,14 @@
|
||||
"ru": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID.",
|
||||
"zh": "No task ID ({prefix}-N) found in commit message: {msg}. Every non-infrastructure commit must have a task ID."
|
||||
},
|
||||
"No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.": {
|
||||
"bg": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
|
||||
"de": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
|
||||
"en": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
|
||||
"pl": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
|
||||
"ru": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
|
||||
"zh": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description."
|
||||
},
|
||||
"No unreleased changes found. Nothing to release.": {
|
||||
"bg": "No unreleased changes found. Nothing to release.",
|
||||
"de": "No unreleased changes found. Nothing to release.",
|
||||
@@ -863,6 +1743,22 @@
|
||||
"ru": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release.",
|
||||
"zh": "No user-facing changes since {tag} — only workflow/infrastructure files changed. Skipping release."
|
||||
},
|
||||
"No versions found.": {
|
||||
"bg": "No versions found.",
|
||||
"de": "No versions found.",
|
||||
"en": "No versions found.",
|
||||
"pl": "No versions found.",
|
||||
"ru": "No versions found.",
|
||||
"zh": "No versions found."
|
||||
},
|
||||
"No workflow runs found for SHA {sha}.": {
|
||||
"bg": "No workflow runs found for SHA {sha}.",
|
||||
"de": "No workflow runs found for SHA {sha}.",
|
||||
"en": "No workflow runs found for SHA {sha}.",
|
||||
"pl": "No workflow runs found for SHA {sha}.",
|
||||
"ru": "No workflow runs found for SHA {sha}.",
|
||||
"zh": "No workflow runs found for SHA {sha}."
|
||||
},
|
||||
"Note: Self-approval not allowed. Posting COMMENT instead.": {
|
||||
"bg": "Note: Self-approval not allowed. Posting COMMENT instead.",
|
||||
"de": "Note: Self-approval not allowed. Posting COMMENT instead.",
|
||||
@@ -871,6 +1767,14 @@
|
||||
"ru": "Note: Self-approval not allowed. Posting COMMENT instead.",
|
||||
"zh": "Note: Self-approval not allowed. Posting COMMENT instead."
|
||||
},
|
||||
"Only check staged files (for pre-commit)": {
|
||||
"bg": "Only check staged files (for pre-commit)",
|
||||
"de": "Only check staged files (for pre-commit)",
|
||||
"en": "Only check staged files (for pre-commit)",
|
||||
"pl": "Only check staged files (for pre-commit)",
|
||||
"ru": "Only check staged files (for pre-commit)",
|
||||
"zh": "Only check staged files (for pre-commit)"
|
||||
},
|
||||
"Oops! Commit message must follow conventional commit format.\n Expected: <type>: <description>\n Got: {subject}\n Allowed types: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE": {
|
||||
"bg": "Опа! Съобщението за commit трябва да следва конвенционален формат.\n Очаква се: <type>: <description>\n Получено: {subject}\n Разрешени типове: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
|
||||
"de": "Ups! Commit-Nachricht muss dem konventionellen Commit-Format folgen.\n Erwartet: <type>: <description>\n Erhalten: {subject}\n Erlaubte Typen: feat, fix, chore, docs, style, refactor,\n perf, test, ci, build, revert, BREAKING CHANGE",
|
||||
@@ -951,6 +1855,14 @@
|
||||
"ru": "Ой! Публикация в PyPI не удалась:\n{stderr}",
|
||||
"zh": "哎呀!PyPI 发布失败:\n{stderr}"
|
||||
},
|
||||
"PASS: All documentation checks passed!": {
|
||||
"bg": "PASS: All documentation checks passed!",
|
||||
"de": "PASS: All documentation checks passed!",
|
||||
"en": "PASS: All documentation checks passed!",
|
||||
"pl": "PASS: All documentation checks passed!",
|
||||
"ru": "PASS: All documentation checks passed!",
|
||||
"zh": "PASS: All documentation checks passed!"
|
||||
},
|
||||
"PASSED: {pair}": {
|
||||
"bg": "PASSED: {pair}",
|
||||
"de": "PASSED: {pair}",
|
||||
@@ -959,6 +1871,22 @@
|
||||
"ru": "PASSED: {pair}",
|
||||
"zh": "PASSED: {pair}"
|
||||
},
|
||||
"PR already exists: #{index} — {url}": {
|
||||
"bg": "PR вече съществува: #{index} — {url}",
|
||||
"de": "PR existiert bereits: #{index} — {url}",
|
||||
"en": "PR already exists: #{index} — {url}",
|
||||
"pl": "PR już istnieje: #{index} — {url}",
|
||||
"ru": "PR уже существует: #{index} — {url}",
|
||||
"zh": "PR 已存在: #{index} — {url}"
|
||||
},
|
||||
"PR number (to fetch title from Gitea)": {
|
||||
"bg": "PR number (to fetch title from Gitea)",
|
||||
"de": "PR number (to fetch title from Gitea)",
|
||||
"en": "PR number (to fetch title from Gitea)",
|
||||
"pl": "PR number (to fetch title from Gitea)",
|
||||
"ru": "PR number (to fetch title from Gitea)",
|
||||
"zh": "PR number (to fetch title from Gitea)"
|
||||
},
|
||||
"PR number must be an integer, got: {pr_number}": {
|
||||
"bg": "PR number must be an integer, got: {pr_number}",
|
||||
"de": "PR number must be an integer, got: {pr_number}",
|
||||
@@ -967,6 +1895,14 @@
|
||||
"ru": "PR number must be an integer, got: {pr_number}",
|
||||
"zh": "PR number must be an integer, got: {pr_number}"
|
||||
},
|
||||
"PR title (auto-fetched if --pr-number given)": {
|
||||
"bg": "PR title (auto-fetched if --pr-number given)",
|
||||
"de": "PR title (auto-fetched if --pr-number given)",
|
||||
"en": "PR title (auto-fetched if --pr-number given)",
|
||||
"pl": "PR title (auto-fetched if --pr-number given)",
|
||||
"ru": "PR title (auto-fetched if --pr-number given)",
|
||||
"zh": "PR title (auto-fetched if --pr-number given)"
|
||||
},
|
||||
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}": {
|
||||
"bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
|
||||
"de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
|
||||
@@ -975,6 +1911,30 @@
|
||||
"ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}",
|
||||
"zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {pr_title}"
|
||||
},
|
||||
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}": {
|
||||
"bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
|
||||
"de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
|
||||
"en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
|
||||
"pl": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
|
||||
"ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
|
||||
"zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}"
|
||||
},
|
||||
"PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}": {
|
||||
"bg": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
|
||||
"de": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
|
||||
"en": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
|
||||
"pl": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
|
||||
"ru": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
|
||||
"zh": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}"
|
||||
},
|
||||
"PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}": {
|
||||
"bg": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
|
||||
"de": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
|
||||
"en": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
|
||||
"pl": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
|
||||
"ru": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
|
||||
"zh": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}"
|
||||
},
|
||||
"PYPI_TOKEN not set and no registry URL configured — skipping PyPI publish. No worries, we'll just create the Gitea release.": {
|
||||
"bg": "PYPI_TOKEN не е зададен и няма конфигуриран URL на registry — пропускаме публикуването в PyPI. Без притеснения, просто ще създадем Gitea release.",
|
||||
"de": "PYPI_TOKEN nicht gesetzt und keine Registry-URL konfiguriert — PyPI-Veröffentlichung wird übersprungen. Keine Sorge, wir erstellen einfach das Gitea-Release.",
|
||||
@@ -983,6 +1943,22 @@
|
||||
"ru": "PYPI_TOKEN не задан и URL registry не настроен — пропускаем публикацию в PyPI. Не беспокойтесь, мы просто создадим Gitea release.",
|
||||
"zh": "未设置 PYPI_TOKEN 且未配置 registry URL — 跳过 PyPI 发布。别担心,我们直接创建 Gitea release。"
|
||||
},
|
||||
"Package owner not specified. Use --owner or set [tool.devx] repo_owner.": {
|
||||
"bg": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||
"de": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||
"en": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||
"pl": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||
"ru": "Package owner not specified. Use --owner or set [tool.devx] repo_owner.",
|
||||
"zh": "Package owner not specified. Use --owner or set [tool.devx] repo_owner."
|
||||
},
|
||||
"Package: {owner}/{name}": {
|
||||
"bg": "Package: {owner}/{name}",
|
||||
"de": "Package: {owner}/{name}",
|
||||
"en": "Package: {owner}/{name}",
|
||||
"pl": "Package: {owner}/{name}",
|
||||
"ru": "Package: {owner}/{name}",
|
||||
"zh": "Package: {owner}/{name}"
|
||||
},
|
||||
"Parsed owner={owner}, repo={repo} from DEVX_REPO_NAME": {
|
||||
"bg": "Разбор на owner={owner}, repo={repo} от DEVX_REPO_NAME",
|
||||
"de": "Owner={owner}, repo={repo} aus DEVX_REPO_NAME analysiert",
|
||||
@@ -991,6 +1967,14 @@
|
||||
"ru": "Извлечён owner={owner}, repo={repo} из DEVX_REPO_NAME",
|
||||
"zh": "从 DEVX_REPO_NAME 解析 owner={owner}, repo={repo}"
|
||||
},
|
||||
"Path to pyproject.toml (default: pyproject.toml in CWD).": {
|
||||
"bg": "Path to pyproject.toml (default: pyproject.toml in CWD).",
|
||||
"de": "Path to pyproject.toml (default: pyproject.toml in CWD).",
|
||||
"en": "Path to pyproject.toml (default: pyproject.toml in CWD).",
|
||||
"pl": "Path to pyproject.toml (default: pyproject.toml in CWD).",
|
||||
"ru": "Path to pyproject.toml (default: pyproject.toml in CWD).",
|
||||
"zh": "Path to pyproject.toml (default: pyproject.toml in CWD)."
|
||||
},
|
||||
"Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.": {
|
||||
"bg": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
|
||||
"de": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
|
||||
@@ -999,6 +1983,38 @@
|
||||
"ru": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit.",
|
||||
"zh": "Per-test speed check FAILED: {count} test(s) exceed {limit}s limit."
|
||||
},
|
||||
"Pre-merge validation failed.": {
|
||||
"bg": "Pre-merge validation failed.",
|
||||
"de": "Pre-merge validation failed.",
|
||||
"en": "Pre-merge validation failed.",
|
||||
"pl": "Pre-merge validation failed.",
|
||||
"ru": "Pre-merge validation failed.",
|
||||
"zh": "Pre-merge validation failed."
|
||||
},
|
||||
"Pre-push check passed: task {task_id} exists.": {
|
||||
"bg": "Pre-push проверката премина: задача {task_id} съществува.",
|
||||
"de": "Pre-push-Prüfung bestanden: Task {task_id} existiert.",
|
||||
"en": "Pre-push check passed: task {task_id} exists.",
|
||||
"pl": "Sprawdzanie pre-push zakończone: zadanie {task_id} istnieje.",
|
||||
"ru": "Pre-push проверка пройдена: задача {task_id} существует.",
|
||||
"zh": "Pre-push 检查通过: 任务 {task_id} 存在。"
|
||||
},
|
||||
"Print warnings but always exit 0": {
|
||||
"bg": "Print warnings but always exit 0",
|
||||
"de": "Print warnings but always exit 0",
|
||||
"en": "Print warnings but always exit 0",
|
||||
"pl": "Print warnings but always exit 0",
|
||||
"ru": "Print warnings but always exit 0",
|
||||
"zh": "Print warnings but always exit 0"
|
||||
},
|
||||
"Provide --manifest or both --dockerfile and --name": {
|
||||
"bg": "Provide --manifest or both --dockerfile and --name",
|
||||
"de": "Provide --manifest or both --dockerfile and --name",
|
||||
"en": "Provide --manifest or both --dockerfile and --name",
|
||||
"pl": "Provide --manifest or both --dockerfile and --name",
|
||||
"ru": "Provide --manifest or both --dockerfile and --name",
|
||||
"zh": "Provide --manifest or both --dockerfile and --name"
|
||||
},
|
||||
"Provide a commit message file or use --git.": {
|
||||
"bg": "Provide a commit message file or use --git.",
|
||||
"de": "Provide a commit message file or use --git.",
|
||||
@@ -1031,6 +2047,22 @@
|
||||
"ru": "Publishing release {tag}...",
|
||||
"zh": "Publishing release {tag}..."
|
||||
},
|
||||
"Push failed for {tag}: {error}": {
|
||||
"bg": "Push failed for {tag}: {error}",
|
||||
"de": "Push failed for {tag}: {error}",
|
||||
"en": "Push failed for {tag}: {error}",
|
||||
"pl": "Push failed for {tag}: {error}",
|
||||
"ru": "Push failed for {tag}: {error}",
|
||||
"zh": "Push failed for {tag}: {error}"
|
||||
},
|
||||
"Pushed README update with badge SHA {sha}": {
|
||||
"bg": "Pushed README update with badge SHA {sha}",
|
||||
"de": "Pushed README update with badge SHA {sha}",
|
||||
"en": "Pushed README update with badge SHA {sha}",
|
||||
"pl": "Pushed README update with badge SHA {sha}",
|
||||
"ru": "Pushed README update with badge SHA {sha}",
|
||||
"zh": "Pushed README update with badge SHA {sha}"
|
||||
},
|
||||
"Pushed release commit to master.": {
|
||||
"bg": "Pushed release commit to master.",
|
||||
"de": "Pushed release commit to master.",
|
||||
@@ -1047,6 +2079,62 @@
|
||||
"ru": "Публикация в PyPI не удалась (некритично — продолжаем создание Gitea release):\n{error}",
|
||||
"zh": "PyPI 发布失败(非致命 — 继续创建 Gitea release):\n{error}"
|
||||
},
|
||||
"REPO argument is required (or set GITHUB_REPOSITORY env var).": {
|
||||
"bg": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
||||
"de": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
||||
"en": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
||||
"pl": "Argument REPO jest wymagany (lub ustaw zmienną GITHUB_REPOSITORY).",
|
||||
"ru": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
||||
"zh": "REPO argument is required (or set GITHUB_REPOSITORY env var)."
|
||||
},
|
||||
"Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars": {
|
||||
"bg": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars",
|
||||
"de": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars",
|
||||
"en": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars",
|
||||
"pl": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars",
|
||||
"ru": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars",
|
||||
"zh": "Registry credentials required: set CI_GITEA_TOKEN and CI_GITEA_USERNAME env vars"
|
||||
},
|
||||
"Registry login failed": {
|
||||
"bg": "Registry login failed",
|
||||
"de": "Registry login failed",
|
||||
"en": "Registry login failed",
|
||||
"pl": "Registry login failed",
|
||||
"ru": "Registry login failed",
|
||||
"zh": "Registry login failed"
|
||||
},
|
||||
"Registry login failed: {error}": {
|
||||
"bg": "Registry login failed: {error}",
|
||||
"de": "Registry login failed: {error}",
|
||||
"en": "Registry login failed: {error}",
|
||||
"pl": "Registry login failed: {error}",
|
||||
"ru": "Registry login failed: {error}",
|
||||
"zh": "Registry login failed: {error}"
|
||||
},
|
||||
"Regular merge commit — running all post-merge jobs.": {
|
||||
"bg": "Regular merge commit — running all post-merge jobs.",
|
||||
"de": "Regular merge commit — running all post-merge jobs.",
|
||||
"en": "Regular merge commit — running all post-merge jobs.",
|
||||
"pl": "Regular merge commit — running all post-merge jobs.",
|
||||
"ru": "Regular merge commit — running all post-merge jobs.",
|
||||
"zh": "Regular merge commit — running all post-merge jobs."
|
||||
},
|
||||
"Release commit — skipping all post-merge jobs.": {
|
||||
"bg": "Release commit — skipping all post-merge jobs.",
|
||||
"de": "Release commit — skipping all post-merge jobs.",
|
||||
"en": "Release commit — skipping all post-merge jobs.",
|
||||
"pl": "Release commit — skipping all post-merge jobs.",
|
||||
"ru": "Release commit — skipping all post-merge jobs.",
|
||||
"zh": "Release commit — skipping all post-merge jobs."
|
||||
},
|
||||
"Automated CI commit (badge) — skipping post-merge jobs.": {
|
||||
"bg": "Automated CI commit (badge) — skipping post-merge jobs.",
|
||||
"de": "Automated CI commit (badge) — skipping post-merge jobs.",
|
||||
"en": "Automated CI commit (badge) — skipping post-merge jobs.",
|
||||
"pl": "Automated CI commit (badge) — skipping post-merge jobs.",
|
||||
"ru": "Automated CI commit (badge) — skipping post-merge jobs.",
|
||||
"zh": "Automated CI commit (badge) — skipping post-merge jobs."
|
||||
},
|
||||
"Release creation failed: {error}": {
|
||||
"bg": "Release creation failed: {error}",
|
||||
"de": "Release creation failed: {error}",
|
||||
@@ -1079,6 +2167,38 @@
|
||||
"ru": "Конфигурация репозитория завершена.",
|
||||
"zh": "仓库配置完成。"
|
||||
},
|
||||
"Repository in owner/name format": {
|
||||
"bg": "Repository in owner/name format",
|
||||
"de": "Repository in owner/name format",
|
||||
"en": "Repository in owner/name format",
|
||||
"pl": "Repository in owner/name format",
|
||||
"ru": "Repository in owner/name format",
|
||||
"zh": "Repository in owner/name format"
|
||||
},
|
||||
"Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.": {
|
||||
"bg": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"de": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"en": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"pl": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"ru": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var.",
|
||||
"zh": "Repository name not set. Use DEVX_REPO_NAME, [tool.devx] repo_name, or GITHUB_REPOSITORY env var."
|
||||
},
|
||||
"Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.": {
|
||||
"bg": "Собственикът на хранилището не е зададен. Използвайте --owner или DEVX_REPO_OWNER env var.",
|
||||
"de": "Repository-Owner nicht gesetzt. Verwende --owner oder DEVX_REPO_OWNER env var.",
|
||||
"en": "Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.",
|
||||
"pl": "Właściciel repozytorium nie jest ustawiony. Użyj --owner lub DEVX_REPO_OWNER env var.",
|
||||
"ru": "Владелец репозитория не установлен. Используйте --owner или DEVX_REPO_OWNER env var.",
|
||||
"zh": "仓库所有者未设置。使用 --owner 或 DEVX_REPO_OWNER 环境变量。"
|
||||
},
|
||||
"Review body must be at least 50 characters.": {
|
||||
"bg": "Review body must be at least 50 characters.",
|
||||
"de": "Review body must be at least 50 characters.",
|
||||
"en": "Review body must be at least 50 characters.",
|
||||
"pl": "Review body must be at least 50 characters.",
|
||||
"ru": "Review body must be at least 50 characters.",
|
||||
"zh": "Review body must be at least 50 characters."
|
||||
},
|
||||
"Roles directory not found: {path}": {
|
||||
"bg": "Roles directory not found: {path}",
|
||||
"de": "Roles directory not found: {path}",
|
||||
@@ -1087,6 +2207,14 @@
|
||||
"ru": "Roles directory not found: {path}",
|
||||
"zh": "Roles directory not found: {path}"
|
||||
},
|
||||
"Runner count: {count}": {
|
||||
"bg": "Runner count: {count}",
|
||||
"de": "Runner count: {count}",
|
||||
"en": "Runner count: {count}",
|
||||
"pl": "Runner count: {count}",
|
||||
"ru": "Runner count: {count}",
|
||||
"zh": "Runner count: {count}"
|
||||
},
|
||||
"Runner index {index} out of range (0..{max})": {
|
||||
"bg": "Индексът на runner {index} е извън диапазона (0..{max})",
|
||||
"de": "Runner-Index {index} außerhalb des Bereichs (0..{max})",
|
||||
@@ -1095,6 +2223,30 @@
|
||||
"ru": "Индекс runner {index} вне диапазона (0..{max})",
|
||||
"zh": "Runner 索引 {index} 超出范围 (0..{max})"
|
||||
},
|
||||
"Runner index {runner_index} is out of range (must be >= 1)": {
|
||||
"bg": "Runner index {runner_index} is out of range (must be >= 1)",
|
||||
"de": "Runner index {runner_index} is out of range (must be >= 1)",
|
||||
"en": "Runner index {runner_index} is out of range (must be >= 1)",
|
||||
"pl": "Runner index {runner_index} is out of range (must be >= 1)",
|
||||
"ru": "Runner index {runner_index} is out of range (must be >= 1)",
|
||||
"zh": "Runner index {runner_index} is out of range (must be >= 1)"
|
||||
},
|
||||
"Runner indices: {indices}": {
|
||||
"bg": "Runner indices: {indices}",
|
||||
"de": "Runner indices: {indices}",
|
||||
"en": "Runner indices: {indices}",
|
||||
"pl": "Runner indices: {indices}",
|
||||
"ru": "Runner indices: {indices}",
|
||||
"zh": "Runner indices: {indices}"
|
||||
},
|
||||
"Runner {i}: {labels}": {
|
||||
"bg": "Runner {i}: {labels}",
|
||||
"de": "Runner {i}: {labels}",
|
||||
"en": "Runner {i}: {labels}",
|
||||
"pl": "Runner {i}: {labels}",
|
||||
"ru": "Runner {i}: {labels}",
|
||||
"zh": "Runner {i}: {labels}"
|
||||
},
|
||||
"Running lint checks...": {
|
||||
"bg": "Running lint checks...",
|
||||
"de": "Running lint checks...",
|
||||
@@ -1111,6 +2263,14 @@
|
||||
"ru": "Running tests...",
|
||||
"zh": "Running tests..."
|
||||
},
|
||||
"Running: {cmd}": {
|
||||
"bg": "Running: {cmd}",
|
||||
"de": "Running: {cmd}",
|
||||
"en": "Running: {cmd}",
|
||||
"pl": "Running: {cmd}",
|
||||
"ru": "Running: {cmd}",
|
||||
"zh": "Running: {cmd}"
|
||||
},
|
||||
"Running: {scenario} on {platform}": {
|
||||
"bg": "Running: {scenario} on {platform}",
|
||||
"de": "Running: {scenario} on {platform}",
|
||||
@@ -1119,6 +2279,22 @@
|
||||
"ru": "Running: {scenario} on {platform}",
|
||||
"zh": "Running: {scenario} on {platform}"
|
||||
},
|
||||
"Skip Vikunja title match check": {
|
||||
"bg": "Skip Vikunja title match check",
|
||||
"de": "Skip Vikunja title match check",
|
||||
"en": "Skip Vikunja title match check",
|
||||
"pl": "Skip Vikunja title match check",
|
||||
"ru": "Skip Vikunja title match check",
|
||||
"zh": "Skip Vikunja title match check"
|
||||
},
|
||||
"Skip branch-behind-master check": {
|
||||
"bg": "Skip branch-behind-master check",
|
||||
"de": "Skip branch-behind-master check",
|
||||
"en": "Skip branch-behind-master check",
|
||||
"pl": "Skip branch-behind-master check",
|
||||
"ru": "Skip branch-behind-master check",
|
||||
"zh": "Skip branch-behind-master check"
|
||||
},
|
||||
"Skipping commit push — no staged changes.": {
|
||||
"bg": "Skipping commit push — no staged changes.",
|
||||
"de": "Skipping commit push — no staged changes.",
|
||||
@@ -1127,6 +2303,22 @@
|
||||
"ru": "Skipping commit push — no staged changes.",
|
||||
"zh": "Skipping commit push — no staged changes."
|
||||
},
|
||||
"Skipping — runner index {runner_index} > max runners {max_runners}": {
|
||||
"bg": "Skipping — runner index {runner_index} > max runners {max_runners}",
|
||||
"de": "Skipping — runner index {runner_index} > max runners {max_runners}",
|
||||
"en": "Skipping — runner index {runner_index} > max runners {max_runners}",
|
||||
"pl": "Skipping — runner index {runner_index} > max runners {max_runners}",
|
||||
"ru": "Skipping — runner index {runner_index} > max runners {max_runners}",
|
||||
"zh": "Skipping — runner index {runner_index} > max runners {max_runners}"
|
||||
},
|
||||
"Synced to latest origin/{branch}": {
|
||||
"bg": "Synced to latest origin/{branch}",
|
||||
"de": "Synced to latest origin/{branch}",
|
||||
"en": "Synced to latest origin/{branch}",
|
||||
"pl": "Synced to latest origin/{branch}",
|
||||
"ru": "Synced to latest origin/{branch}",
|
||||
"zh": "Synced to latest origin/{branch}"
|
||||
},
|
||||
"Syncing {count} documentation pages to wiki...": {
|
||||
"bg": "Syncing {count} documentation pages to wiki...",
|
||||
"de": "Syncing {count} documentation pages to wiki...",
|
||||
@@ -1151,14 +2343,6 @@
|
||||
"ru": "Tag is required (or use --from-tag).",
|
||||
"zh": "Tag is required (or use --from-tag)."
|
||||
},
|
||||
"REPO argument is required (or set GITHUB_REPOSITORY env var).": {
|
||||
"bg": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
||||
"de": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
||||
"en": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
||||
"pl": "Argument REPO jest wymagany (lub ustaw zmienną GITHUB_REPOSITORY).",
|
||||
"ru": "REPO argument is required (or set GITHUB_REPOSITORY env var).",
|
||||
"zh": "REPO argument is required (or set GITHUB_REPOSITORY env var)."
|
||||
},
|
||||
"Tag v{version} already existed. Publish workflow should already have been triggered.": {
|
||||
"bg": "Tag v{version} already existed. Publish workflow should already have been triggered.",
|
||||
"de": "Tag v{version} already existed. Publish workflow should already have been triggered.",
|
||||
@@ -1215,6 +2399,14 @@
|
||||
"ru": "Tests passed.",
|
||||
"zh": "Tests passed."
|
||||
},
|
||||
"Timeout reached after {timeout}s.": {
|
||||
"bg": "Timeout reached after {timeout}s.",
|
||||
"de": "Timeout reached after {timeout}s.",
|
||||
"en": "Timeout reached after {timeout}s.",
|
||||
"pl": "Timeout reached after {timeout}s.",
|
||||
"ru": "Timeout reached after {timeout}s.",
|
||||
"zh": "Timeout reached after {timeout}s."
|
||||
},
|
||||
"Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).": {
|
||||
"bg": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
|
||||
"de": "Unit tests passed in {duration:.2f}s (under {max}s limit, all tests under {single}s per-test limit).",
|
||||
@@ -1239,6 +2431,14 @@
|
||||
"ru": "Unknown check category '{check}'. Available: all, user-facing{tags}",
|
||||
"zh": "Unknown check category '{check}'. Available: all, user-facing{tags}"
|
||||
},
|
||||
"Updated badge URLs in {filename}": {
|
||||
"bg": "Updated badge URLs in {filename}",
|
||||
"de": "Updated badge URLs in {filename}",
|
||||
"en": "Updated badge URLs in {filename}",
|
||||
"pl": "Updated badge URLs in {filename}",
|
||||
"ru": "Updated badge URLs in {filename}",
|
||||
"zh": "Updated badge URLs in {filename}"
|
||||
},
|
||||
"Updated version in {init}": {
|
||||
"bg": "Updated version in {init}",
|
||||
"de": "Updated version in {init}",
|
||||
@@ -1255,6 +2455,22 @@
|
||||
"ru": "Updated {changelog_file}",
|
||||
"zh": "Updated {changelog_file}"
|
||||
},
|
||||
"VIKUNJA_TOKEN is not set. Required to derive PR title.": {
|
||||
"bg": "VIKUNJA_TOKEN не е зададен. Необходим за извличане на PR заглавие.",
|
||||
"de": "VIKUNJA_TOKEN nicht gesetzt. Erforderlich zum Ableiten des PR-Titels.",
|
||||
"en": "VIKUNJA_TOKEN is not set. Required to derive PR title.",
|
||||
"pl": "VIKUNJA_TOKEN nie jest ustawiony. Wymagany do pobrania tytułu PR.",
|
||||
"ru": "VIKUNJA_TOKEN не установлен. Требуется для получения заголовка PR.",
|
||||
"zh": "VIKUNJA_TOKEN 未设置。推导 PR 标题所需。"
|
||||
},
|
||||
"VIKUNJA_TOKEN is not set. Set it in .env or environment.": {
|
||||
"bg": "VIKUNJA_TOKEN не е зададен. Задайте го в .env или средата.",
|
||||
"de": "VIKUNJA_TOKEN nicht gesetzt. In .env oder Umgebung setzen.",
|
||||
"en": "VIKUNJA_TOKEN is not set. Set it in .env or environment.",
|
||||
"pl": "VIKUNJA_TOKEN nie jest ustawiony. Ustaw go w .env lub środowisku.",
|
||||
"ru": "VIKUNJA_TOKEN не установлен. Установите его в .env или среде.",
|
||||
"zh": "VIKUNJA_TOKEN 未设置。在 .env 或环境中设置它。"
|
||||
},
|
||||
"VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.": {
|
||||
"bg": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
|
||||
"de": "VIKUNJA_TOKEN is not set. This is required in CI to validate PR titles.",
|
||||
@@ -1271,6 +2487,14 @@
|
||||
"ru": "Version file: {file}",
|
||||
"zh": "Version file: {file}"
|
||||
},
|
||||
"Version stays at v{version} — no version bump from git-cliff. Commits since last tag don't warrant a new release. Skipping.": {
|
||||
"bg": "",
|
||||
"de": "",
|
||||
"en": "Version stays at v{version} — no version bump from git-cliff. Commits since last tag don't warrant a new release. Skipping.",
|
||||
"pl": "",
|
||||
"ru": "",
|
||||
"zh": ""
|
||||
},
|
||||
"Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.": {
|
||||
"bg": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
|
||||
"de": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
|
||||
@@ -1279,6 +2503,14 @@
|
||||
"ru": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update.",
|
||||
"zh": "Vikunja API error (HTTP {status}): {message}. Task {task_id} was NOT updated. The merge succeeded but the Vikunja task needs manual update."
|
||||
},
|
||||
"Vikunja task {task_id} not found in project {project_id}.\n Create it first:\n python -m devx.tools.create_task --title \"Task title\"\n Or check that the task ID in the branch name is correct.": {
|
||||
"bg": "Vikunja задача {task_id} не е намерена в проект {project_id}.\n Създайте я първо:\n python -m devx.tools.create_task --title \"Заглавие на задача\"\n Или проверете че ID на задачата в името на клона е правилно.",
|
||||
"de": "Vikunja-Task {task_id} in Projekt {project_id} nicht gefunden.\n Zuerst erstellen:\n python -m devx.tools.create_task --title \"Task-Titel\"\n Oder prüfen, ob die Task-ID im Branch-Namen korrekt ist.",
|
||||
"en": "Vikunja task {task_id} not found in project {project_id}.\n Create it first:\n python -m devx.tools.create_task --title \"Task title\"\n Or check that the task ID in the branch name is correct.",
|
||||
"pl": "Zadanie Vikunja {task_id} nie znalezione w projekcie {project_id}.\n Utwórz je najpierw:\n python -m devx.tools.create_task --title \"Tytuł zadania\"\n Lub sprawdź, czy ID zadania w nazwie gałęzi jest poprawne.",
|
||||
"ru": "Задача Vikunja {task_id} не найдена в проекте {project_id}.\n Сначала создайте её:\n python -m devx.tools.create_task --title \"Заголовок задачи\"\n Или проверьте, что ID задачи в имени ветки корректен.",
|
||||
"zh": "在项目 {project_id} 中找不到 Vikunja 任务 {task_id}。\n 请先创建:\n python -m devx.tools.create_task --title \"任务标题\"\n 或检查分支名称中的任务 ID 是否正确。"
|
||||
},
|
||||
"WARNING: --skip-tests passed — skipping test verification.": {
|
||||
"bg": "WARNING: --skip-tests passed — skipping test verification.",
|
||||
"de": "WARNING: --skip-tests passed — skipping test verification.",
|
||||
@@ -1295,6 +2527,38 @@
|
||||
"ru": "ВНИМАНИЕ: Файл .taskid ({file_id}) устарел и не совпадает с именем ветки ({branch_id}). Удалите .taskid из репозитория — имя ветки — единственный источник истины.",
|
||||
"zh": "警告:.taskid 文件 ({file_id}) 已弃用,与分支名称 ({branch_id}) 不一致。请从仓库中删除 .taskid — 分支名称是唯一的真实来源。"
|
||||
},
|
||||
"WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.": {
|
||||
"bg": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
|
||||
"de": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
|
||||
"en": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
|
||||
"pl": "OSTRZEŻENIE: Nie można pobrać listy stron wiki po ponownych próbach. Sama synchronizacja zakończyła się sukcesem (zaktualizowano {count} stron), ale kontrola integralności nie mogła ich zweryfikować z powodu przejściowego problemu z API.",
|
||||
"ru": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue.",
|
||||
"zh": "WARNING: Could not fetch wiki page list after retries. The sync itself succeeded ({count} pages updated), but the integrity check could not verify them due to a transient API issue."
|
||||
},
|
||||
"WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.": {
|
||||
"bg": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
|
||||
"de": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
|
||||
"en": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
|
||||
"pl": "OSTRZEŻENIE: Nie można ponownie pobrać listy stron wiki do weryfikacji. Pomijanie weryfikacji treści z powodu przejściowego problemu z API.",
|
||||
"ru": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue.",
|
||||
"zh": "WARNING: Could not re-fetch wiki page list for verification. Skipping content verification due to transient API issue."
|
||||
},
|
||||
"WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.": {
|
||||
"bg": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не е зададен — пропускане на проверката за съществуване на задача. Задайте го в .env за пълна валидация.",
|
||||
"de": "WARNUNG: VIKUNJA_TOKEN nicht gesetzt — Task-Existenzprüfung übersprungen. In .env setzen für volle Validierung.",
|
||||
"en": "WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.",
|
||||
"pl": "OSTRZEŻENIE: VIKUNJA_TOKEN nie jest ustawiony — pomijanie sprawdzania istnienia zadania. Ustaw w .env, aby włączyć pełną walidację.",
|
||||
"ru": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не установлен — пропуск проверки существования задачи. Установите в .env для полной проверки.",
|
||||
"zh": "警告: VIKUNJA_TOKEN 未设置 — 跳过任务存在性检查。在 .env 中设置以启用完整验证。"
|
||||
},
|
||||
"Waiting for CI checks to complete (timeout: {timeout}s)...": {
|
||||
"bg": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"de": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"en": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"pl": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"ru": "Waiting for CI checks to complete (timeout: {timeout}s)...",
|
||||
"zh": "Waiting for CI checks to complete (timeout: {timeout}s)..."
|
||||
},
|
||||
"Warning: could not fetch tags from origin.": {
|
||||
"bg": "Warning: could not fetch tags from origin.",
|
||||
"de": "Warning: could not fetch tags from origin.",
|
||||
@@ -1303,6 +2567,54 @@
|
||||
"ru": "Warning: could not fetch tags from origin.",
|
||||
"zh": "Warning: could not fetch tags from origin."
|
||||
},
|
||||
"Warning: instance-level runners query failed: {error}": {
|
||||
"bg": "Warning: instance-level runners query failed: {error}",
|
||||
"de": "Warning: instance-level runners query failed: {error}",
|
||||
"en": "Warning: instance-level runners query failed: {error}",
|
||||
"pl": "Warning: instance-level runners query failed: {error}",
|
||||
"ru": "Warning: instance-level runners query failed: {error}",
|
||||
"zh": "Warning: instance-level runners query failed: {error}"
|
||||
},
|
||||
"Warning: instance-level runners query returned HTTP {status}": {
|
||||
"bg": "Warning: instance-level runners query returned HTTP {status}",
|
||||
"de": "Warning: instance-level runners query returned HTTP {status}",
|
||||
"en": "Warning: instance-level runners query returned HTTP {status}",
|
||||
"pl": "Warning: instance-level runners query returned HTTP {status}",
|
||||
"ru": "Warning: instance-level runners query returned HTTP {status}",
|
||||
"zh": "Warning: instance-level runners query returned HTTP {status}"
|
||||
},
|
||||
"Warning: org-level runners query failed: {error}": {
|
||||
"bg": "Warning: org-level runners query failed: {error}",
|
||||
"de": "Warning: org-level runners query failed: {error}",
|
||||
"en": "Warning: org-level runners query failed: {error}",
|
||||
"pl": "Warning: org-level runners query failed: {error}",
|
||||
"ru": "Warning: org-level runners query failed: {error}",
|
||||
"zh": "Warning: org-level runners query failed: {error}"
|
||||
},
|
||||
"Warning: org-level runners query returned HTTP {status}": {
|
||||
"bg": "Warning: org-level runners query returned HTTP {status}",
|
||||
"de": "Warning: org-level runners query returned HTTP {status}",
|
||||
"en": "Warning: org-level runners query returned HTTP {status}",
|
||||
"pl": "Warning: org-level runners query returned HTTP {status}",
|
||||
"ru": "Warning: org-level runners query returned HTTP {status}",
|
||||
"zh": "Warning: org-level runners query returned HTTP {status}"
|
||||
},
|
||||
"Warning: repo-level runners query failed: {error}": {
|
||||
"bg": "Warning: repo-level runners query failed: {error}",
|
||||
"de": "Warning: repo-level runners query failed: {error}",
|
||||
"en": "Warning: repo-level runners query failed: {error}",
|
||||
"pl": "Warning: repo-level runners query failed: {error}",
|
||||
"ru": "Warning: repo-level runners query failed: {error}",
|
||||
"zh": "Warning: repo-level runners query failed: {error}"
|
||||
},
|
||||
"Warning: repo-level runners query returned HTTP {status}": {
|
||||
"bg": "Warning: repo-level runners query returned HTTP {status}",
|
||||
"de": "Warning: repo-level runners query returned HTTP {status}",
|
||||
"en": "Warning: repo-level runners query returned HTTP {status}",
|
||||
"pl": "Warning: repo-level runners query returned HTTP {status}",
|
||||
"ru": "Warning: repo-level runners query returned HTTP {status}",
|
||||
"zh": "Warning: repo-level runners query returned HTTP {status}"
|
||||
},
|
||||
"Wiki integrity check failed — {count} issue(s)": {
|
||||
"bg": "Wiki integrity check failed — {count} issue(s)",
|
||||
"de": "Wiki integrity check failed — {count} issue(s)",
|
||||
@@ -1319,21 +2631,53 @@
|
||||
"ru": "Wiki verification failed — {failures} page(s) empty or mismatched",
|
||||
"zh": "Wiki verification failed — {failures} page(s) empty or mismatched"
|
||||
},
|
||||
"[tool.devx] missing required keys: {keys}": {
|
||||
"bg": "[tool.devx] липсват задължителни ключове: {keys}",
|
||||
"de": "[tool.devx] fehlt erforderliche Schlüssel: {keys}",
|
||||
"en": "[tool.devx] missing required keys: {keys}",
|
||||
"pl": "[tool.devx] brak wymaganych kluczy: {keys}",
|
||||
"ru": "[tool.devx] отсутствуют обязательные ключи: {keys}",
|
||||
"zh": "[tool.devx] 缺少必需的键: {keys}"
|
||||
"Wrote tag {tag} to GITHUB_OUTPUT.": {
|
||||
"bg": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"de": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"en": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"pl": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"ru": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"zh": "Wrote tag {tag} to GITHUB_OUTPUT."
|
||||
},
|
||||
"[dry-run] Would commit: release: v{version}": {
|
||||
"bg": "[dry-run] Would commit: release: v{version}",
|
||||
"de": "[dry-run] Would commit: release: v{version}",
|
||||
"en": "[dry-run] Would commit: release: v{version}",
|
||||
"pl": "[dry-run] Utworzono by commit: release: v{version}",
|
||||
"ru": "[dry-run] Would commit: release: v{version}",
|
||||
"zh": "[dry-run] Would commit: release: v{version}"
|
||||
"[check-dep-docs] Passed: all dependencies are documented": {
|
||||
"bg": "[check-dep-docs] Passed: all dependencies are documented",
|
||||
"de": "[check-dep-docs] Passed: all dependencies are documented",
|
||||
"en": "[check-dep-docs] Passed: all dependencies are documented",
|
||||
"pl": "[check-dep-docs] Passed: all dependencies are documented",
|
||||
"ru": "[check-dep-docs] Passed: all dependencies are documented",
|
||||
"zh": "[check-dep-docs] Passed: all dependencies are documented"
|
||||
},
|
||||
"[check-mutable-globals] Passed: no mutable path globals found": {
|
||||
"bg": "[check-mutable-globals] Passed: no mutable path globals found",
|
||||
"de": "[check-mutable-globals] Passed: no mutable path globals found",
|
||||
"en": "[check-mutable-globals] Passed: no mutable path globals found",
|
||||
"pl": "[check-mutable-globals] Passed: no mutable path globals found",
|
||||
"ru": "[check-mutable-globals] Passed: no mutable path globals found",
|
||||
"zh": "[check-mutable-globals] Passed: no mutable path globals found"
|
||||
},
|
||||
"[check_agent_docs] Passed: scanned {count} file(s), no stale references": {
|
||||
"bg": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
|
||||
"de": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
|
||||
"en": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
|
||||
"pl": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
|
||||
"ru": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
|
||||
"zh": "[check_agent_docs] Passed: scanned {count} file(s), no stale references"
|
||||
},
|
||||
"[check_test_coverage] No changed files to check.": {
|
||||
"bg": "[check_test_coverage] No changed files to check.",
|
||||
"de": "[check_test_coverage] No changed files to check.",
|
||||
"en": "[check_test_coverage] No changed files to check.",
|
||||
"pl": "[check_test_coverage] No changed files to check.",
|
||||
"ru": "[check_test_coverage] No changed files to check.",
|
||||
"zh": "[check_test_coverage] No changed files to check."
|
||||
},
|
||||
"[dry-run] Would commit: release: v{version} [skip ci]": {
|
||||
"bg": "[dry-run] Would commit: release: v{version} [skip ci]",
|
||||
"de": "[dry-run] Would commit: release: v{version} [skip ci]",
|
||||
"en": "[dry-run] Would commit: release: v{version} [skip ci]",
|
||||
"pl": "[dry-run] Utworzono by commit: release: v{version} [skip ci]",
|
||||
"ru": "[dry-run] Would commit: release: v{version} [skip ci]",
|
||||
"zh": "[dry-run] Would commit: release: v{version} [skip ci]"
|
||||
},
|
||||
"[dry-run] Would create tag: v{version}": {
|
||||
"bg": "[dry-run] Would create tag: v{version}",
|
||||
@@ -1383,6 +2727,14 @@
|
||||
"ru": "[dry-run] Would update {init}",
|
||||
"zh": "[dry-run] Would update {init}"
|
||||
},
|
||||
"[tool.devx] missing required keys: {keys}": {
|
||||
"bg": "[tool.devx] липсват задължителни ключове: {keys}",
|
||||
"de": "[tool.devx] fehlt erforderliche Schlüssel: {keys}",
|
||||
"en": "[tool.devx] missing required keys: {keys}",
|
||||
"pl": "[tool.devx] brak wymaganych kluczy: {keys}",
|
||||
"ru": "[tool.devx] отсутствуют обязательные ключи: {keys}",
|
||||
"zh": "[tool.devx] 缺少必需的键: {keys}"
|
||||
},
|
||||
"active": {
|
||||
"bg": "активен",
|
||||
"de": "aktiv",
|
||||
@@ -1399,6 +2751,22 @@
|
||||
"ru": "завершён",
|
||||
"zh": "已完成"
|
||||
},
|
||||
"count={count}": {
|
||||
"bg": "count={count}",
|
||||
"de": "count={count}",
|
||||
"en": "count={count}",
|
||||
"pl": "count={count}",
|
||||
"ru": "count={count}",
|
||||
"zh": "count={count}"
|
||||
},
|
||||
"devx version mismatch across extras: {detail}": {
|
||||
"bg": "несъответствие на версията на devx между extras: {detail}",
|
||||
"de": "devx-Versionskonflikt zwischen Extras: {detail}",
|
||||
"en": "devx version mismatch across extras: {detail}",
|
||||
"pl": "niezgodność wersji devx między extras: {detail}",
|
||||
"ru": "несоответствие версии devx между extras: {detail}",
|
||||
"zh": "devx 版本在 extras 之间不一致: {detail}"
|
||||
},
|
||||
"failed": {
|
||||
"bg": "неуспешен",
|
||||
"de": "fehlgeschlagen",
|
||||
@@ -1455,6 +2823,14 @@
|
||||
"ru": "неактивен",
|
||||
"zh": "未激活"
|
||||
},
|
||||
"indices={indices}": {
|
||||
"bg": "indices={indices}",
|
||||
"de": "indices={indices}",
|
||||
"en": "indices={indices}",
|
||||
"pl": "indices={indices}",
|
||||
"ru": "indices={indices}",
|
||||
"zh": "indices={indices}"
|
||||
},
|
||||
"mapping.json keys and values must be strings, got {k}={v}": {
|
||||
"bg": "mapping.json keys and values must be strings, got {k}={v}",
|
||||
"de": "mapping.json keys and values must be strings, got {k}={v}",
|
||||
@@ -1487,6 +2863,22 @@
|
||||
"ru": "pyproject.toml не найден в текущей директории.",
|
||||
"zh": "在当前目录中未找到 pyproject.toml。"
|
||||
},
|
||||
"tea login '{name}' already configured.": {
|
||||
"bg": "tea login '{name}' already configured.",
|
||||
"de": "tea login '{name}' already configured.",
|
||||
"en": "tea login '{name}' already configured.",
|
||||
"pl": "tea login '{name}' already configured.",
|
||||
"ru": "tea login '{name}' already configured.",
|
||||
"zh": "tea login '{name}' already configured."
|
||||
},
|
||||
"tea not installed — skipping login configuration.": {
|
||||
"bg": "tea not installed — skipping login configuration.",
|
||||
"de": "tea not installed — skipping login configuration.",
|
||||
"en": "tea not installed — skipping login configuration.",
|
||||
"pl": "tea not installed — skipping login configuration.",
|
||||
"ru": "tea not installed — skipping login configuration.",
|
||||
"zh": "tea not installed — skipping login configuration."
|
||||
},
|
||||
"unknown": {
|
||||
"bg": "неизвестен",
|
||||
"de": "unbekannt",
|
||||
@@ -1503,356 +2895,204 @@
|
||||
"ru": "{file} already exists. Use --force to overwrite.",
|
||||
"zh": "{file} already exists. Use --force to overwrite."
|
||||
},
|
||||
"Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description": {
|
||||
"bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание",
|
||||
"de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung",
|
||||
"en": "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description",
|
||||
"pl": "Gałąź '{branch}' nie zawiera ID zadania.\n Oczekiwany format: {prefix}-N-krótki-opis",
|
||||
"ru": "Ветка '{branch}' не содержит ID задачи.\n Ожидаемый формат: {prefix}-N-краткое-описание",
|
||||
"zh": "分支 '{branch}' 不包含任务 ID。\n 预期格式: {prefix}-N-简短描述"
|
||||
"{separator}": {
|
||||
"bg": "{separator}",
|
||||
"de": "{separator}",
|
||||
"en": "{separator}",
|
||||
"pl": "{separator}",
|
||||
"ru": "{separator}",
|
||||
"zh": "{separator}"
|
||||
},
|
||||
"Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description\n Example: {prefix}-42-add-feature\n Fix: rename the branch or create a Vikunja task first:\n python -m devx.tools.create_task --title \"Task title\"": {
|
||||
"bg": "Клон '{branch}' не съдържа ID на задача.\n Очакван формат: {prefix}-N-кратко-описание\n Пример: {prefix}-42-add-feature\n Решение: преименувайте клона или създайте Vikunja задача:\n python -m devx.tools.create_task --title \"Заглавие на задача\"",
|
||||
"de": "Branch '{branch}' enthält keine Task-ID.\n Erwartetes Format: {prefix}-N-kurz-beschreibung\n Beispiel: {prefix}-42-add-feature\n Fix: Branch umbenennen oder Vikunja-Task erstellen:\n python -m devx.tools.create_task --title \"Task-Titel\"",
|
||||
"en": "Branch '{branch}' does not contain a task ID.\n Expected format: {prefix}-N-short-description\n Example: {prefix}-42-add-feature\n Fix: rename the branch or create a Vikunja task first:\n python -m devx.tools.create_task --title \"Task title\"",
|
||||
"pl": "Gałąź '{branch}' nie zawiera ID zadania.\n Oczekiwany format: {prefix}-N-krótki-opis\n Przykład: {prefix}-42-add-feature\n Naprawa: zmień nazwę gałęzi lub utwórz zadanie Vikunja:\n python -m devx.tools.create_task --title \"Tytuł zadania\"",
|
||||
"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 \"任务标题\""
|
||||
"Failed to push release commit after 3 attempts. Manual intervention required.": {
|
||||
"bg": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||
"de": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||
"en": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||
"pl": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||
"ru": "Failed to push release commit after 3 attempts. Manual intervention required.",
|
||||
"zh": "Failed to push release commit after 3 attempts. Manual intervention required."
|
||||
},
|
||||
"Could not find Vikunja task {task_id} in project {project_id}.": {
|
||||
"bg": "Не е намерена Vikunja задача {task_id} в проект {project_id}.",
|
||||
"de": "Vikunja-Task {task_id} in Projekt {project_id} nicht gefunden.",
|
||||
"en": "Could not find Vikunja task {task_id} in project {project_id}.",
|
||||
"pl": "Nie znaleziono zadania Vikunja {task_id} w projekcie {project_id}.",
|
||||
"ru": "Не найдена задача Vikunja {task_id} в проекте {project_id}.",
|
||||
"zh": "在项目 {project_id} 中找不到 Vikunja 任务 {task_id}。"
|
||||
"Push attempt {n}/3 failed: {err}": {
|
||||
"bg": "Push attempt {n}/3 failed: {err}",
|
||||
"de": "Push attempt {n}/3 failed: {err}",
|
||||
"en": "Push attempt {n}/3 failed: {err}",
|
||||
"pl": "Push attempt {n}/3 failed: {err}",
|
||||
"ru": "Push attempt {n}/3 failed: {err}",
|
||||
"zh": "Push attempt {n}/3 failed: {err}"
|
||||
},
|
||||
"Could not detect current branch: {error}": {
|
||||
"bg": "Не може да се определи текущия клон: {error}",
|
||||
"de": "Aktueller Branch konnte nicht erkannt werden: {error}",
|
||||
"en": "Could not detect current branch: {error}",
|
||||
"pl": "Nie można wykryć bieżącej gałęzi: {error}",
|
||||
"ru": "Не удалось определить текущую ветку: {error}",
|
||||
"zh": "无法检测当前分支: {error}"
|
||||
"Rebase attempt {n}/3 failed: {err}": {
|
||||
"bg": "Rebase attempt {n}/3 failed: {err}",
|
||||
"de": "Rebase attempt {n}/3 failed: {err}",
|
||||
"en": "Rebase attempt {n}/3 failed: {err}",
|
||||
"pl": "Rebase attempt {n}/3 failed: {err}",
|
||||
"ru": "Rebase attempt {n}/3 failed: {err}",
|
||||
"zh": "Rebase attempt {n}/3 failed: {err}"
|
||||
},
|
||||
"Created PR #{index}: {title}\n {url}": {
|
||||
"bg": "Създаден PR #{index}: {title}\n {url}",
|
||||
"de": "PR erstellt #{index}: {title}\n {url}",
|
||||
"en": "Created PR #{index}: {title}\n {url}",
|
||||
"pl": "Utworzono PR #{index}: {title}\n {url}",
|
||||
"ru": "Создан PR #{index}: {title}\n {url}",
|
||||
"zh": "已创建 PR #{index}: {title}\n {url}"
|
||||
"Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.": {
|
||||
"bg": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||
"de": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||
"en": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||
"pl": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||
"ru": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label.",
|
||||
"zh": "Auto-rebase failed with HTTP {status}: {message}\nRebase manually:\n git fetch origin master && git rebase origin/master && git push --force-with-lease\nThen re-add the ready-to-merge label."
|
||||
},
|
||||
"Created Vikunja task: {identifier} (id={task_id})": {
|
||||
"bg": "Създадена Vikunja задача: {identifier} (id={task_id})",
|
||||
"de": "Vikunja-Task erstellt: {identifier} (id={task_id})",
|
||||
"en": "Created Vikunja task: {identifier} (id={task_id})",
|
||||
"pl": "Utworzono zadanie Vikunja: {identifier} (id={task_id})",
|
||||
"ru": "Создана задача Vikunja: {identifier} (id={task_id})",
|
||||
"zh": "已创建 Vikunja 任务: {identifier} (id={task_id})"
|
||||
"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."
|
||||
},
|
||||
"Next steps:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-short-description\n 3. Implement changes, commit with conventional commit format\n 4. git push -u origin HEAD\n 5. make create-pr (creates PR with title: {identifier}: {title})": {
|
||||
"bg": "Следващи стъпки:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-кратко-описание\n 3. Имплементирайте промените, commit с conventional commit формат\n 4. git push -u origin HEAD\n 5. make create-pr (създава PR с заглавие: {identifier}: {title})",
|
||||
"de": "Nächste Schritte:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-kurz-beschreibung\n 3. Änderungen implementieren, mit Conventional-Commit-Format committen\n 4. git push -u origin HEAD\n 5. make create-pr (erstellt PR mit Titel: {identifier}: {title})",
|
||||
"en": "Next steps:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-short-description\n 3. Implement changes, commit with conventional commit format\n 4. git push -u origin HEAD\n 5. make create-pr (creates PR with title: {identifier}: {title})",
|
||||
"pl": "Następne kroki:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-krótki-opis\n 3. Wprowadź zmiany, commituj w formacie conventional commit\n 4. git push -u origin HEAD\n 5. make create-pr (tworzy PR z tytułem: {identifier}: {title})",
|
||||
"ru": "Следующие шаги:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-краткое-описание\n 3. Реализуйте изменения, коммитьте в conventional commit формате\n 4. git push -u origin HEAD\n 5. make create-pr (создаёт PR с заголовком: {identifier}: {title})",
|
||||
"zh": "后续步骤:\n 1. git checkout master && git pull\n 2. git checkout -b {prefix}-{num}-简短描述\n 3. 实现更改,使用 conventional commit 格式提交\n 4. git push -u origin HEAD\n 5. make create-pr (创建 PR,标题: {identifier}: {title})"
|
||||
"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."
|
||||
},
|
||||
"PR already exists: #{index} — {url}": {
|
||||
"bg": "PR вече съществува: #{index} — {url}",
|
||||
"de": "PR existiert bereits: #{index} — {url}",
|
||||
"en": "PR already exists: #{index} — {url}",
|
||||
"pl": "PR już istnieje: #{index} — {url}",
|
||||
"ru": "PR уже существует: #{index} — {url}",
|
||||
"zh": "PR 已存在: #{index} — {url}"
|
||||
"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..."
|
||||
},
|
||||
"Pre-push check passed: task {task_id} exists.": {
|
||||
"bg": "Pre-push проверката премина: задача {task_id} съществува.",
|
||||
"de": "Pre-push-Prüfung bestanden: Task {task_id} existiert.",
|
||||
"en": "Pre-push check passed: task {task_id} exists.",
|
||||
"pl": "Sprawdzanie pre-push zakończone: zadanie {task_id} istnieje.",
|
||||
"ru": "Pre-push проверка пройдена: задача {task_id} существует.",
|
||||
"zh": "Pre-push 检查通过: 任务 {task_id} 存在。"
|
||||
"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."
|
||||
},
|
||||
"REPO_TOKEN is not set. Required to create a PR.": {
|
||||
"bg": "REPO_TOKEN не е зададен. Необходим за създаване на PR.",
|
||||
"de": "REPO_TOKEN nicht gesetzt. Erforderlich zum Erstellen eines PR.",
|
||||
"en": "REPO_TOKEN is not set. Required to create a PR.",
|
||||
"pl": "REPO_TOKEN nie jest ustawiony. Wymagany do utworzenia PR.",
|
||||
"ru": "REPO_TOKEN не установлен. Требуется для создания PR.",
|
||||
"zh": "REPO_TOKEN 未设置。创建 PR 所需。"
|
||||
"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)."
|
||||
},
|
||||
"Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var.": {
|
||||
"bg": "Името на хранилището не е зададено. Използвайте DEVX_REPO_NAME или GITHUB_REPOSITORY env var.",
|
||||
"de": "Repository-Name nicht gesetzt. Verwende DEVX_REPO_NAME oder GITHUB_REPOSITORY env var.",
|
||||
"en": "Repository name not set. Use DEVX_REPO_NAME or GITHUB_REPOSITORY env var.",
|
||||
"pl": "Nazwa repozytorium nie jest ustawiona. Użyj DEVX_REPO_NAME lub GITHUB_REPOSITORY env var.",
|
||||
"ru": "Имя репозитория не установлено. Используйте DEVX_REPO_NAME или GITHUB_REPOSITORY env var.",
|
||||
"zh": "仓库名称未设置。使用 DEVX_REPO_NAME 或 GITHUB_REPOSITORY 环境变量。"
|
||||
"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."
|
||||
},
|
||||
"Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.": {
|
||||
"bg": "Собственикът на хранилището не е зададен. Използвайте --owner или DEVX_REPO_OWNER env var.",
|
||||
"de": "Repository-Owner nicht gesetzt. Verwende --owner oder DEVX_REPO_OWNER env var.",
|
||||
"en": "Repository owner not set. Use --owner or DEVX_REPO_OWNER env var.",
|
||||
"pl": "Właściciel repozytorium nie jest ustawiony. Użyj --owner lub DEVX_REPO_OWNER env var.",
|
||||
"ru": "Владелец репозитория не установлен. Используйте --owner или DEVX_REPO_OWNER env var.",
|
||||
"zh": "仓库所有者未设置。使用 --owner 或 DEVX_REPO_OWNER 环境变量。"
|
||||
"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."
|
||||
},
|
||||
"VIKUNJA_TOKEN is not set. Required to derive PR title.": {
|
||||
"bg": "VIKUNJA_TOKEN не е зададен. Необходим за извличане на PR заглавие.",
|
||||
"de": "VIKUNJA_TOKEN nicht gesetzt. Erforderlich zum Ableiten des PR-Titels.",
|
||||
"en": "VIKUNJA_TOKEN is not set. Required to derive PR title.",
|
||||
"pl": "VIKUNJA_TOKEN nie jest ustawiony. Wymagany do pobrania tytułu PR.",
|
||||
"ru": "VIKUNJA_TOKEN не установлен. Требуется для получения заголовка PR.",
|
||||
"zh": "VIKUNJA_TOKEN 未设置。推导 PR 标题所需。"
|
||||
"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}"
|
||||
},
|
||||
"VIKUNJA_TOKEN is not set. Set it in .env or environment.": {
|
||||
"bg": "VIKUNJA_TOKEN не е зададен. Задайте го в .env или средата.",
|
||||
"de": "VIKUNJA_TOKEN nicht gesetzt. In .env oder Umgebung setzen.",
|
||||
"en": "VIKUNJA_TOKEN is not set. Set it in .env or environment.",
|
||||
"pl": "VIKUNJA_TOKEN nie jest ustawiony. Ustaw go w .env lub środowisku.",
|
||||
"ru": "VIKUNJA_TOKEN не установлен. Установите его в .env или среде.",
|
||||
"zh": "VIKUNJA_TOKEN 未设置。在 .env 或环境中设置它。"
|
||||
"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..."
|
||||
},
|
||||
"Vikunja task {task_id} not found in project {project_id}.\n Create it first:\n python -m devx.tools.create_task --title \"Task title\"\n Or check that the task ID in the branch name is correct.": {
|
||||
"bg": "Vikunja задача {task_id} не е намерена в проект {project_id}.\n Създайте я първо:\n python -m devx.tools.create_task --title \"Заглавие на задача\"\n Или проверете че ID на задачата в името на клона е правилно.",
|
||||
"de": "Vikunja-Task {task_id} in Projekt {project_id} nicht gefunden.\n Zuerst erstellen:\n python -m devx.tools.create_task --title \"Task-Titel\"\n Oder prüfen, ob die Task-ID im Branch-Namen korrekt ist.",
|
||||
"en": "Vikunja task {task_id} not found in project {project_id}.\n Create it first:\n python -m devx.tools.create_task --title \"Task title\"\n Or check that the task ID in the branch name is correct.",
|
||||
"pl": "Zadanie Vikunja {task_id} nie znalezione w projekcie {project_id}.\n Utwórz je najpierw:\n python -m devx.tools.create_task --title \"Tytuł zadania\"\n Lub sprawdź, czy ID zadania w nazwie gałęzi jest poprawne.",
|
||||
"ru": "Задача Vikunja {task_id} не найдена в проекте {project_id}.\n Сначала создайте её:\n python -m devx.tools.create_task --title \"Заголовок задачи\"\n Или проверьте, что ID задачи в имени ветки корректен.",
|
||||
"zh": "在项目 {project_id} 中找不到 Vikunja 任务 {task_id}。\n 请先创建:\n python -m devx.tools.create_task --title \"任务标题\"\n 或检查分支名称中的任务 ID 是否正确。"
|
||||
"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."
|
||||
},
|
||||
"WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.": {
|
||||
"bg": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не е зададен — пропускане на проверката за съществуване на задача. Задайте го в .env за пълна валидация.",
|
||||
"de": "WARNUNG: VIKUNJA_TOKEN nicht gesetzt — Task-Existenzprüfung übersprungen. In .env setzen für volle Validierung.",
|
||||
"en": "WARNING: VIKUNJA_TOKEN not set — skipping task existence check. Set it in .env to enable full validation.",
|
||||
"pl": "OSTRZEŻENIE: VIKUNJA_TOKEN nie jest ustawiony — pomijanie sprawdzania istnienia zadania. Ustaw w .env, aby włączyć pełną walidację.",
|
||||
"ru": "ПРЕДУПРЕЖДЕНИЕ: VIKUNJA_TOKEN не установлен — пропуск проверки существования задачи. Установите в .env для полной проверки.",
|
||||
"zh": "警告: VIKUNJA_TOKEN 未设置 — 跳过任务存在性检查。在 .env 中设置以启用完整验证。"
|
||||
"Force-pushing...": {
|
||||
"bg": "Force-pushing...",
|
||||
"de": "Force-pushing...",
|
||||
"en": "Force-pushing...",
|
||||
"pl": "Force-pushing...",
|
||||
"ru": "Force-pushing...",
|
||||
"zh": "Force-pushing..."
|
||||
},
|
||||
"[check-mutable-globals] Passed: no mutable path globals found": {
|
||||
"bg": "[check-mutable-globals] Passed: no mutable path globals found",
|
||||
"de": "[check-mutable-globals] Passed: no mutable path globals found",
|
||||
"en": "[check-mutable-globals] Passed: no mutable path globals found",
|
||||
"pl": "[check-mutable-globals] Passed: no mutable path globals found",
|
||||
"ru": "[check-mutable-globals] Passed: no mutable path globals found",
|
||||
"zh": "[check-mutable-globals] Passed: no mutable path globals found"
|
||||
"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."
|
||||
},
|
||||
"[check_agent_docs] Passed: scanned {count} file(s), no stale references": {
|
||||
"bg": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
|
||||
"de": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
|
||||
"en": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
|
||||
"pl": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
|
||||
"ru": "[check_agent_docs] Passed: scanned {count} file(s), no stale references",
|
||||
"zh": "[check_agent_docs] Passed: scanned {count} file(s), no stale references"
|
||||
"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."
|
||||
},
|
||||
"[check_test_coverage] No changed files to check.": {
|
||||
"bg": "[check_test_coverage] No changed files to check.",
|
||||
"de": "[check_test_coverage] No changed files to check.",
|
||||
"en": "[check_test_coverage] No changed files to check.",
|
||||
"pl": "[check_test_coverage] No changed files to check.",
|
||||
"ru": "[check_test_coverage] No changed files to check.",
|
||||
"zh": "[check_test_coverage] No changed files to check."
|
||||
"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."
|
||||
},
|
||||
"Additional directory to scan (default: scripts, tests). Can be repeated.": {
|
||||
"bg": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
||||
"de": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
||||
"en": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
||||
"pl": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
||||
"ru": "Additional directory to scan (default: scripts, tests). Can be repeated.",
|
||||
"zh": "Additional directory to scan (default: scripts, tests). Can be repeated."
|
||||
"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"
|
||||
},
|
||||
"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",
|
||||
"en": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
|
||||
"pl": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
|
||||
"ru": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master",
|
||||
"zh": "Branch is behind origin/master. Rebase first: git fetch origin master && git rebase origin/master"
|
||||
"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}"
|
||||
},
|
||||
"Branch name (e.g., DEVX-256-fix-foo)": {
|
||||
"bg": "Branch name (e.g., DEVX-256-fix-foo)",
|
||||
"de": "Branch name (e.g., DEVX-256-fix-foo)",
|
||||
"en": "Branch name (e.g., DEVX-256-fix-foo)",
|
||||
"pl": "Branch name (e.g., DEVX-256-fix-foo)",
|
||||
"ru": "Branch name (e.g., DEVX-256-fix-foo)",
|
||||
"zh": "Branch name (e.g., DEVX-256-fix-foo)"
|
||||
"Rebase successful.": {
|
||||
"bg": "Rebase successful.",
|
||||
"de": "Rebase successful.",
|
||||
"en": "Rebase successful.",
|
||||
"pl": "Rebase successful.",
|
||||
"ru": "Rebase successful.",
|
||||
"zh": "Rebase successful."
|
||||
},
|
||||
"Branch name must contain a task ID.": {
|
||||
"bg": "Branch name must contain a task ID.",
|
||||
"de": "Branch name must contain a task ID.",
|
||||
"en": "Branch name must contain a task ID.",
|
||||
"pl": "Branch name must contain a task ID.",
|
||||
"ru": "Branch name must contain a task ID.",
|
||||
"zh": "Branch name must contain a task ID."
|
||||
"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..."
|
||||
},
|
||||
"Check that changed files have corresponding tests": {
|
||||
"bg": "Check that changed files have corresponding tests",
|
||||
"de": "Check that changed files have corresponding tests",
|
||||
"en": "Check that changed files have corresponding tests",
|
||||
"pl": "Check that changed files have corresponding tests",
|
||||
"ru": "Check that changed files have corresponding tests",
|
||||
"zh": "Check that changed files have corresponding tests"
|
||||
"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..."
|
||||
},
|
||||
"Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).": {
|
||||
"bg": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
|
||||
"de": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
|
||||
"en": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
|
||||
"pl": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
|
||||
"ru": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found).",
|
||||
"zh": "Could not fetch PR title from Gitea (REPO_TOKEN not set or PR not found)."
|
||||
},
|
||||
"Dependencies must have documentation comments.": {
|
||||
"bg": "Dependencies must have documentation comments.",
|
||||
"de": "Dependencies must have documentation comments.",
|
||||
"en": "Dependencies must have documentation comments.",
|
||||
"pl": "Dependencies must have documentation comments.",
|
||||
"ru": "Dependencies must have documentation comments.",
|
||||
"zh": "Dependencies must have documentation comments."
|
||||
},
|
||||
"FAILED: {count} undocumented dependency/ies": {
|
||||
"bg": "FAILED: {count} undocumented dependency/ies",
|
||||
"de": "FAILED: {count} undocumented dependency/ies",
|
||||
"en": "FAILED: {count} undocumented dependency/ies",
|
||||
"pl": "FAILED: {count} undocumented dependency/ies",
|
||||
"ru": "FAILED: {count} undocumented dependency/ies",
|
||||
"zh": "FAILED: {count} undocumented dependency/ies"
|
||||
},
|
||||
"Found {count} mutable global(s) — use factory functions or pytest fixtures.": {
|
||||
"bg": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
|
||||
"de": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
|
||||
"en": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
|
||||
"pl": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
|
||||
"ru": "Found {count} mutable global(s) — use factory functions or pytest fixtures.",
|
||||
"zh": "Found {count} mutable global(s) — use factory functions or pytest fixtures."
|
||||
},
|
||||
"Found {count} stale documentation reference(s)": {
|
||||
"bg": "Found {count} stale documentation reference(s)",
|
||||
"de": "Found {count} stale documentation reference(s)",
|
||||
"en": "Found {count} stale documentation reference(s)",
|
||||
"pl": "Found {count} stale documentation reference(s)",
|
||||
"ru": "Found {count} stale documentation reference(s)",
|
||||
"zh": "Found {count} stale documentation reference(s)"
|
||||
},
|
||||
"No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.": {
|
||||
"bg": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
|
||||
"de": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
|
||||
"en": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
|
||||
"pl": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
|
||||
"ru": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description.",
|
||||
"zh": "No task ID found in branch name '{branch}'. Expected format: {prefix}-N-description."
|
||||
},
|
||||
"Only check staged files (for pre-commit)": {
|
||||
"bg": "Only check staged files (for pre-commit)",
|
||||
"de": "Only check staged files (for pre-commit)",
|
||||
"en": "Only check staged files (for pre-commit)",
|
||||
"pl": "Only check staged files (for pre-commit)",
|
||||
"ru": "Only check staged files (for pre-commit)",
|
||||
"zh": "Only check staged files (for pre-commit)"
|
||||
},
|
||||
"PR number (to fetch title from Gitea)": {
|
||||
"bg": "PR number (to fetch title from Gitea)",
|
||||
"de": "PR number (to fetch title from Gitea)",
|
||||
"en": "PR number (to fetch title from Gitea)",
|
||||
"pl": "PR number (to fetch title from Gitea)",
|
||||
"ru": "PR number (to fetch title from Gitea)",
|
||||
"zh": "PR number (to fetch title from Gitea)"
|
||||
},
|
||||
"PR title (auto-fetched if --pr-number given)": {
|
||||
"bg": "PR title (auto-fetched if --pr-number given)",
|
||||
"de": "PR title (auto-fetched if --pr-number given)",
|
||||
"en": "PR title (auto-fetched if --pr-number given)",
|
||||
"pl": "PR title (auto-fetched if --pr-number given)",
|
||||
"ru": "PR title (auto-fetched if --pr-number given)",
|
||||
"zh": "PR title (auto-fetched if --pr-number given)"
|
||||
},
|
||||
"PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}": {
|
||||
"bg": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
|
||||
"de": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
|
||||
"en": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
|
||||
"pl": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
|
||||
"ru": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}",
|
||||
"zh": "PR title does not match Vikunja task title.\n Expected: {expected}\n Got: {title}"
|
||||
},
|
||||
"PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}": {
|
||||
"bg": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
|
||||
"de": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
|
||||
"en": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
|
||||
"pl": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
|
||||
"ru": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}",
|
||||
"zh": "PR title must follow format '{prefix}-N: <task title>'.\n Got: {title}"
|
||||
},
|
||||
"PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}": {
|
||||
"bg": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
|
||||
"de": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
|
||||
"en": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
|
||||
"pl": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
|
||||
"ru": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}",
|
||||
"zh": "PR title task ID mismatch.\n Branch task ID: {task_id}\n PR title: {title}"
|
||||
},
|
||||
"Path to pyproject.toml (default: pyproject.toml in CWD).": {
|
||||
"bg": "Path to pyproject.toml (default: pyproject.toml in CWD).",
|
||||
"de": "Path to pyproject.toml (default: pyproject.toml in CWD).",
|
||||
"en": "Path to pyproject.toml (default: pyproject.toml in CWD).",
|
||||
"pl": "Path to pyproject.toml (default: pyproject.toml in CWD).",
|
||||
"ru": "Path to pyproject.toml (default: pyproject.toml in CWD).",
|
||||
"zh": "Path to pyproject.toml (default: pyproject.toml in CWD)."
|
||||
},
|
||||
"Pre-merge validation failed.": {
|
||||
"bg": "Pre-merge validation failed.",
|
||||
"de": "Pre-merge validation failed.",
|
||||
"en": "Pre-merge validation failed.",
|
||||
"pl": "Pre-merge validation failed.",
|
||||
"ru": "Pre-merge validation failed.",
|
||||
"zh": "Pre-merge validation failed."
|
||||
},
|
||||
"Print warnings but always exit 0": {
|
||||
"bg": "Print warnings but always exit 0",
|
||||
"de": "Print warnings but always exit 0",
|
||||
"en": "Print warnings but always exit 0",
|
||||
"pl": "Print warnings but always exit 0",
|
||||
"ru": "Print warnings but always exit 0",
|
||||
"zh": "Print warnings but always exit 0"
|
||||
},
|
||||
"Repository in owner/name format": {
|
||||
"bg": "Repository in owner/name format",
|
||||
"de": "Repository in owner/name format",
|
||||
"en": "Repository in owner/name format",
|
||||
"pl": "Repository in owner/name format",
|
||||
"ru": "Repository in owner/name format",
|
||||
"zh": "Repository in owner/name format"
|
||||
},
|
||||
"Skip Vikunja title match check": {
|
||||
"bg": "Skip Vikunja title match check",
|
||||
"de": "Skip Vikunja title match check",
|
||||
"en": "Skip Vikunja title match check",
|
||||
"pl": "Skip Vikunja title match check",
|
||||
"ru": "Skip Vikunja title match check",
|
||||
"zh": "Skip Vikunja title match check"
|
||||
},
|
||||
"Skip branch-behind-master check": {
|
||||
"bg": "Skip branch-behind-master check",
|
||||
"de": "Skip branch-behind-master check",
|
||||
"en": "Skip branch-behind-master check",
|
||||
"pl": "Skip branch-behind-master check",
|
||||
"ru": "Skip branch-behind-master check",
|
||||
"zh": "Skip branch-behind-master check"
|
||||
},
|
||||
"[check-dep-docs] Passed: all dependencies are documented": {
|
||||
"bg": "[check-dep-docs] Passed: all dependencies are documented",
|
||||
"de": "[check-dep-docs] Passed: all dependencies are documented",
|
||||
"en": "[check-dep-docs] Passed: all dependencies are documented",
|
||||
"pl": "[check-dep-docs] Passed: all dependencies are documented",
|
||||
"ru": "[check-dep-docs] Passed: all dependencies are documented",
|
||||
"zh": "[check-dep-docs] Passed: all dependencies are documented"
|
||||
},
|
||||
"Wrote tag {tag} to GITHUB_OUTPUT.": {
|
||||
"bg": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"de": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"en": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"ru": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"zh": "Wrote tag {tag} to GITHUB_OUTPUT.",
|
||||
"pl": "Wrote tag {tag} to GITHUB_OUTPUT."
|
||||
" - {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"
|
||||
}
|
||||
}
|
||||
|
||||
+135
-25
@@ -6,7 +6,7 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from devx.api_clients import GiteaClient, VikunjaClient, _is_retryable, _parse_error
|
||||
from devx.api_clients import GiteaClient, VikunjaClient, _parse_error
|
||||
from devx.config import (
|
||||
DEFAULT_PER_PAGE,
|
||||
DEFAULT_TIMEOUT,
|
||||
@@ -233,6 +233,18 @@ class TestGiteaClient:
|
||||
json={"Do": "squash", "MergeTitleField": "fix: bug"},
|
||||
)
|
||||
|
||||
def test_update_pr_branch(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response())
|
||||
|
||||
client.update_pr_branch(7, style="rebase")
|
||||
client._session.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://git.example.com/repos/owner/repo/pulls/7/update",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
params={"style": "rebase"},
|
||||
)
|
||||
|
||||
def test_get_pr_labels(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response([{"name": "ready-to-merge"}]))
|
||||
@@ -507,7 +519,7 @@ class TestGiteaClient:
|
||||
assert result["id"] == 1
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
@patch("time.sleep")
|
||||
def test_request_retries_on_429(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should retry on 429 rate limit with exponential backoff."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
@@ -520,7 +532,7 @@ class TestGiteaClient:
|
||||
assert client._session.request.call_count == 3
|
||||
assert mock_sleep.call_count == 2
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
@patch("time.sleep")
|
||||
def test_request_retries_on_503(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should retry on 503 service unavailable."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
@@ -532,7 +544,7 @@ class TestGiteaClient:
|
||||
assert result.json() == {"ok": True}
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
@patch("time.sleep")
|
||||
def test_request_no_retry_on_404(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should NOT retry on 404 — it's not a transient error."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
@@ -545,7 +557,7 @@ class TestGiteaClient:
|
||||
assert client._session.request.call_count == 1
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
@patch("time.sleep")
|
||||
def test_request_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should retry on connection errors."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
@@ -555,7 +567,7 @@ class TestGiteaClient:
|
||||
assert result.json() == {"ok": True}
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
@patch("time.sleep")
|
||||
def test_request_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should raise APIError after max retries on persistent 503."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
@@ -567,7 +579,7 @@ class TestGiteaClient:
|
||||
assert exc_info.value.status == 503
|
||||
assert client._session.request.call_count == 3 # MAX_RETRIES
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
@patch("time.sleep")
|
||||
def test_request_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should raise APIError after max retries on persistent connection errors."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
@@ -691,7 +703,7 @@ class TestVikunjaClient:
|
||||
json={"id": 42, "title": "My task", "done": True},
|
||||
)
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
@patch("time.sleep")
|
||||
def test_http_error_raises_api_error(self, mock_sleep: MagicMock) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
mock_resp = MagicMock()
|
||||
@@ -713,7 +725,7 @@ class TestVikunjaClient:
|
||||
client.list_tasks()
|
||||
assert "connection failed" in str(exc_info.value)
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
@patch("time.sleep")
|
||||
def test_vikunja_retries_on_503(self, mock_sleep: MagicMock) -> None:
|
||||
"""VikunjaClient should also retry on 503."""
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
@@ -725,7 +737,7 @@ class TestVikunjaClient:
|
||||
assert len(result) == 1
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
@patch("time.sleep")
|
||||
def test_vikunja_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
|
||||
"""VikunjaClient should retry on connection errors."""
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
@@ -735,7 +747,7 @@ class TestVikunjaClient:
|
||||
assert len(result) == 1
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
@patch("time.sleep")
|
||||
def test_vikunja_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||
"""VikunjaClient should raise APIError after max retries on persistent 503."""
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
@@ -747,7 +759,7 @@ class TestVikunjaClient:
|
||||
assert exc_info.value.status == 503
|
||||
assert client._session.request.call_count == 3 # MAX_RETRIES
|
||||
|
||||
@patch("devx.api_clients.time.sleep")
|
||||
@patch("time.sleep")
|
||||
def test_vikunja_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||
"""VikunjaClient should raise APIError after max retries on persistent connection errors."""
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
@@ -781,21 +793,119 @@ class TestVikunjaClient:
|
||||
call_kwargs = client._session.request.call_args.kwargs
|
||||
assert call_kwargs["json"]["description"] == ""
|
||||
|
||||
def test_find_task_by_identifier_found(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response([{"identifier": "DEVX-1"}, {"identifier": "DEVX-42", "title": "Found"}])
|
||||
)
|
||||
result = client.find_task_by_identifier(6, "DEVX-42", per_page=50)
|
||||
assert result is not None
|
||||
assert result["title"] == "Found"
|
||||
|
||||
class TestIsRetryable:
|
||||
def test_connection_error_is_retryable(self) -> None:
|
||||
assert _is_retryable(requests.ConnectionError("refused")) is True
|
||||
def test_find_task_by_identifier_not_found(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response([{"identifier": "DEVX-1"}, {"identifier": "DEVX-2"}])
|
||||
)
|
||||
result = client.find_task_by_identifier(6, "DEVX-99", per_page=50)
|
||||
assert result is None
|
||||
|
||||
def test_timeout_is_retryable(self) -> None:
|
||||
assert _is_retryable(requests.Timeout("timed out")) is True
|
||||
def test_find_task_by_identifier_empty_project(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(return_value=_mock_response([]))
|
||||
result = client.find_task_by_identifier(6, "DEVX-1", per_page=50)
|
||||
assert result is None
|
||||
|
||||
def test_429_is_retryable(self) -> None:
|
||||
err = _mock_http_error(429, "rate limited")
|
||||
assert _is_retryable(err) is True
|
||||
def test_find_task_by_identifier_paginates(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
full_page = [{"identifier": f"DEVX-{i}"} for i in range(50)]
|
||||
client._session.request = MagicMock(
|
||||
side_effect=[
|
||||
_mock_response(full_page),
|
||||
_mock_response([{"identifier": "DEVX-50", "title": "Found on page 2"}]),
|
||||
]
|
||||
)
|
||||
result = client.find_task_by_identifier(6, "DEVX-50", per_page=50)
|
||||
assert result is not None
|
||||
assert result["title"] == "Found on page 2"
|
||||
|
||||
def test_404_is_not_retryable(self) -> None:
|
||||
err = _mock_http_error(404, "not found")
|
||||
assert _is_retryable(err) is False
|
||||
|
||||
def test_generic_exception_is_not_retryable(self) -> None:
|
||||
assert _is_retryable(ValueError("oops")) is False
|
||||
class TestGiteaClientPrLabels:
|
||||
def test_add_pr_label(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({}))
|
||||
client.add_pr_label(42, ["ready-to-merge"])
|
||||
client._session.request.assert_called_once_with(
|
||||
"POST",
|
||||
"https://git.example.com/repos/owner/repo/issues/42/labels",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
json={"labels": ["ready-to-merge"]},
|
||||
)
|
||||
|
||||
def test_add_pr_label_multiple(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({}))
|
||||
client.add_pr_label(42, ["ready-to-merge", "reviewed"])
|
||||
call_kwargs = client._session.request.call_args.kwargs
|
||||
assert call_kwargs["json"]["labels"] == ["ready-to-merge", "reviewed"]
|
||||
|
||||
def test_get_pr_label_names(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response([{"name": "bug"}, {"name": "ready-to-merge"}]))
|
||||
result = client.get_pr_label_names(42)
|
||||
assert result == ["bug", "ready-to-merge"]
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/issues/42/labels",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
|
||||
class TestGiteaClientActions:
|
||||
def test_list_action_runs(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response({"workflow_runs": [{"id": 1, "status": "completed"}], "total_count": 1})
|
||||
)
|
||||
result = client.list_action_runs(branch="feature-branch", limit=1)
|
||||
assert result["total_count"] == 1
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/actions/runs",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
params={"branch": "feature-branch", "limit": 1},
|
||||
)
|
||||
|
||||
def test_get_action_run_jobs(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(
|
||||
return_value=_mock_response({"jobs": [{"id": 100, "name": "quality", "conclusion": "failure"}]})
|
||||
)
|
||||
result = client.get_action_run_jobs(1410)
|
||||
assert len(result) == 1
|
||||
assert result[0]["name"] == "quality"
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/actions/runs/1410/jobs",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
def test_get_action_run_jobs_empty(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({}))
|
||||
result = client.get_action_run_jobs(1410)
|
||||
assert result == []
|
||||
|
||||
def test_get_action_job_logs(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.text = "log line 1\nlog line 2"
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
client._session.request = MagicMock(return_value=mock_resp)
|
||||
result = client.get_action_job_logs(10026)
|
||||
assert "log line 1" in result
|
||||
client._session.request.assert_called_once_with(
|
||||
"GET",
|
||||
"https://git.example.com/repos/owner/repo/actions/jobs/10026/logs",
|
||||
timeout=DEFAULT_TIMEOUT,
|
||||
)
|
||||
|
||||
@@ -6,12 +6,12 @@ import click
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci._shared import run_cmd
|
||||
from devx.ci.auto_merge import (
|
||||
extract_conventional_msg,
|
||||
extract_task_id,
|
||||
main,
|
||||
read_taskid,
|
||||
run_cmd,
|
||||
validate_pr_title,
|
||||
validate_pr_title_matches_vikunja,
|
||||
)
|
||||
@@ -195,6 +195,43 @@ class TestExtractConventionalMsg:
|
||||
]
|
||||
assert extract_conventional_msg(commits) == "feat: add feature"
|
||||
|
||||
def test_prefers_feat_over_refactor(self) -> None:
|
||||
"""When both feat and refactor commits exist, feat wins."""
|
||||
commits = [
|
||||
{"commit": {"message": "refactor: add find_task_by_identifier"}},
|
||||
{"commit": {"message": "fix: remove hardcoded fallbacks"}},
|
||||
{"commit": {"message": "feat: add manual review support"}},
|
||||
]
|
||||
assert extract_conventional_msg(commits) == "feat: add manual review support"
|
||||
|
||||
def test_prefers_fix_over_docs(self) -> None:
|
||||
commits = [
|
||||
{"commit": {"message": "docs: update README"}},
|
||||
{"commit": {"message": "fix: resolve bug"}},
|
||||
]
|
||||
assert extract_conventional_msg(commits) == "fix: resolve bug"
|
||||
|
||||
def test_scope_in_prefix(self) -> None:
|
||||
commits = [
|
||||
{"commit": {"message": "refactor(ci): cleanup code"}},
|
||||
{"commit": {"message": "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 --
|
||||
|
||||
@@ -217,7 +254,7 @@ class TestRunCmd:
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch.dict("os.environ", {"REPO_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.GiteaClient")
|
||||
def test_full_merge_flow(
|
||||
@@ -239,14 +276,14 @@ class TestMain:
|
||||
assert result.exit_code == 0, result.output
|
||||
mock_client.merge_pr.assert_called_once_with(7, "DEVX-19: fix: resolve timeout")
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": ""}, clear=True)
|
||||
def test_no_token_raises(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["DEVX-19-fix", "DEVX-19: test", "owner/repo", "7"])
|
||||
assert result.exit_code != 0
|
||||
assert "REPO_TOKEN" in result.output
|
||||
assert "CI_GITEA_TOKEN" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.auto_merge.GiteaClient")
|
||||
def test_no_task_id_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
@@ -256,7 +293,7 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
assert "No task ID" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.auto_merge.GiteaClient")
|
||||
def test_invalid_pr_title_raises(self, mock_client_cls: MagicMock, tmp_path, monkeypatch) -> None: # type: ignore[no-untyped-def]
|
||||
monkeypatch.chdir(tmp_path)
|
||||
@@ -266,17 +303,16 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
assert "format" in result.output.lower()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_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.GiteaClient")
|
||||
def test_merge_behind_master_raises_no_rebase(
|
||||
def test_merge_behind_master_auto_rebases(
|
||||
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
"""When branch is behind master, auto-merge should NOT rebase.
|
||||
"""When branch is behind master, auto-merge rebases via Gitea API.
|
||||
|
||||
Auto-rebasing creates a feedback loop: the force-push triggers a new
|
||||
pull_request synchronize event, which starts a new CI run, which runs
|
||||
auto-merge again, which rebases again, etc.
|
||||
The rebase triggers a new CI run. The next auto-merge attempt will
|
||||
find the branch up-to-date and merge successfully.
|
||||
"""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
@@ -292,13 +328,41 @@ class TestMain:
|
||||
main,
|
||||
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert result.exit_code == 0
|
||||
assert "behind master" in result.output.lower()
|
||||
assert "rebase manually" in result.output.lower()
|
||||
# Must NOT have called merge_pr twice (no retry after rebase)
|
||||
assert "auto-rebasing" in result.output.lower()
|
||||
# Should have called update_pr_branch to trigger server-side rebase
|
||||
mock_client.update_pr_branch.assert_called_once_with(7, style="rebase")
|
||||
# Must NOT have called merge_pr twice (no immediate retry)
|
||||
assert mock_client.merge_pr.call_count == 1
|
||||
|
||||
@patch.dict("os.environ", {"REPO_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.GiteaClient")
|
||||
def test_merge_behind_master_rebase_failure_raises(
|
||||
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
"""When auto-rebase fails, raise with manual rebase instructions."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr_commits.return_value = [
|
||||
{"commit": {"message": "fix: resolve timeout"}},
|
||||
]
|
||||
mock_client.merge_pr.side_effect = APIError(405, "HEAD branch is behind master")
|
||||
mock_client.update_pr_branch.side_effect = APIError(409, "Conflict during rebase")
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "auto-rebase failed" in result.output.lower()
|
||||
assert "rebase manually" in result.output.lower()
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "VIKUNJA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.ci.auto_merge.validate_pr_title_matches_vikunja")
|
||||
@patch("devx.ci.auto_merge.GiteaClient")
|
||||
def test_merge_failure_raises(
|
||||
@@ -321,7 +385,7 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
assert "Merge failed" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_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.GiteaClient")
|
||||
def test_no_conventional_msg_raises(
|
||||
@@ -342,7 +406,7 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
assert "conventional commit" in result.output.lower()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||
def test_invalid_pr_number_raises(self, tmp_path, monkeypatch) -> None:
|
||||
"""Non-integer PR number should raise."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
@@ -351,7 +415,7 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
assert "PR number must be an integer" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||
def test_invalid_repo_format_raises(self, tmp_path, monkeypatch) -> None:
|
||||
"""Repo without owner/name should raise."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
@@ -360,13 +424,13 @@ class TestMain:
|
||||
assert result.exit_code != 0
|
||||
assert "owner/name" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_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.GiteaClient")
|
||||
def test_merge_behind_master_does_not_force_push(
|
||||
def test_merge_behind_master_does_not_run_git_commands(
|
||||
self, mock_client_cls: MagicMock, _mock_validate: MagicMock, tmp_path, monkeypatch
|
||||
) -> None: # type: ignore[no-untyped-def]
|
||||
"""Verify no git commands are run when branch is behind master."""
|
||||
"""When behind master, auto-merge uses API rebase — no local git commands."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
mock_client = MagicMock()
|
||||
@@ -376,14 +440,14 @@ class TestMain:
|
||||
mock_client.merge_pr.side_effect = APIError(405, "HEAD branch is behind master")
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
with patch("devx.ci.auto_merge.run_cmd") as mock_run:
|
||||
with patch("devx.ci._shared.run_cmd") as mock_run:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["DEVX-19-fix-bug", "DEVX-19: Fix timeout", "owner/repo", "7"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
# No git commands should be run (no rebase, no push)
|
||||
assert result.exit_code == 0
|
||||
# No local git commands should be run (rebase is via API)
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,737 @@
|
||||
"""Unit tests for devx.tools.build_image."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click import ClickException
|
||||
from click.testing import CliRunner
|
||||
|
||||
import devx.tools.build_image as build_image
|
||||
from devx.tools.build_image import (
|
||||
ImageSpec,
|
||||
build_full_tag,
|
||||
load_manifest,
|
||||
push_image,
|
||||
registry_login,
|
||||
)
|
||||
from devx.tools.build_image import (
|
||||
build_image as do_build,
|
||||
)
|
||||
from devx.tools.clean_images import select_for_deletion, sort_versions_by_date
|
||||
|
||||
|
||||
class TestImageSpec:
|
||||
def test_from_dict_minimal(self) -> None:
|
||||
spec = ImageSpec.from_dict({"name": "ci-base", "dockerfile": "docker/ci-base/Dockerfile"})
|
||||
assert spec.name == "ci-base"
|
||||
assert spec.dockerfile == "docker/ci-base/Dockerfile"
|
||||
assert spec.context == "."
|
||||
assert spec.tags == ["latest"]
|
||||
|
||||
def test_from_dict_full(self) -> None:
|
||||
spec = ImageSpec.from_dict(
|
||||
{
|
||||
"name": "ci-quality",
|
||||
"dockerfile": "docker/ci-quality/Dockerfile",
|
||||
"context": ".",
|
||||
"tags": ["latest", "0.19.3"],
|
||||
}
|
||||
)
|
||||
assert spec.name == "ci-quality"
|
||||
assert spec.dockerfile == "docker/ci-quality/Dockerfile"
|
||||
assert spec.context == "."
|
||||
assert spec.tags == ["latest", "0.19.3"]
|
||||
|
||||
def test_from_dict_missing_name(self) -> None:
|
||||
with pytest.raises(ValueError, match="missing 'name'"):
|
||||
ImageSpec.from_dict({"dockerfile": "Dockerfile"})
|
||||
|
||||
def test_from_dict_missing_dockerfile(self) -> None:
|
||||
with pytest.raises(ValueError, match="missing 'dockerfile'"):
|
||||
ImageSpec.from_dict({"name": "ci-base"})
|
||||
|
||||
def test_from_dict_tags_not_list(self) -> None:
|
||||
with pytest.raises(ValueError, match="tags.*must be a list"):
|
||||
ImageSpec.from_dict(
|
||||
{
|
||||
"name": "ci-base",
|
||||
"dockerfile": "Dockerfile",
|
||||
"tags": "latest",
|
||||
}
|
||||
)
|
||||
|
||||
def test_from_dict_empty_tags_defaults_to_latest(self) -> None:
|
||||
spec = ImageSpec.from_dict(
|
||||
{
|
||||
"name": "ci-base",
|
||||
"dockerfile": "Dockerfile",
|
||||
"tags": [],
|
||||
}
|
||||
)
|
||||
assert spec.tags == ["latest"]
|
||||
|
||||
|
||||
class TestBuildFullTag:
|
||||
def test_no_registry(self) -> None:
|
||||
assert build_full_tag(None, "ci-base", "latest") == "ci-base:latest"
|
||||
|
||||
def test_with_registry(self) -> None:
|
||||
assert build_full_tag("git.example.com", "ci-base", "0.1.0") == "git.example.com/ci-base:0.1.0"
|
||||
|
||||
def test_with_registry_and_path(self) -> None:
|
||||
assert (
|
||||
build_full_tag("git.example.com", "oblachno/ci-base", "latest") == "git.example.com/oblachno/ci-base:latest"
|
||||
)
|
||||
|
||||
|
||||
class TestLoadManifest:
|
||||
def test_load_valid_manifest(self, tmp_path: Path) -> None:
|
||||
manifest = tmp_path / "images.json"
|
||||
manifest.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{"name": "ci-base", "dockerfile": "docker/ci-base/Dockerfile"},
|
||||
{"name": "ci-quality", "dockerfile": "docker/ci-quality/Dockerfile", "tags": ["latest", "1.0"]},
|
||||
]
|
||||
)
|
||||
)
|
||||
specs = load_manifest(manifest)
|
||||
assert len(specs) == 2
|
||||
assert specs[0].name == "ci-base"
|
||||
assert specs[1].tags == ["latest", "1.0"]
|
||||
|
||||
def test_load_missing_file(self, tmp_path: Path) -> None:
|
||||
with pytest.raises(ClickException, match="not found"):
|
||||
load_manifest(tmp_path / "nonexistent.json")
|
||||
|
||||
def test_load_not_a_list(self, tmp_path: Path) -> None:
|
||||
manifest = tmp_path / "images.json"
|
||||
manifest.write_text(json.dumps({"name": "ci-base"}))
|
||||
with pytest.raises(ClickException, match="must be a JSON list"):
|
||||
load_manifest(manifest)
|
||||
|
||||
|
||||
class TestRegistryLogin:
|
||||
def test_success(self) -> None:
|
||||
mock_result = MagicMock(returncode=0, stderr="", stdout="")
|
||||
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run:
|
||||
assert registry_login("git.example.com", "user", "token") is True
|
||||
assert mock_run.call_args.args[0] == [
|
||||
"docker",
|
||||
"login",
|
||||
"git.example.com",
|
||||
"-u",
|
||||
"user",
|
||||
"--password-stdin",
|
||||
]
|
||||
assert mock_run.call_args.kwargs["input"] == "token"
|
||||
|
||||
def test_failure(self) -> None:
|
||||
mock_result = MagicMock(returncode=1, stderr="auth failed", stdout="")
|
||||
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
|
||||
assert registry_login("git.example.com", "user", "bad") is False
|
||||
|
||||
def test_dry_run(self) -> None:
|
||||
with patch("devx.tools.build_image.subprocess.run") as mock_run:
|
||||
assert registry_login("git.example.com", "user", "token", dry_run=True) is True
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
class TestBuildImage:
|
||||
def test_success(self, tmp_path: Path) -> None:
|
||||
dockerfile = tmp_path / "Dockerfile"
|
||||
dockerfile.touch()
|
||||
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile), context=".", tags=["latest"])
|
||||
mock_result = MagicMock(returncode=0)
|
||||
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
|
||||
assert do_build(spec) is True
|
||||
|
||||
def test_dockerfile_not_found(self) -> None:
|
||||
spec = ImageSpec(name="ci-base", dockerfile="nonexistent/Dockerfile")
|
||||
assert do_build(spec) is False
|
||||
|
||||
def test_build_failure(self, tmp_path: Path) -> None:
|
||||
dockerfile = tmp_path / "Dockerfile"
|
||||
dockerfile.touch()
|
||||
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile))
|
||||
mock_result = MagicMock(returncode=1)
|
||||
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
|
||||
assert do_build(spec) is False
|
||||
|
||||
def test_dry_run(self, tmp_path: Path) -> None:
|
||||
dockerfile = tmp_path / "Dockerfile"
|
||||
dockerfile.touch()
|
||||
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile), tags=["latest", "1.0"])
|
||||
with patch("devx.tools.build_image.subprocess.run") as mock_run:
|
||||
assert do_build(spec, dry_run=True) is True
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_with_registry(self, tmp_path: Path) -> None:
|
||||
dockerfile = tmp_path / "Dockerfile"
|
||||
dockerfile.touch()
|
||||
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile), tags=["latest"])
|
||||
mock_result = MagicMock(returncode=0)
|
||||
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run:
|
||||
assert do_build(spec, registry="git.example.com") is True
|
||||
cmd = mock_run.call_args.args[0]
|
||||
assert "-t" in cmd
|
||||
idx = cmd.index("-t")
|
||||
assert cmd[idx + 1] == "git.example.com/ci-base:latest"
|
||||
|
||||
def test_pull_flag(self, tmp_path: Path) -> None:
|
||||
dockerfile = tmp_path / "Dockerfile"
|
||||
dockerfile.touch()
|
||||
spec = ImageSpec(name="ci-base", dockerfile=str(dockerfile))
|
||||
mock_result = MagicMock(returncode=0)
|
||||
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run:
|
||||
assert do_build(spec, pull=True) is True
|
||||
cmd = mock_run.call_args.args[0]
|
||||
assert "--pull" in cmd
|
||||
|
||||
|
||||
class TestPushImage:
|
||||
def test_success(self) -> None:
|
||||
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest", "1.0"])
|
||||
mock_result = MagicMock(returncode=0, stderr="", stdout="")
|
||||
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result) as mock_run:
|
||||
assert push_image(spec, "git.example.com") is True
|
||||
assert mock_run.call_count == 2
|
||||
|
||||
def test_partial_failure(self) -> None:
|
||||
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest", "1.0"])
|
||||
results = [
|
||||
MagicMock(returncode=0, stderr="", stdout=""),
|
||||
MagicMock(returncode=1, stderr="push failed", stdout=""),
|
||||
]
|
||||
with patch("devx.tools.build_image.subprocess.run", side_effect=results):
|
||||
assert push_image(spec, "git.example.com") is False
|
||||
|
||||
def test_dry_run(self) -> None:
|
||||
spec = ImageSpec(name="ci-base", dockerfile="Dockerfile", tags=["latest"])
|
||||
with patch("devx.tools.build_image.subprocess.run") as mock_run:
|
||||
assert push_image(spec, "git.example.com", dry_run=True) is True
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
class TestSortVersions:
|
||||
def test_sort_by_created_at_desc(self) -> None:
|
||||
versions = [
|
||||
{"version": "0.1.0", "created_at": "2025-01-01T00:00:00Z"},
|
||||
{"version": "0.3.0", "created_at": "2025-03-01T00:00:00Z"},
|
||||
{"version": "0.2.0", "created_at": "2025-02-01T00:00:00Z"},
|
||||
]
|
||||
result = sort_versions_by_date(versions)
|
||||
assert [v["version"] for v in result] == ["0.3.0", "0.2.0", "0.1.0"]
|
||||
|
||||
def test_sort_fallback_to_version(self) -> None:
|
||||
versions = [
|
||||
{"version": "0.1.0"},
|
||||
{"version": "0.3.0"},
|
||||
{"version": "0.2.0"},
|
||||
]
|
||||
result = sort_versions_by_date(versions)
|
||||
assert [v["version"] for v in result] == ["0.3.0", "0.2.0", "0.1.0"]
|
||||
|
||||
|
||||
class TestSelectForDeletion:
|
||||
def test_keep_2(self) -> None:
|
||||
versions = [
|
||||
{"version": "0.1.0", "created_at": "2025-01-01"},
|
||||
{"version": "0.2.0", "created_at": "2025-02-01"},
|
||||
{"version": "0.3.0", "created_at": "2025-03-01"},
|
||||
{"version": "0.4.0", "created_at": "2025-04-01"},
|
||||
]
|
||||
to_delete = select_for_deletion(versions, keep=2)
|
||||
assert len(to_delete) == 2
|
||||
assert {v["version"] for v in to_delete} == {"0.1.0", "0.2.0"}
|
||||
|
||||
def test_preserve_latest_tag(self) -> None:
|
||||
versions = [
|
||||
{"version": "latest", "created_at": "2025-01-01"},
|
||||
{"version": "0.2.0", "created_at": "2025-02-01"},
|
||||
{"version": "0.3.0", "created_at": "2025-03-01"},
|
||||
{"version": "0.4.0", "created_at": "2025-04-01"},
|
||||
]
|
||||
to_delete = select_for_deletion(versions, keep=2)
|
||||
deleted_versions = {v["version"] for v in to_delete}
|
||||
assert "latest" not in deleted_versions
|
||||
# latest is oldest by date but still preserved
|
||||
assert "0.2.0" in deleted_versions
|
||||
|
||||
def test_keep_all(self) -> None:
|
||||
versions = [
|
||||
{"version": "0.1.0", "created_at": "2025-01-01"},
|
||||
{"version": "0.2.0", "created_at": "2025-02-01"},
|
||||
]
|
||||
to_delete = select_for_deletion(versions, keep=2)
|
||||
assert len(to_delete) == 0
|
||||
|
||||
def test_keep_more_than_available(self) -> None:
|
||||
versions = [
|
||||
{"version": "0.1.0", "created_at": "2025-01-01"},
|
||||
]
|
||||
to_delete = select_for_deletion(versions, keep=5)
|
||||
assert len(to_delete) == 0
|
||||
|
||||
|
||||
class TestCleanImagesAPI:
|
||||
"""Tests for the clean_images module's API functions."""
|
||||
|
||||
def test_list_package_versions(self) -> None:
|
||||
from devx.tools.clean_images import list_package_versions
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = [{"version": "0.1.0"}]
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
with patch("devx.tools.clean_images.requests.get", return_value=mock_resp) as mock_get:
|
||||
versions = list_package_versions(
|
||||
"https://git.example.com/api/v1",
|
||||
"oblachno-oss",
|
||||
"ci-base",
|
||||
"token",
|
||||
)
|
||||
assert versions == [{"version": "0.1.0"}]
|
||||
assert "page=1" in mock_get.call_args.args[0]
|
||||
|
||||
def test_list_package_versions_pagination(self) -> None:
|
||||
from devx.tools.clean_images import list_package_versions
|
||||
|
||||
# First page: 50 items, second page: 3 items, third page: empty
|
||||
page1 = [{"version": f"0.{i}.0"} for i in range(50)]
|
||||
page2 = [{"version": f"1.{i}.0"} for i in range(3)]
|
||||
responses = [
|
||||
MagicMock(json=MagicMock(return_value=page1), raise_for_status=MagicMock()),
|
||||
MagicMock(json=MagicMock(return_value=page2), raise_for_status=MagicMock()),
|
||||
MagicMock(json=MagicMock(return_value=[]), raise_for_status=MagicMock()),
|
||||
]
|
||||
with patch("devx.tools.clean_images.requests.get", side_effect=responses):
|
||||
versions = list_package_versions(
|
||||
"https://git.example.com/api/v1",
|
||||
"oblachno-oss",
|
||||
"ci-base",
|
||||
"token",
|
||||
)
|
||||
assert len(versions) == 53
|
||||
|
||||
def test_delete_package_version_success(self) -> None:
|
||||
from devx.tools.clean_images import delete_package_version
|
||||
|
||||
mock_resp = MagicMock(status_code=204)
|
||||
with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp) as mock_del:
|
||||
assert (
|
||||
delete_package_version(
|
||||
"https://git.example.com/api/v1",
|
||||
"oblachno-oss",
|
||||
"ci-base",
|
||||
"0.1.0",
|
||||
"token",
|
||||
)
|
||||
is True
|
||||
)
|
||||
# Verify URL includes container type
|
||||
url = mock_del.call_args.args[0]
|
||||
assert "/container/" in url
|
||||
|
||||
def test_delete_package_version_404_treated_as_success(self) -> None:
|
||||
from devx.tools.clean_images import delete_package_version
|
||||
|
||||
mock_resp = MagicMock(status_code=404)
|
||||
with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp):
|
||||
assert (
|
||||
delete_package_version(
|
||||
"https://git.example.com/api/v1",
|
||||
"oblachno-oss",
|
||||
"ci-base",
|
||||
"0.1.0",
|
||||
"token",
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_delete_package_version_failure(self) -> None:
|
||||
from devx.tools.clean_images import delete_package_version
|
||||
|
||||
mock_resp = MagicMock(status_code=403)
|
||||
with patch("devx.tools.clean_images.requests.delete", return_value=mock_resp):
|
||||
assert (
|
||||
delete_package_version(
|
||||
"https://git.example.com/api/v1",
|
||||
"oblachno-oss",
|
||||
"ci-base",
|
||||
"0.1.0",
|
||||
"token",
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_delete_package_version_retries_on_5xx(self) -> None:
|
||||
from devx.tools.clean_images import delete_package_version
|
||||
|
||||
responses = [
|
||||
MagicMock(status_code=500),
|
||||
MagicMock(status_code=502),
|
||||
MagicMock(status_code=204),
|
||||
]
|
||||
with patch("devx.tools.clean_images.requests.delete", side_effect=responses):
|
||||
with patch("devx.tools.clean_images.time.sleep"):
|
||||
assert (
|
||||
delete_package_version(
|
||||
"https://git.example.com/api/v1",
|
||||
"oblachno-oss",
|
||||
"ci-base",
|
||||
"0.1.0",
|
||||
"token",
|
||||
max_retries=3,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_delete_package_version_retries_on_exception(self) -> None:
|
||||
import requests as req
|
||||
|
||||
from devx.tools.clean_images import delete_package_version
|
||||
|
||||
responses = [
|
||||
req.ConnectionError("network down"),
|
||||
MagicMock(status_code=204),
|
||||
]
|
||||
with patch("devx.tools.clean_images.requests.delete", side_effect=responses):
|
||||
with patch("devx.tools.clean_images.time.sleep"):
|
||||
assert (
|
||||
delete_package_version(
|
||||
"https://git.example.com/api/v1",
|
||||
"oblachno-oss",
|
||||
"ci-base",
|
||||
"0.1.0",
|
||||
"token",
|
||||
max_retries=3,
|
||||
)
|
||||
is True
|
||||
)
|
||||
|
||||
def test_delete_package_version_exhausts_retries_on_exception(self) -> None:
|
||||
import requests as req
|
||||
|
||||
from devx.tools.clean_images import delete_package_version
|
||||
|
||||
with patch(
|
||||
"devx.tools.clean_images.requests.delete",
|
||||
side_effect=req.ConnectionError("network down"),
|
||||
):
|
||||
with patch("devx.tools.clean_images.time.sleep"):
|
||||
assert (
|
||||
delete_package_version(
|
||||
"https://git.example.com/api/v1",
|
||||
"oblachno-oss",
|
||||
"ci-base",
|
||||
"0.1.0",
|
||||
"token",
|
||||
max_retries=2,
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_delete_package_version_exhausts_retries_on_5xx(self) -> None:
|
||||
from devx.tools.clean_images import delete_package_version
|
||||
|
||||
with patch(
|
||||
"devx.tools.clean_images.requests.delete",
|
||||
return_value=MagicMock(status_code=500),
|
||||
):
|
||||
with patch("devx.tools.clean_images.time.sleep"):
|
||||
assert (
|
||||
delete_package_version(
|
||||
"https://git.example.com/api/v1",
|
||||
"oblachno-oss",
|
||||
"ci-base",
|
||||
"0.1.0",
|
||||
"token",
|
||||
max_retries=2,
|
||||
)
|
||||
is False
|
||||
)
|
||||
|
||||
def test_delete_package_version_zero_retries(self) -> None:
|
||||
from devx.tools.clean_images import delete_package_version
|
||||
|
||||
with patch("devx.tools.clean_images.requests.delete") as mock_del:
|
||||
assert (
|
||||
delete_package_version(
|
||||
"https://git.example.com/api/v1",
|
||||
"oblachno-oss",
|
||||
"ci-base",
|
||||
"0.1.0",
|
||||
"token",
|
||||
max_retries=0,
|
||||
)
|
||||
is False
|
||||
)
|
||||
mock_del.assert_not_called()
|
||||
|
||||
|
||||
class TestCLIBuildImage:
|
||||
def test_single_image_build(self, tmp_path: Path) -> None:
|
||||
dockerfile = tmp_path / "Dockerfile"
|
||||
dockerfile.touch()
|
||||
runner = CliRunner()
|
||||
mock_result = MagicMock(returncode=0)
|
||||
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
|
||||
result = runner.invoke(
|
||||
build_image.main,
|
||||
["--dockerfile", str(dockerfile), "--name", "ci-base", "--tag", "latest"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_manifest_build(self, tmp_path: Path) -> None:
|
||||
dockerfile = tmp_path / "Dockerfile"
|
||||
dockerfile.touch()
|
||||
manifest = tmp_path / "images.json"
|
||||
manifest.write_text(
|
||||
json.dumps(
|
||||
[
|
||||
{"name": "ci-base", "dockerfile": str(dockerfile)},
|
||||
]
|
||||
)
|
||||
)
|
||||
runner = CliRunner()
|
||||
mock_result = MagicMock(returncode=0)
|
||||
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
|
||||
result = runner.invoke(
|
||||
build_image.main,
|
||||
["--manifest", str(manifest)],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_missing_dockerfile_and_manifest(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(build_image.main, [])
|
||||
assert result.exit_code != 0
|
||||
assert "manifest" in result.output.lower() or "dockerfile" in result.output.lower()
|
||||
|
||||
def test_push_without_registry(self, tmp_path: Path) -> None:
|
||||
dockerfile = tmp_path / "Dockerfile"
|
||||
dockerfile.touch()
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
build_image.main,
|
||||
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "registry" in result.output.lower()
|
||||
|
||||
def test_push_without_credentials(self, tmp_path: Path) -> None:
|
||||
dockerfile = tmp_path / "Dockerfile"
|
||||
dockerfile.touch()
|
||||
runner = CliRunner()
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
result = runner.invoke(
|
||||
build_image.main,
|
||||
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push", "--registry", "git.example.com"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "credential" in result.output.lower() or "token" in result.output.lower()
|
||||
|
||||
def test_dry_run(self, tmp_path: Path) -> None:
|
||||
dockerfile = tmp_path / "Dockerfile"
|
||||
dockerfile.touch()
|
||||
runner = CliRunner()
|
||||
with patch("devx.tools.build_image.subprocess.run") as mock_run:
|
||||
result = runner.invoke(
|
||||
build_image.main,
|
||||
["--dockerfile", str(dockerfile), "--name", "ci-base", "--dry-run"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
mock_run.assert_not_called()
|
||||
assert "dry-run" in result.output
|
||||
|
||||
def test_build_failure_exits_with_error(self, tmp_path: Path) -> None:
|
||||
dockerfile = tmp_path / "Dockerfile"
|
||||
dockerfile.touch()
|
||||
runner = CliRunner()
|
||||
mock_result = MagicMock(returncode=1)
|
||||
with patch("devx.tools.build_image.subprocess.run", return_value=mock_result):
|
||||
result = runner.invoke(
|
||||
build_image.main,
|
||||
["--dockerfile", str(dockerfile), "--name", "ci-base"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_push_login_failure(self, tmp_path: Path) -> None:
|
||||
dockerfile = tmp_path / "Dockerfile"
|
||||
dockerfile.touch()
|
||||
runner = CliRunner()
|
||||
login_result = MagicMock(returncode=1, stderr="auth failed", stdout="")
|
||||
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake", "CI_GITEA_USERNAME": "user"}):
|
||||
with patch("devx.tools.build_image.subprocess.run", return_value=login_result):
|
||||
result = runner.invoke(
|
||||
build_image.main,
|
||||
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push", "--registry", "git.example.com"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "login" in result.output.lower()
|
||||
|
||||
def test_push_image_failure(self, tmp_path: Path) -> None:
|
||||
dockerfile = tmp_path / "Dockerfile"
|
||||
dockerfile.touch()
|
||||
runner = CliRunner()
|
||||
build_result = MagicMock(returncode=0)
|
||||
login_result = MagicMock(returncode=0, stderr="", stdout="")
|
||||
push_result = MagicMock(returncode=1, stderr="push failed", stdout="")
|
||||
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake", "CI_GITEA_USERNAME": "user"}):
|
||||
with patch(
|
||||
"devx.tools.build_image.subprocess.run",
|
||||
side_effect=[login_result, build_result, push_result],
|
||||
):
|
||||
result = runner.invoke(
|
||||
build_image.main,
|
||||
["--dockerfile", str(dockerfile), "--name", "ci-base", "--push", "--registry", "git.example.com"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
class TestCLICleanImages:
|
||||
def test_dry_run(self) -> None:
|
||||
from devx.tools.clean_images import main as clean_main
|
||||
|
||||
runner = CliRunner()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = [
|
||||
{"version": "0.1.0", "created_at": "2025-01-01"},
|
||||
{"version": "0.2.0", "created_at": "2025-02-01"},
|
||||
{"version": "0.3.0", "created_at": "2025-03-01"},
|
||||
]
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
|
||||
with patch("devx.tools.clean_images.requests.get", return_value=mock_resp):
|
||||
result = runner.invoke(
|
||||
clean_main,
|
||||
["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "1", "--dry-run"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "dry-run" in result.output
|
||||
assert "0.1.0" in result.output
|
||||
|
||||
def test_no_token(self) -> None:
|
||||
from devx.tools.clean_images import main as clean_main
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.dict("os.environ", {}, clear=True):
|
||||
result = runner.invoke(
|
||||
clean_main,
|
||||
["--owner", "oblachno-oss", "--name", "ci-base"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "token" in result.output.lower()
|
||||
|
||||
def test_no_versions_found(self) -> None:
|
||||
from devx.tools.clean_images import main as clean_main
|
||||
|
||||
runner = CliRunner()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = []
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
|
||||
with patch("devx.tools.clean_images.requests.get", return_value=mock_resp):
|
||||
result = runner.invoke(
|
||||
clean_main,
|
||||
["--owner", "oblachno-oss", "--name", "ci-base", "--dry-run"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "No versions" in result.output
|
||||
|
||||
def test_actual_delete(self) -> None:
|
||||
from devx.tools.clean_images import main as clean_main
|
||||
|
||||
runner = CliRunner()
|
||||
list_resp = MagicMock()
|
||||
list_resp.json.return_value = [
|
||||
{"version": "0.1.0", "created_at": "2025-01-01"},
|
||||
{"version": "0.2.0", "created_at": "2025-02-01"},
|
||||
{"version": "0.3.0", "created_at": "2025-03-01"},
|
||||
]
|
||||
list_resp.raise_for_status = MagicMock()
|
||||
delete_resp = MagicMock(status_code=204)
|
||||
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
|
||||
with patch("devx.tools.clean_images.requests.get", return_value=list_resp):
|
||||
with patch("devx.tools.clean_images.requests.delete", return_value=delete_resp):
|
||||
result = runner.invoke(
|
||||
clean_main,
|
||||
["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "2"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Deleted" in result.output
|
||||
|
||||
def test_list_request_exception(self) -> None:
|
||||
import requests as req
|
||||
|
||||
from devx.tools.clean_images import main as clean_main
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
|
||||
with patch(
|
||||
"devx.tools.clean_images.requests.get",
|
||||
side_effect=req.ConnectionError("network down"),
|
||||
):
|
||||
result = runner.invoke(
|
||||
clean_main,
|
||||
["--owner", "oblachno-oss", "--name", "ci-base", "--dry-run"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "Failed to list" in result.output
|
||||
|
||||
def test_delete_failure_in_cli(self) -> None:
|
||||
from devx.tools.clean_images import main as clean_main
|
||||
|
||||
runner = CliRunner()
|
||||
list_resp = MagicMock()
|
||||
list_resp.json.return_value = [
|
||||
{"version": "0.1.0", "created_at": "2025-01-01"},
|
||||
{"version": "0.2.0", "created_at": "2025-02-01"},
|
||||
{"version": "0.3.0", "created_at": "2025-03-01"},
|
||||
]
|
||||
list_resp.raise_for_status = MagicMock()
|
||||
delete_resp = MagicMock(status_code=403)
|
||||
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
|
||||
with patch("devx.tools.clean_images.requests.get", return_value=list_resp):
|
||||
with patch("devx.tools.clean_images.requests.delete", return_value=delete_resp):
|
||||
with patch("devx.tools.clean_images.time.sleep"):
|
||||
result = runner.invoke(
|
||||
clean_main,
|
||||
["--owner", "oblachno-oss", "--name", "ci-base", "--keep", "2"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "FAILED" in result.output
|
||||
|
||||
@patch("devx.tools.clean_images.REPO_OWNER", "oblachno-oss")
|
||||
def test_owner_from_config(self) -> None:
|
||||
from devx.tools.clean_images import main as clean_main
|
||||
|
||||
runner = CliRunner()
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = []
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
|
||||
with patch("devx.tools.clean_images.requests.get", return_value=mock_resp):
|
||||
result = runner.invoke(
|
||||
clean_main,
|
||||
["--name", "ci-base", "--dry-run"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "oblachno-oss/ci-base" in result.output
|
||||
|
||||
@patch("devx.tools.clean_images.REPO_OWNER", "")
|
||||
def test_no_owner_raises(self) -> None:
|
||||
from devx.tools.clean_images import main as clean_main
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "fake"}):
|
||||
result = runner.invoke(
|
||||
clean_main,
|
||||
["--name", "ci-base"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "owner" in result.output.lower()
|
||||
@@ -78,7 +78,7 @@ class TestGetPrTitleFromGitea:
|
||||
assert get_pr_title_from_gitea("owner/repo", 1) is None
|
||||
|
||||
def test_returns_none_with_invalid_repo(self) -> None:
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True):
|
||||
assert get_pr_title_from_gitea("invalid", 1) is None
|
||||
|
||||
@patch("devx.ci.check_auto_merge_ready.GiteaClient")
|
||||
@@ -86,16 +86,18 @@ class TestGetPrTitleFromGitea:
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr.return_value = {"title": "DEVX-1: Fix bug"}
|
||||
mock_client_cls.return_value = mock_client
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True):
|
||||
result = get_pr_title_from_gitea("owner/repo", 1)
|
||||
assert result == "DEVX-1: Fix bug"
|
||||
|
||||
@patch("devx.ci.check_auto_merge_ready.GiteaClient")
|
||||
def test_returns_none_on_exception(self, mock_client_cls: MagicMock) -> None:
|
||||
from devx.exceptions import APIError
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_pr.side_effect = Exception("API error")
|
||||
mock_client.get_pr.side_effect = APIError(500, "API error")
|
||||
mock_client_cls.return_value = mock_client
|
||||
with patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True):
|
||||
with patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True):
|
||||
result = get_pr_title_from_gitea("owner/repo", 1)
|
||||
assert result is None
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.check_test_coverage import (
|
||||
BUILTIN_RULES,
|
||||
DEFAULT_SKIP_EXTENSIONS,
|
||||
@@ -13,7 +15,7 @@ from devx.tools.check_test_coverage import (
|
||||
_load_rules,
|
||||
_resolve_test_path,
|
||||
_should_skip_file,
|
||||
main,
|
||||
cli,
|
||||
)
|
||||
|
||||
|
||||
@@ -201,7 +203,10 @@ class TestMain:
|
||||
),
|
||||
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
assert main([]) == 0
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 0
|
||||
assert "No changed files" in result.output
|
||||
|
||||
def test_all_have_tests(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "scripts" / "tests").mkdir(parents=True)
|
||||
@@ -214,7 +219,10 @@ class TestMain:
|
||||
),
|
||||
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
assert main([]) == 0
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 0
|
||||
assert "have tests" in result.output
|
||||
|
||||
def test_missing_test_returns_1(self, tmp_path: Path) -> None:
|
||||
with (
|
||||
@@ -225,7 +233,9 @@ class TestMain:
|
||||
),
|
||||
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
assert main([]) == 1
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_warn_only_returns_0(self, tmp_path: Path) -> None:
|
||||
with (
|
||||
@@ -236,4 +246,6 @@ class TestMain:
|
||||
),
|
||||
patch("devx.tools.check_test_coverage.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
assert main(["--warn-only"]) == 0
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--warn-only"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@@ -184,6 +184,14 @@ class TestMain:
|
||||
result = runner.invoke(check_translations.main, ["--translations", str(trans_file)])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_no_translations_file_skips(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When no translations file is found, should pass with skip message."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(check_translations.main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "No translations file found" in result.output
|
||||
|
||||
|
||||
class TestPrintResult:
|
||||
def test_prints_all_good(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
@@ -248,8 +256,8 @@ class TestDevxI18n:
|
||||
import devx.i18n
|
||||
|
||||
importlib.reload(devx.i18n)
|
||||
# "ERROR: REPO_TOKEN is not set." has a German translation
|
||||
result = devx.i18n._("ERROR: REPO_TOKEN is not set.")
|
||||
# "ERROR: CI_GITEA_TOKEN is not set." has a German translation
|
||||
result = devx.i18n._("ERROR: CI_GITEA_TOKEN is not set.")
|
||||
assert "FEHLER" in result
|
||||
|
||||
# Restore
|
||||
|
||||
@@ -264,6 +264,16 @@ class TestChangeClassifier:
|
||||
assert fc.is_user_facing
|
||||
assert fc.matched_rule == "user_facing_overrides"
|
||||
|
||||
def test_user_facing_override_glob_matches_nested(self) -> None:
|
||||
"""User-facing overrides support glob patterns like infrastructure."""
|
||||
classifier = self._make_classifier(
|
||||
infrastructure=[".gitea/**"],
|
||||
user_facing_overrides=[".gitea/**"],
|
||||
)
|
||||
fc = classifier.classify_file(".gitea/workflows/ci.yml")
|
||||
assert fc.is_user_facing
|
||||
assert fc.matched_rule == "user_facing_overrides"
|
||||
|
||||
def test_user_facing_override_beats_infrastructure_override(self) -> None:
|
||||
"""User-facing overrides beat infrastructure overrides (safety first)."""
|
||||
classifier = self._make_classifier(
|
||||
@@ -489,7 +499,7 @@ class TestMain:
|
||||
@patch("devx.ci.classify_changes.get_changed_files")
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_workflow_only_exits_2(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
mock_changes.return_value = [".gitea/workflows/ci.yml", "docs/index.md"]
|
||||
mock_changes.return_value = ["docs/index.md", "README.md"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 2
|
||||
@@ -572,7 +582,7 @@ class TestMain:
|
||||
@patch("devx.ci.classify_changes.get_changed_files")
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_quiet_workflow_only(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
mock_changes.return_value = [".gitea/workflows/ci.yml"]
|
||||
mock_changes.return_value = ["docs/index.md"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--quiet"])
|
||||
assert result.exit_code == 0
|
||||
@@ -631,7 +641,7 @@ class TestMain:
|
||||
@patch("devx.ci.classify_changes.get_changed_files")
|
||||
@patch("devx.ci.classify_changes.get_latest_tag", return_value="v0.3.0")
|
||||
def test_check_user_facing_false(self, mock_tag: MagicMock, mock_changes: MagicMock) -> None:
|
||||
mock_changes.return_value = [".gitea/workflows/ci.yml", "tests/test_foo.py"]
|
||||
mock_changes.return_value = ["docs/index.md", "tests/test_foo.py"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--check", "user-facing", "--quiet"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@@ -87,6 +87,13 @@ class TestCiCommands:
|
||||
assert result.exit_code == 0
|
||||
mock_run.assert_called_once_with("devx.ci.doc_coverage", [])
|
||||
|
||||
@patch("devx.cli._run_module")
|
||||
def test_ci_lint_docs(self, mock_run: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["ci", "lint-docs", "--", "--root", "."])
|
||||
assert result.exit_code == 0
|
||||
mock_run.assert_called_once_with("devx.ci.lint_docs", ["--root", "."])
|
||||
|
||||
@patch("devx.cli._run_module")
|
||||
def test_ci_notify_failure(self, mock_run: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
@@ -194,6 +201,20 @@ class TestToolsCommands:
|
||||
assert result.exit_code == 0
|
||||
mock_run.assert_called_once_with("devx.tools.setup", [])
|
||||
|
||||
@patch("devx.cli._run_module")
|
||||
def test_tools_rebase(self, mock_run: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["tools", "rebase", "--", "--no-push"])
|
||||
assert result.exit_code == 0
|
||||
mock_run.assert_called_once_with("devx.tools.rebase", ["--no-push"])
|
||||
|
||||
@patch("devx.cli._run_module")
|
||||
def test_tools_pr_rebase(self, mock_run: MagicMock) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["tools", "pr-rebase", "--", "--pr", "42"])
|
||||
assert result.exit_code == 0
|
||||
mock_run.assert_called_once_with("devx.tools.pr_rebase", ["--pr", "42"])
|
||||
|
||||
|
||||
class TestMoleculeCommands:
|
||||
@patch("devx.cli._run_module")
|
||||
|
||||
@@ -8,6 +8,7 @@ from click.testing import CliRunner
|
||||
|
||||
from devx.exceptions import APIError
|
||||
from devx.tools.configure_repo import (
|
||||
_STANDARD_LABELS,
|
||||
_default_branch_protection_config,
|
||||
_default_repo_settings_config,
|
||||
_handle_http_error,
|
||||
@@ -32,9 +33,10 @@ class TestDefaultConfigs:
|
||||
assert config["branch_name"] == "master"
|
||||
assert config["enable_push"] is True
|
||||
assert config["enable_push_whitelist"] is False
|
||||
assert config["required_approvals"] == 0
|
||||
assert config["required_approvals"] == 1
|
||||
assert isinstance(config["status_check_contexts"], list)
|
||||
assert "CI / quality (pull_request)" in config["status_check_contexts"]
|
||||
assert config["block_admin_merge_override"] is True
|
||||
|
||||
def test_default_repo_settings_config(self) -> None:
|
||||
config = _default_repo_settings_config()
|
||||
@@ -47,7 +49,7 @@ class TestDefaultConfigs:
|
||||
|
||||
|
||||
class TestConfigureRepo:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.tools.configure_repo.GiteaClient")
|
||||
def test_configure_repo_success(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
@@ -57,8 +59,9 @@ class TestConfigureRepo:
|
||||
|
||||
mock_client.ensure_branch_protection.assert_called_once()
|
||||
mock_client.update_repo_settings.assert_called_once()
|
||||
assert mock_client.ensure_label.call_count == len(_STANDARD_LABELS)
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.tools.configure_repo.GiteaClient")
|
||||
def test_configure_repo_api_error(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
@@ -69,10 +72,10 @@ class TestConfigureRepo:
|
||||
configure_repo(token="tok", owner="owner", repo="repo")
|
||||
|
||||
def test_configure_repo_no_token(self) -> None:
|
||||
with pytest.raises(click.ClickException, match="REPO_TOKEN"):
|
||||
with pytest.raises(click.ClickException, match="CI_GITEA_TOKEN"):
|
||||
configure_repo(token="", owner="owner", repo="repo")
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.tools.configure_repo.GiteaClient")
|
||||
def test_configure_repo_custom_configs(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
@@ -99,10 +102,25 @@ class TestConfigureRepo:
|
||||
|
||||
mock_client.ensure_branch_protection.assert_called_once_with("develop", custom_bp)
|
||||
mock_client.update_repo_settings.assert_called_once_with(custom_rs)
|
||||
# Labels are created regardless of custom configs
|
||||
assert mock_client.ensure_label.call_count == len(_STANDARD_LABELS)
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.tools.configure_repo.GiteaClient")
|
||||
def test_configure_repo_creates_all_standard_labels(self, mock_client_cls: MagicMock) -> None:
|
||||
"""Verify all standard labels are ensured with correct names."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
configure_repo(token="tok", owner="owner", repo="repo")
|
||||
|
||||
created_names = [call.args[0] for call in mock_client.ensure_label.call_args_list]
|
||||
expected_names = [lbl["name"] for lbl in _STANDARD_LABELS]
|
||||
assert created_names == expected_names
|
||||
|
||||
|
||||
class TestMain:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "DEVX_REPO_NAME": "myrepo"}, clear=True)
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "DEVX_REPO_NAME": "myrepo"}, clear=True)
|
||||
@patch("devx.tools.configure_repo.GiteaClient")
|
||||
def test_main_success_with_env_repo(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
@@ -113,8 +131,9 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
mock_client.ensure_branch_protection.assert_called_once()
|
||||
mock_client.update_repo_settings.assert_called_once()
|
||||
assert mock_client.ensure_label.call_count == len(_STANDARD_LABELS)
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.tools.configure_repo.GiteaClient")
|
||||
def test_main_success_with_cli_repo(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
@@ -124,8 +143,9 @@ class TestMain:
|
||||
result = runner.invoke(main, ["--repo", "myrepo", "--owner", "myorg"])
|
||||
assert result.exit_code == 0
|
||||
mock_client.ensure_branch_protection.assert_called_once()
|
||||
assert mock_client.ensure_label.call_count == len(_STANDARD_LABELS)
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.tools.configure_repo.GiteaClient")
|
||||
def test_main_api_error(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
@@ -142,16 +162,17 @@ class TestMain:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--repo", "myrepo"])
|
||||
assert result.exit_code != 0
|
||||
assert "REPO_TOKEN" in result.output
|
||||
assert "CI_GITEA_TOKEN" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.tools.configure_repo.REPO_NAME", "")
|
||||
def test_main_no_repo(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code != 0
|
||||
assert "Repository name not specified" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"}, clear=True)
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.tools.configure_repo.GiteaClient")
|
||||
def test_main_custom_branch(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
@@ -164,7 +185,7 @@ class TestMain:
|
||||
args = mock_client.ensure_branch_protection.call_args
|
||||
assert args[0][0] == "develop"
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok", "DEVX_REPO_NAME": "oblachno/infra"}, clear=True)
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "DEVX_REPO_NAME": "oblachno/infra"}, clear=True)
|
||||
@patch("devx.tools.configure_repo.GiteaClient")
|
||||
def test_main_parses_owner_repo_from_env(self, mock_client_cls: MagicMock) -> None:
|
||||
"""DEVX_REPO_NAME with 'owner/repo' format should be split."""
|
||||
@@ -181,7 +202,7 @@ class TestMain:
|
||||
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
{"REPO_TOKEN": "tok", "DEVX_REPO_NAME": "infra", "DEVX_REPO_OWNER": "oblachno"},
|
||||
{"CI_GITEA_TOKEN": "tok", "DEVX_REPO_NAME": "infra", "DEVX_REPO_OWNER": "oblachno"},
|
||||
clear=True,
|
||||
)
|
||||
@patch("devx.tools.configure_repo.REPO_OWNER", "oblachno")
|
||||
@@ -197,3 +218,19 @@ class TestMain:
|
||||
call_args = mock_client_cls.call_args
|
||||
assert call_args[0][2] == "oblachno" # owner
|
||||
assert call_args[0][3] == "infra" # repo
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.tools.configure_repo.REPO_NAME", "devx")
|
||||
@patch("devx.tools.configure_repo.REPO_OWNER", "oblachno-oss")
|
||||
@patch("devx.tools.configure_repo.GiteaClient")
|
||||
def test_main_repo_from_pyproject(self, mock_client_cls: MagicMock) -> None:
|
||||
"""When no env var is set, repo name should come from pyproject.toml."""
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
call_args = mock_client_cls.call_args
|
||||
assert call_args[0][2] == "oblachno-oss" # owner
|
||||
assert call_args[0][3] == "devx" # repo
|
||||
|
||||
@@ -29,10 +29,22 @@ class TestGetRepoName:
|
||||
def test_from_env(self) -> None:
|
||||
assert get_repo_name() == "infra"
|
||||
|
||||
@patch("devx.tools.create_pr.REPO_NAME", "devx")
|
||||
@patch.dict("os.environ", {"GITHUB_REPOSITORY": "oblachno/infra"}, clear=True)
|
||||
def test_env_overrides_pyproject(self) -> None:
|
||||
assert get_repo_name() == "infra"
|
||||
|
||||
@patch("devx.tools.create_pr.REPO_NAME", "devx")
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_from_pyproject(self) -> None:
|
||||
assert get_repo_name() == "devx"
|
||||
|
||||
@patch("devx.tools.create_pr.REPO_NAME", "")
|
||||
@patch.dict("os.environ", {"GITHUB_REPOSITORY": "oblachno/infra"}, clear=True)
|
||||
def test_from_github(self) -> None:
|
||||
assert get_repo_name() == "infra"
|
||||
|
||||
@patch("devx.tools.create_pr.REPO_NAME", "")
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_missing_raises(self) -> None:
|
||||
with pytest.raises(click.ClickException, match="Repository name"):
|
||||
@@ -44,7 +56,7 @@ class TestGetVikunjaTaskTitle:
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_found(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = [{"identifier": "DEVX-42", "title": "Add feature"}]
|
||||
mock_client.find_task_by_identifier.return_value = {"identifier": "DEVX-42", "title": "Add feature"}
|
||||
mock_client_cls.return_value = mock_client
|
||||
assert get_vikunja_task_title("DEVX-42") == "Add feature"
|
||||
|
||||
@@ -57,20 +69,7 @@ class TestGetVikunjaTaskTitle:
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_not_found(self, mock_client_cls: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.list_project_tasks.return_value = []
|
||||
mock_client_cls.return_value = mock_client
|
||||
with pytest.raises(click.ClickException, match="Could not find"):
|
||||
get_vikunja_task_title("DEVX-42")
|
||||
|
||||
@patch("devx.tools.create_pr.VikunjaClient")
|
||||
@patch.dict("os.environ", {"VIKUNJA_TOKEN": "tok"})
|
||||
def test_pagination_not_found(self, mock_client_cls: MagicMock) -> None:
|
||||
from devx.config import DEFAULT_PER_PAGE
|
||||
|
||||
mock_client = MagicMock()
|
||||
page1 = [{"identifier": f"OTHER-{i}"} for i in range(DEFAULT_PER_PAGE)]
|
||||
page2 = [{"identifier": "OTHER-99"}]
|
||||
mock_client.list_project_tasks.side_effect = [page1, page2]
|
||||
mock_client.find_task_by_identifier.return_value = None
|
||||
mock_client_cls.return_value = mock_client
|
||||
with pytest.raises(click.ClickException, match="Could not find"):
|
||||
get_vikunja_task_title("DEVX-42")
|
||||
@@ -95,7 +94,7 @@ class TestCreatePr:
|
||||
@patch("devx.tools.create_pr.GiteaClient")
|
||||
@patch("devx.tools.create_pr.get_vikunja_task_title", return_value="Add feature")
|
||||
@patch("devx.tools.create_pr.find_existing_pr", return_value=None)
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
def test_creates_new_pr(self, mock_find: MagicMock, mock_title: MagicMock, mock_gitea: MagicMock) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.create_pr.return_value = {"number": 15, "html_url": "https://git.example.com/pr/15"}
|
||||
@@ -112,7 +111,7 @@ class TestCreatePr:
|
||||
@patch("devx.tools.create_pr.GiteaClient")
|
||||
@patch("devx.tools.create_pr.get_vikunja_task_title", return_value="Add feature")
|
||||
@patch("devx.tools.create_pr.find_existing_pr")
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
def test_existing_pr_idempotent(self, mock_find: MagicMock, mock_title: MagicMock, mock_gitea: MagicMock) -> None:
|
||||
mock_find.return_value = {"number": 10, "html_url": "https://git.example.com/pr/10"}
|
||||
mock_client = MagicMock()
|
||||
@@ -123,10 +122,10 @@ class TestCreatePr:
|
||||
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
def test_no_repo_token(self) -> None:
|
||||
with pytest.raises(click.ClickException, match="REPO_TOKEN"):
|
||||
with pytest.raises(click.ClickException, match="CI_GITEA_TOKEN"):
|
||||
create_pr("DEVX-42-fix", "master", "", "owner", "repo")
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
def test_no_task_id_in_branch(self) -> None:
|
||||
with pytest.raises(click.ClickException, match="does not contain a task ID"):
|
||||
create_pr("feature-branch", "master", "", "owner", "repo")
|
||||
|
||||
@@ -38,6 +38,31 @@ class TestIsReleaseCommit:
|
||||
assert detect_release_commit.is_release_commit("") is False
|
||||
|
||||
|
||||
class TestIsBadgeCommit:
|
||||
def test_badge_commit(self) -> None:
|
||||
assert detect_release_commit.is_badge_commit("chore: update badge URLs to commit abc123 [skip ci]") is True
|
||||
|
||||
def test_regular_chore(self) -> None:
|
||||
assert detect_release_commit.is_badge_commit("chore: cleanup deps") is False
|
||||
|
||||
def test_empty(self) -> None:
|
||||
assert detect_release_commit.is_badge_commit("") is False
|
||||
|
||||
|
||||
class TestIsAutomatedCommit:
|
||||
def test_release_is_automated(self) -> None:
|
||||
assert detect_release_commit.is_automated_commit("release: v1.0.0 [skip ci]") is True
|
||||
|
||||
def test_badge_is_automated(self) -> None:
|
||||
assert detect_release_commit.is_automated_commit("chore: update badge URLs to commit abc123 [skip ci]") is True
|
||||
|
||||
def test_regular_is_not_automated(self) -> None:
|
||||
assert detect_release_commit.is_automated_commit("OBL-INFRA-363: fix: something") is False
|
||||
|
||||
def test_empty(self) -> None:
|
||||
assert detect_release_commit.is_automated_commit("") is False
|
||||
|
||||
|
||||
class TestWriteGithubOutput:
|
||||
def test_write(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gh_file = tmp_path / "output.txt"
|
||||
@@ -62,7 +87,26 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
assert "Release commit" in result.output
|
||||
with open(gh_file) as f:
|
||||
assert "is-release=true" in f.read()
|
||||
content = f.read()
|
||||
assert "is-release=true" in content
|
||||
assert "is-automated=true" in content
|
||||
|
||||
def test_badge_commit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gh_file = tmp_path / "output.txt"
|
||||
monkeypatch.setenv("GITHUB_OUTPUT", str(gh_file))
|
||||
with patch.object(
|
||||
detect_release_commit,
|
||||
"get_commit_message",
|
||||
return_value="chore: update badge URLs to commit abc123 [skip ci]",
|
||||
):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(detect_release_commit.main, [])
|
||||
assert result.exit_code == 0
|
||||
assert "Automated CI commit" in result.output
|
||||
with open(gh_file) as f:
|
||||
content = f.read()
|
||||
assert "is-release=false" in content
|
||||
assert "is-automated=true" in content
|
||||
|
||||
def test_regular_commit(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
gh_file = tmp_path / "output.txt"
|
||||
@@ -73,4 +117,6 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
assert "Regular merge commit" in result.output
|
||||
with open(gh_file) as f:
|
||||
assert "is-release=false" in f.read()
|
||||
content = f.read()
|
||||
assert "is-release=false" in content
|
||||
assert "is-automated=false" in content
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Unit tests for devx.ci.distribute_items."""
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.distribute_items import (
|
||||
DEFAULT_WEIGHT,
|
||||
distribute,
|
||||
items_for_runner,
|
||||
main,
|
||||
parse_items,
|
||||
parse_weighted_items,
|
||||
)
|
||||
|
||||
|
||||
class TestParseItems:
|
||||
def test_string_array(self) -> None:
|
||||
assert parse_items('["a", "b", "c"]') == ["a", "b", "c"]
|
||||
|
||||
def test_object_array(self) -> None:
|
||||
raw = '[{"id": "a", "weight": 2}, {"id": "b"}]'
|
||||
assert parse_items(raw) == ["a", "b"]
|
||||
|
||||
def test_empty_array(self) -> None:
|
||||
assert parse_items("[]") == []
|
||||
|
||||
def test_not_an_array(self) -> None:
|
||||
with pytest.raises(Exception, match="must be a JSON array"):
|
||||
parse_items('{"key": "value"}')
|
||||
|
||||
def test_invalid_entry_type(self) -> None:
|
||||
with pytest.raises(Exception, match="must be a string or an object"):
|
||||
parse_items("[42]")
|
||||
|
||||
def test_object_without_id(self) -> None:
|
||||
with pytest.raises(Exception, match="must be a string or an object"):
|
||||
parse_items('[{"weight": 2}]')
|
||||
|
||||
|
||||
class TestParseWeightedItems:
|
||||
def test_string_array_default_weights(self) -> None:
|
||||
items, weights = parse_weighted_items('["a", "b"]')
|
||||
assert items == ["a", "b"]
|
||||
assert weights == [DEFAULT_WEIGHT, DEFAULT_WEIGHT]
|
||||
|
||||
def test_object_array_with_weights(self) -> None:
|
||||
items, weights = parse_weighted_items('[{"id": "a", "weight": 5}, {"id": "b", "weight": 1}]')
|
||||
assert items == ["a", "b"]
|
||||
assert weights == [5, 1]
|
||||
|
||||
def test_object_array_missing_weight(self) -> None:
|
||||
items, weights = parse_weighted_items('[{"id": "a"}]')
|
||||
assert items == ["a"]
|
||||
assert weights == [DEFAULT_WEIGHT]
|
||||
|
||||
def test_not_an_array(self) -> None:
|
||||
with pytest.raises(Exception, match="must be a JSON array"):
|
||||
parse_weighted_items('"hello"')
|
||||
|
||||
def test_invalid_entry(self) -> None:
|
||||
with pytest.raises(Exception, match="must be a string or an object"):
|
||||
parse_weighted_items("[true]")
|
||||
|
||||
|
||||
class TestDistribute:
|
||||
def test_even_split(self) -> None:
|
||||
items = [f"vm-{i}" for i in range(6)]
|
||||
weights = [1] * 6
|
||||
groups = distribute(items, weights, 3)
|
||||
assert len(groups) == 3
|
||||
assert all(len(g) == 2 for g in groups)
|
||||
|
||||
def test_uneven_split(self) -> None:
|
||||
items = [f"vm-{i}" for i in range(5)]
|
||||
weights = [1] * 5
|
||||
groups = distribute(items, weights, 3)
|
||||
assert len(groups[0]) == 2
|
||||
assert len(groups[1]) == 2
|
||||
assert len(groups[2]) == 1
|
||||
|
||||
def test_more_runners_than_items(self) -> None:
|
||||
items = ["vm-a"]
|
||||
weights = [1]
|
||||
groups = distribute(items, weights, 5)
|
||||
assert len(groups) == 5
|
||||
assert len(groups[0]) == 1
|
||||
assert all(len(g) == 0 for g in groups[1:])
|
||||
|
||||
def test_lpt_heavy_item_on_least_loaded(self) -> None:
|
||||
items = ["heavy", "light1", "light2", "light3"]
|
||||
weights = [10, 1, 1, 1]
|
||||
groups = distribute(items, weights, 2)
|
||||
# Heavy item goes to runner 0, lights go to runner 1 (least loaded)
|
||||
assert "heavy" in groups[0]
|
||||
# Runner 1 should have more items but less total weight
|
||||
assert len(groups[1]) >= 2
|
||||
|
||||
def test_empty_items(self) -> None:
|
||||
groups = distribute([], [], 3)
|
||||
assert len(groups) == 3
|
||||
assert all(len(g) == 0 for g in groups)
|
||||
|
||||
def test_single_runner(self) -> None:
|
||||
items = ["a", "b", "c"]
|
||||
weights = [1, 2, 3]
|
||||
groups = distribute(items, weights, 1)
|
||||
assert len(groups) == 1
|
||||
assert len(groups[0]) == 3
|
||||
|
||||
|
||||
class TestItemsForRunner:
|
||||
def test_returns_assigned_subset(self) -> None:
|
||||
items = ["a", "b", "c", "d", "e", "f"]
|
||||
weights = [1] * 6
|
||||
result = items_for_runner(items, weights, 0, 3)
|
||||
assert len(result) == 2
|
||||
assert all(item in items for item in result)
|
||||
|
||||
def test_out_of_range(self) -> None:
|
||||
with pytest.raises(Exception, match="out of range"):
|
||||
items_for_runner(["a"], [1], 5, 3)
|
||||
|
||||
def test_negative_index(self) -> None:
|
||||
with pytest.raises(Exception, match="out of range"):
|
||||
items_for_runner(["a"], [1], -1, 3)
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_stdin_string_array(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--runner-index", "1", "--max-runners", "2"], input='["a", "b", "c"]')
|
||||
assert result.exit_code == 0
|
||||
# LPT: heaviest first, so "a" goes to runner 0, "b" to runner 1, "c" to runner 0
|
||||
# All weights equal, so round-robin-ish: runner 0 gets "a","c"; runner 1 gets "b"
|
||||
assert "a" in result.output
|
||||
|
||||
def test_stdin_object_array(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--runner-index", "1", "--max-runners", "2"],
|
||||
input='[{"id": "a", "weight": 5}, {"id": "b", "weight": 1}]',
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "a" in result.output
|
||||
|
||||
def test_items_file(self, tmp_path: object) -> None:
|
||||
import pathlib
|
||||
|
||||
items_file = pathlib.Path(str(tmp_path)) / "items.json"
|
||||
items_file.write_text('["x", "y", "z"]')
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--items-file", str(items_file), "--runner-index", "1", "--max-runners", "3"])
|
||||
assert result.exit_code == 0
|
||||
assert "x" in result.output
|
||||
|
||||
def test_print_all_groups_no_runner_index(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--max-runners", "2"], input='["a", "b"]')
|
||||
assert result.exit_code == 0
|
||||
assert "Runner 0:" in result.output
|
||||
assert "Runner 1:" in result.output
|
||||
|
||||
def test_empty_stdin(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--runner-index", "1", "--max-runners", "3"], input="")
|
||||
assert result.exit_code == 0
|
||||
# Empty input → empty assigned items
|
||||
assert result.output.strip() == ""
|
||||
|
||||
def test_github_env(self, tmp_path: object, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import pathlib
|
||||
|
||||
gh_env = pathlib.Path(str(tmp_path)) / "gh_env"
|
||||
gh_env.write_text("")
|
||||
monkeypatch.setenv("GITHUB_ENV", str(gh_env))
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--runner-index", "1", "--max-runners", "2", "--github-env"],
|
||||
input='["a", "b"]',
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
content = gh_env.read_text()
|
||||
assert "ASSIGNED_ITEMS=" in content
|
||||
assert "SKIP=false" in content
|
||||
|
||||
def test_skip_if_excess(self, tmp_path: object, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import pathlib
|
||||
|
||||
gh_env = pathlib.Path(str(tmp_path)) / "gh_env"
|
||||
gh_env.write_text("")
|
||||
monkeypatch.setenv("GITHUB_ENV", str(gh_env))
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--runner-index", "5", "--max-runners", "3", "--github-env", "--skip-if-excess"],
|
||||
input='["a"]',
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
content = gh_env.read_text()
|
||||
assert "ASSIGNED_ITEMS=" in content
|
||||
assert "SKIP=true" in content
|
||||
|
||||
def test_runner_index_zero(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--runner-index", "0"], input='["a"]')
|
||||
assert result.exit_code != 0
|
||||
assert "out of range" in result.output
|
||||
|
||||
def test_default_max_runners(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--runner-index", "1"], input='["a"]')
|
||||
assert result.exit_code == 0
|
||||
assert "a" in result.output
|
||||
|
||||
def test_github_env_not_set(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("GITHUB_ENV", raising=False)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--runner-index", "1", "--github-env"],
|
||||
input='["a"]',
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "GITHUB_ENV" in result.output
|
||||
|
||||
def test_invalid_json(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--runner-index", "1"], input="not json")
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_multiline_github_env(self, tmp_path: object, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
import pathlib
|
||||
|
||||
gh_env = pathlib.Path(str(tmp_path)) / "gh_env"
|
||||
gh_env.write_text("")
|
||||
monkeypatch.setenv("GITHUB_ENV", str(gh_env))
|
||||
runner = CliRunner()
|
||||
# Items with newlines in their IDs would trigger multiline syntax
|
||||
# Normal items don't have newlines, but test the path anyway
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["--runner-index", "1", "--max-runners", "1", "--github-env"],
|
||||
input='["a\\nb"]',
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
content = gh_env.read_text()
|
||||
# Item "a\nb" contains a newline → heredoc syntax
|
||||
assert "ASSIGNED_ITEMS<<" in content
|
||||
@@ -12,10 +12,13 @@ from devx.ci.doc_coverage import (
|
||||
main,
|
||||
)
|
||||
|
||||
# Path to devx's own source directory (for testing)
|
||||
DEVX_SRC_DIR = Path(__file__).resolve().parent.parent.parent / "src" / "devx"
|
||||
|
||||
|
||||
class TestExtractCliCommands:
|
||||
def test_extracts_commands(self) -> None:
|
||||
commands = extract_cli_commands()
|
||||
commands = extract_cli_commands(DEVX_SRC_DIR)
|
||||
# devx CLI has commands under ci, tools, and molecule groups
|
||||
assert "auto-merge" in commands
|
||||
assert "release" in commands
|
||||
@@ -24,39 +27,30 @@ class TestExtractCliCommands:
|
||||
assert "install-tools" in commands
|
||||
|
||||
def test_returns_list(self) -> None:
|
||||
commands = extract_cli_commands()
|
||||
commands = extract_cli_commands(DEVX_SRC_DIR)
|
||||
assert isinstance(commands, list)
|
||||
assert len(commands) > 0
|
||||
|
||||
def test_no_cli_file(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_no_cli_file(self, tmp_path: Path) -> None:
|
||||
"""Returns empty list when CLI file doesn't exist."""
|
||||
from devx.ci import doc_coverage
|
||||
|
||||
monkeypatch.setattr(doc_coverage, "CLI_FILE", Path("/nonexistent/cli.py"))
|
||||
commands = extract_cli_commands()
|
||||
commands = extract_cli_commands(tmp_path)
|
||||
assert commands == []
|
||||
|
||||
def test_def_fallback_no_explicit_name(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_def_fallback_no_explicit_name(self, tmp_path: Path) -> None:
|
||||
"""When a command decorator has no explicit name, falls back to the def name."""
|
||||
from devx.ci import doc_coverage
|
||||
|
||||
fake_cli = tmp_path / "cli.py"
|
||||
fake_cli.write_text("@click.group()\ndef cli():\n pass\n@cli.command()\ndef my_command():\n pass\n")
|
||||
monkeypatch.setattr(doc_coverage, "CLI_FILE", fake_cli)
|
||||
commands = extract_cli_commands()
|
||||
commands = extract_cli_commands(tmp_path)
|
||||
assert "my_command" in commands
|
||||
|
||||
def test_command_decorator_no_def_fallback(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_command_decorator_no_def_fallback(self, tmp_path: Path) -> None:
|
||||
"""When a command decorator has no name and no following def, it is skipped."""
|
||||
from devx.ci import doc_coverage
|
||||
|
||||
fake_cli = tmp_path / "cli.py"
|
||||
# The last @cli.command() has no explicit name and no def statement after it
|
||||
fake_cli.write_text(
|
||||
"@click.group()\ndef cli():\n pass\n@cli.command()\ndef real_cmd():\n pass\n@cli.command()\npass\n"
|
||||
)
|
||||
monkeypatch.setattr(doc_coverage, "CLI_FILE", fake_cli)
|
||||
commands = extract_cli_commands()
|
||||
commands = extract_cli_commands(tmp_path)
|
||||
# real_cmd should be found via def fallback; the bare @cli.command() is skipped
|
||||
assert "real_cmd" in commands
|
||||
assert "pass" not in commands
|
||||
@@ -96,19 +90,29 @@ class TestMain:
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").mkdir(parents=True)
|
||||
(docs / "tech").mkdir(parents=True)
|
||||
# Get actual commands from the CLI
|
||||
commands = extract_cli_commands()
|
||||
src = tmp_path / "src" / "devx"
|
||||
src.mkdir(parents=True)
|
||||
(src / "ci").mkdir()
|
||||
(src / "__init__.py").write_text("")
|
||||
(src / "ci" / "__init__.py").write_text("")
|
||||
# Create a fake cli.py with some commands
|
||||
(src / "cli.py").write_text(
|
||||
"@click.group()\ndef cli():\n pass\n"
|
||||
"@cli.command('release')\ndef release():\n pass\n"
|
||||
"@cli.command('setup')\ndef setup():\n pass\n"
|
||||
)
|
||||
# Create a fake module and CI script
|
||||
(src / "config.py").write_text("# config module")
|
||||
(src / "ci" / "auto_merge.py").write_text("# auto_merge script")
|
||||
# Write cli-commands.md with all commands
|
||||
cli_content = "\n".join(f"## {cmd}" for cmd in commands)
|
||||
cli_content = "## release\n\n## setup\n"
|
||||
(docs / "user" / "cli-commands.md").write_text(cli_content)
|
||||
# Write architecture.md with all modules
|
||||
from devx.ci.doc_coverage import REQUIRED_MODULES, REQUIRED_SCRIPTS
|
||||
|
||||
(docs / "tech" / "architecture.md").write_text(" ".join(REQUIRED_MODULES))
|
||||
(docs / "tech" / "architecture.md").write_text("config.py")
|
||||
# Write ci-cd-workflow.md with all scripts
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text(" ".join(REQUIRED_SCRIPTS))
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text("auto_merge.py")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src)])
|
||||
assert result.exit_code == 0
|
||||
assert "100%" in result.output
|
||||
|
||||
@@ -117,11 +121,21 @@ class TestMain:
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").mkdir(parents=True)
|
||||
(docs / "tech").mkdir(parents=True)
|
||||
src = tmp_path / "src" / "devx"
|
||||
src.mkdir(parents=True)
|
||||
(src / "ci").mkdir()
|
||||
(src / "__init__.py").write_text("")
|
||||
(src / "ci" / "__init__.py").write_text("")
|
||||
(src / "cli.py").write_text(
|
||||
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
|
||||
)
|
||||
(src / "config.py").write_text("# config")
|
||||
(src / "ci" / "auto_merge.py").write_text("# auto_merge")
|
||||
(docs / "user" / "cli-commands.md").write_text("No commands here.")
|
||||
(docs / "tech" / "architecture.md").write_text("No modules here.")
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs), "--fail-on-missing"])
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src), "--fail-on-missing"])
|
||||
assert result.exit_code == 1
|
||||
|
||||
def test_missing_docs_warn_only(self, tmp_path: Path) -> None:
|
||||
@@ -129,10 +143,55 @@ class TestMain:
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").mkdir(parents=True)
|
||||
(docs / "tech").mkdir(parents=True)
|
||||
src = tmp_path / "src" / "devx"
|
||||
src.mkdir(parents=True)
|
||||
(src / "ci").mkdir()
|
||||
(src / "__init__.py").write_text("")
|
||||
(src / "ci" / "__init__.py").write_text("")
|
||||
(src / "cli.py").write_text(
|
||||
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
|
||||
)
|
||||
(src / "config.py").write_text("# config")
|
||||
(src / "ci" / "auto_merge.py").write_text("# auto_merge")
|
||||
(docs / "user" / "cli-commands.md").write_text("No commands here.")
|
||||
(docs / "tech" / "architecture.md").write_text("No modules here.")
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text("No scripts here.")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs), "--source-dir", str(src)])
|
||||
assert result.exit_code == 0
|
||||
assert "MISSING" in result.output
|
||||
|
||||
def test_auto_detect_scripts_dir(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When src/ doesn't exist but scripts/ does, auto-detect it."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").mkdir(parents=True)
|
||||
(docs / "tech").mkdir(parents=True)
|
||||
scripts = tmp_path / "scripts"
|
||||
scripts.mkdir()
|
||||
(scripts / "cli.py").write_text(
|
||||
"@click.group()\ndef cli():\n pass\n@cli.command('release')\ndef release():\n pass\n"
|
||||
)
|
||||
(scripts / "config.py").write_text("# config")
|
||||
(docs / "user" / "cli-commands.md").write_text("## release\n")
|
||||
(docs / "tech" / "architecture.md").write_text("config.py")
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text("")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_no_source_dir_falls_back_to_required(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""When no source dir exists, falls back to REQUIRED_MODULES/SCRIPTS."""
|
||||
monkeypatch.chdir(tmp_path)
|
||||
docs = tmp_path / "docs"
|
||||
(docs / "user").mkdir(parents=True)
|
||||
(docs / "tech").mkdir(parents=True)
|
||||
(docs / "user" / "cli-commands.md").write_text("")
|
||||
from devx.ci.doc_coverage import REQUIRED_MODULES, REQUIRED_SCRIPTS
|
||||
|
||||
(docs / "tech" / "architecture.md").write_text(" ".join(REQUIRED_MODULES))
|
||||
(docs / "tech" / "ci-cd-workflow.md").write_text(" ".join(REQUIRED_SCRIPTS))
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--docs-dir", str(docs)])
|
||||
# No source dir found, so no CLI commands, but modules/scripts from REQUIRED lists
|
||||
assert result.exit_code == 0
|
||||
|
||||
@@ -7,7 +7,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from devx.gitea_cli import TeaCLI, TeaCLIError, _extract_issue_number, _extract_pr_number
|
||||
from devx.gitea_cli import TeaCLI, TeaCLIError, _extract_issue_number, _extract_pr_number, configure_tea_login
|
||||
|
||||
|
||||
class TestExtractIssueNumber:
|
||||
@@ -359,3 +359,37 @@ class TestWhoami:
|
||||
mock_result = MagicMock(returncode=0, stdout="testuser", stderr="")
|
||||
with patch("subprocess.run", return_value=mock_result):
|
||||
assert cli.whoami() == "testuser"
|
||||
|
||||
|
||||
class TestConfigureTeaLogin:
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": ""}, clear=True)
|
||||
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
|
||||
def test_no_token_skips(self, mock_which: MagicMock) -> None:
|
||||
"""configure_tea_login with no token prints skip message and returns."""
|
||||
configure_tea_login()
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.gitea_cli.shutil.which", return_value=None)
|
||||
def test_no_tea_skips(self, mock_which: MagicMock) -> None:
|
||||
"""configure_tea_login with no tea binary prints skip message and returns."""
|
||||
configure_tea_login()
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("devx.gitea_cli.subprocess.run")
|
||||
def test_configures_login_when_not_present(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None:
|
||||
"""configure_tea_login adds login when not already configured."""
|
||||
mock_list = MagicMock(returncode=0, stdout="")
|
||||
mock_subprocess.return_value = mock_list
|
||||
configure_tea_login()
|
||||
assert mock_subprocess.call_count >= 2 # login list + login add + login default
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("devx.gitea_cli.subprocess.run")
|
||||
def test_skips_when_already_configured(self, mock_subprocess: MagicMock, mock_which: MagicMock) -> None:
|
||||
"""configure_tea_login skips if login already exists."""
|
||||
mock_list = MagicMock(returncode=0, stdout="devx https://git.example.com")
|
||||
mock_subprocess.return_value = mock_list
|
||||
configure_tea_login()
|
||||
assert mock_subprocess.call_count == 1 # only login list, no add
|
||||
|
||||
@@ -8,21 +8,22 @@ import pytest
|
||||
from click import ClickException
|
||||
|
||||
import devx.tools.install_checkmake as install_checkmake
|
||||
from devx.tools._shared import arch_string
|
||||
|
||||
|
||||
class TestArch:
|
||||
class TestArchString:
|
||||
def test_amd64(self) -> None:
|
||||
with patch.object(platform, "machine", return_value="x86_64"):
|
||||
assert install_checkmake._arch() == "amd64"
|
||||
assert arch_string() == "amd64"
|
||||
|
||||
def test_arm64(self) -> None:
|
||||
with patch.object(platform, "machine", return_value="aarch64"):
|
||||
assert install_checkmake._arch() == "arm64"
|
||||
assert arch_string() == "arm64"
|
||||
|
||||
def test_unsupported(self) -> None:
|
||||
with patch.object(platform, "machine", return_value="riscv64"):
|
||||
with pytest.raises(ClickException):
|
||||
install_checkmake._arch()
|
||||
arch_string()
|
||||
|
||||
|
||||
class TestInstallWithGo:
|
||||
@@ -63,13 +64,19 @@ class TestDownloadBinary:
|
||||
|
||||
class TestMain:
|
||||
def test_already_installed(self) -> None:
|
||||
from click.testing import CliRunner
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/checkmake"):
|
||||
install_checkmake.main()
|
||||
runner = CliRunner()
|
||||
runner.invoke(install_checkmake.cli, [])
|
||||
|
||||
def test_install_with_go(self) -> None:
|
||||
from click.testing import CliRunner
|
||||
|
||||
with patch("shutil.which", side_effect=[None, "/usr/bin/go"]):
|
||||
with patch("subprocess.run") as mock_run:
|
||||
install_checkmake.main()
|
||||
runner = CliRunner()
|
||||
runner.invoke(install_checkmake.cli, [])
|
||||
mock_run.assert_called_once_with(
|
||||
[
|
||||
"/usr/bin/go",
|
||||
@@ -80,6 +87,8 @@ class TestMain:
|
||||
)
|
||||
|
||||
def test_download_when_no_go(self, tmp_path: Path) -> None:
|
||||
from click.testing import CliRunner
|
||||
|
||||
target = tmp_path / "checkmake"
|
||||
|
||||
def _write_file(url: str, path: str) -> tuple[str, None]:
|
||||
@@ -90,5 +99,6 @@ class TestMain:
|
||||
with patch("shutil.which", side_effect=[None, None]):
|
||||
with patch.object(platform, "machine", return_value="x86_64"):
|
||||
with patch("urllib.request.urlretrieve", side_effect=_write_file) as mock_retrieve:
|
||||
install_checkmake.main()
|
||||
runner = CliRunner()
|
||||
runner.invoke(install_checkmake.cli, [])
|
||||
mock_retrieve.assert_called_once()
|
||||
|
||||
@@ -222,6 +222,24 @@ class TestInstallTea:
|
||||
assert (tmp_path / "tea").exists()
|
||||
|
||||
|
||||
class TestInstallHadolint:
|
||||
def test_already_installed(self) -> None:
|
||||
with patch.object(install_tools, "_is_installed", return_value=True):
|
||||
assert install_tools.install_hadolint() is True
|
||||
|
||||
def test_install(self, tmp_path: Path) -> None:
|
||||
def _write_file(url: str, path: Path) -> tuple[str, None]:
|
||||
Path(path).write_bytes(b"binary")
|
||||
return str(path), None
|
||||
|
||||
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(install_tools, "_download", side_effect=_write_file):
|
||||
assert install_tools.install_hadolint() is True
|
||||
assert (tmp_path / "hadolint").exists()
|
||||
|
||||
|
||||
class TestListTools:
|
||||
def test_list(self, tmp_path: Path) -> None:
|
||||
with patch.object(install_tools, "TARGET_DIR", tmp_path):
|
||||
@@ -251,6 +269,11 @@ class TestInstallTool:
|
||||
assert install_tools._install_tool("tea") is True
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_hadolint(self) -> None:
|
||||
with patch.object(install_tools, "install_hadolint", return_value=True) as mock:
|
||||
assert install_tools._install_tool("hadolint") is True
|
||||
mock.assert_called_once()
|
||||
|
||||
def test_unknown_tool(self) -> None:
|
||||
with pytest.raises(ClickException, match="Unknown tool"):
|
||||
install_tools._install_tool("unknown")
|
||||
@@ -269,7 +292,7 @@ class TestMain:
|
||||
with patch.object(install_tools, "_install_tool", return_value=True) as mock_install:
|
||||
result = runner.invoke(install_tools.main, [])
|
||||
assert result.exit_code == 0
|
||||
assert mock_install.call_count == 4
|
||||
assert mock_install.call_count == 5
|
||||
|
||||
def test_install_specific_tool(self) -> None:
|
||||
runner = CliRunner()
|
||||
|
||||
@@ -101,7 +101,7 @@ class TestCli:
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_URL": "https://gitea.example",
|
||||
"REPO_TOKEN": "token",
|
||||
"CI_GITEA_TOKEN": "token",
|
||||
"RUN_ID": "123",
|
||||
"JOB_NAME": "integration-tests",
|
||||
"MATRIX_INDEX": "0",
|
||||
@@ -148,7 +148,7 @@ class TestCli:
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_URL": "https://gitea.example",
|
||||
"REPO_TOKEN": "token",
|
||||
"CI_GITEA_TOKEN": "token",
|
||||
"RUN_ID": "123",
|
||||
"JOB_NAME": "integration-tests",
|
||||
"MATRIX_INDEX": "0",
|
||||
@@ -193,7 +193,7 @@ class TestCli:
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_URL": "https://gitea.example",
|
||||
"REPO_TOKEN": "token",
|
||||
"CI_GITEA_TOKEN": "token",
|
||||
"RUN_ID": "123",
|
||||
"JOB_NAME": "integration-tests",
|
||||
"MATRIX_INDEX": "0",
|
||||
@@ -239,7 +239,7 @@ class TestCli:
|
||||
assert "without cross-runner cancellation" in result.output
|
||||
|
||||
def test_partial_env_vars_runs_without_polling(self) -> None:
|
||||
"""Only GITEA_URL set (missing REPO_TOKEN and RUN_ID) — should skip polling."""
|
||||
"""Only GITEA_URL set (missing CI_GITEA_TOKEN and RUN_ID) — should skip polling."""
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
"""Unit tests for devx.ci.lint_docs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.lint_docs import (
|
||||
check_docs_structure,
|
||||
check_duplicate_headings,
|
||||
check_heading_hierarchy,
|
||||
check_internal_links,
|
||||
check_required_files,
|
||||
check_stale_docs,
|
||||
check_todo_fixme,
|
||||
check_trailing_whitespace,
|
||||
extract_headings,
|
||||
extract_links,
|
||||
main,
|
||||
slugify,
|
||||
strip_code_blocks,
|
||||
)
|
||||
|
||||
|
||||
class TestSlugify:
|
||||
def test_basic(self) -> None:
|
||||
assert slugify("Hello World") == "hello-world"
|
||||
|
||||
def test_special_chars(self) -> None:
|
||||
assert slugify("Hello, World!") == "hello-world"
|
||||
|
||||
def test_multiple_spaces(self) -> None:
|
||||
assert slugify("Hello World") == "hello-world"
|
||||
|
||||
def test_trailing_dash(self) -> None:
|
||||
assert slugify("Hello World -") == "hello-world--"
|
||||
|
||||
def test_empty(self) -> None:
|
||||
assert slugify("") == ""
|
||||
|
||||
|
||||
class TestExtractHeadings:
|
||||
def test_extracts_headings(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "test.md"
|
||||
f.write_text("# Title\n\n## Section\n\n### Subsection\n")
|
||||
headings = extract_headings(f)
|
||||
assert "title" in headings
|
||||
assert headings["title"] == 1
|
||||
assert "section" in headings
|
||||
assert headings["section"] == 2
|
||||
assert "subsection" in headings
|
||||
assert headings["subsection"] == 3
|
||||
|
||||
def test_no_headings(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "test.md"
|
||||
f.write_text("Just some text.\nNo headings here.\n")
|
||||
headings = extract_headings(f)
|
||||
assert headings == {}
|
||||
|
||||
def test_ignores_headings_in_code_blocks(self, tmp_path: Path) -> None:
|
||||
"""Headings inside code blocks should not be detected."""
|
||||
f = tmp_path / "test.md"
|
||||
f.write_text("# Title\n\n```bash\n# Not a heading\n## Also not\n```\n\n## Real Section\n")
|
||||
headings = extract_headings(f)
|
||||
assert "title" in headings
|
||||
assert "real-section" in headings
|
||||
assert "not-a-heading" not in headings
|
||||
assert "also-not" not in headings
|
||||
|
||||
|
||||
class TestStripCodeBlocks:
|
||||
def test_strips_fenced_blocks(self) -> None:
|
||||
content = "Before\n```bash\n# comment\n```\nAfter"
|
||||
result = strip_code_blocks(content)
|
||||
assert "# comment" not in result
|
||||
assert "Before" in result
|
||||
assert "After" in result
|
||||
|
||||
def test_strips_multiple_blocks(self) -> None:
|
||||
content = "# Title\n```python\ncode1\n```\nText\n```yaml\ncode2\n```\nEnd"
|
||||
result = strip_code_blocks(content)
|
||||
assert "code1" not in result
|
||||
assert "code2" not in result
|
||||
assert "Text" in result
|
||||
assert "End" in result
|
||||
|
||||
def test_no_code_blocks(self) -> None:
|
||||
content = "# Title\n\nSome text."
|
||||
result = strip_code_blocks(content)
|
||||
assert result == content
|
||||
|
||||
def test_preserves_line_numbers(self) -> None:
|
||||
content = "Line1\n```\nLine3\n```\nLine5"
|
||||
result = strip_code_blocks(content)
|
||||
lines = result.splitlines()
|
||||
assert len(lines) == 5
|
||||
assert lines[0] == "Line1"
|
||||
assert lines[4] == "Line5"
|
||||
|
||||
|
||||
class TestExtractLinks:
|
||||
def test_extracts_internal_links(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "test.md"
|
||||
f.write_text("[link](other.md)\n[external](https://example.com)\n[anchor](#section)\n")
|
||||
links = extract_links(f)
|
||||
# Should return internal + anchor links (not http or mailto)
|
||||
assert len(links) == 2
|
||||
assert links[0][2] == "other.md"
|
||||
assert links[1][2] == "#section"
|
||||
|
||||
def test_extracts_links_with_anchors(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "test.md"
|
||||
f.write_text("[link](other.md#section)\n")
|
||||
links = extract_links(f)
|
||||
assert len(links) == 1
|
||||
assert links[0][2] == "other.md#section"
|
||||
|
||||
def test_skips_mailto(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "test.md"
|
||||
f.write_text("[email](mailto:test@example.com)\n")
|
||||
links = extract_links(f)
|
||||
assert links == []
|
||||
|
||||
|
||||
class TestCheckRequiredFiles:
|
||||
def test_all_present(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("# README")
|
||||
(tmp_path / "AGENTS.md").write_text("# AGENTS")
|
||||
(tmp_path / "CHANGELOG.md").write_text("# CHANGELOG")
|
||||
issues = check_required_files(tmp_path)
|
||||
assert issues == []
|
||||
|
||||
def test_missing_files(self, tmp_path: Path) -> None:
|
||||
issues = check_required_files(tmp_path)
|
||||
assert len(issues) == 3
|
||||
assert any("README.md" in i for i in issues)
|
||||
assert any("AGENTS.md" in i for i in issues)
|
||||
assert any("CHANGELOG.md" in i for i in issues)
|
||||
|
||||
|
||||
class TestCheckDocsStructure:
|
||||
def test_all_present(self, tmp_path: Path) -> None:
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "index.md").write_text("# Home")
|
||||
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
|
||||
issues = check_docs_structure(tmp_path, docs)
|
||||
assert issues == []
|
||||
|
||||
def test_missing_docs_dir(self, tmp_path: Path) -> None:
|
||||
issues = check_docs_structure(tmp_path, tmp_path / "docs")
|
||||
assert len(issues) == 1
|
||||
assert "Docs directory not found" in issues[0]
|
||||
|
||||
def test_missing_index(self, tmp_path: Path) -> None:
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
issues = check_docs_structure(tmp_path, docs)
|
||||
assert any("index.md" in i for i in issues)
|
||||
|
||||
def test_invalid_mapping_json(self, tmp_path: Path) -> None:
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "index.md").write_text("# Home")
|
||||
(docs / "mapping.json").write_text("{invalid json")
|
||||
issues = check_docs_structure(tmp_path, docs)
|
||||
assert any("invalid JSON" in i for i in issues)
|
||||
|
||||
def test_empty_mapping(self, tmp_path: Path) -> None:
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "index.md").write_text("# Home")
|
||||
(docs / "mapping.json").write_text("{}")
|
||||
issues = check_docs_structure(tmp_path, docs)
|
||||
assert any("empty" in i for i in issues)
|
||||
|
||||
def test_mapping_not_object(self, tmp_path: Path) -> None:
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "index.md").write_text("# Home")
|
||||
(docs / "mapping.json").write_text("[]")
|
||||
issues = check_docs_structure(tmp_path, docs)
|
||||
assert any("JSON object" in i for i in issues)
|
||||
|
||||
|
||||
class TestCheckInternalLinks:
|
||||
def test_valid_links(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("[link](docs/guide.md)\n")
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "guide.md").write_text("# Guide\n")
|
||||
issues = check_internal_links(tmp_path, docs)
|
||||
assert issues == []
|
||||
|
||||
def test_broken_file_link(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("[link](nonexistent.md)\n")
|
||||
issues = check_internal_links(tmp_path, tmp_path / "docs")
|
||||
assert len(issues) == 1
|
||||
assert "file not found" in issues[0]
|
||||
|
||||
def test_broken_anchor(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("[link](#missing-section)\n")
|
||||
issues = check_internal_links(tmp_path, tmp_path / "docs")
|
||||
assert len(issues) == 1
|
||||
assert "broken anchor" in issues[0]
|
||||
|
||||
def test_broken_anchor_in_target(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("[link](guide.md#missing)\n")
|
||||
(tmp_path / "guide.md").write_text("# Guide\n")
|
||||
issues = check_internal_links(tmp_path, tmp_path / "docs")
|
||||
assert len(issues) == 1
|
||||
assert "broken anchor" in issues[0]
|
||||
|
||||
def test_valid_anchor_in_target(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("[link](guide.md#section)\n")
|
||||
(tmp_path / "guide.md").write_text("# Section\n")
|
||||
issues = check_internal_links(tmp_path, tmp_path / "docs")
|
||||
assert issues == []
|
||||
|
||||
def test_wiki_page_link_skipped(self, tmp_path: Path) -> None:
|
||||
"""Links matching wiki page names in mapping.json should be skipped."""
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "index.md").write_text("[Architecture](Architecture)\n")
|
||||
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home", "tech/architecture.md": "Architecture"}))
|
||||
issues = check_internal_links(tmp_path, docs)
|
||||
assert issues == []
|
||||
|
||||
def test_non_wiki_page_no_extension_skipped(self, tmp_path: Path) -> None:
|
||||
"""Links without file extension and no slash should be skipped (can't verify)."""
|
||||
(tmp_path / "README.md").write_text("[SomePage](SomePage)\n")
|
||||
issues = check_internal_links(tmp_path, tmp_path / "docs")
|
||||
assert issues == []
|
||||
|
||||
def test_broken_anchor_in_target_with_content(self, tmp_path: Path) -> None:
|
||||
"""Broken anchor in an existing target file should be flagged."""
|
||||
(tmp_path / "README.md").write_text("[link](guide.md#missing)\n")
|
||||
(tmp_path / "guide.md").write_text("# Real Title\n\nSome content here.\n")
|
||||
issues = check_internal_links(tmp_path, tmp_path / "docs")
|
||||
assert len(issues) == 1
|
||||
assert "broken anchor" in issues[0]
|
||||
|
||||
def test_valid_anchor_in_target_with_content(self, tmp_path: Path) -> None:
|
||||
"""Valid anchor in an existing target file should pass."""
|
||||
(tmp_path / "README.md").write_text("[link](guide.md#real-title)\n")
|
||||
(tmp_path / "guide.md").write_text("# Real Title\n\nSome content.\n")
|
||||
issues = check_internal_links(tmp_path, tmp_path / "docs")
|
||||
assert issues == []
|
||||
|
||||
def test_invalid_mapping_json_ignored(self, tmp_path: Path) -> None:
|
||||
"""Invalid mapping.json should not crash link checking."""
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "index.md").write_text("[link](guide.md)\n")
|
||||
(docs / "guide.md").write_text("# Guide\n")
|
||||
(docs / "mapping.json").write_text("{invalid json")
|
||||
issues = check_internal_links(tmp_path, docs)
|
||||
# Should still work — just without wiki page mappings
|
||||
assert issues == []
|
||||
|
||||
|
||||
class TestCheckHeadingHierarchy:
|
||||
def test_valid_hierarchy(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("# Title\n## Section\n### Sub\n")
|
||||
issues = check_heading_hierarchy(tmp_path)
|
||||
assert issues == []
|
||||
|
||||
def test_skipped_level(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("# Title\n### Sub\n")
|
||||
issues = check_heading_hierarchy(tmp_path)
|
||||
assert len(issues) == 1
|
||||
assert "hierarchy skip" in issues[0]
|
||||
|
||||
|
||||
class TestCheckTodoFixme:
|
||||
def test_no_todo(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("Just some text.\n")
|
||||
issues = check_todo_fixme(tmp_path)
|
||||
assert issues == []
|
||||
|
||||
def test_found_todo(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("TODO: fix this later\n")
|
||||
issues = check_todo_fixme(tmp_path)
|
||||
assert len(issues) == 1
|
||||
assert "TODO" in issues[0]
|
||||
|
||||
def test_found_fixme(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("FIXME: broken code\n")
|
||||
issues = check_todo_fixme(tmp_path)
|
||||
assert len(issues) == 1
|
||||
assert "FIXME" in issues[0]
|
||||
|
||||
def test_ignores_todo_in_rules(self, tmp_path: Path) -> None:
|
||||
"""References to 'TODO' in rules docs should not be flagged."""
|
||||
(tmp_path / "README.md").write_text("Best practices (no `print()`, no `TODO`/`FIXME`)\n")
|
||||
issues = check_todo_fixme(tmp_path)
|
||||
assert issues == []
|
||||
|
||||
def test_ignores_todo_without_colon(self, tmp_path: Path) -> None:
|
||||
"""'TODO' without a colon should not be flagged."""
|
||||
(tmp_path / "README.md").write_text("The TODO list is empty\n")
|
||||
issues = check_todo_fixme(tmp_path)
|
||||
assert issues == []
|
||||
|
||||
|
||||
class TestCheckTrailingWhitespace:
|
||||
def test_no_trailing(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("No trailing whitespace here\n")
|
||||
issues = check_trailing_whitespace(tmp_path)
|
||||
assert issues == []
|
||||
|
||||
def test_trailing_spaces(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("Trailing spaces \n")
|
||||
issues = check_trailing_whitespace(tmp_path)
|
||||
assert len(issues) == 1
|
||||
assert "trailing whitespace" in issues[0]
|
||||
|
||||
def test_trailing_tabs(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("Trailing tabs\t\n")
|
||||
issues = check_trailing_whitespace(tmp_path)
|
||||
assert len(issues) == 1
|
||||
|
||||
|
||||
class TestCheckStaleDocs:
|
||||
def test_fresh_doc(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("Fresh content\n")
|
||||
issues = check_stale_docs(tmp_path)
|
||||
assert issues == []
|
||||
|
||||
def test_stale_doc(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "README.md"
|
||||
f.write_text("Old content\n")
|
||||
# Set mtime to 200 days ago
|
||||
old_time = (datetime.now() - timedelta(days=200)).timestamp()
|
||||
import os
|
||||
|
||||
os.utime(f, (old_time, old_time))
|
||||
issues = check_stale_docs(tmp_path)
|
||||
assert len(issues) == 1
|
||||
assert "stale" in issues[0]
|
||||
|
||||
|
||||
class TestCheckDuplicateHeadings:
|
||||
def test_no_duplicates(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("# Title\n## Section\n")
|
||||
issues = check_duplicate_headings(tmp_path)
|
||||
assert issues == []
|
||||
|
||||
def test_duplicates(self, tmp_path: Path) -> None:
|
||||
(tmp_path / "README.md").write_text("# Title\n# Title\n")
|
||||
issues = check_duplicate_headings(tmp_path)
|
||||
assert len(issues) == 1
|
||||
assert "duplicate heading" in issues[0]
|
||||
|
||||
def test_changelog_excluded(self, tmp_path: Path) -> None:
|
||||
"""CHANGELOG.md should be excluded from duplicate heading checks."""
|
||||
(tmp_path / "CHANGELOG.md").write_text("# Features\n# Features\n# Features\n")
|
||||
issues = check_duplicate_headings(tmp_path)
|
||||
assert issues == []
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_passes_clean_repo(self, tmp_path: Path) -> None:
|
||||
"""A clean repo with all files should pass."""
|
||||
(tmp_path / "README.md").write_text("# Title\n\nContent here.\n")
|
||||
(tmp_path / "AGENTS.md").write_text("# AGENTS\n\nContent here.\n")
|
||||
(tmp_path / "CHANGELOG.md").write_text("# Changelog\n\nContent here.\n")
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "index.md").write_text("# Home\n")
|
||||
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--root", str(tmp_path)])
|
||||
assert result.exit_code == 0
|
||||
assert "PASS" in result.output
|
||||
|
||||
def test_fails_on_missing_files(self, tmp_path: Path) -> None:
|
||||
"""Missing required files should fail."""
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--root", str(tmp_path)])
|
||||
assert result.exit_code == 1
|
||||
assert "FAIL" in result.output
|
||||
|
||||
def test_fix_trailing_whitespace(self, tmp_path: Path) -> None:
|
||||
"""--fix should auto-fix trailing whitespace."""
|
||||
(tmp_path / "README.md").write_text("# Title\n\nContent here. \n")
|
||||
(tmp_path / "AGENTS.md").write_text("# AGENTS\n\nContent here.\n")
|
||||
(tmp_path / "CHANGELOG.md").write_text("# Changelog\n\nContent here.\n")
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "index.md").write_text("# Home\n")
|
||||
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--root", str(tmp_path), "--fix"])
|
||||
assert result.exit_code == 0
|
||||
# Verify whitespace was fixed
|
||||
content = (tmp_path / "README.md").read_text()
|
||||
assert "Content here. \n" not in content
|
||||
assert "Content here.\n" in content
|
||||
|
||||
def test_no_check_links(self, tmp_path: Path) -> None:
|
||||
"""--no-check-links should skip link checking."""
|
||||
(tmp_path / "README.md").write_text("# Title\n[broken](nonexistent.md)\n")
|
||||
(tmp_path / "AGENTS.md").write_text("# AGENTS\n")
|
||||
(tmp_path / "CHANGELOG.md").write_text("# Changelog\n")
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "index.md").write_text("# Home\n")
|
||||
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--root", str(tmp_path), "--no-check-links"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_stale_docs_warning(self, tmp_path: Path) -> None:
|
||||
"""--check-stale should warn but not fail."""
|
||||
(tmp_path / "README.md").write_text("# Title\n")
|
||||
(tmp_path / "AGENTS.md").write_text("# AGENTS\n")
|
||||
(tmp_path / "CHANGELOG.md").write_text("# Changelog\n")
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "index.md").write_text("# Home\n")
|
||||
(docs / "mapping.json").write_text(json.dumps({"index.md": "Home"}))
|
||||
# Make README stale
|
||||
import os
|
||||
|
||||
f = tmp_path / "README.md"
|
||||
old_time = (datetime.now() - timedelta(days=200)).timestamp()
|
||||
os.utime(f, (old_time, old_time))
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(main, ["--root", str(tmp_path), "--check-stale"])
|
||||
# Stale docs are warnings, not errors
|
||||
assert result.exit_code == 0
|
||||
assert "stale" in result.output
|
||||
@@ -220,7 +220,7 @@ class TestCli:
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_URL": "https://gitea.example",
|
||||
"REPO_TOKEN": "token",
|
||||
"CI_GITEA_TOKEN": "token",
|
||||
"RUN_ID": "123",
|
||||
"JOB_NAME": "molecule-tests",
|
||||
"MATRIX_INDEX": "0",
|
||||
@@ -288,7 +288,7 @@ class TestCli:
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_URL": "https://gitea.example",
|
||||
"REPO_TOKEN": "token",
|
||||
"CI_GITEA_TOKEN": "token",
|
||||
"RUN_ID": "123",
|
||||
"JOB_NAME": "molecule-tests",
|
||||
"MATRIX_INDEX": "0",
|
||||
@@ -325,7 +325,7 @@ class TestCli:
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_URL": "https://gitea.example",
|
||||
"REPO_TOKEN": "token",
|
||||
"CI_GITEA_TOKEN": "token",
|
||||
"RUN_ID": "123",
|
||||
"JOB_NAME": "molecule-tests",
|
||||
"MATRIX_INDEX": "0",
|
||||
@@ -371,7 +371,7 @@ class TestCli:
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_URL": "https://gitea.example",
|
||||
"REPO_TOKEN": "token",
|
||||
"CI_GITEA_TOKEN": "token",
|
||||
"RUN_ID": "123",
|
||||
"JOB_NAME": "molecule-tests",
|
||||
"MATRIX_INDEX": "0",
|
||||
@@ -418,7 +418,7 @@ class TestCli:
|
||||
os.environ,
|
||||
{
|
||||
"GITEA_URL": "https://gitea.example",
|
||||
"REPO_TOKEN": "token",
|
||||
"CI_GITEA_TOKEN": "token",
|
||||
"RUN_ID": "123",
|
||||
"JOB_NAME": "molecule-tests",
|
||||
"MATRIX_INDEX": "0",
|
||||
|
||||
@@ -4,12 +4,12 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.ci.notify_failure import _configure_tea_login, main
|
||||
from devx.gitea_cli import TeaCLIError
|
||||
from devx.ci.notify_failure import main
|
||||
from devx.gitea_cli import TeaCLIError, configure_tea_login
|
||||
|
||||
|
||||
class TestNotifyFailure:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_creates_issue_with_tea(self, mock_tea_cls: MagicMock) -> None:
|
||||
mock_tea = MagicMock()
|
||||
@@ -36,7 +36,7 @@ class TestNotifyFailure:
|
||||
mock_tea.create_issue.assert_called_once()
|
||||
mock_tea.add_label.assert_called_once_with("owner/repo", 42, ["bug"])
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_tea_creates_issue_without_bug_label(self, mock_tea_cls: MagicMock) -> None:
|
||||
mock_tea = MagicMock()
|
||||
@@ -53,7 +53,7 @@ class TestNotifyFailure:
|
||||
assert "issue #43" in result.output
|
||||
mock_tea.add_label.assert_not_called()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_tea_error_raises(self, mock_tea_cls: MagicMock) -> None:
|
||||
"""When tea fails, the workflow fails — no fallback."""
|
||||
@@ -70,7 +70,7 @@ class TestNotifyFailure:
|
||||
assert result.exit_code != 0
|
||||
assert "tea" in result.output.lower()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_tea_list_labels_error_continues_without_labels(self, mock_tea_cls: MagicMock) -> None:
|
||||
"""If listing labels fails via tea, issue is still created without labels."""
|
||||
@@ -87,7 +87,7 @@ class TestNotifyFailure:
|
||||
assert result.exit_code == 0
|
||||
assert "issue #50" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_tea_add_label_error_is_ignored(self, mock_tea_cls: MagicMock) -> None:
|
||||
"""If adding label fails via tea, issue is still reported as created."""
|
||||
@@ -105,7 +105,7 @@ class TestNotifyFailure:
|
||||
assert result.exit_code == 0
|
||||
assert "issue #51" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": ""}, clear=True)
|
||||
def test_missing_token_exits(self) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
@@ -113,10 +113,10 @@ class TestNotifyFailure:
|
||||
["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "REPO_TOKEN" in result.output
|
||||
assert "CI_GITEA_TOKEN" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("devx.ci.notify_failure.shutil.which", return_value=None)
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.gitea_cli.shutil.which", return_value=None)
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_auto_login_no_tea_skips(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None:
|
||||
"""--auto-login with tea not installed skips login and still creates issue."""
|
||||
@@ -133,11 +133,11 @@ class TestNotifyFailure:
|
||||
assert result.exit_code == 0
|
||||
assert "issue #60" in result.output
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
|
||||
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": ""}, clear=True)
|
||||
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_auto_login_no_token_skips_login(self, mock_tea_cls: MagicMock, mock_which: MagicMock) -> None:
|
||||
"""--auto-login with no REPO_TOKEN skips login but raises before creating issue."""
|
||||
"""--auto-login with no CI_GITEA_TOKEN skips login but raises before creating issue."""
|
||||
mock_tea = MagicMock()
|
||||
mock_tea_cls.return_value = mock_tea
|
||||
|
||||
@@ -147,25 +147,25 @@ class TestNotifyFailure:
|
||||
["--repo", "owner/repo", "--run-id", "1", "--workflow", "release", "--commit", "abc", "--auto-login"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "REPO_TOKEN" in result.output
|
||||
assert "CI_GITEA_TOKEN" in result.output
|
||||
|
||||
|
||||
class TestConfigureTeaLogin:
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": ""}, clear=True)
|
||||
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": ""}, clear=True)
|
||||
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
|
||||
def test_no_token_skips(self, mock_which: MagicMock) -> None:
|
||||
"""_configure_tea_login with no token prints skip message and returns."""
|
||||
_configure_tea_login()
|
||||
"""configure_tea_login with no token prints skip message and returns."""
|
||||
configure_tea_login()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("devx.ci.notify_failure.shutil.which", return_value=None)
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.gitea_cli.shutil.which", return_value=None)
|
||||
def test_no_tea_skips(self, mock_which: MagicMock) -> None:
|
||||
"""_configure_tea_login with no tea binary prints skip message and returns."""
|
||||
_configure_tea_login()
|
||||
"""configure_tea_login with no tea binary prints skip message and returns."""
|
||||
configure_tea_login()
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("devx.ci.notify_failure.subprocess.run")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("devx.gitea_cli.subprocess.run")
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_auto_login_configures_tea(
|
||||
self, mock_tea_cls: MagicMock, mock_subprocess: MagicMock, mock_which: MagicMock
|
||||
@@ -191,9 +191,9 @@ class TestConfigureTeaLogin:
|
||||
# tea login add was called
|
||||
assert mock_subprocess.call_count >= 2
|
||||
|
||||
@patch.dict("os.environ", {"REPO_TOKEN": "tok"})
|
||||
@patch("devx.ci.notify_failure.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("devx.ci.notify_failure.subprocess.run")
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"})
|
||||
@patch("devx.gitea_cli.shutil.which", return_value="/usr/bin/tea")
|
||||
@patch("devx.gitea_cli.subprocess.run")
|
||||
@patch("devx.ci.notify_failure.TeaCLI")
|
||||
def test_auto_login_skips_if_already_configured(
|
||||
self, mock_tea_cls: MagicMock, mock_subprocess: MagicMock, mock_which: MagicMock
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Unit tests for devx.tools.pr_label."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.pr_label import cli
|
||||
|
||||
|
||||
class TestCli:
|
||||
def test_no_token_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.delenv("CI_GITEA_TOKEN", raising=False)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge"])
|
||||
assert result.exit_code != 0
|
||||
assert "CI_GITEA_TOKEN" in result.output
|
||||
|
||||
@patch("devx.tools.pr_label.REPO_OWNER", "")
|
||||
def test_no_owner_raises(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge"])
|
||||
assert result.exit_code != 0
|
||||
assert "owner" in result.output.lower()
|
||||
|
||||
@patch("devx.tools.pr_label.GiteaClient")
|
||||
def test_adds_new_label(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr_label_names.return_value = []
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge"])
|
||||
assert result.exit_code == 0
|
||||
client.add_pr_label.assert_called_once_with(42, ["ready-to-merge"])
|
||||
assert "Added label" in result.output
|
||||
|
||||
@patch("devx.tools.pr_label.GiteaClient")
|
||||
def test_skips_existing_label(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr_label_names.return_value = ["ready-to-merge"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge"])
|
||||
assert result.exit_code == 0
|
||||
client.add_pr_label.assert_not_called()
|
||||
assert "already" in result.output
|
||||
|
||||
@patch("devx.tools.pr_label.GiteaClient")
|
||||
def test_mixed_new_and_existing(self, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
client = mock_client_cls.return_value
|
||||
client.get_pr_label_names.return_value = ["reviewed"]
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--pr", "42", "--label", "ready-to-merge", "--label", "reviewed"])
|
||||
assert result.exit_code == 0
|
||||
client.add_pr_label.assert_called_once_with(42, ["ready-to-merge"])
|
||||
assert "Added label" in result.output
|
||||
assert "already" in result.output
|
||||
|
||||
@patch("devx.tools.pr_label.GiteaClient")
|
||||
@patch("devx.tools.pr_status.subprocess.run")
|
||||
def test_auto_detect_pr(
|
||||
self, mock_subprocess: MagicMock, mock_client_cls: MagicMock, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setenv("CI_GITEA_TOKEN", "tok")
|
||||
monkeypatch.setenv("DEVX_REPO_OWNER", "owner")
|
||||
monkeypatch.setenv("DEVX_REPO_NAME", "repo")
|
||||
mock_subprocess.return_value = MagicMock(returncode=0, stdout="feature-branch\n")
|
||||
client = mock_client_cls.return_value
|
||||
client.list_prs.return_value = [{"number": 42, "head": {"ref": "feature-branch"}}]
|
||||
client.get_pr_label_names.return_value = []
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["--label", "ready-to-merge"])
|
||||
assert result.exit_code == 0
|
||||
client.add_pr_label.assert_called_once_with(42, ["ready-to-merge"])
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user