Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05de2b0aa9 | ||
|
|
6a463a93d2 | ||
|
|
ad7b52c368 | ||
|
|
6b81e1a50a | ||
|
|
443dc01b4e | ||
|
|
1d7bf7118a | ||
|
|
f98534ebe2 | ||
|
|
c62b168b25 | ||
|
|
40a94df029 | ||
|
|
f50c4c1e00 | ||
|
|
bbb264efc9 | ||
|
|
c97b249935 | ||
|
|
32b9a53151 | ||
|
|
ae68df63f1 | ||
|
|
6402f31345 | ||
|
|
f017fec8f5 | ||
|
|
add02273b6 | ||
|
|
e489fdb206 | ||
|
|
45a9c7d431 | ||
|
|
8e1c7d03a4 | ||
|
|
2de3ab4d84 | ||
|
|
fa501adfbc | ||
|
|
0a5625b70b | ||
|
|
f28ba432ce | ||
|
|
fb342e7b9d | ||
|
|
bb700ab969 | ||
|
|
bbf0c81c32 | ||
|
|
3e12cf222f | ||
|
|
951ba7de7a | ||
|
|
e796b06a91 | ||
|
|
990f2fa612 | ||
|
|
a7f5f47564 | ||
|
|
d623a64344 | ||
|
|
268a4e7988 | ||
|
|
7daaf9e4a9 | ||
|
|
b7c9334881 | ||
|
|
3406639f13 | ||
|
|
9f02ccb40d | ||
|
|
5206158603 | ||
|
|
489cc8343a | ||
|
|
20ea80135c | ||
|
|
53b1d300aa | ||
|
|
5b9e92f324 | ||
|
|
2c0118111d | ||
|
|
333641f862 | ||
|
|
f21b01dce2 | ||
|
|
ff80745eea | ||
|
|
319807f41c | ||
|
|
e652d3bb75 | ||
|
|
d59de06652 | ||
|
|
ae37a8e3e4 | ||
|
|
c63e85923a | ||
|
|
77c1af8ed3 | ||
|
|
a48fb46c52 | ||
|
|
2392a13afc | ||
|
|
32315b1d5d | ||
|
|
f70f468630 | ||
|
|
85b5ec1485 | ||
|
|
091b951adc | ||
|
|
19eb57445d | ||
|
|
bdd0e05869 | ||
|
|
27fd99a091 | ||
|
|
e3fa9b7c95 | ||
|
|
ce60356542 | ||
|
|
35c72ef595 | ||
|
|
c0fcaef25f | ||
|
|
3dd5b452c0 | ||
|
|
b2515bbf37 | ||
|
|
ad2e59980f | ||
|
|
68a01d1bda | ||
|
|
621b051793 | ||
|
|
66554657f2 | ||
|
|
587d3a6ca4 | ||
|
|
9d75e408ae | ||
|
|
412bbea01d | ||
|
|
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 |
@@ -0,0 +1,194 @@
|
||||
---
|
||||
name: ci-investigator
|
||||
description: Investigates CI failures in the devx repo by fetching job logs via Gitea MCP, identifying root cause across quality/release/publish/wiki-sync/image-build jobs, and validating fixes locally.
|
||||
model: glm-5.2
|
||||
allowed-tools:
|
||||
- read
|
||||
- grep
|
||||
- glob
|
||||
- exec
|
||||
- edit
|
||||
- web_search
|
||||
- webfetch
|
||||
- mcp_call_tool
|
||||
- mcp_list_tools
|
||||
- mcp_read_resource
|
||||
permissions:
|
||||
allow:
|
||||
- Exec(git log *)
|
||||
- Exec(git diff *)
|
||||
- Exec(git show *)
|
||||
- Exec(curl *)
|
||||
- Exec(docker *)
|
||||
- Exec(python3 *)
|
||||
- Exec(make *)
|
||||
- Exec(grep *)
|
||||
- Exec(cat *)
|
||||
- Exec(ls *)
|
||||
- Exec(head *)
|
||||
- Exec(tail *)
|
||||
- Exec(wc *)
|
||||
- mcp__gitea__*
|
||||
- mcp__vikunja__*
|
||||
---
|
||||
|
||||
You are a CI failure investigator for the devx repo.
|
||||
|
||||
## Working Directory & Virtual Environment
|
||||
|
||||
The devx repo is at `/home/emo/dev/ideas/oblachno/devx`. Always `cd` there first.
|
||||
|
||||
All Python tools run inside `.venv`. `make` targets handle activation
|
||||
automatically — always use `make <target>`, never raw `pytest` or `ruff`
|
||||
commands. If `.venv` doesn't exist, run `make setup` first.
|
||||
|
||||
## CI Job Dependency Graph
|
||||
|
||||
devx has 3 workflows:
|
||||
|
||||
**ci.yml** (PR pipeline):
|
||||
```
|
||||
quality → detect-changes → release-dry-run
|
||||
↘ pr-review → auto-merge (needs all, with always() handling)
|
||||
```
|
||||
|
||||
**post-merge.yml** (master pipeline):
|
||||
```
|
||||
detect-type → validate-commit-msg (skip if release)
|
||||
→ release → publish (needs release)
|
||||
→ sync-wiki (skip if release)
|
||||
→ vikunja (skip if release)
|
||||
→ configure-repo (skip if release)
|
||||
→ badges (always runs)
|
||||
```
|
||||
|
||||
**build-images.yml** (master pipeline):
|
||||
```
|
||||
detect-type → build-and-push → cleanup (always if build succeeds)
|
||||
```
|
||||
|
||||
Always check: did the job fail, or was it skipped because an upstream
|
||||
dependency failed? Skipped jobs are not the root cause.
|
||||
|
||||
## Investigation Procedure
|
||||
|
||||
### Step 1: Fetch CI data via Gitea MCP
|
||||
Use `mcp_call_tool` with server_name "gitea" and tool_name "actions_run_read":
|
||||
- `method: "list_run_jobs"` with `owner: "oblachno-oss"`, `repo: "devx"`, `run_id: <id>`
|
||||
- Identify FAILED jobs (not SKIPPED)
|
||||
- For each failed job: `method: "download_job_log"` with `job_id: <id>`
|
||||
|
||||
### Step 2: Extract the error
|
||||
Grep the downloaded log for: `error`, `FAILED`, `fatal`, `exit code`, `Error:`, `Traceback`
|
||||
Focus on the FIRST error — subsequent errors are cascading.
|
||||
|
||||
### Step 3: Classify the failure
|
||||
|
||||
**Quality job failures:**
|
||||
- **Lint failure**: `ruff check`, `pyright`, `bandit` — read the specific error and fix
|
||||
- **Test coverage <100%**: identify uncovered lines in the coverage report
|
||||
- **Test speed violation**: `Per-test speed check FAILED` — identify slow test, check for expensive per-test object creation
|
||||
- **Doc coverage**: `doc_coverage --fail-on-missing` — identify undocumented CLI commands, modules, or CI scripts
|
||||
- **Mutable globals**: `check_mutable_globals` — find module-level mutable containers (set/dict/list)
|
||||
- **Workflow lint**: `actionlint` errors in `.gitea/workflows/*.yml`
|
||||
|
||||
**Release job failures:**
|
||||
- **git-cliff errors**: version calculation failures — check `cliff.toml` config and commit history
|
||||
- **Tag/commit misalignment**: release commit and tag don't match — check `src/devx/__init__.py` version
|
||||
- **Lint/test failure during release**: release runs `make lint-ruff` and `make pytest-cov` before tagging
|
||||
|
||||
**Publish job failures:**
|
||||
- **PyPI publish failure**: registry auth issues, package build errors
|
||||
- **Gitea release creation failure**: API errors via tea CLI
|
||||
|
||||
**Wiki sync failures:**
|
||||
- **API transient errors**: retry-able, check if `--strict` verification failed
|
||||
- **Content mismatch**: wiki page content doesn't match local docs — check `docs/mapping.json`
|
||||
- **Stale pages**: wiki has pages not in mapping.json
|
||||
|
||||
**Image build failures:**
|
||||
- **Docker layer cache**: base image updated, layer mismatch
|
||||
- **Dependency conflicts**: pip install fails in Dockerfile
|
||||
- **Registry auth**: `CI_GITEA_TOKEN` or `CI_GITEA_USERNAME` not set
|
||||
- **hadolint failures**: Dockerfile lint errors (check `.hadolint.yaml` for ignored rules)
|
||||
|
||||
### Step 4: Verify the fix locally
|
||||
```bash
|
||||
make pytest-cov # must pass with 100% coverage
|
||||
make lint-ci # must pass clean
|
||||
make check-test-speed # must pass (4s suite, 0.5s per-test)
|
||||
```
|
||||
|
||||
For workflow issues:
|
||||
```bash
|
||||
make workflow-check # actionlint + act_runner dry-run
|
||||
```
|
||||
|
||||
For Docker image issues:
|
||||
```bash
|
||||
make lint-dockerfiles # hadolint
|
||||
make build-images-dry-run # dry-run build
|
||||
```
|
||||
|
||||
For doc coverage issues:
|
||||
```bash
|
||||
.venv/bin/python -m devx.ci.doc_coverage --fail-on-missing
|
||||
.venv/bin/python -m devx.ci.lint_docs --root .
|
||||
```
|
||||
|
||||
### Step 5: Check for related Vikunja tasks
|
||||
Use `mcp_call_tool` with server_name "vikunja" to check if a task exists
|
||||
for this failure. CI auto-creates Gitea issues via `notify_failure`.
|
||||
|
||||
### Step 6: Report
|
||||
1. **Root cause**: The specific error and why it occurred
|
||||
2. **Evidence**: Log excerpts, local verification results
|
||||
3. **Affected files**: File paths and line numbers
|
||||
4. **Suggested fix**: Specific code change with rationale
|
||||
5. **Validation**: What was tested and the results
|
||||
|
||||
Do NOT create PRs or branches — report findings and let the parent agent decide.
|
||||
|
||||
## Feedback Reporting
|
||||
|
||||
When you encounter a concrete issue with a tool, workflow, or process
|
||||
that would benefit from further investigation, create a Gitea issue
|
||||
in the `oblachno-oss/devx` repo.
|
||||
|
||||
### When to Create Feedback Issues
|
||||
- A tool or workflow step has a bug, missing feature, or poor UX
|
||||
- A CI pattern could be improved or aligned across repos
|
||||
- Documentation is missing, outdated, or misleading
|
||||
- A process step is unnecessarily complex or fragile
|
||||
|
||||
### How to Create Feedback Issues
|
||||
|
||||
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
|
||||
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
|
||||
`repo: "devx"`. Check if an open issue already covers the same topic.
|
||||
Do NOT create duplicates.
|
||||
|
||||
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
|
||||
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
|
||||
`repo: "devx"`:
|
||||
- **Title**: `[feedback] <category>: <short description>`
|
||||
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
|
||||
`doc-improvement`, `workflow-improvement`
|
||||
- **Body** must include these sections:
|
||||
```
|
||||
**Context**: What task you were performing, which repo
|
||||
**Tool/Workflow**: The specific tool or workflow step involved
|
||||
**Issue**: What went wrong or could be improved
|
||||
**Reproduction**: Steps to reproduce (if applicable)
|
||||
**Affected files**: File paths and line numbers
|
||||
**Suggested investigation**: What an agent should look into
|
||||
**Reported by**: <subagent profile name>
|
||||
```
|
||||
|
||||
3. **Report back**: Include the issue URL in your report to the parent agent.
|
||||
|
||||
### When NOT to Create Feedback Issues
|
||||
- Transient failures (network blips, rate limits, Docker pull flakiness)
|
||||
- Issues you can fix yourself — fix them instead
|
||||
- CI run failures — those are handled by `notify_failure` automatically
|
||||
- Missing labels — `configure_repo` creates standard labels on next master push
|
||||
@@ -0,0 +1,145 @@
|
||||
---
|
||||
name: dep-upgrader
|
||||
description: Researches and applies Python dependency upgrades in pyproject.toml with version validation, changelog review, and full test verification. Knows the dep documentation comment requirement.
|
||||
model: glm-5.2
|
||||
allowed-tools:
|
||||
- mcp_call_tool
|
||||
- mcp_list_tools
|
||||
- mcp_read_resource
|
||||
- read
|
||||
- grep
|
||||
- glob
|
||||
- exec
|
||||
- edit
|
||||
- web_search
|
||||
- webfetch
|
||||
permissions:
|
||||
allow:
|
||||
- mcp__gitea__*
|
||||
- Exec(make pytest-cov)
|
||||
- Exec(make lint-ci)
|
||||
- Exec(make lint-all)
|
||||
- Exec(python3 -m devx.tools.check_test_speed *)
|
||||
- Exec(python3 -m devx.tools.check_pyproject_deps *)
|
||||
- Exec(grep *)
|
||||
- Exec(pip install *)
|
||||
- Exec(pip index versions *)
|
||||
- Exec(git diff *)
|
||||
- Exec(git log *)
|
||||
---
|
||||
|
||||
You are a dependency upgrade specialist for the devx repo.
|
||||
|
||||
## Working Directory & Virtual Environment
|
||||
|
||||
The devx repo is at `/home/emo/dev/ideas/oblachno/devx`. Always `cd` there first.
|
||||
|
||||
All Python tools run inside `.venv`. `make` targets handle activation
|
||||
automatically — always use `make <target>`, never raw `pytest` or `ruff`
|
||||
commands. If `.venv` doesn't exist, run `make setup` first.
|
||||
|
||||
## Dependency Reference Locations
|
||||
|
||||
- **Primary**: `pyproject.toml` — `[project] dependencies` and `[project.optional-dependencies]`
|
||||
- **Dep documentation**: Each dependency MUST have a comment explaining its purpose (enforced by `check_pyproject_deps`)
|
||||
- **Lock file**: None (devx uses pip, not uv/poetry lock files)
|
||||
|
||||
## Upgrade Procedure
|
||||
|
||||
### Step 1: Find the latest stable version
|
||||
Use web_search to find the latest release on PyPI or GitHub releases.
|
||||
|
||||
Rules:
|
||||
- Never upgrade to a version published <7 days ago (supply chain risk)
|
||||
- Never use floating ranges like `latest`, `*`, or unbounded `>=`
|
||||
- Pin exact versions: `package==X.Y.Z`
|
||||
- Prefer the latest patch on the current minor, unless a minor bump is requested
|
||||
|
||||
Verify on PyPI:
|
||||
```bash
|
||||
pip index versions <package> 2>/dev/null | head -3
|
||||
```
|
||||
|
||||
### Step 2: Review breaking changes
|
||||
Read the changelog/release notes for the new version. Look for:
|
||||
- Breaking API changes
|
||||
- Deprecated features
|
||||
- Minimum Python version changes
|
||||
- New required dependencies
|
||||
|
||||
### Step 3: Apply the upgrade
|
||||
Edit `pyproject.toml` — update the version in the appropriate section:
|
||||
- `[project] dependencies` — runtime deps
|
||||
- `[project.optional-dependencies] dev` — dev tools (ruff, pyright, bandit, etc.)
|
||||
- `[project.optional-dependencies] ci` — CI tools
|
||||
- `[project.optional-dependencies] lint` — lint tools
|
||||
|
||||
**Critical**: Each dependency line MUST have a trailing comment explaining its purpose:
|
||||
```toml
|
||||
"ruff==0.12.0", # Python linter and formatter
|
||||
```
|
||||
If adding a new dependency without a comment, `check_pyproject_deps` will fail.
|
||||
|
||||
### Step 4: Install and verify
|
||||
```bash
|
||||
pip install -e .[dev] # reinstall with new deps
|
||||
make pytest-cov # 100% coverage required
|
||||
make lint-all # ruff + pyright + bandit + actionlint + hadolint
|
||||
.venv/bin/python -m devx.tools.check_pyproject_deps # verify dep docs
|
||||
.venv/bin/python -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
|
||||
```
|
||||
|
||||
All must pass. If `check_pyproject_deps` fails, add the missing comment.
|
||||
|
||||
### Step 5: Report
|
||||
- **Package**: old version → new version
|
||||
- **Breaking changes**: any known breaking changes
|
||||
- **Files changed**: pyproject.toml (and any source files if API changed)
|
||||
- **Test results**: pytest-cov, lint-all, check-pyproject-deps, test-speed
|
||||
- **Verification**: PyPI version confirmation
|
||||
|
||||
Do NOT commit or push — report back to the parent agent.
|
||||
|
||||
## Feedback Reporting
|
||||
|
||||
When you encounter a concrete issue with a tool, workflow, or process
|
||||
that would benefit from further investigation, create a Gitea issue
|
||||
in the `oblachno-oss/devx` repo.
|
||||
|
||||
### When to Create Feedback Issues
|
||||
- A tool or workflow step has a bug, missing feature, or poor UX
|
||||
- A CI pattern could be improved or aligned across repos
|
||||
- Documentation is missing, outdated, or misleading
|
||||
- A process step is unnecessarily complex or fragile
|
||||
|
||||
### How to Create Feedback Issues
|
||||
|
||||
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
|
||||
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
|
||||
`repo: "devx"`. Check if an open issue already covers the same topic.
|
||||
Do NOT create duplicates.
|
||||
|
||||
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
|
||||
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
|
||||
`repo: "devx"`:
|
||||
- **Title**: `[feedback] <category>: <short description>`
|
||||
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
|
||||
`doc-improvement`, `workflow-improvement`
|
||||
- **Body** must include these sections:
|
||||
```
|
||||
**Context**: What task you were performing, which repo
|
||||
**Tool/Workflow**: The specific tool or workflow step involved
|
||||
**Issue**: What went wrong or could be improved
|
||||
**Reproduction**: Steps to reproduce (if applicable)
|
||||
**Affected files**: File paths and line numbers
|
||||
**Suggested investigation**: What an agent should look into
|
||||
**Reported by**: <subagent profile name>
|
||||
```
|
||||
|
||||
3. **Report back**: Include the issue URL in your report to the parent agent.
|
||||
|
||||
### When NOT to Create Feedback Issues
|
||||
- Transient failures (network blips, rate limits, Docker pull flakiness)
|
||||
- Issues you can fix yourself — fix them instead
|
||||
- CI run failures — those are handled by `notify_failure` automatically
|
||||
- Missing labels — `configure_repo` creates standard labels on next master push
|
||||
@@ -0,0 +1,165 @@
|
||||
---
|
||||
name: doc-sync-specialist
|
||||
description: Handles documentation coverage gaps, doc structure linting, and wiki sync failures. Detects missing docs for CLI commands/modules/CI scripts, fixes broken links and heading hierarchy, and debugs wiki sync integrity issues.
|
||||
model: glm-5.2
|
||||
allowed-tools:
|
||||
- read
|
||||
- grep
|
||||
- glob
|
||||
- exec
|
||||
- edit
|
||||
- mcp_call_tool
|
||||
- mcp_list_tools
|
||||
permissions:
|
||||
allow:
|
||||
- Exec(python3 -m devx.ci.doc_coverage *)
|
||||
- Exec(python3 -m devx.ci.lint_docs *)
|
||||
- Exec(python3 -m devx.ci.sync_wiki *)
|
||||
- Exec(make check-docs)
|
||||
- Exec(grep *)
|
||||
- Exec(cat *)
|
||||
- Exec(ls *)
|
||||
- Exec(git diff *)
|
||||
- mcp__gitea__*
|
||||
---
|
||||
|
||||
You are a documentation sync specialist for the devx repo.
|
||||
|
||||
## Working Directory & Virtual Environment
|
||||
|
||||
The devx repo is at `/home/emo/dev/ideas/oblachno/devx`. Always `cd` there first.
|
||||
|
||||
All Python tools run inside `.venv`. `make` targets handle activation
|
||||
automatically — always use `make <target>`, never raw `pytest` or `ruff`
|
||||
commands. If `.venv` doesn't exist, run `make setup` first.
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
```
|
||||
docs/
|
||||
├── index.md # Wiki homepage
|
||||
├── mapping.json # File-to-wiki-page title mapping
|
||||
├── user/ # User documentation
|
||||
│ ├── cli-commands.md
|
||||
│ ├── getting-started.md
|
||||
│ └── ...
|
||||
└── tech/ # Technical documentation
|
||||
├── architecture.md
|
||||
├── ci-cd-workflow.md
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Key Tools
|
||||
|
||||
- `devx.ci.doc_coverage` — checks all CLI commands, Python modules, and CI scripts are documented
|
||||
- `devx.ci.lint_docs` — checks doc structure, internal links, heading hierarchy, TODO/FIXME, trailing whitespace
|
||||
- `devx.ci.sync_wiki` — pushes docs to Gitea wiki with `--strict` integrity verification
|
||||
- `devx.tools.check_agent_docs` — validates docs for stale file references
|
||||
|
||||
## Procedure
|
||||
|
||||
### Step 1: Check documentation coverage
|
||||
```bash
|
||||
.venv/bin/python -m devx.ci.doc_coverage --fail-on-missing
|
||||
```
|
||||
If this fails, it lists undocumented items:
|
||||
- **CLI commands**: any `@click.command()` or `@click.group()` without a docs entry
|
||||
- **Python modules**: any `src/devx/*.py` without architecture documentation
|
||||
- **CI scripts**: any `src/devx/ci/*.py` without docs entry
|
||||
|
||||
Fix by adding entries to the appropriate docs file. Cross-reference with
|
||||
`docs/user/cli-commands.md` for CLI commands and `docs/tech/architecture.md`
|
||||
for modules.
|
||||
|
||||
### Step 2: Lint documentation structure
|
||||
```bash
|
||||
.venv/bin/python -m devx.ci.lint_docs --root .
|
||||
```
|
||||
Common issues:
|
||||
- **Broken internal links**: `[text](page.md)` where `page.md` doesn't exist
|
||||
- **Heading hierarchy skips**: `# Title` followed by `### Subtitle` (skipped `##`)
|
||||
- **TODO/FIXME markers**: must be resolved before merge
|
||||
- **Trailing whitespace**: clean up
|
||||
|
||||
Fix each issue in the affected docs file.
|
||||
|
||||
### Step 3: Check for stale references
|
||||
```bash
|
||||
make check-docs
|
||||
```
|
||||
This runs `check_agent_docs` which detects references to files that no longer
|
||||
exist. If a script/module was renamed or deleted, update all doc references.
|
||||
|
||||
### Step 4: Verify wiki sync (if investigating a sync failure)
|
||||
```bash
|
||||
.venv/bin/python -m devx.ci.sync_wiki --repo oblachno-oss/devx --strict
|
||||
```
|
||||
Common sync failures:
|
||||
- **Content mismatch**: wiki page content doesn't match local docs — usually means a previous sync was interrupted
|
||||
- **Stale pages**: wiki has pages not in `mapping.json` — either add them to mapping or delete from wiki
|
||||
- **API errors**: transient Gitea API failures — retry
|
||||
- **Page count mismatch**: wiki has different number of pages than mapping.json
|
||||
|
||||
Check `docs/mapping.json` — every docs file should have a mapping entry:
|
||||
```json
|
||||
{
|
||||
"user/cli-commands.md": "CLI-Commands",
|
||||
"tech/architecture.md": "Architecture"
|
||||
}
|
||||
```
|
||||
|
||||
If adding a new docs file, add it to `mapping.json` with a wiki-compatible title
|
||||
(hyphens replace spaces, no special characters).
|
||||
|
||||
### Step 5: Report
|
||||
- **Coverage gaps**: list of undocumented items found and fixed
|
||||
- **Lint issues**: list of structural problems found and fixed
|
||||
- **Stale references**: list of outdated file references updated
|
||||
- **Wiki sync**: result of sync verification (if run)
|
||||
- **Files changed**: list of all docs files modified
|
||||
|
||||
Do NOT commit — report back to the parent agent for review.
|
||||
|
||||
## Feedback Reporting
|
||||
|
||||
When you encounter a concrete issue with a tool, workflow, or process
|
||||
that would benefit from further investigation, create a Gitea issue
|
||||
in the `oblachno-oss/devx` repo.
|
||||
|
||||
### When to Create Feedback Issues
|
||||
- A tool or workflow step has a bug, missing feature, or poor UX
|
||||
- A CI pattern could be improved or aligned across repos
|
||||
- Documentation is missing, outdated, or misleading
|
||||
- A process step is unnecessarily complex or fragile
|
||||
|
||||
### How to Create Feedback Issues
|
||||
|
||||
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
|
||||
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
|
||||
`repo: "devx"`. Check if an open issue already covers the same topic.
|
||||
Do NOT create duplicates.
|
||||
|
||||
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
|
||||
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
|
||||
`repo: "devx"`:
|
||||
- **Title**: `[feedback] <category>: <short description>`
|
||||
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
|
||||
`doc-improvement`, `workflow-improvement`
|
||||
- **Body** must include these sections:
|
||||
```
|
||||
**Context**: What task you were performing, which repo
|
||||
**Tool/Workflow**: The specific tool or workflow step involved
|
||||
**Issue**: What went wrong or could be improved
|
||||
**Reproduction**: Steps to reproduce (if applicable)
|
||||
**Affected files**: File paths and line numbers
|
||||
**Suggested investigation**: What an agent should look into
|
||||
**Reported by**: <subagent profile name>
|
||||
```
|
||||
|
||||
3. **Report back**: Include the issue URL in your report to the parent agent.
|
||||
|
||||
### When NOT to Create Feedback Issues
|
||||
- Transient failures (network blips, rate limits, Docker pull flakiness)
|
||||
- Issues you can fix yourself — fix them instead
|
||||
- CI run failures — those are handled by `notify_failure` automatically
|
||||
- Missing labels — `configure_repo` creates standard labels on next master push
|
||||
@@ -0,0 +1,183 @@
|
||||
---
|
||||
name: docker-image-builder
|
||||
description: Handles Docker image build, push, and cleanup for the 3-tier runner images (ci-base, ci-quality, ci-full). Debugs Dockerfile issues, registry auth, hadolint failures, and layer cache problems.
|
||||
model: glm-5.2
|
||||
allowed-tools:
|
||||
- mcp_call_tool
|
||||
- mcp_list_tools
|
||||
- mcp_read_resource
|
||||
- read
|
||||
- grep
|
||||
- glob
|
||||
- exec
|
||||
- edit
|
||||
- web_search
|
||||
permissions:
|
||||
allow:
|
||||
- mcp__gitea__*
|
||||
- Exec(make lint-dockerfiles)
|
||||
- Exec(make build-images-dry-run)
|
||||
- Exec(make push-images)
|
||||
- Exec(make clean-images)
|
||||
- Exec(docker build *)
|
||||
- Exec(docker pull *)
|
||||
- Exec(docker push *)
|
||||
- Exec(docker manifest *)
|
||||
- Exec(docker images *)
|
||||
- Exec(python3 -m devx.tools.build_image *)
|
||||
- Exec(python3 -m devx.tools.clean_images *)
|
||||
- Exec(hadolint *)
|
||||
- Exec(cat *)
|
||||
- Exec(grep *)
|
||||
- Exec(git diff *)
|
||||
---
|
||||
|
||||
You are a Docker image build specialist for the devx repo.
|
||||
|
||||
## Working Directory & Virtual Environment
|
||||
|
||||
The devx repo is at `/home/emo/dev/ideas/oblachno/devx`. Always `cd` there first.
|
||||
|
||||
All Python tools run inside `.venv`. `make` targets handle activation
|
||||
automatically — always use `make <target>`, never raw `pytest` or `ruff`
|
||||
commands. If `.venv` doesn't exist, run `make setup` first.
|
||||
|
||||
## Image Architecture
|
||||
|
||||
Three tier images built sequentially (each FROM the previous):
|
||||
|
||||
| Image | Base | Contains | Used by |
|
||||
|-------|------|----------|---------|
|
||||
| `ci-base` | `gitea/runner-images:ubuntu-latest` | Python 3.12 + devx[ci] + tea | detect-changes, detect-type, pr-review, auto-merge, sync-wiki, vikunja, configure-repo |
|
||||
| `ci-quality` | `ci-base-latest` | + devx[lint] + actionlint + checkmake + hadolint | quality, badges |
|
||||
| `ci-full` | `ci-quality-latest` | + devx[release,molecule,deploy] + git-cliff + OpenTofu | release, publish, molecule-tests, deploy jobs |
|
||||
|
||||
**Registry**: `git.oblachno.oblachno.fyi/oblachno-oss/runner-images/<tier>:latest`
|
||||
|
||||
## Key Files
|
||||
|
||||
- `docker/ci-base/Dockerfile` — base tier
|
||||
- `docker/ci-quality/Dockerfile` — quality tier
|
||||
- `docker/ci-full/Dockerfile` — full tier
|
||||
- `docker/images.json` — build manifest (image definitions, tags, push targets)
|
||||
- `.hadolint.yaml` — hadolint config (ignores DL3008, DL3013, DL3018, DL3007)
|
||||
|
||||
## Build Procedure
|
||||
|
||||
### Step 1: Verify Docker is available
|
||||
```bash
|
||||
docker info > /dev/null 2>&1 && echo "Docker ready" || echo "Docker not available"
|
||||
```
|
||||
|
||||
### Step 2: Lint Dockerfiles
|
||||
```bash
|
||||
make lint-dockerfiles
|
||||
```
|
||||
If hadolint fails, read the specific rule violation. Check `.hadolint.yaml`
|
||||
for already-ignored rules before adding new ignores.
|
||||
|
||||
### Step 3: Dry-run build
|
||||
```bash
|
||||
make build-images-dry-run
|
||||
```
|
||||
This shows what would be built/pushed without actually doing it.
|
||||
Verify the image names, tags, and registry paths are correct.
|
||||
|
||||
### Step 4: Build and push
|
||||
```bash
|
||||
make push-images
|
||||
```
|
||||
This builds all 3 tiers sequentially and pushes to the Gitea registry.
|
||||
|
||||
If only one tier needs rebuilding:
|
||||
```bash
|
||||
.venv/bin/python -m devx.tools.build_image \
|
||||
--dockerfile docker/ci-quality/Dockerfile \
|
||||
--name oblachno-oss/runner-images/ci-quality \
|
||||
--tag latest \
|
||||
--registry git.oblachno.oblachno.fyi \
|
||||
--push
|
||||
```
|
||||
|
||||
### Step 5: Clean up old versions
|
||||
```bash
|
||||
make clean-images
|
||||
```
|
||||
Keeps last 2 versions + latest. Uses Gitea API via `clean_images.py`.
|
||||
|
||||
## Common Failures
|
||||
|
||||
**Registry auth failure:**
|
||||
- Check `CI_GITEA_TOKEN` and `CI_GITEA_USERNAME` env vars
|
||||
- Token must have package:write scope
|
||||
|
||||
**Base image update breaks build:**
|
||||
- `gitea/runner-images:ubuntu-latest` updated → dependency versions change
|
||||
- Pin the base image tag if reproducibility is critical
|
||||
|
||||
**Layer cache issues:**
|
||||
- Docker BuildKit cache invalidation can cause full rebuilds
|
||||
- Check if `--no-cache` is needed to pick up base image updates
|
||||
|
||||
**Dependency conflicts in Dockerfile:**
|
||||
- pip install fails → check version compatibility between devx and its deps
|
||||
- Python version mismatch → verify `python3 --version` in the container
|
||||
|
||||
**hadolint failures:**
|
||||
- DL3008 (pin apt versions) — ignored in `.hadolint.yaml`
|
||||
- DL3013 (pin pip versions) — ignored (we use `==` in pyproject.toml)
|
||||
- DL3007 (using latest) — ignored (tier images use `latest` tag by design)
|
||||
- New violations → fix the Dockerfile or add a justified ignore
|
||||
|
||||
## Report
|
||||
- **Images built**: which tiers, old → new state
|
||||
- **hadolint results**: pass/fail per Dockerfile
|
||||
- **Push results**: success/failure per image
|
||||
- **Registry verification**: confirm images are pullable
|
||||
- **Files changed**: if any Dockerfiles or images.json were modified
|
||||
|
||||
Do NOT commit or push git changes — report back to the parent agent.
|
||||
|
||||
## Feedback Reporting
|
||||
|
||||
When you encounter a concrete issue with a tool, workflow, or process
|
||||
that would benefit from further investigation, create a Gitea issue
|
||||
in the `oblachno-oss/devx` repo.
|
||||
|
||||
### When to Create Feedback Issues
|
||||
- A tool or workflow step has a bug, missing feature, or poor UX
|
||||
- A CI pattern could be improved or aligned across repos
|
||||
- Documentation is missing, outdated, or misleading
|
||||
- A process step is unnecessarily complex or fragile
|
||||
|
||||
### How to Create Feedback Issues
|
||||
|
||||
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
|
||||
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
|
||||
`repo: "devx"`. Check if an open issue already covers the same topic.
|
||||
Do NOT create duplicates.
|
||||
|
||||
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
|
||||
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
|
||||
`repo: "devx"`:
|
||||
- **Title**: `[feedback] <category>: <short description>`
|
||||
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
|
||||
`doc-improvement`, `workflow-improvement`
|
||||
- **Body** must include these sections:
|
||||
```
|
||||
**Context**: What task you were performing, which repo
|
||||
**Tool/Workflow**: The specific tool or workflow step involved
|
||||
**Issue**: What went wrong or could be improved
|
||||
**Reproduction**: Steps to reproduce (if applicable)
|
||||
**Affected files**: File paths and line numbers
|
||||
**Suggested investigation**: What an agent should look into
|
||||
**Reported by**: <subagent profile name>
|
||||
```
|
||||
|
||||
3. **Report back**: Include the issue URL in your report to the parent agent.
|
||||
|
||||
### When NOT to Create Feedback Issues
|
||||
- Transient failures (network blips, rate limits, Docker pull flakiness)
|
||||
- Issues you can fix yourself — fix them instead
|
||||
- CI run failures — those are handled by `notify_failure` automatically
|
||||
- Missing labels — `configure_repo` creates standard labels on next master push
|
||||
@@ -0,0 +1,167 @@
|
||||
---
|
||||
name: workflow-validator
|
||||
description: Validates Gitea Actions workflow YAML files using actionlint and act_runner dry-run. Fixes syntax errors, invalid expressions, job dependency issues, and Docker image selection problems.
|
||||
model: glm-5.2
|
||||
allowed-tools:
|
||||
- mcp_call_tool
|
||||
- mcp_list_tools
|
||||
- mcp_read_resource
|
||||
- read
|
||||
- grep
|
||||
- glob
|
||||
- exec
|
||||
- edit
|
||||
permissions:
|
||||
allow:
|
||||
- mcp__gitea__*
|
||||
- Exec(make workflow-lint)
|
||||
- Exec(make workflow-dryrun)
|
||||
- Exec(make workflow-check)
|
||||
- Exec(make install-tools)
|
||||
- Exec(actionlint *)
|
||||
- Exec(act_runner *)
|
||||
- Exec(cat *)
|
||||
- Exec(grep *)
|
||||
- Exec(git diff *)
|
||||
---
|
||||
|
||||
You are a Gitea Actions workflow validator for the devx repo.
|
||||
|
||||
## Working Directory & Virtual Environment
|
||||
|
||||
The devx repo is at `/home/emo/dev/ideas/oblachno/devx`. Always `cd` there first.
|
||||
|
||||
All Python tools run inside `.venv`. `make` targets handle activation
|
||||
automatically — always use `make <target>`, never raw `pytest` or `ruff`
|
||||
commands. If `.venv` doesn't exist, run `make setup` first.
|
||||
|
||||
## Key Files
|
||||
|
||||
- `.gitea/workflows/ci.yml` — PR pipeline (quality, detect-changes, release-dry-run, pr-review, auto-merge)
|
||||
- `.gitea/workflows/post-merge.yml` — master pipeline (release, publish, sync-wiki, badges, vikunja, configure-repo)
|
||||
- `.gitea/workflows/build-images.yml` — Docker image build pipeline
|
||||
- `.gitea/actionlint.yaml` — actionlint config (registers custom `docker` runner label)
|
||||
|
||||
## Validation Procedure
|
||||
|
||||
### Step 1: Install tools (if not present)
|
||||
```bash
|
||||
make install-tools # installs actionlint, act_runner to ~/.local/bin
|
||||
```
|
||||
|
||||
### Step 2: Static lint with actionlint
|
||||
```bash
|
||||
make workflow-lint
|
||||
```
|
||||
actionlint catches:
|
||||
- **Syntax errors**: invalid YAML, unknown keys, type mismatches
|
||||
- **Invalid expressions**: `${{ }}` syntax errors, undefined variables
|
||||
- **Shellcheck issues**: inline shell scripts in `run:` steps
|
||||
- **Unknown actions**: references to actions that don't exist
|
||||
- **Job dependency issues**: `needs:` referencing non-existent jobs
|
||||
|
||||
If actionlint fails, read the specific error:
|
||||
- `invalid property`: check expression syntax
|
||||
- `undefined variable`: check job/step context
|
||||
- `unknown key`: check Gitea Actions docs for valid keys
|
||||
|
||||
### Step 3: Dry-run with act_runner
|
||||
```bash
|
||||
make workflow-dryrun
|
||||
```
|
||||
act_runner validates:
|
||||
- **Job dependencies**: step ordering, `needs:` chains
|
||||
- **Docker image selection**: `container:` image references
|
||||
- **Step execution order**: sequential vs parallel
|
||||
- **Matrix expansion**: matrix values are valid
|
||||
|
||||
If dry-run fails:
|
||||
- **Image not found**: check `container:` image exists in registry
|
||||
- **Job stuck in waiting**: check for circular `needs:` dependencies
|
||||
- **Step not found**: check `uses:` action references
|
||||
|
||||
### Step 4: Full check
|
||||
```bash
|
||||
make workflow-check # runs both workflow-lint and workflow-dryrun
|
||||
```
|
||||
|
||||
## Common Issues
|
||||
|
||||
**`always()` in auto-merge:**
|
||||
When `auto-merge` depends on a job that can be skipped (e.g. `molecule-tests`),
|
||||
the `if:` condition MUST include `always() &&` at the start. Without it,
|
||||
Gitea Actions skips `auto-merge` when any dependency is skipped, even if
|
||||
the condition explicitly allows `result == 'skipped'`.
|
||||
|
||||
```yaml
|
||||
auto-merge:
|
||||
needs: [quality, detect-changes, pr-review, molecule-tests]
|
||||
if: >-
|
||||
always() &&
|
||||
github.event_name == 'pull_request' &&
|
||||
needs.quality.result == 'success' &&
|
||||
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped')
|
||||
```
|
||||
|
||||
**Custom runner labels:**
|
||||
The `docker` runner label is registered in `.gitea/actionlint.yaml`.
|
||||
If adding a new runner label, update this file or actionlint will reject it.
|
||||
|
||||
**Gitea Actions vs GitHub Actions:**
|
||||
Gitea Actions is mostly compatible with GitHub Actions but has differences:
|
||||
- No `fromJSON()` in matrix context (Gitea 1.26.x)
|
||||
- `concurrency` blocks can cause jobs to get stuck (Gitea 1.26.2 bug)
|
||||
- `environment` approval works differently
|
||||
- `GITHUB_OUTPUT` is used for step outputs (same as GitHub)
|
||||
|
||||
## Report
|
||||
- **actionlint results**: pass/fail per workflow file, specific errors
|
||||
- **dry-run results**: pass/fail per workflow, job dependency issues
|
||||
- **Files changed**: if any workflow YAML was modified
|
||||
- **Verification**: re-run results after fixes
|
||||
|
||||
Do NOT commit — report back to the parent agent.
|
||||
|
||||
## Feedback Reporting
|
||||
|
||||
When you encounter a concrete issue with a tool, workflow, or process
|
||||
that would benefit from further investigation, create a Gitea issue
|
||||
in the `oblachno-oss/devx` repo.
|
||||
|
||||
### When to Create Feedback Issues
|
||||
- A tool or workflow step has a bug, missing feature, or poor UX
|
||||
- A CI pattern could be improved or aligned across repos
|
||||
- Documentation is missing, outdated, or misleading
|
||||
- A process step is unnecessarily complex or fragile
|
||||
|
||||
### How to Create Feedback Issues
|
||||
|
||||
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
|
||||
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
|
||||
`repo: "devx"`. Check if an open issue already covers the same topic.
|
||||
Do NOT create duplicates.
|
||||
|
||||
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
|
||||
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
|
||||
`repo: "devx"`:
|
||||
- **Title**: `[feedback] <category>: <short description>`
|
||||
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
|
||||
`doc-improvement`, `workflow-improvement`
|
||||
- **Body** must include these sections:
|
||||
```
|
||||
**Context**: What task you were performing, which repo
|
||||
**Tool/Workflow**: The specific tool or workflow step involved
|
||||
**Issue**: What went wrong or could be improved
|
||||
**Reproduction**: Steps to reproduce (if applicable)
|
||||
**Affected files**: File paths and line numbers
|
||||
**Suggested investigation**: What an agent should look into
|
||||
**Reported by**: <subagent profile name>
|
||||
```
|
||||
|
||||
3. **Report back**: Include the issue URL in your report to the parent agent.
|
||||
|
||||
### When NOT to Create Feedback Issues
|
||||
- Transient failures (network blips, rate limits, Docker pull flakiness)
|
||||
- Issues you can fix yourself — fix them instead
|
||||
- CI run failures — those are handled by `notify_failure` automatically
|
||||
- Missing labels — `configure_repo` creates standard labels on next master push
|
||||
@@ -0,0 +1,37 @@
|
||||
# devx-workflow
|
||||
|
||||
Quick reference for devx tools when working on the devx repo itself.
|
||||
|
||||
## PR Workflow (use these, not raw git/tea/MCP)
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Create Vikunja task | `make create-task -- --title "..." --description "..."` |
|
||||
| Create PR | `make create-pr` |
|
||||
| Push + create PR | `make push-with-pr` |
|
||||
| Check CI status | `make devx-pr-status` or `make devx-pr-status PR=42 WAIT=1` |
|
||||
| Fetch CI failure logs | `make devx-pr-logs` or `make devx-pr-logs PR=42 JOB=quality TAIL=50` |
|
||||
| Add ready-to-merge label | `make devx-pr-label` or `make devx-pr-label PR=42` |
|
||||
| Post PR review | `make devx-pr-review PR=42 EVENT=APPROVE BODY="..." CHECKLIST=1,2,3,4,5,6,7,8,9,10,11,12,13` |
|
||||
| Rebase current branch | `make rebase` |
|
||||
| Rebase PR via API | `make pr-rebase` or `make pr-rebase PR=42` |
|
||||
|
||||
## Auto-merge Behavior
|
||||
|
||||
When the `ready-to-merge` label is added and all CI checks pass:
|
||||
1. Auto-merge validates PR title format (`DEVX-N: <vikunja task title>`)
|
||||
2. If branch is behind master, auto-merge **rebases via Gitea API** automatically
|
||||
3. The rebase triggers a new CI run; the next auto-merge attempt merges
|
||||
4. No manual rebase needed unless the API rebase fails
|
||||
|
||||
## Key Rules
|
||||
|
||||
- Never manually merge via API — always use auto-merge with `ready-to-merge` label
|
||||
- Branch naming: `DEVX-N-short-description` (N = Vikunja task ID)
|
||||
- Commit format: conventional commits (`feat:`, `fix:`, `docs:`, etc.)
|
||||
- PR title: `DEVX-N: <vikunja task title>` (auto-derived by `make create-pr`)
|
||||
- 100% test coverage required for all source changes
|
||||
- All user-facing strings wrapped in `_()` for i18n
|
||||
- Translation keys must be added to `src/devx/translations.json`
|
||||
- New CLI commands must be documented in `docs/user/cli-commands.md`
|
||||
- New tools must be registered in `src/devx/cli.py` and added to Make targets
|
||||
@@ -0,0 +1,98 @@
|
||||
# testing-and-debugging
|
||||
|
||||
Make targets for testing, debugging, and CI investigation. **Use these
|
||||
instead of raw `pytest`, `ruff`, or `actionlint` commands.**
|
||||
|
||||
## Why Make Targets
|
||||
|
||||
Make targets encapsulate the correct venv activation, PYTHONPATH, env
|
||||
vars, and flags. Running raw commands bypasses venv activation and
|
||||
produces false failures (missing dependencies, wrong Python version).
|
||||
|
||||
## Unit Tests
|
||||
|
||||
| Task | Command | Notes |
|
||||
|------|---------|-------|
|
||||
| Run all unit tests | `make test-unit` | Fast, no coverage |
|
||||
| Run with coverage | `make pytest-cov` | **Required before push** — enforces 100% |
|
||||
| Run single test | `make pytest-cov TEST=tests/test_foo.py::test_bar` | |
|
||||
| Check test speed | `make check-test-speed` | Fails if tests > 10s total or > 0.5s each |
|
||||
| Check test coverage | `make check-test-coverage` | Fails if source changed but tests didn't |
|
||||
|
||||
## Linting
|
||||
|
||||
| Task | Command | Notes |
|
||||
|------|---------|-------|
|
||||
| Full lint | `make lint-all` | ruff + workflow-lint + lint-dockerfiles |
|
||||
| Ruff only | `make lint-ruff` | |
|
||||
| Format check | `make lint-format` | |
|
||||
| Type check | `make typecheck` | pyright |
|
||||
| Bandit | `make lint-bandit` | Security linter |
|
||||
| Workflow lint | `make workflow-check` | actionlint + act_runner dry-run |
|
||||
| Dockerfile lint | `make lint-dockerfiles` | hadolint on all Dockerfiles |
|
||||
| Check mutable globals | `make check-mutable-globals` | Detects module-level mutable state |
|
||||
| Check dep docs | `make check-dep-docs` | Verifies pyproject.toml deps have comments |
|
||||
|
||||
## Pre-Push Verification
|
||||
|
||||
**Before pushing any branch:**
|
||||
|
||||
```bash
|
||||
make pre-push
|
||||
```
|
||||
|
||||
This runs `lint-all` + `pytest-cov`. The pre-push git hook only
|
||||
validates the Vikunja task exists — it does NOT run tests. You must
|
||||
run `make pre-push` manually.
|
||||
|
||||
## CI Failure Investigation
|
||||
|
||||
When investigating a CI failure:
|
||||
|
||||
1. **Fetch logs via MCP** — use `mcp_call_tool` with gitea server,
|
||||
`actions_run_read` method, `download_job_log` tool
|
||||
2. **Reproduce locally** — use `make pytest-cov` or `make lint-all`
|
||||
depending on which CI job failed
|
||||
3. **Never run raw pytest** — always use the make target
|
||||
|
||||
## Virtual Environment
|
||||
|
||||
All commands run inside `.venv`. `make` targets handle activation
|
||||
automatically. For raw commands (rare), activate first:
|
||||
|
||||
```bash
|
||||
source activate.sh # bash/zsh
|
||||
source activate.fish # fish
|
||||
source activate.zsh # zsh
|
||||
```
|
||||
|
||||
If `.venv` doesn't exist, run `make setup` first.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Coverage Verification Before Push
|
||||
|
||||
**Always run `make pytest-cov` before pushing** — CI enforces 100%
|
||||
coverage and will fail the PR if any lines are uncovered. This is the
|
||||
most common cause of CI quality job failures after code changes. The
|
||||
pre-push git hook only validates Vikunja task existence, not tests.
|
||||
|
||||
### API Response Type Checking
|
||||
|
||||
Never use `is True`/`is False` identity checks on API response values.
|
||||
Many APIs return boolean values as strings (`"true"`/`"false"`). Use
|
||||
the `is_truthy()`/`is_falsy()` helpers from `devx.utils.api` or compare
|
||||
against string values.
|
||||
|
||||
### Time Mocking in Tests
|
||||
|
||||
Always mock `time.sleep` and `time.monotonic` in unit tests using
|
||||
`@patch` decorators. Real sleep calls make tests slow and exceed test
|
||||
speed limits (10s total, 0.5s per test).
|
||||
|
||||
### Mutable Global State
|
||||
|
||||
The `check-mutable-globals` tool detects module-level mutable state
|
||||
(lists, dicts, sets) that can cause test pollution. Avoid module-level
|
||||
mutable defaults — use factory functions or `None` with initialization
|
||||
inside functions.
|
||||
@@ -5,7 +5,9 @@ name: Build Images
|
||||
# devx and all dependencies into the image.
|
||||
#
|
||||
# Triggers:
|
||||
# - On push to master (after post-merge release completes)
|
||||
# - After post-merge workflow completes successfully (workflow_run)
|
||||
# This ensures images are only rebuilt AFTER the release is published
|
||||
# to PyPI, so the image always has the latest released version.
|
||||
# - Manually via workflow_dispatch
|
||||
#
|
||||
# The workflow builds 3 tier images in sequence:
|
||||
@@ -15,14 +17,16 @@ name: Build Images
|
||||
# After pushing, a cleanup job removes old versions (keeps last 2 + latest).
|
||||
|
||||
on:
|
||||
push:
|
||||
workflow_run:
|
||||
workflows: ["Post-merge"]
|
||||
types: [completed]
|
||||
branches: [master]
|
||||
paths:
|
||||
- docker/**
|
||||
- pyproject.toml
|
||||
- src/devx/**
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: build-images
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
detect-type:
|
||||
runs-on: docker
|
||||
@@ -45,7 +49,11 @@ jobs:
|
||||
|
||||
build-and-push:
|
||||
needs: [detect-type]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
if: >-
|
||||
needs.detect-type.outputs.is-release == 'false' && (
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
|
||||
)
|
||||
runs-on: docker
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
@@ -104,7 +112,8 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "build-images/build-and-push" \
|
||||
--commit "${{ github.sha }}"
|
||||
--commit "${{ github.sha }}" \
|
||||
--auto-login
|
||||
|
||||
cleanup:
|
||||
needs: [build-and-push]
|
||||
|
||||
+45
-12
@@ -19,41 +19,59 @@ jobs:
|
||||
run: make setup-image
|
||||
- name: Lint all
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
make lint-all
|
||||
- name: Unit tests with 100% coverage
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
make pytest-cov
|
||||
- name: Check unit test speed
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.tools.check_test_speed --max-seconds 6 --max-single-seconds 0.5
|
||||
- name: Documentation coverage check
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.doc_coverage --fail-on-missing
|
||||
- name: Documentation lint check
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.lint_docs --root .
|
||||
- name: Documentation version reference check
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.tools.check_doc_versions --root .
|
||||
- name: Vale prose lint check
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
make devx-vale
|
||||
- name: Translation completeness check
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.check_translations
|
||||
- name: Dependency security scan
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
# Install pip in venv if missing (needed by pip-audit)
|
||||
.venv/bin/python -m ensurepip 2>/dev/null || true
|
||||
PIPAPI_PYTHON_LOCATION=$PWD/.venv/bin/python \
|
||||
pip-audit --desc --skip-editable 2>&1 || true
|
||||
- name: Workflow dry-run validation
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
# Best-effort: only runs if act_runner is installed
|
||||
if command -v act_runner >/dev/null 2>&1; then
|
||||
@@ -82,7 +100,7 @@ jobs:
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.classify_changes \
|
||||
--base "origin/master" \
|
||||
--head "${{ github.event.pull_request.head.sha || github.sha }}" \
|
||||
@@ -109,7 +127,7 @@ jobs:
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.release --dry-run
|
||||
|
||||
@@ -131,7 +149,7 @@ jobs:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
set -euo pipefail
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.pr_review \
|
||||
"${{ github.event.number }}" \
|
||||
"${{ github.repository }}"
|
||||
@@ -160,6 +178,21 @@ jobs:
|
||||
token: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
- name: Set up environment
|
||||
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:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
@@ -171,7 +204,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" \
|
||||
|
||||
@@ -8,7 +8,8 @@ name: Post-merge
|
||||
# detect-type ──┬── validate-commit-msg (skip if release commit)
|
||||
# ├── release (skip if release commit)
|
||||
# │ └── publish (needs release — builds & publishes to PyPI)
|
||||
# ├── badges (ALWAYS runs — even on release commits)
|
||||
# ├── badges (needs release — ALWAYS runs, waits for release
|
||||
# │ so version badge picks up new __version__)
|
||||
# ├── configure-repo (independent — skip if release commit)
|
||||
# ├── sync-wiki (skip if release commit — runs for ALL merges)
|
||||
# └── vikunja (skip if release commit — runs for ALL merges)
|
||||
@@ -17,9 +18,10 @@ name: Post-merge
|
||||
# release succeeds. This ensures the wiki and task tracker are updated
|
||||
# even for infrastructure-only changes (docs, CI config, etc.).
|
||||
#
|
||||
# The badges job uses `if: always()` with no is-release condition so it
|
||||
# runs on every push to master, including release commits. This ensures
|
||||
# badges (tests, coverage, version, etc.) are always current.
|
||||
# The badges job uses `if: always()` and needs `release` so it waits for
|
||||
# the release job to complete (whether it ran or was skipped). This ensures
|
||||
# the version badge always reflects the latest __version__ on master.
|
||||
# Badges run on every push to master, including release commits.
|
||||
#
|
||||
# When release creates a "release: vX.Y.Z" commit and tag, the publish
|
||||
# job (which depends on release) builds and publishes the package to the
|
||||
@@ -51,7 +53,7 @@ jobs:
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.detect_release_commit
|
||||
|
||||
validate-commit-msg:
|
||||
@@ -73,7 +75,7 @@ jobs:
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
git log -1 --format=%B > commit-msg.txt
|
||||
python3 -m devx.ci.validate_commit_msg commit-msg.txt --branch master
|
||||
rm -f commit-msg.txt
|
||||
@@ -107,7 +109,7 @@ jobs:
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.release
|
||||
- name: Notify on failure
|
||||
@@ -122,7 +124,8 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/release" \
|
||||
--commit "${{ github.sha }}"
|
||||
--commit "${{ github.sha }}" \
|
||||
--auto-login
|
||||
|
||||
publish:
|
||||
needs: [release]
|
||||
@@ -145,7 +148,7 @@ jobs:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
python3 -m devx.ci.publish "${{ needs.release.outputs.tag }}" "${{ github.repository }}" --auto-login
|
||||
- name: Notify on failure
|
||||
@@ -160,14 +163,18 @@ 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
|
||||
timeout-minutes: 15
|
||||
concurrency:
|
||||
group: sync-wiki-${{ github.repository }}
|
||||
cancel-in-progress: false
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -182,8 +189,8 @@ jobs:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --strict
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --verify
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
@@ -195,10 +202,11 @@ 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]
|
||||
needs: [detect-type, release]
|
||||
if: always()
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
|
||||
@@ -222,7 +230,7 @@ jobs:
|
||||
env:
|
||||
PRE_COMMIT_ALLOW_NO_CONFIG: "1"
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.push_badges
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
@@ -235,7 +243,8 @@ 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]
|
||||
@@ -258,7 +267,7 @@ jobs:
|
||||
DEVX_VIKUNJA_PROJECT_ID: "8"
|
||||
PYTHONPATH: src
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
@@ -271,7 +280,8 @@ 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]
|
||||
@@ -290,9 +300,11 @@ jobs:
|
||||
env:
|
||||
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
|
||||
PYTHONPATH: src
|
||||
DEVX_REPO_NAME: devx
|
||||
DEVX_REPO_OWNER: oblachno-oss
|
||||
run: |
|
||||
. .venv/bin/activate
|
||||
python3 -m devx.tools.configure_repo --repo devx --owner oblachno-oss
|
||||
. .venv/bin/activate 2>/dev/null || true
|
||||
python3 -m devx.tools.configure_repo
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
env:
|
||||
@@ -304,4 +316,5 @@ jobs:
|
||||
--repo "${{ github.repository }}" \
|
||||
--run-id "${{ github.run_id }}" \
|
||||
--workflow "post-merge/configure-repo" \
|
||||
--commit "${{ github.sha }}"
|
||||
--commit "${{ github.sha }}" \
|
||||
--auto-login
|
||||
|
||||
@@ -49,6 +49,44 @@ repos:
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: checkmake
|
||||
name: checkmake Makefile linter
|
||||
entry: make checkmake
|
||||
language: system
|
||||
files: (Makefile|\.mak)$
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: check-test-speed
|
||||
name: unit test speed check
|
||||
entry: .venv/bin/python -m devx.tools.check_test_speed --max-seconds 6 --max-single-seconds 0.5
|
||||
language: system
|
||||
types: [python]
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: check-translations
|
||||
name: translation completeness check
|
||||
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.check_translations
|
||||
language: system
|
||||
files: ^src/devx/translations\.json$
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: doc-coverage
|
||||
name: documentation coverage check
|
||||
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.doc_coverage --fail-on-missing
|
||||
language: system
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: lint-docs
|
||||
name: documentation lint check
|
||||
entry: env PYTHONPATH=src .venv/bin/python -m devx.ci.lint_docs --root .
|
||||
language: system
|
||||
pass_filenames: false
|
||||
stages: [pre-commit]
|
||||
|
||||
- id: pytest-cov
|
||||
name: pytest with 100% coverage
|
||||
entry: make pytest-cov
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Vale configuration for devx documentation
|
||||
# https://vale.sh/docs/
|
||||
|
||||
StylesPath = .vale/styles
|
||||
|
||||
# Packages are downloaded via `vale sync`
|
||||
Packages = write-good, Google, Readability
|
||||
|
||||
# Minimum alert level to display (suggestion, warning, error)
|
||||
MinAlertLevel = warning
|
||||
|
||||
# Project vocabulary — terms not flagged as spelling errors
|
||||
Vocab = devx
|
||||
|
||||
[*.{md}]
|
||||
# Enable style guides
|
||||
BasedOnStyles = Vale, write-good, Google, Readability, devx
|
||||
|
||||
# Google style — relax rules too strict for technical docs
|
||||
Google.Contractions = NO
|
||||
Google.WordList = NO
|
||||
Google.Acronyms = NO
|
||||
Google.We = NO
|
||||
Google.Will = NO
|
||||
Google.Colons = NO
|
||||
Google.Headings = NO
|
||||
Google.EmDash = NO
|
||||
Google.Units = NO
|
||||
|
||||
# write-good — relax rules too strict for technical writing
|
||||
write-good.E-Prime = NO
|
||||
write-good.So = NO
|
||||
write-good.ThereIs = NO
|
||||
write-good.TooWordy = NO
|
||||
|
||||
# Vale defaults — spelling catches too many technical terms
|
||||
Vale.Terms = NO
|
||||
Vale.Repetition = NO
|
||||
Vale.Spelling = NO
|
||||
|
||||
# Readability — warnings only, technical docs are naturally complex
|
||||
Readability.FleschReadingEase = suggestion
|
||||
Readability.ColemanLiau = suggestion
|
||||
Readability.LIX = suggestion
|
||||
Readability.GunningFog = suggestion
|
||||
Readability.SMOG = suggestion
|
||||
@@ -0,0 +1,9 @@
|
||||
extends: existence
|
||||
message: "Use 'AM' or 'PM' (preceded by a space)."
|
||||
link: "https://developers.google.com/style/word-list"
|
||||
level: error
|
||||
nonword: true
|
||||
tokens:
|
||||
- '\d{1,2}[AP]M\b'
|
||||
- '\d{1,2} ?[ap]m\b'
|
||||
- '\d{1,2} ?[aApP]\.[mM]\.'
|
||||
@@ -0,0 +1,64 @@
|
||||
extends: conditional
|
||||
message: "Spell out '%s', if it's unfamiliar to the audience."
|
||||
link: 'https://developers.google.com/style/abbreviations'
|
||||
level: suggestion
|
||||
ignorecase: false
|
||||
# Ensures that the existence of 'first' implies the existence of 'second'.
|
||||
first: '\b([A-Z]{3,5})\b'
|
||||
second: '(?:\b[A-Z][a-z]+ )+\(([A-Z]{3,5})\)'
|
||||
# ... with the exception of these:
|
||||
exceptions:
|
||||
- API
|
||||
- ASP
|
||||
- CLI
|
||||
- CPU
|
||||
- CSS
|
||||
- CSV
|
||||
- DEBUG
|
||||
- DOM
|
||||
- DPI
|
||||
- FAQ
|
||||
- GCC
|
||||
- GDB
|
||||
- GET
|
||||
- GPU
|
||||
- GTK
|
||||
- GUI
|
||||
- HTML
|
||||
- HTTP
|
||||
- HTTPS
|
||||
- IDE
|
||||
- JAR
|
||||
- JSON
|
||||
- JSX
|
||||
- LESS
|
||||
- LLDB
|
||||
- NET
|
||||
- NOTE
|
||||
- NVDA
|
||||
- OSS
|
||||
- PATH
|
||||
- PDF
|
||||
- PHP
|
||||
- POST
|
||||
- RAM
|
||||
- REPL
|
||||
- RSA
|
||||
- SCM
|
||||
- SCSS
|
||||
- SDK
|
||||
- SQL
|
||||
- SSH
|
||||
- SSL
|
||||
- SVG
|
||||
- TBD
|
||||
- TCP
|
||||
- TODO
|
||||
- URI
|
||||
- URL
|
||||
- USB
|
||||
- UTF
|
||||
- XML
|
||||
- XSS
|
||||
- YAML
|
||||
- ZIP
|
||||
@@ -0,0 +1,8 @@
|
||||
extends: existence
|
||||
message: "'%s' should be in lowercase."
|
||||
link: 'https://developers.google.com/style/colons'
|
||||
nonword: true
|
||||
level: warning
|
||||
scope: sentence
|
||||
tokens:
|
||||
- '(?<!:[^ ]+?):\s[A-Z]'
|
||||
@@ -0,0 +1,30 @@
|
||||
extends: substitution
|
||||
message: "Use '%s' instead of '%s'."
|
||||
link: 'https://developers.google.com/style/contractions'
|
||||
level: suggestion
|
||||
ignorecase: true
|
||||
action:
|
||||
name: replace
|
||||
swap:
|
||||
are not: aren't
|
||||
cannot: can't
|
||||
could not: couldn't
|
||||
did not: didn't
|
||||
do not: don't
|
||||
does not: doesn't
|
||||
has not: hasn't
|
||||
have not: haven't
|
||||
how is: how's
|
||||
is not: isn't
|
||||
it is: it's
|
||||
should not: shouldn't
|
||||
that is: that's
|
||||
they are: they're
|
||||
was not: wasn't
|
||||
we are: we're
|
||||
we have: we've
|
||||
were not: weren't
|
||||
what is: what's
|
||||
when is: when's
|
||||
where is: where's
|
||||
will not: won't
|
||||
@@ -0,0 +1,9 @@
|
||||
extends: existence
|
||||
message: "Use 'July 31, 2016' format, not '%s'."
|
||||
link: 'https://developers.google.com/style/dates-times'
|
||||
ignorecase: true
|
||||
level: error
|
||||
nonword: true
|
||||
tokens:
|
||||
- '\d{1,2}(?:\.|/)\d{1,2}(?:\.|/)\d{4}'
|
||||
- '\d{1,2} (?:Jan(?:uary)?|Feb(?:ruary)?|Mar(?:ch)?|Apr(?:il)|May|Jun(?:e)|Jul(?:y)|Aug(?:ust)|Sep(?:tember)?|Oct(?:ober)|Nov(?:ember)?|Dec(?:ember)?) \d{4}'
|
||||
@@ -0,0 +1,9 @@
|
||||
extends: existence
|
||||
message: "In general, don't use an ellipsis."
|
||||
link: 'https://developers.google.com/style/ellipses'
|
||||
nonword: true
|
||||
level: warning
|
||||
action:
|
||||
name: remove
|
||||
tokens:
|
||||
- '\.\.\.'
|
||||
@@ -0,0 +1,13 @@
|
||||
extends: existence
|
||||
message: "Don't put a space before or after a dash."
|
||||
link: "https://developers.google.com/style/dashes"
|
||||
nonword: true
|
||||
level: error
|
||||
action:
|
||||
name: edit
|
||||
params:
|
||||
- trim
|
||||
- " "
|
||||
tokens:
|
||||
- '\s[—–]\s'
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
extends: existence
|
||||
message: "Don't use exclamation points in text."
|
||||
link: "https://developers.google.com/style/exclamation-points"
|
||||
nonword: true
|
||||
level: error
|
||||
action:
|
||||
name: edit
|
||||
params:
|
||||
- trim_right
|
||||
- "!"
|
||||
tokens:
|
||||
- '\w+!(?:\s|$)'
|
||||
@@ -0,0 +1,13 @@
|
||||
extends: existence
|
||||
message: "Avoid first-person pronouns such as '%s'."
|
||||
link: 'https://developers.google.com/style/pronouns#personal-pronouns'
|
||||
ignorecase: true
|
||||
level: warning
|
||||
nonword: true
|
||||
tokens:
|
||||
- (?:^|\s)I\s
|
||||
- (?:^|\s)I,\s
|
||||
- \bI'm\b
|
||||
- \bme\b
|
||||
- \bmy\b
|
||||
- \bmine\b
|
||||
@@ -0,0 +1,9 @@
|
||||
extends: existence
|
||||
message: "Don't use '%s' as a gender-neutral pronoun."
|
||||
link: 'https://developers.google.com/style/pronouns#gender-neutral-pronouns'
|
||||
level: error
|
||||
ignorecase: true
|
||||
tokens:
|
||||
- he/she
|
||||
- s/he
|
||||
- \(s\)he
|
||||
@@ -0,0 +1,43 @@
|
||||
extends: substitution
|
||||
message: "Consider using '%s' instead of '%s'."
|
||||
ignorecase: true
|
||||
link: "https://developers.google.com/style/inclusive-documentation"
|
||||
level: error
|
||||
action:
|
||||
name: replace
|
||||
swap:
|
||||
(?:alumna|alumnus): graduate
|
||||
(?:alumnae|alumni): graduates
|
||||
air(?:m[ae]n|wom[ae]n): pilot(s)
|
||||
anchor(?:m[ae]n|wom[ae]n): anchor(s)
|
||||
authoress: author
|
||||
camera(?:m[ae]n|wom[ae]n): camera operator(s)
|
||||
door(?:m[ae]|wom[ae]n): concierge(s)
|
||||
draft(?:m[ae]n|wom[ae]n): drafter(s)
|
||||
fire(?:m[ae]n|wom[ae]n): firefighter(s)
|
||||
fisher(?:m[ae]n|wom[ae]n): fisher(s)
|
||||
fresh(?:m[ae]n|wom[ae]n): first-year student(s)
|
||||
garbage(?:m[ae]n|wom[ae]n): waste collector(s)
|
||||
lady lawyer: lawyer
|
||||
ladylike: courteous
|
||||
mail(?:m[ae]n|wom[ae]n): mail carriers
|
||||
man and wife: husband and wife
|
||||
man enough: strong enough
|
||||
mankind: human kind|humanity
|
||||
manmade: manufactured
|
||||
manpower: personnel
|
||||
middle(?:m[ae]n|wom[ae]n): intermediary
|
||||
news(?:m[ae]n|wom[ae]n): journalist(s)
|
||||
ombuds(?:man|woman): ombuds
|
||||
oneupmanship: upstaging
|
||||
poetess: poet
|
||||
police(?:m[ae]n|wom[ae]n): police officer(s)
|
||||
repair(?:m[ae]n|wom[ae]n): technician(s)
|
||||
sales(?:m[ae]n|wom[ae]n): salesperson or sales people
|
||||
service(?:m[ae]n|wom[ae]n): soldier(s)
|
||||
steward(?:ess)?: flight attendant
|
||||
tribes(?:m[ae]n|wom[ae]n): tribe member(s)
|
||||
waitress: waiter
|
||||
woman doctor: doctor
|
||||
woman scientist[s]?: scientist(s)
|
||||
work(?:m[ae]n|wom[ae]n): worker(s)
|
||||
@@ -0,0 +1,13 @@
|
||||
extends: existence
|
||||
message: "Don't put a period at the end of a heading."
|
||||
link: "https://developers.google.com/style/capitalization#capitalization-in-titles-and-headings"
|
||||
nonword: true
|
||||
level: warning
|
||||
scope: heading
|
||||
action:
|
||||
name: edit
|
||||
params:
|
||||
- trim_right
|
||||
- "."
|
||||
tokens:
|
||||
- '[a-z0-9][.]\s*$'
|
||||
@@ -0,0 +1,29 @@
|
||||
extends: capitalization
|
||||
message: "'%s' should use sentence-style capitalization."
|
||||
link: "https://developers.google.com/style/capitalization#capitalization-in-titles-and-headings"
|
||||
level: warning
|
||||
scope: heading
|
||||
match: $sentence
|
||||
indicators:
|
||||
- ":"
|
||||
exceptions:
|
||||
- Azure
|
||||
- CLI
|
||||
- Cosmos
|
||||
- Docker
|
||||
- Emmet
|
||||
- gRPC
|
||||
- I
|
||||
- Kubernetes
|
||||
- Linux
|
||||
- macOS
|
||||
- Marketplace
|
||||
- MongoDB
|
||||
- REPL
|
||||
- Studio
|
||||
- TypeScript
|
||||
- URLs
|
||||
- Visual
|
||||
- VS
|
||||
- Windows
|
||||
- JSON
|
||||
@@ -0,0 +1,11 @@
|
||||
extends: substitution
|
||||
message: "Use '%s' instead of '%s'."
|
||||
link: 'https://developers.google.com/style/abbreviations'
|
||||
ignorecase: true
|
||||
level: error
|
||||
nonword: true
|
||||
action:
|
||||
name: replace
|
||||
swap:
|
||||
'\b(?:eg|e\.g\.)(?=[\s,;])': for example
|
||||
'\b(?:ie|i\.e\.)(?=[\s,;])': that is
|
||||
@@ -0,0 +1,14 @@
|
||||
extends: existence
|
||||
message: "'%s' doesn't need a hyphen."
|
||||
link: "https://developers.google.com/style/hyphens"
|
||||
level: error
|
||||
ignorecase: false
|
||||
nonword: true
|
||||
action:
|
||||
name: edit
|
||||
params:
|
||||
- regex
|
||||
- "-"
|
||||
- " "
|
||||
tokens:
|
||||
- '\b[^\s-]+ly-\w+\b'
|
||||
@@ -0,0 +1,12 @@
|
||||
extends: existence
|
||||
message: "Don't use plurals in parentheses such as in '%s'."
|
||||
link: "https://developers.google.com/style/plurals-parentheses"
|
||||
level: error
|
||||
nonword: true
|
||||
action:
|
||||
name: edit
|
||||
params:
|
||||
- trim_right
|
||||
- "(s)"
|
||||
tokens:
|
||||
- '\b\w+\(s\)'
|
||||
@@ -0,0 +1,7 @@
|
||||
extends: existence
|
||||
message: "Spell out all ordinal numbers ('%s') in text."
|
||||
link: 'https://developers.google.com/style/numbers'
|
||||
level: error
|
||||
nonword: true
|
||||
tokens:
|
||||
- \d+(?:st|nd|rd|th)
|
||||
@@ -0,0 +1,7 @@
|
||||
extends: existence
|
||||
message: "Use the Oxford comma in '%s'."
|
||||
link: 'https://developers.google.com/style/commas'
|
||||
scope: sentence
|
||||
level: warning
|
||||
tokens:
|
||||
- '(?:[^,]+,){1,}\s\w+\s(?:and|or)'
|
||||
@@ -0,0 +1,7 @@
|
||||
extends: existence
|
||||
message: "Use parentheses judiciously."
|
||||
link: 'https://developers.google.com/style/parentheses'
|
||||
nonword: true
|
||||
level: suggestion
|
||||
tokens:
|
||||
- '\(.+\)'
|
||||
@@ -0,0 +1,184 @@
|
||||
extends: existence
|
||||
link: 'https://developers.google.com/style/voice'
|
||||
message: "In general, use active voice instead of passive voice ('%s')."
|
||||
ignorecase: true
|
||||
level: suggestion
|
||||
raw:
|
||||
- \b(am|are|were|being|is|been|was|be)\b\s*
|
||||
tokens:
|
||||
- '[\w]+ed'
|
||||
- awoken
|
||||
- beat
|
||||
- become
|
||||
- been
|
||||
- begun
|
||||
- bent
|
||||
- beset
|
||||
- bet
|
||||
- bid
|
||||
- bidden
|
||||
- bitten
|
||||
- bled
|
||||
- blown
|
||||
- born
|
||||
- bought
|
||||
- bound
|
||||
- bred
|
||||
- broadcast
|
||||
- broken
|
||||
- brought
|
||||
- built
|
||||
- burnt
|
||||
- burst
|
||||
- cast
|
||||
- caught
|
||||
- chosen
|
||||
- clung
|
||||
- come
|
||||
- cost
|
||||
- crept
|
||||
- cut
|
||||
- dealt
|
||||
- dived
|
||||
- done
|
||||
- drawn
|
||||
- dreamt
|
||||
- driven
|
||||
- drunk
|
||||
- dug
|
||||
- eaten
|
||||
- fallen
|
||||
- fed
|
||||
- felt
|
||||
- fit
|
||||
- fled
|
||||
- flown
|
||||
- flung
|
||||
- forbidden
|
||||
- foregone
|
||||
- forgiven
|
||||
- forgotten
|
||||
- forsaken
|
||||
- fought
|
||||
- found
|
||||
- frozen
|
||||
- given
|
||||
- gone
|
||||
- gotten
|
||||
- ground
|
||||
- grown
|
||||
- heard
|
||||
- held
|
||||
- hidden
|
||||
- hit
|
||||
- hung
|
||||
- hurt
|
||||
- kept
|
||||
- knelt
|
||||
- knit
|
||||
- known
|
||||
- laid
|
||||
- lain
|
||||
- leapt
|
||||
- learnt
|
||||
- led
|
||||
- left
|
||||
- lent
|
||||
- let
|
||||
- lighted
|
||||
- lost
|
||||
- made
|
||||
- meant
|
||||
- met
|
||||
- misspelt
|
||||
- mistaken
|
||||
- mown
|
||||
- overcome
|
||||
- overdone
|
||||
- overtaken
|
||||
- overthrown
|
||||
- paid
|
||||
- pled
|
||||
- proven
|
||||
- put
|
||||
- quit
|
||||
- read
|
||||
- rid
|
||||
- ridden
|
||||
- risen
|
||||
- run
|
||||
- rung
|
||||
- said
|
||||
- sat
|
||||
- sawn
|
||||
- seen
|
||||
- sent
|
||||
- set
|
||||
- sewn
|
||||
- shaken
|
||||
- shaven
|
||||
- shed
|
||||
- shod
|
||||
- shone
|
||||
- shorn
|
||||
- shot
|
||||
- shown
|
||||
- shrunk
|
||||
- shut
|
||||
- slain
|
||||
- slept
|
||||
- slid
|
||||
- slit
|
||||
- slung
|
||||
- smitten
|
||||
- sold
|
||||
- sought
|
||||
- sown
|
||||
- sped
|
||||
- spent
|
||||
- spilt
|
||||
- spit
|
||||
- split
|
||||
- spoken
|
||||
- spread
|
||||
- sprung
|
||||
- spun
|
||||
- stolen
|
||||
- stood
|
||||
- stridden
|
||||
- striven
|
||||
- struck
|
||||
- strung
|
||||
- stuck
|
||||
- stung
|
||||
- stunk
|
||||
- sung
|
||||
- sunk
|
||||
- swept
|
||||
- swollen
|
||||
- sworn
|
||||
- swum
|
||||
- swung
|
||||
- taken
|
||||
- taught
|
||||
- thought
|
||||
- thrived
|
||||
- thrown
|
||||
- thrust
|
||||
- told
|
||||
- torn
|
||||
- trodden
|
||||
- understood
|
||||
- upheld
|
||||
- upset
|
||||
- wed
|
||||
- wept
|
||||
- withheld
|
||||
- withstood
|
||||
- woken
|
||||
- won
|
||||
- worn
|
||||
- wound
|
||||
- woven
|
||||
- written
|
||||
- wrung
|
||||
@@ -0,0 +1,7 @@
|
||||
extends: existence
|
||||
message: "Don't use periods with acronyms or initialisms such as '%s'."
|
||||
link: 'https://developers.google.com/style/abbreviations'
|
||||
level: error
|
||||
nonword: true
|
||||
tokens:
|
||||
- '\b(?:[A-Z]\.){3,}'
|
||||
@@ -0,0 +1,7 @@
|
||||
extends: existence
|
||||
message: "Commas and periods go inside quotation marks."
|
||||
link: 'https://developers.google.com/style/quotation-marks'
|
||||
level: error
|
||||
nonword: true
|
||||
tokens:
|
||||
- '"[^"]+"[.,?]'
|
||||
@@ -0,0 +1,7 @@
|
||||
extends: existence
|
||||
message: "Don't add words such as 'from' or 'between' to describe a range of numbers."
|
||||
link: 'https://developers.google.com/style/hyphens'
|
||||
nonword: true
|
||||
level: warning
|
||||
tokens:
|
||||
- '(?:from|between)\s\d+\s?-\s?\d+'
|
||||
@@ -0,0 +1,8 @@
|
||||
extends: existence
|
||||
message: "Use semicolons judiciously."
|
||||
link: 'https://developers.google.com/style/semicolons'
|
||||
nonword: true
|
||||
scope: sentence
|
||||
level: suggestion
|
||||
tokens:
|
||||
- ';'
|
||||
@@ -0,0 +1,11 @@
|
||||
extends: existence
|
||||
message: "Don't use internet slang abbreviations such as '%s'."
|
||||
link: 'https://developers.google.com/style/abbreviations'
|
||||
ignorecase: true
|
||||
level: error
|
||||
tokens:
|
||||
- 'tl;dr'
|
||||
- ymmv
|
||||
- rtfm
|
||||
- imo
|
||||
- fwiw
|
||||
@@ -0,0 +1,10 @@
|
||||
extends: existence
|
||||
message: "'%s' should have one space."
|
||||
link: 'https://developers.google.com/style/sentence-spacing'
|
||||
level: error
|
||||
nonword: true
|
||||
action:
|
||||
name: remove
|
||||
tokens:
|
||||
- '[a-z][.?!] {2,}[A-Z]'
|
||||
- '[a-z][.?!][A-Z]'
|
||||
@@ -0,0 +1,10 @@
|
||||
extends: existence
|
||||
message: "In general, use American spelling instead of '%s'."
|
||||
link: 'https://developers.google.com/style/spelling'
|
||||
ignorecase: true
|
||||
level: warning
|
||||
tokens:
|
||||
- '(?:\w+)nised?'
|
||||
- 'colour'
|
||||
- 'labour'
|
||||
- 'centre'
|
||||
@@ -0,0 +1,8 @@
|
||||
extends: existence
|
||||
message: "Put a nonbreaking space between the number and the unit in '%s'."
|
||||
link: "https://developers.google.com/style/units-of-measure"
|
||||
nonword: true
|
||||
level: error
|
||||
tokens:
|
||||
- \b\d+(?:B|kB|MB|GB|TB)
|
||||
- \b\d+(?:ns|ms|s|min|h|d)
|
||||
@@ -0,0 +1,11 @@
|
||||
extends: existence
|
||||
message: "Try to avoid using first-person plural like '%s'."
|
||||
link: 'https://developers.google.com/style/pronouns#personal-pronouns'
|
||||
level: warning
|
||||
ignorecase: true
|
||||
tokens:
|
||||
- we
|
||||
- we'(?:ve|re)
|
||||
- ours?
|
||||
- us
|
||||
- let's
|
||||
@@ -0,0 +1,7 @@
|
||||
extends: existence
|
||||
message: "Avoid using '%s'."
|
||||
link: 'https://developers.google.com/style/tense'
|
||||
ignorecase: true
|
||||
level: warning
|
||||
tokens:
|
||||
- will
|
||||
@@ -0,0 +1,80 @@
|
||||
extends: substitution
|
||||
message: "Use '%s' instead of '%s'."
|
||||
link: "https://developers.google.com/style/word-list"
|
||||
level: warning
|
||||
ignorecase: false
|
||||
action:
|
||||
name: replace
|
||||
swap:
|
||||
"(?:API Console|dev|developer) key": API key
|
||||
"(?:cell ?phone|smart ?phone)": phone|mobile phone
|
||||
"(?:dev|developer|APIs) console": API console
|
||||
"(?:e-mail|Email|E-mail)": email
|
||||
"(?:file ?path|path ?name)": path
|
||||
"(?:kill|terminate|abort)": stop|exit|cancel|end
|
||||
"(?:OAuth ?2|Oauth)": OAuth 2.0
|
||||
"(?:ok|Okay)": OK|okay
|
||||
"(?:WiFi|wifi)": Wi-Fi
|
||||
'[\.]+apk': APK
|
||||
'3\-D': 3D
|
||||
'Google (?:I\-O|IO)': Google I/O
|
||||
"tap (?:&|and) hold": touch & hold
|
||||
"un(?:check|select)": clear
|
||||
above: preceding
|
||||
account name: username
|
||||
action bar: app bar
|
||||
admin: administrator
|
||||
Ajax: AJAX
|
||||
a\.k\.a|aka: or|also known as
|
||||
Android device: Android-powered device
|
||||
android: Android
|
||||
API explorer: APIs Explorer
|
||||
application: app
|
||||
approx\.: approximately
|
||||
authN: authentication
|
||||
authZ: authorization
|
||||
autoupdate: automatically update
|
||||
cellular data: mobile data
|
||||
cellular network: mobile network
|
||||
chapter: documents|pages|sections
|
||||
check box: checkbox
|
||||
CLI: command-line tool
|
||||
click on: click|click in
|
||||
Cloud: Google Cloud Platform|GCP
|
||||
Container Engine: Kubernetes Engine
|
||||
content type: media type
|
||||
curated roles: predefined roles
|
||||
data are: data is
|
||||
Developers Console: Google API Console|API Console
|
||||
disabled?: turn off|off
|
||||
ephemeral IP address: ephemeral external IP address
|
||||
fewer data: less data
|
||||
file name: filename
|
||||
firewalls: firewall rules
|
||||
functionality: capability|feature
|
||||
Google account: Google Account
|
||||
Google accounts: Google Accounts
|
||||
Googling: search with Google
|
||||
grayed-out: unavailable
|
||||
HTTPs: HTTPS
|
||||
in order to: to
|
||||
ingest: import|load
|
||||
k8s: Kubernetes
|
||||
long press: touch & hold
|
||||
network IP address: internal IP address
|
||||
omnibox: address bar
|
||||
open-source: open source
|
||||
overview screen: recents screen
|
||||
regex: regular expression
|
||||
SHA1: SHA-1|HAS-SHA1
|
||||
sign into: sign in to
|
||||
sign-?on: single sign-on
|
||||
static IP address: static external IP address
|
||||
stylesheet: style sheet
|
||||
synch: sync
|
||||
tablename: table name
|
||||
tablet: device
|
||||
touch: tap
|
||||
url: URL
|
||||
vs\.: versus
|
||||
World Wide Web: web
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"feed": "https://github.com/errata-ai/Google/releases.atom",
|
||||
"vale_version": ">=1.0.0"
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
extends: metric
|
||||
message: "Try to keep the Automated Readability Index (%s) below 8."
|
||||
link: https://en.wikipedia.org/wiki/Automated_readability_index
|
||||
|
||||
formula: |
|
||||
(4.71 * (characters / words)) + (0.5 * (words / sentences)) - 21.43
|
||||
|
||||
condition: "> 8"
|
||||
@@ -0,0 +1,8 @@
|
||||
extends: metric
|
||||
message: "Try to keep the Coleman–Liau Index grade (%s) below 9."
|
||||
link: https://en.wikipedia.org/wiki/Coleman%E2%80%93Liau_index
|
||||
|
||||
formula: |
|
||||
(0.0588 * (characters / words) * 100) - (0.296 * (sentences / words) * 100) - 15.8
|
||||
|
||||
condition: "> 9"
|
||||
@@ -0,0 +1,8 @@
|
||||
extends: metric
|
||||
message: "Try to keep the Flesch–Kincaid grade level (%s) below 8."
|
||||
link: https://en.wikipedia.org/wiki/Flesch%E2%80%93Kincaid_readability_tests
|
||||
|
||||
formula: |
|
||||
(0.39 * (words / sentences)) + (11.8 * (syllables / words)) - 15.59
|
||||
|
||||
condition: "> 8"
|
||||
@@ -0,0 +1,8 @@
|
||||
extends: metric
|
||||
message: "Try to keep the Flesch reading ease score (%s) above 70."
|
||||
link: https://en.wikipedia.org/wiki/Flesch%E2%80%93Kincaid_readability_tests
|
||||
|
||||
formula: |
|
||||
206.835 - (1.015 * (words / sentences)) - (84.6 * (syllables / words))
|
||||
|
||||
condition: "< 70"
|
||||
@@ -0,0 +1,8 @@
|
||||
extends: metric
|
||||
message: "Try to keep the Gunning-Fog index (%s) below 10."
|
||||
link: https://en.wikipedia.org/wiki/Gunning_fog_index
|
||||
|
||||
formula: |
|
||||
0.4 * ((words / sentences) + 100 * (complex_words / words))
|
||||
|
||||
condition: "> 10"
|
||||
@@ -0,0 +1,17 @@
|
||||
extends: metric
|
||||
message: "Try to keep the LIX score (%s) below 35."
|
||||
|
||||
link: https://en.wikipedia.org/wiki/Lix_(readability_test)
|
||||
# Very Easy: 20 - 25
|
||||
#
|
||||
# Easy: 30 - 35
|
||||
#
|
||||
# Medium: 40 - 45
|
||||
#
|
||||
# Difficult: 50 - 55
|
||||
#
|
||||
# Very Difficult: 60+
|
||||
formula: |
|
||||
(words / sentences) + ((long_words * 100) / words)
|
||||
|
||||
condition: "> 35"
|
||||
@@ -0,0 +1,8 @@
|
||||
extends: metric
|
||||
message: "Try to keep the SMOG grade (%s) below 10."
|
||||
link: https://en.wikipedia.org/wiki/SMOG
|
||||
|
||||
formula: |
|
||||
1.0430 * math.sqrt((polysyllabic_words * 30.0) / sentences) + 3.1291
|
||||
|
||||
condition: "> 10"
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"feed": "https://github.com/errata-ai/Readability/releases.atom",
|
||||
"vale_version": ">=2.13.0"
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
devx
|
||||
Gitea
|
||||
ZITADEL
|
||||
OpenTofu
|
||||
Ansible
|
||||
Vaultwarden
|
||||
Nextcloud
|
||||
Vikunja
|
||||
Mattermost
|
||||
Prometheus
|
||||
Grafana
|
||||
Loki
|
||||
Alertmanager
|
||||
Promtail
|
||||
pyproject
|
||||
tofu
|
||||
act_runner
|
||||
actionlint
|
||||
hadolint
|
||||
git-cliff
|
||||
pre-commit
|
||||
semver
|
||||
changelog
|
||||
idempotent
|
||||
rootless
|
||||
OIDC
|
||||
SSO
|
||||
SAML
|
||||
LDAP
|
||||
pytest
|
||||
molecule
|
||||
ruff
|
||||
pyright
|
||||
bandit
|
||||
Vikunja
|
||||
oblachno
|
||||
Oblachno
|
||||
Bulgarian
|
||||
@@ -0,0 +1,6 @@
|
||||
extends: existence
|
||||
message: "Unlabeled code block — add a language tag (```bash, ```yaml, etc.)"
|
||||
level: warning
|
||||
scope: raw
|
||||
raw:
|
||||
- '(?s)```\n(?!.*```)'
|
||||
@@ -0,0 +1,13 @@
|
||||
extends: existence
|
||||
message: "Avoid '%s' — it's condescending in technical documentation"
|
||||
level: warning
|
||||
ignorecase: true
|
||||
tokens:
|
||||
- '\bsimply\b'
|
||||
- '\bjust\b'
|
||||
- '\bobviously\b'
|
||||
- '\bof course\b'
|
||||
- '\bas you (can )?see\b'
|
||||
- '\beasily\b'
|
||||
- '\btrivial\b'
|
||||
- '\bstraightforward\b'
|
||||
@@ -0,0 +1,3 @@
|
||||
# Custom Vale style for devx documentation
|
||||
|
||||
Project-specific terminology and style rules
|
||||
@@ -0,0 +1,11 @@
|
||||
extends: substitution
|
||||
message: "Use '%s' instead of '%s' (terminology consistency)"
|
||||
level: error
|
||||
ignorecase: false
|
||||
swap:
|
||||
'\b(?i)gitea\b': Gitea
|
||||
'\b(?i)zitadel\b': ZITADEL
|
||||
'\b(?i)opentofu\b': OpenTofu
|
||||
'\b(?i)vaultwarden\b': Vaultwarden
|
||||
'\b(?i)nextcloud\b': Nextcloud
|
||||
'\b(?i)mattermost\b': Mattermost
|
||||
@@ -0,0 +1,702 @@
|
||||
extends: existence
|
||||
message: "Try to avoid using clichés like '%s'."
|
||||
ignorecase: true
|
||||
level: warning
|
||||
tokens:
|
||||
- a chip off the old block
|
||||
- a clean slate
|
||||
- a dark and stormy night
|
||||
- a far cry
|
||||
- a fine kettle of fish
|
||||
- a loose cannon
|
||||
- a penny saved is a penny earned
|
||||
- a tough row to hoe
|
||||
- a word to the wise
|
||||
- ace in the hole
|
||||
- acid test
|
||||
- add insult to injury
|
||||
- against all odds
|
||||
- air your dirty laundry
|
||||
- all fun and games
|
||||
- all in a day's work
|
||||
- all talk, no action
|
||||
- all thumbs
|
||||
- all your eggs in one basket
|
||||
- all's fair in love and war
|
||||
- all's well that ends well
|
||||
- almighty dollar
|
||||
- American as apple pie
|
||||
- an axe to grind
|
||||
- another day, another dollar
|
||||
- armed to the teeth
|
||||
- as luck would have it
|
||||
- as old as time
|
||||
- as the crow flies
|
||||
- at loose ends
|
||||
- at my wits end
|
||||
- avoid like the plague
|
||||
- babe in the woods
|
||||
- back against the wall
|
||||
- back in the saddle
|
||||
- back to square one
|
||||
- back to the drawing board
|
||||
- bad to the bone
|
||||
- badge of honor
|
||||
- bald faced liar
|
||||
- ballpark figure
|
||||
- banging your head against a brick wall
|
||||
- baptism by fire
|
||||
- barking up the wrong tree
|
||||
- bat out of hell
|
||||
- be all and end all
|
||||
- beat a dead horse
|
||||
- beat around the bush
|
||||
- been there, done that
|
||||
- beggars can't be choosers
|
||||
- behind the eight ball
|
||||
- bend over backwards
|
||||
- benefit of the doubt
|
||||
- bent out of shape
|
||||
- best thing since sliced bread
|
||||
- bet your bottom dollar
|
||||
- better half
|
||||
- better late than never
|
||||
- better mousetrap
|
||||
- better safe than sorry
|
||||
- between a rock and a hard place
|
||||
- beyond the pale
|
||||
- bide your time
|
||||
- big as life
|
||||
- big cheese
|
||||
- big fish in a small pond
|
||||
- big man on campus
|
||||
- bigger they are the harder they fall
|
||||
- bird in the hand
|
||||
- bird's eye view
|
||||
- birds and the bees
|
||||
- birds of a feather flock together
|
||||
- bit the hand that feeds you
|
||||
- bite the bullet
|
||||
- bite the dust
|
||||
- bitten off more than he can chew
|
||||
- black as coal
|
||||
- black as pitch
|
||||
- black as the ace of spades
|
||||
- blast from the past
|
||||
- bleeding heart
|
||||
- blessing in disguise
|
||||
- blind ambition
|
||||
- blind as a bat
|
||||
- blind leading the blind
|
||||
- blood is thicker than water
|
||||
- blood sweat and tears
|
||||
- blow off steam
|
||||
- blow your own horn
|
||||
- blushing bride
|
||||
- boils down to
|
||||
- bolt from the blue
|
||||
- bone to pick
|
||||
- bored stiff
|
||||
- bored to tears
|
||||
- bottomless pit
|
||||
- boys will be boys
|
||||
- bright and early
|
||||
- brings home the bacon
|
||||
- broad across the beam
|
||||
- broken record
|
||||
- brought back to reality
|
||||
- bull by the horns
|
||||
- bull in a china shop
|
||||
- burn the midnight oil
|
||||
- burning question
|
||||
- burning the candle at both ends
|
||||
- burst your bubble
|
||||
- bury the hatchet
|
||||
- busy as a bee
|
||||
- by hook or by crook
|
||||
- call a spade a spade
|
||||
- called onto the carpet
|
||||
- calm before the storm
|
||||
- can of worms
|
||||
- can't cut the mustard
|
||||
- can't hold a candle to
|
||||
- case of mistaken identity
|
||||
- cat got your tongue
|
||||
- cat's meow
|
||||
- caught in the crossfire
|
||||
- caught red-handed
|
||||
- checkered past
|
||||
- chomping at the bit
|
||||
- cleanliness is next to godliness
|
||||
- clear as a bell
|
||||
- clear as mud
|
||||
- close to the vest
|
||||
- cock and bull story
|
||||
- cold shoulder
|
||||
- come hell or high water
|
||||
- cool as a cucumber
|
||||
- cool, calm, and collected
|
||||
- cost a king's ransom
|
||||
- count your blessings
|
||||
- crack of dawn
|
||||
- crash course
|
||||
- creature comforts
|
||||
- cross that bridge when you come to it
|
||||
- crushing blow
|
||||
- cry like a baby
|
||||
- cry me a river
|
||||
- cry over spilt milk
|
||||
- crystal clear
|
||||
- curiosity killed the cat
|
||||
- cut and dried
|
||||
- cut through the red tape
|
||||
- cut to the chase
|
||||
- cute as a bugs ear
|
||||
- cute as a button
|
||||
- cute as a puppy
|
||||
- cuts to the quick
|
||||
- dark before the dawn
|
||||
- day in, day out
|
||||
- dead as a doornail
|
||||
- devil is in the details
|
||||
- dime a dozen
|
||||
- divide and conquer
|
||||
- dog and pony show
|
||||
- dog days
|
||||
- dog eat dog
|
||||
- dog tired
|
||||
- don't burn your bridges
|
||||
- don't count your chickens
|
||||
- don't look a gift horse in the mouth
|
||||
- don't rock the boat
|
||||
- don't step on anyone's toes
|
||||
- don't take any wooden nickels
|
||||
- down and out
|
||||
- down at the heels
|
||||
- down in the dumps
|
||||
- down the hatch
|
||||
- down to earth
|
||||
- draw the line
|
||||
- dressed to kill
|
||||
- dressed to the nines
|
||||
- drives me up the wall
|
||||
- dull as dishwater
|
||||
- dyed in the wool
|
||||
- eagle eye
|
||||
- ear to the ground
|
||||
- early bird catches the worm
|
||||
- easier said than done
|
||||
- easy as pie
|
||||
- eat your heart out
|
||||
- eat your words
|
||||
- eleventh hour
|
||||
- even the playing field
|
||||
- every dog has its day
|
||||
- every fiber of my being
|
||||
- everything but the kitchen sink
|
||||
- eye for an eye
|
||||
- face the music
|
||||
- facts of life
|
||||
- fair weather friend
|
||||
- fall by the wayside
|
||||
- fan the flames
|
||||
- feast or famine
|
||||
- feather your nest
|
||||
- feathered friends
|
||||
- few and far between
|
||||
- fifteen minutes of fame
|
||||
- filthy vermin
|
||||
- fine kettle of fish
|
||||
- fish out of water
|
||||
- fishing for a compliment
|
||||
- fit as a fiddle
|
||||
- fit the bill
|
||||
- fit to be tied
|
||||
- flash in the pan
|
||||
- flat as a pancake
|
||||
- flip your lid
|
||||
- flog a dead horse
|
||||
- fly by night
|
||||
- fly the coop
|
||||
- follow your heart
|
||||
- for all intents and purposes
|
||||
- for the birds
|
||||
- for what it's worth
|
||||
- force of nature
|
||||
- force to be reckoned with
|
||||
- forgive and forget
|
||||
- fox in the henhouse
|
||||
- free and easy
|
||||
- free as a bird
|
||||
- fresh as a daisy
|
||||
- full steam ahead
|
||||
- fun in the sun
|
||||
- garbage in, garbage out
|
||||
- gentle as a lamb
|
||||
- get a kick out of
|
||||
- get a leg up
|
||||
- get down and dirty
|
||||
- get the lead out
|
||||
- get to the bottom of
|
||||
- get your feet wet
|
||||
- gets my goat
|
||||
- gilding the lily
|
||||
- give and take
|
||||
- go against the grain
|
||||
- go at it tooth and nail
|
||||
- go for broke
|
||||
- go him one better
|
||||
- go the extra mile
|
||||
- go with the flow
|
||||
- goes without saying
|
||||
- good as gold
|
||||
- good deed for the day
|
||||
- good things come to those who wait
|
||||
- good time was had by all
|
||||
- good times were had by all
|
||||
- greased lightning
|
||||
- greek to me
|
||||
- green thumb
|
||||
- green-eyed monster
|
||||
- grist for the mill
|
||||
- growing like a weed
|
||||
- hair of the dog
|
||||
- hand to mouth
|
||||
- happy as a clam
|
||||
- happy as a lark
|
||||
- hasn't a clue
|
||||
- have a nice day
|
||||
- have high hopes
|
||||
- have the last laugh
|
||||
- haven't got a row to hoe
|
||||
- head honcho
|
||||
- head over heels
|
||||
- hear a pin drop
|
||||
- heard it through the grapevine
|
||||
- heart's content
|
||||
- heavy as lead
|
||||
- hem and haw
|
||||
- high and dry
|
||||
- high and mighty
|
||||
- high as a kite
|
||||
- hit paydirt
|
||||
- hold your head up high
|
||||
- hold your horses
|
||||
- hold your own
|
||||
- hold your tongue
|
||||
- honest as the day is long
|
||||
- horns of a dilemma
|
||||
- horse of a different color
|
||||
- hot under the collar
|
||||
- hour of need
|
||||
- I beg to differ
|
||||
- icing on the cake
|
||||
- if the shoe fits
|
||||
- if the shoe were on the other foot
|
||||
- in a jam
|
||||
- in a jiffy
|
||||
- in a nutshell
|
||||
- in a pig's eye
|
||||
- in a pinch
|
||||
- in a word
|
||||
- in hot water
|
||||
- in the gutter
|
||||
- in the nick of time
|
||||
- in the thick of it
|
||||
- in your dreams
|
||||
- it ain't over till the fat lady sings
|
||||
- it goes without saying
|
||||
- it takes all kinds
|
||||
- it takes one to know one
|
||||
- it's a small world
|
||||
- it's only a matter of time
|
||||
- ivory tower
|
||||
- Jack of all trades
|
||||
- jockey for position
|
||||
- jog your memory
|
||||
- joined at the hip
|
||||
- judge a book by its cover
|
||||
- jump down your throat
|
||||
- jump in with both feet
|
||||
- jump on the bandwagon
|
||||
- jump the gun
|
||||
- jump to conclusions
|
||||
- just a hop, skip, and a jump
|
||||
- just the ticket
|
||||
- justice is blind
|
||||
- keep a stiff upper lip
|
||||
- keep an eye on
|
||||
- keep it simple, stupid
|
||||
- keep the home fires burning
|
||||
- keep up with the Joneses
|
||||
- keep your chin up
|
||||
- keep your fingers crossed
|
||||
- kick the bucket
|
||||
- kick up your heels
|
||||
- kick your feet up
|
||||
- kid in a candy store
|
||||
- kill two birds with one stone
|
||||
- kiss of death
|
||||
- knock it out of the park
|
||||
- knock on wood
|
||||
- knock your socks off
|
||||
- know him from Adam
|
||||
- know the ropes
|
||||
- know the score
|
||||
- knuckle down
|
||||
- knuckle sandwich
|
||||
- knuckle under
|
||||
- labor of love
|
||||
- ladder of success
|
||||
- land on your feet
|
||||
- lap of luxury
|
||||
- last but not least
|
||||
- last hurrah
|
||||
- last-ditch effort
|
||||
- law of the jungle
|
||||
- law of the land
|
||||
- lay down the law
|
||||
- leaps and bounds
|
||||
- let sleeping dogs lie
|
||||
- let the cat out of the bag
|
||||
- let the good times roll
|
||||
- let your hair down
|
||||
- let's talk turkey
|
||||
- letter perfect
|
||||
- lick your wounds
|
||||
- lies like a rug
|
||||
- life's a bitch
|
||||
- life's a grind
|
||||
- light at the end of the tunnel
|
||||
- lighter than a feather
|
||||
- lighter than air
|
||||
- like clockwork
|
||||
- like father like son
|
||||
- like taking candy from a baby
|
||||
- like there's no tomorrow
|
||||
- lion's share
|
||||
- live and learn
|
||||
- live and let live
|
||||
- long and short of it
|
||||
- long lost love
|
||||
- look before you leap
|
||||
- look down your nose
|
||||
- look what the cat dragged in
|
||||
- looking a gift horse in the mouth
|
||||
- looks like death warmed over
|
||||
- loose cannon
|
||||
- lose your head
|
||||
- lose your temper
|
||||
- loud as a horn
|
||||
- lounge lizard
|
||||
- loved and lost
|
||||
- low man on the totem pole
|
||||
- luck of the draw
|
||||
- luck of the Irish
|
||||
- make hay while the sun shines
|
||||
- make money hand over fist
|
||||
- make my day
|
||||
- make the best of a bad situation
|
||||
- make the best of it
|
||||
- make your blood boil
|
||||
- man of few words
|
||||
- man's best friend
|
||||
- mark my words
|
||||
- meaningful dialogue
|
||||
- missed the boat on that one
|
||||
- moment in the sun
|
||||
- moment of glory
|
||||
- moment of truth
|
||||
- money to burn
|
||||
- more power to you
|
||||
- more than one way to skin a cat
|
||||
- movers and shakers
|
||||
- moving experience
|
||||
- naked as a jaybird
|
||||
- naked truth
|
||||
- neat as a pin
|
||||
- needle in a haystack
|
||||
- needless to say
|
||||
- neither here nor there
|
||||
- never look back
|
||||
- never say never
|
||||
- nip and tuck
|
||||
- nip it in the bud
|
||||
- no guts, no glory
|
||||
- no love lost
|
||||
- no pain, no gain
|
||||
- no skin off my back
|
||||
- no stone unturned
|
||||
- no time like the present
|
||||
- no use crying over spilled milk
|
||||
- nose to the grindstone
|
||||
- not a hope in hell
|
||||
- not a minute's peace
|
||||
- not in my backyard
|
||||
- not playing with a full deck
|
||||
- not the end of the world
|
||||
- not written in stone
|
||||
- nothing to sneeze at
|
||||
- nothing ventured nothing gained
|
||||
- now we're cooking
|
||||
- off the top of my head
|
||||
- off the wagon
|
||||
- off the wall
|
||||
- old hat
|
||||
- older and wiser
|
||||
- older than dirt
|
||||
- older than Methuselah
|
||||
- on a roll
|
||||
- on cloud nine
|
||||
- on pins and needles
|
||||
- on the bandwagon
|
||||
- on the money
|
||||
- on the nose
|
||||
- on the rocks
|
||||
- on the spot
|
||||
- on the tip of my tongue
|
||||
- on the wagon
|
||||
- on thin ice
|
||||
- once bitten, twice shy
|
||||
- one bad apple doesn't spoil the bushel
|
||||
- one born every minute
|
||||
- one brick short
|
||||
- one foot in the grave
|
||||
- one in a million
|
||||
- one red cent
|
||||
- only game in town
|
||||
- open a can of worms
|
||||
- open and shut case
|
||||
- open the flood gates
|
||||
- opportunity doesn't knock twice
|
||||
- out of pocket
|
||||
- out of sight, out of mind
|
||||
- out of the frying pan into the fire
|
||||
- out of the woods
|
||||
- out on a limb
|
||||
- over a barrel
|
||||
- over the hump
|
||||
- pain and suffering
|
||||
- pain in the
|
||||
- panic button
|
||||
- par for the course
|
||||
- part and parcel
|
||||
- party pooper
|
||||
- pass the buck
|
||||
- patience is a virtue
|
||||
- pay through the nose
|
||||
- penny pincher
|
||||
- perfect storm
|
||||
- pig in a poke
|
||||
- pile it on
|
||||
- pillar of the community
|
||||
- pin your hopes on
|
||||
- pitter patter of little feet
|
||||
- plain as day
|
||||
- plain as the nose on your face
|
||||
- play by the rules
|
||||
- play your cards right
|
||||
- playing the field
|
||||
- playing with fire
|
||||
- pleased as punch
|
||||
- plenty of fish in the sea
|
||||
- point with pride
|
||||
- poor as a church mouse
|
||||
- pot calling the kettle black
|
||||
- pretty as a picture
|
||||
- pull a fast one
|
||||
- pull your punches
|
||||
- pulling your leg
|
||||
- pure as the driven snow
|
||||
- put it in a nutshell
|
||||
- put one over on you
|
||||
- put the cart before the horse
|
||||
- put the pedal to the metal
|
||||
- put your best foot forward
|
||||
- put your foot down
|
||||
- quick as a bunny
|
||||
- quick as a lick
|
||||
- quick as a wink
|
||||
- quick as lightning
|
||||
- quiet as a dormouse
|
||||
- rags to riches
|
||||
- raining buckets
|
||||
- raining cats and dogs
|
||||
- rank and file
|
||||
- rat race
|
||||
- reap what you sow
|
||||
- red as a beet
|
||||
- red herring
|
||||
- reinvent the wheel
|
||||
- rich and famous
|
||||
- rings a bell
|
||||
- ripe old age
|
||||
- ripped me off
|
||||
- rise and shine
|
||||
- road to hell is paved with good intentions
|
||||
- rob Peter to pay Paul
|
||||
- roll over in the grave
|
||||
- rub the wrong way
|
||||
- ruled the roost
|
||||
- running in circles
|
||||
- sad but true
|
||||
- sadder but wiser
|
||||
- salt of the earth
|
||||
- scared stiff
|
||||
- scared to death
|
||||
- sealed with a kiss
|
||||
- second to none
|
||||
- see eye to eye
|
||||
- seen the light
|
||||
- seize the day
|
||||
- set the record straight
|
||||
- set the world on fire
|
||||
- set your teeth on edge
|
||||
- sharp as a tack
|
||||
- shoot for the moon
|
||||
- shoot the breeze
|
||||
- shot in the dark
|
||||
- shoulder to the wheel
|
||||
- sick as a dog
|
||||
- sigh of relief
|
||||
- signed, sealed, and delivered
|
||||
- sink or swim
|
||||
- six of one, half a dozen of another
|
||||
- skating on thin ice
|
||||
- slept like a log
|
||||
- slinging mud
|
||||
- slippery as an eel
|
||||
- slow as molasses
|
||||
- smart as a whip
|
||||
- smooth as a baby's bottom
|
||||
- sneaking suspicion
|
||||
- snug as a bug in a rug
|
||||
- sow wild oats
|
||||
- spare the rod, spoil the child
|
||||
- speak of the devil
|
||||
- spilled the beans
|
||||
- spinning your wheels
|
||||
- spitting image of
|
||||
- spoke with relish
|
||||
- spread like wildfire
|
||||
- spring to life
|
||||
- squeaky wheel gets the grease
|
||||
- stands out like a sore thumb
|
||||
- start from scratch
|
||||
- stick in the mud
|
||||
- still waters run deep
|
||||
- stitch in time
|
||||
- stop and smell the roses
|
||||
- straight as an arrow
|
||||
- straw that broke the camel's back
|
||||
- strong as an ox
|
||||
- stubborn as a mule
|
||||
- stuff that dreams are made of
|
||||
- stuffed shirt
|
||||
- sweating blood
|
||||
- sweating bullets
|
||||
- take a load off
|
||||
- take one for the team
|
||||
- take the bait
|
||||
- take the bull by the horns
|
||||
- take the plunge
|
||||
- takes one to know one
|
||||
- takes two to tango
|
||||
- the more the merrier
|
||||
- the real deal
|
||||
- the real McCoy
|
||||
- the red carpet treatment
|
||||
- the same old story
|
||||
- there is no accounting for taste
|
||||
- thick as a brick
|
||||
- thick as thieves
|
||||
- thin as a rail
|
||||
- think outside of the box
|
||||
- third time's the charm
|
||||
- this day and age
|
||||
- this hurts me worse than it hurts you
|
||||
- this point in time
|
||||
- three sheets to the wind
|
||||
- through thick and thin
|
||||
- throw in the towel
|
||||
- tie one on
|
||||
- tighter than a drum
|
||||
- time and time again
|
||||
- time is of the essence
|
||||
- tip of the iceberg
|
||||
- tired but happy
|
||||
- to coin a phrase
|
||||
- to each his own
|
||||
- to make a long story short
|
||||
- to the best of my knowledge
|
||||
- toe the line
|
||||
- tongue in cheek
|
||||
- too good to be true
|
||||
- too hot to handle
|
||||
- too numerous to mention
|
||||
- touch with a ten foot pole
|
||||
- tough as nails
|
||||
- trial and error
|
||||
- trials and tribulations
|
||||
- tried and true
|
||||
- trip down memory lane
|
||||
- twist of fate
|
||||
- two cents worth
|
||||
- two peas in a pod
|
||||
- ugly as sin
|
||||
- under the counter
|
||||
- under the gun
|
||||
- under the same roof
|
||||
- under the weather
|
||||
- until the cows come home
|
||||
- unvarnished truth
|
||||
- up the creek
|
||||
- uphill battle
|
||||
- upper crust
|
||||
- upset the applecart
|
||||
- vain attempt
|
||||
- vain effort
|
||||
- vanquish the enemy
|
||||
- vested interest
|
||||
- waiting for the other shoe to drop
|
||||
- wakeup call
|
||||
- warm welcome
|
||||
- watch your p's and q's
|
||||
- watch your tongue
|
||||
- watching the clock
|
||||
- water under the bridge
|
||||
- weather the storm
|
||||
- weed them out
|
||||
- week of Sundays
|
||||
- went belly up
|
||||
- wet behind the ears
|
||||
- what goes around comes around
|
||||
- what you see is what you get
|
||||
- when it rains, it pours
|
||||
- when push comes to shove
|
||||
- when the cat's away
|
||||
- when the going gets tough, the tough get going
|
||||
- white as a sheet
|
||||
- whole ball of wax
|
||||
- whole hog
|
||||
- whole nine yards
|
||||
- wild goose chase
|
||||
- will wonders never cease?
|
||||
- wisdom of the ages
|
||||
- wise as an owl
|
||||
- wolf at the door
|
||||
- words fail me
|
||||
- work like a dog
|
||||
- world weary
|
||||
- worst nightmare
|
||||
- worth its weight in gold
|
||||
- wrong side of the bed
|
||||
- yanking your chain
|
||||
- yappy as a dog
|
||||
- years young
|
||||
- you are what you eat
|
||||
- you can run but you can't hide
|
||||
- you only live once
|
||||
- you're the boss
|
||||
- young and foolish
|
||||
- young and vibrant
|
||||
@@ -0,0 +1,32 @@
|
||||
extends: existence
|
||||
message: "Try to avoid using '%s'."
|
||||
ignorecase: true
|
||||
level: suggestion
|
||||
tokens:
|
||||
- am
|
||||
- are
|
||||
- aren't
|
||||
- be
|
||||
- been
|
||||
- being
|
||||
- he's
|
||||
- here's
|
||||
- here's
|
||||
- how's
|
||||
- i'm
|
||||
- is
|
||||
- isn't
|
||||
- it's
|
||||
- she's
|
||||
- that's
|
||||
- there's
|
||||
- they're
|
||||
- was
|
||||
- wasn't
|
||||
- we're
|
||||
- were
|
||||
- weren't
|
||||
- what's
|
||||
- where's
|
||||
- who's
|
||||
- you're
|
||||
@@ -0,0 +1,11 @@
|
||||
extends: repetition
|
||||
message: "'%s' is repeated!"
|
||||
level: warning
|
||||
alpha: true
|
||||
action:
|
||||
name: edit
|
||||
params:
|
||||
- truncate
|
||||
- " "
|
||||
tokens:
|
||||
- '[^\s]+'
|
||||
@@ -0,0 +1,183 @@
|
||||
extends: existence
|
||||
message: "'%s' may be passive voice. Use active voice if you can."
|
||||
ignorecase: true
|
||||
level: warning
|
||||
raw:
|
||||
- \b(am|are|were|being|is|been|was|be)\b\s*
|
||||
tokens:
|
||||
- '[\w]+ed'
|
||||
- awoken
|
||||
- beat
|
||||
- become
|
||||
- been
|
||||
- begun
|
||||
- bent
|
||||
- beset
|
||||
- bet
|
||||
- bid
|
||||
- bidden
|
||||
- bitten
|
||||
- bled
|
||||
- blown
|
||||
- born
|
||||
- bought
|
||||
- bound
|
||||
- bred
|
||||
- broadcast
|
||||
- broken
|
||||
- brought
|
||||
- built
|
||||
- burnt
|
||||
- burst
|
||||
- cast
|
||||
- caught
|
||||
- chosen
|
||||
- clung
|
||||
- come
|
||||
- cost
|
||||
- crept
|
||||
- cut
|
||||
- dealt
|
||||
- dived
|
||||
- done
|
||||
- drawn
|
||||
- dreamt
|
||||
- driven
|
||||
- drunk
|
||||
- dug
|
||||
- eaten
|
||||
- fallen
|
||||
- fed
|
||||
- felt
|
||||
- fit
|
||||
- fled
|
||||
- flown
|
||||
- flung
|
||||
- forbidden
|
||||
- foregone
|
||||
- forgiven
|
||||
- forgotten
|
||||
- forsaken
|
||||
- fought
|
||||
- found
|
||||
- frozen
|
||||
- given
|
||||
- gone
|
||||
- gotten
|
||||
- ground
|
||||
- grown
|
||||
- heard
|
||||
- held
|
||||
- hidden
|
||||
- hit
|
||||
- hung
|
||||
- hurt
|
||||
- kept
|
||||
- knelt
|
||||
- knit
|
||||
- known
|
||||
- laid
|
||||
- lain
|
||||
- leapt
|
||||
- learnt
|
||||
- led
|
||||
- left
|
||||
- lent
|
||||
- let
|
||||
- lighted
|
||||
- lost
|
||||
- made
|
||||
- meant
|
||||
- met
|
||||
- misspelt
|
||||
- mistaken
|
||||
- mown
|
||||
- overcome
|
||||
- overdone
|
||||
- overtaken
|
||||
- overthrown
|
||||
- paid
|
||||
- pled
|
||||
- proven
|
||||
- put
|
||||
- quit
|
||||
- read
|
||||
- rid
|
||||
- ridden
|
||||
- risen
|
||||
- run
|
||||
- rung
|
||||
- said
|
||||
- sat
|
||||
- sawn
|
||||
- seen
|
||||
- sent
|
||||
- set
|
||||
- sewn
|
||||
- shaken
|
||||
- shaven
|
||||
- shed
|
||||
- shod
|
||||
- shone
|
||||
- shorn
|
||||
- shot
|
||||
- shown
|
||||
- shrunk
|
||||
- shut
|
||||
- slain
|
||||
- slept
|
||||
- slid
|
||||
- slit
|
||||
- slung
|
||||
- smitten
|
||||
- sold
|
||||
- sought
|
||||
- sown
|
||||
- sped
|
||||
- spent
|
||||
- spilt
|
||||
- spit
|
||||
- split
|
||||
- spoken
|
||||
- spread
|
||||
- sprung
|
||||
- spun
|
||||
- stolen
|
||||
- stood
|
||||
- stridden
|
||||
- striven
|
||||
- struck
|
||||
- strung
|
||||
- stuck
|
||||
- stung
|
||||
- stunk
|
||||
- sung
|
||||
- sunk
|
||||
- swept
|
||||
- swollen
|
||||
- sworn
|
||||
- swum
|
||||
- swung
|
||||
- taken
|
||||
- taught
|
||||
- thought
|
||||
- thrived
|
||||
- thrown
|
||||
- thrust
|
||||
- told
|
||||
- torn
|
||||
- trodden
|
||||
- understood
|
||||
- upheld
|
||||
- upset
|
||||
- wed
|
||||
- wept
|
||||
- withheld
|
||||
- withstood
|
||||
- woken
|
||||
- won
|
||||
- worn
|
||||
- wound
|
||||
- woven
|
||||
- written
|
||||
- wrung
|
||||
@@ -0,0 +1,27 @@
|
||||
Based on [write-good](https://github.com/btford/write-good).
|
||||
|
||||
> Naive linter for English prose for developers who can't write good and wanna learn to do other stuff good too.
|
||||
|
||||
```text
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2014 Brian Ford
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
extends: existence
|
||||
message: "Don't start a sentence with '%s'."
|
||||
level: error
|
||||
raw:
|
||||
- '(?:[;-]\s)so[\s,]|\bSo[\s,]'
|
||||
@@ -0,0 +1,6 @@
|
||||
extends: existence
|
||||
message: "Don't start a sentence with '%s'."
|
||||
ignorecase: false
|
||||
level: error
|
||||
raw:
|
||||
- '(?:[;-]\s)There\s(is|are)|\bThere\s(is|are)\b'
|
||||
@@ -0,0 +1,221 @@
|
||||
extends: existence
|
||||
message: "'%s' is too wordy."
|
||||
ignorecase: true
|
||||
level: warning
|
||||
tokens:
|
||||
- a number of
|
||||
- abundance
|
||||
- accede to
|
||||
- accelerate
|
||||
- accentuate
|
||||
- accompany
|
||||
- accomplish
|
||||
- accorded
|
||||
- accrue
|
||||
- acquiesce
|
||||
- acquire
|
||||
- additional
|
||||
- adjacent to
|
||||
- adjustment
|
||||
- admissible
|
||||
- advantageous
|
||||
- adversely impact
|
||||
- advise
|
||||
- aforementioned
|
||||
- aggregate
|
||||
- aircraft
|
||||
- all of
|
||||
- all things considered
|
||||
- alleviate
|
||||
- allocate
|
||||
- along the lines of
|
||||
- already existing
|
||||
- alternatively
|
||||
- amazing
|
||||
- ameliorate
|
||||
- anticipate
|
||||
- apparent
|
||||
- appreciable
|
||||
- as a matter of fact
|
||||
- as a means of
|
||||
- as far as I'm concerned
|
||||
- as of yet
|
||||
- as to
|
||||
- as yet
|
||||
- ascertain
|
||||
- assistance
|
||||
- at the present time
|
||||
- at this time
|
||||
- attain
|
||||
- attributable to
|
||||
- authorize
|
||||
- because of the fact that
|
||||
- belated
|
||||
- benefit from
|
||||
- bestow
|
||||
- by means of
|
||||
- by virtue of
|
||||
- by virtue of the fact that
|
||||
- cease
|
||||
- close proximity
|
||||
- commence
|
||||
- comply with
|
||||
- concerning
|
||||
- consequently
|
||||
- consolidate
|
||||
- constitutes
|
||||
- demonstrate
|
||||
- depart
|
||||
- designate
|
||||
- discontinue
|
||||
- due to the fact that
|
||||
- each and every
|
||||
- economical
|
||||
- eliminate
|
||||
- elucidate
|
||||
- employ
|
||||
- endeavor
|
||||
- enumerate
|
||||
- equitable
|
||||
- equivalent
|
||||
- evaluate
|
||||
- evidenced
|
||||
- exclusively
|
||||
- expedite
|
||||
- expend
|
||||
- expiration
|
||||
- facilitate
|
||||
- factual evidence
|
||||
- feasible
|
||||
- finalize
|
||||
- first and foremost
|
||||
- for all intents and purposes
|
||||
- for the most part
|
||||
- for the purpose of
|
||||
- forfeit
|
||||
- formulate
|
||||
- have a tendency to
|
||||
- honest truth
|
||||
- however
|
||||
- if and when
|
||||
- impacted
|
||||
- implement
|
||||
- in a manner of speaking
|
||||
- in a timely manner
|
||||
- in a very real sense
|
||||
- in accordance with
|
||||
- in addition
|
||||
- in all likelihood
|
||||
- in an effort to
|
||||
- in between
|
||||
- in excess of
|
||||
- in lieu of
|
||||
- in light of the fact that
|
||||
- in many cases
|
||||
- in my opinion
|
||||
- in order to
|
||||
- in regard to
|
||||
- in some instances
|
||||
- in terms of
|
||||
- in the case of
|
||||
- in the event that
|
||||
- in the final analysis
|
||||
- in the nature of
|
||||
- in the near future
|
||||
- in the process of
|
||||
- inception
|
||||
- incumbent upon
|
||||
- indicate
|
||||
- indication
|
||||
- initiate
|
||||
- irregardless
|
||||
- is applicable to
|
||||
- is authorized to
|
||||
- is responsible for
|
||||
- it is
|
||||
- it is essential
|
||||
- it seems that
|
||||
- it was
|
||||
- magnitude
|
||||
- maximum
|
||||
- methodology
|
||||
- minimize
|
||||
- minimum
|
||||
- modify
|
||||
- monitor
|
||||
- multiple
|
||||
- necessitate
|
||||
- nevertheless
|
||||
- not certain
|
||||
- not many
|
||||
- not often
|
||||
- not unless
|
||||
- not unlike
|
||||
- notwithstanding
|
||||
- null and void
|
||||
- numerous
|
||||
- objective
|
||||
- obligate
|
||||
- obtain
|
||||
- on the contrary
|
||||
- on the other hand
|
||||
- one particular
|
||||
- optimum
|
||||
- overall
|
||||
- owing to the fact that
|
||||
- participate
|
||||
- particulars
|
||||
- pass away
|
||||
- pertaining to
|
||||
- point in time
|
||||
- portion
|
||||
- possess
|
||||
- preclude
|
||||
- previously
|
||||
- prior to
|
||||
- prioritize
|
||||
- procure
|
||||
- proficiency
|
||||
- provided that
|
||||
- purchase
|
||||
- put simply
|
||||
- readily apparent
|
||||
- refer back
|
||||
- regarding
|
||||
- relocate
|
||||
- remainder
|
||||
- remuneration
|
||||
- requirement
|
||||
- reside
|
||||
- residence
|
||||
- retain
|
||||
- satisfy
|
||||
- shall
|
||||
- should you wish
|
||||
- similar to
|
||||
- solicit
|
||||
- span across
|
||||
- strategize
|
||||
- subsequent
|
||||
- substantial
|
||||
- successfully complete
|
||||
- sufficient
|
||||
- terminate
|
||||
- the month of
|
||||
- the point I am trying to make
|
||||
- therefore
|
||||
- time period
|
||||
- took advantage of
|
||||
- transmit
|
||||
- transpire
|
||||
- type of
|
||||
- until such time as
|
||||
- utilization
|
||||
- utilize
|
||||
- validate
|
||||
- various different
|
||||
- what I mean to say is
|
||||
- whether or not
|
||||
- with respect to
|
||||
- with the exception of
|
||||
- witnessed
|
||||
@@ -0,0 +1,29 @@
|
||||
extends: existence
|
||||
message: "'%s' is a weasel word!"
|
||||
ignorecase: true
|
||||
level: warning
|
||||
tokens:
|
||||
- clearly
|
||||
- completely
|
||||
- exceedingly
|
||||
- excellent
|
||||
- extremely
|
||||
- fairly
|
||||
- huge
|
||||
- interestingly
|
||||
- is a number
|
||||
- largely
|
||||
- mostly
|
||||
- obviously
|
||||
- quite
|
||||
- relatively
|
||||
- remarkably
|
||||
- several
|
||||
- significantly
|
||||
- substantially
|
||||
- surprisingly
|
||||
- tiny
|
||||
- usually
|
||||
- various
|
||||
- vast
|
||||
- very
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"feed": "https://github.com/errata-ai/write-good/releases.atom",
|
||||
"vale_version": ">=1.0.0"
|
||||
}
|
||||
@@ -1,22 +1,38 @@
|
||||
# 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, vale 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)
|
||||
make workflow-dryrun # Dry-run all workflows in Docker (act_runner exec --dryrun)
|
||||
make workflow-check # workflow-lint + workflow-dryrun
|
||||
make devx-check-doc-versions # Verify docs version refs match __version__
|
||||
make devx-vale # Run Vale prose linter on docs and README
|
||||
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)
|
||||
- **actionlint, git-cliff, act_runner, tea, hadolint, vale** 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)
|
||||
@@ -43,7 +59,7 @@ devx is a reusable Python package providing development and CI/CD tools for obla
|
||||
|
||||
### Package Structure
|
||||
|
||||
```
|
||||
```text
|
||||
src/devx/
|
||||
├── __init__.py # Version (single source of truth, read by setuptools)
|
||||
├── cli.py # Click-based CLI entry point (devx command)
|
||||
@@ -52,29 +68,32 @@ 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, code blocks, orphans)
|
||||
├── 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, vale
|
||||
│ ├── install_checkmake.py # Install checkmake (Makefile linter)
|
||||
│ ├── check_doc_versions.py # Verify docs version refs match __version__
|
||||
│ ├── 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
|
||||
@@ -82,14 +101,24 @@ src/devx/
|
||||
│ ├── check_pyproject_deps.py # Validate pyproject.toml deps have documentation comments
|
||||
│ ├── check_test_coverage.py # Ensure changed files have corresponding tests (configurable rules)
|
||||
│ ├── check_agent_docs.py # Validate docs for stale file references (configurable patterns)
|
||||
│ ├── check_config.py # Validate pyproject.toml [tool.devx] config
|
||||
│ ├── configure_repo.py # Branch protection and label setup
|
||||
│ └── generate_badges.py # Badge SVG generation
|
||||
│ ├── generate_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
|
||||
```
|
||||
|
||||
@@ -132,7 +161,7 @@ git checkout -b DEVX-N-short-description
|
||||
|
||||
### 4. Commit (Conventional Commits)
|
||||
Branch commits use conventional commit format (no `DEVX-N:` prefix):
|
||||
```
|
||||
```text
|
||||
feat: add new feature
|
||||
fix: resolve bug
|
||||
docs: update README
|
||||
@@ -300,14 +329,14 @@ setuptools via `dynamic = ["version"]` in `pyproject.toml`.
|
||||
|
||||
### Task ID Resolution
|
||||
|
||||
`auto_merge` resolves the task ID solely from the branch name (e.g.
|
||||
`auto_merge` resolves the task ID solely from the branch name (for example
|
||||
`DEVX-12-fix-foo` → `DEVX-12`). Branch names must include the task ID
|
||||
prefix — there is no `.taskid` file fallback. If a stale `.taskid` file
|
||||
exists in the repo, a deprecation warning is printed advising its removal.
|
||||
|
||||
### Workflow `auto-merge` Job and `always()`
|
||||
|
||||
When `auto-merge` depends on a job that can be skipped (e.g.
|
||||
When `auto-merge` depends on a job that can be skipped (for example
|
||||
`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
|
||||
@@ -339,7 +368,7 @@ balanced distribution when test items have varying costs:
|
||||
2. **LPT assignment**: Items are sorted by weight (descending), then
|
||||
each is assigned to the runner with the least total weight.
|
||||
|
||||
This ensures heavy scenarios (e.g. `nextcloud`) are spread across
|
||||
This ensures heavy scenarios (for example `nextcloud`) are spread across
|
||||
different runners rather than clustered on one, reducing the
|
||||
longest-runner time from ~16 min to ~11 min with 6 runners.
|
||||
|
||||
@@ -357,7 +386,7 @@ devx uses environment variables with `.env` file fallback for configuration.
|
||||
| `DEVX_REPO_NAME` | **(none — must be set)** | Repository name (or `owner/repo`) |
|
||||
| `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) |
|
||||
| `DEVX_VIKUNJA_PROJECT_ID` | `6` | Vikunja project ID |
|
||||
| `DEVX_LANG` | `en` | Language for i18n (en, bg) |
|
||||
| `DEVX_LANG` | `en` | Language for i18n (en, bg, de, pl, ru, zh) |
|
||||
| `CI_GITEA_TOKEN` | (from .env) | Gitea API token |
|
||||
| `VIKUNJA_TOKEN` | (from .env) | Vikunja API token |
|
||||
|
||||
@@ -374,7 +403,7 @@ the `[tool.devx]` section in `pyproject.toml`. This allows per-project
|
||||
customization without environment variables.
|
||||
|
||||
**Base config** (`[tool.devx]`):
|
||||
- `task_prefix` — Task ID prefix (e.g. `"DEVX"`, `"GRM"`, `"OBL-INFRA"`)
|
||||
- `task_prefix` — Task ID prefix (for example `"DEVX"`, `"GRM"`, `"OBL-INFRA"`)
|
||||
- `vikunja_project_id` — Vikunja project ID
|
||||
- `repo_owner` / `repo_name` — Gitea repository coordinates
|
||||
- `gitea_api_url` / `vikunja_api_url` — API endpoints
|
||||
@@ -398,13 +427,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) |
|
||||
@@ -428,6 +461,7 @@ projects.
|
||||
| `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 |
|
||||
@@ -441,6 +475,7 @@ 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`)
|
||||
@@ -453,7 +488,7 @@ 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 | quality, badges |
|
||||
| `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):
|
||||
@@ -503,3 +538,113 @@ create-task: devx-create-task
|
||||
- Line length: 120 chars
|
||||
- Secrets are passed via environment variables, never on the command line
|
||||
- All user-facing strings wrapped in `_()` for i18n
|
||||
|
||||
### Container-Level Fix Verification (Mandatory)
|
||||
|
||||
**Rule:** Before pushing any fix that modifies container state (CA certs,
|
||||
config files, installed packages, daemon restarts), reproduce the exact
|
||||
sequence locally with the actual Docker image. Do not push to CI as the
|
||||
first test.
|
||||
|
||||
This is a hard rule, not a suggestion. CI cycles take 20+ minutes and
|
||||
ephemeral staging VMs are destroyed after each run, making interactive
|
||||
debugging impossible. A local reproduction takes 30 seconds and catches
|
||||
silent failures immediately.
|
||||
|
||||
**Procedure:**
|
||||
1. `docker pull <actual_image>`
|
||||
2. `docker run -d --name <test> ...` and wait for it to start
|
||||
3. Run the exact commands from the Ansible task or script
|
||||
4. Verify the state change took effect
|
||||
5. Clean up: `docker rm -f <test>`
|
||||
|
||||
### Verified State Modification (Mandatory)
|
||||
|
||||
Ansible tasks that modify container state with `changed_when: false`
|
||||
MUST include a post-task verification step that confirms the state
|
||||
change took effect. `changed_when: false` suppresses both change
|
||||
detection AND failure visibility — a task can silently do nothing and
|
||||
report `ok`.
|
||||
|
||||
## Subagent Delegation Policy
|
||||
|
||||
Custom subagent profiles are defined in `.devin/agents/` (project-specific)
|
||||
and `~/.config/devin/agents/` (global, shared across repos). The agent MUST
|
||||
automatically delegate to the appropriate subagent based on the task —
|
||||
the user should not need to specify which profile to use.
|
||||
|
||||
### Available Profiles
|
||||
|
||||
**Global** (shared across all projects):
|
||||
|
||||
| Profile | Location | Purpose |
|
||||
|---------|----------|---------|
|
||||
| `pr-reviewer` | `~/.config/devin/agents/` | 13-category PR checklist + quality gates |
|
||||
| `release-check` | `~/.config/devin/agents/` | Pre-merge readiness validation |
|
||||
|
||||
**devx-specific** (in `.devin/agents/`):
|
||||
|
||||
| Profile | Purpose |
|
||||
|---------|---------|
|
||||
| `ci-investigator` | Investigate CI failures (quality, release, publish, wiki sync, image build) |
|
||||
| `dep-upgrader` | Python dependency upgrades in pyproject.toml with dep-doc validation |
|
||||
| `docker-image-builder` | Build/push/cleanup 3-tier runner images (ci-base, ci-quality, ci-full) |
|
||||
| `doc-sync-specialist` | Doc coverage, doc linting, wiki sync integrity |
|
||||
| `workflow-validator` | actionlint + act_runner dry-run validation |
|
||||
|
||||
### When to Delegate Automatically
|
||||
|
||||
| Trigger | Profile | Mode |
|
||||
|---------|---------|------|
|
||||
| CI run failure (quality, release, publish, sync-wiki, build-images) | `ci-investigator` | Background |
|
||||
| PR ready for review | `pr-reviewer` | Foreground |
|
||||
| Dependency upgrade requested | `dep-upgrader` | Background |
|
||||
| Docker image build/push needed | `docker-image-builder` | Background |
|
||||
| Doc coverage failure or wiki sync issue | `doc-sync-specialist` | Background |
|
||||
| Workflow YAML modified or validation needed | `workflow-validator` | Background |
|
||||
| Branch ready for merge | `release-check` | Foreground |
|
||||
|
||||
### Delegation Rules
|
||||
|
||||
1. **Auto-select the profile.** Do not ask the user which profile to use.
|
||||
2. **Background by default, foreground when blocking.**
|
||||
3. **Provide full context in the prompt** — subagents don't inherit conversation history.
|
||||
4. **One subagent per concern.** Chain: investigate → fix in main session → review.
|
||||
5. **Don't delegate trivial work** (<30s, <50 lines of context).
|
||||
6. **Compact after subagent returns.**
|
||||
7. **Never skip delegation to save time** — it keeps main context small.
|
||||
|
||||
|
||||
## Feedback Issue Handling
|
||||
|
||||
Subagents create Gitea issues in the current repo when they encounter
|
||||
tool, workflow, or process issues that warrant follow-up. These issues
|
||||
use the `feedback` label plus a category label (`tooling`,
|
||||
`ci-improvement`, `doc-improvement`, `workflow-improvement`).
|
||||
|
||||
Standard labels are created automatically by `configure_repo` (runs in
|
||||
post-merge on every master push). If a label does not exist yet, the
|
||||
subagent's issue creation will still succeed — labels can be added
|
||||
afterwards.
|
||||
|
||||
### When a Subagent Reports a Feedback Issue URL
|
||||
|
||||
1. **Acknowledge it** in your response to the user — mention the issue URL
|
||||
2. **Do NOT close or modify** the issue — it is for follow-up work
|
||||
3. **Do NOT create a PR** to address it unless the user explicitly asks
|
||||
4. If the user asks to address feedback, spawn a subagent to investigate
|
||||
the issue and implement a fix
|
||||
|
||||
### Creating Feedback Issues Manually
|
||||
|
||||
As the parent agent, you can also create feedback issues directly using
|
||||
the Gitea MCP (`issue_write` with `create_issue` method). Follow the
|
||||
same format as subagents:
|
||||
|
||||
- Title: `[feedback] <category>: <short description>`
|
||||
- Labels: `feedback` + category label
|
||||
- Body: include context, tool/workflow, issue, reproduction, affected
|
||||
files, suggested investigation, and "Reported by: parent agent"
|
||||
|
||||
Always deduplicate first via `list_issues` with `labels: "feedback"`.
|
||||
|
||||
|
||||
+251
@@ -2,6 +2,257 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [0.36.2] - 2026-07-07
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- GiteaClient.set_repo_variable uses PUT instead of PATCH
|
||||
|
||||
## [0.36.1] - 2026-07-07
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Preserve .badges/ dir during git clean in push_badges
|
||||
|
||||
## [0.36.0] - 2026-07-07
|
||||
|
||||
### Features
|
||||
|
||||
- Add GiteaClient repo variable methods and parallelize pytest-cov
|
||||
|
||||
## [0.35.7] - 2026-07-06
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Use Gitea wiki dash-marker filename convention
|
||||
|
||||
## [0.35.6] - 2026-07-06
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add delay before wiki verification to avoid race condition
|
||||
|
||||
## [0.35.5] - 2026-07-06
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Embed token in wiki clone URL for push auth
|
||||
|
||||
## [0.35.4] - 2026-07-06
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Configure git identity before commit in sync_wiki
|
||||
|
||||
## [0.35.3] - 2026-07-06
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Replace --strict with --verify for sync_wiki
|
||||
|
||||
## [0.35.2] - 2026-07-06
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Exclude .vale directory from lint_docs scanning
|
||||
|
||||
## [0.35.1] - 2026-07-06
|
||||
|
||||
### Refactor
|
||||
|
||||
- Rewrite sync_wiki.py to use git-based approach
|
||||
|
||||
## [0.35.0] - 2026-07-06
|
||||
|
||||
### Features
|
||||
|
||||
- Enrich lint_docs.py with single H1, max depth, line length, code block lang, orphan checks
|
||||
|
||||
## [0.34.0] - 2026-07-06
|
||||
|
||||
### Features
|
||||
|
||||
- Enhance documentation-as-code with badges, version refs, Vale
|
||||
|
||||
## [0.33.4] - 2026-07-06
|
||||
|
||||
### Refactor
|
||||
|
||||
- Remove project-specific references from devx
|
||||
|
||||
## [0.33.3] - 2026-07-06
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Make wiki sync resilient to API timeouts and stale page lists
|
||||
|
||||
## [0.33.2] - 2026-07-05
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Abort sync_wiki when list_wiki_pages fails
|
||||
|
||||
## [0.33.1] - 2026-07-05
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Build images after post-merge publish, not on push
|
||||
|
||||
## [0.33.0] - 2026-07-05
|
||||
|
||||
### Features
|
||||
|
||||
- Add check_api_identity_checks, setup_ssh_key, and api utils
|
||||
|
||||
## [0.32.1] - 2026-07-01
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Add missing i18n translations for new tools
|
||||
|
||||
## [0.32.0] - 2026-07-01
|
||||
|
||||
### Features
|
||||
|
||||
- Extract docker-login, tofu-ops, check-deps, install-tofu to Python tools
|
||||
|
||||
## [0.31.0] - 2026-07-01
|
||||
|
||||
### Features
|
||||
|
||||
- Centralize venv management in devx.mak
|
||||
|
||||
## [0.30.0] - 2026-07-01
|
||||
|
||||
### Features
|
||||
|
||||
- Add standard label creation to configure_repo
|
||||
|
||||
## [0.29.1] - 2026-07-01
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Strip task ID prefix from commit messages in extract_conventional_msg
|
||||
|
||||
## [0.29.0] - 2026-07-01
|
||||
|
||||
### Features
|
||||
|
||||
- Detect badge commits as automated CI commits
|
||||
|
||||
## [0.28.0] - 2026-07-01
|
||||
|
||||
### Features
|
||||
|
||||
- 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
|
||||
|
||||
@@ -6,6 +6,36 @@ BIN := $(VENV)/bin
|
||||
|
||||
all: setup
|
||||
|
||||
# --- devx.mak integration ----------------------------------------------------
|
||||
# Include shared targets from the devx package itself (venv management,
|
||||
# workflow-lint, notify-failure, checkmake, lint targets, quality checks, etc.)
|
||||
# Since devx IS the package, we can include its own devx.mak.
|
||||
DEVX_PYTHON := $(BIN)/python
|
||||
DEVX_VENV := $(VENV)
|
||||
DEVX_BIN := $(BIN)
|
||||
DEVX_LINT_PATHS := src/ tests/
|
||||
DEVX_COV_PKG := src/devx
|
||||
DEVX_TEST_PATHS := tests/
|
||||
|
||||
DEVX_MAK := $(shell $(BIN)/python -c \
|
||||
"from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
|
||||
2>/dev/null)
|
||||
# Fallback: when the venv doesn't exist yet (chicken-and-egg), use the
|
||||
# source tree copy directly. devx IS the package, so src/devx/make/devx.mak
|
||||
# is always available in this repo.
|
||||
ifeq ($(strip $(DEVX_MAK)),)
|
||||
DEVX_MAK := $(CURDIR)/src/devx/make/devx.mak
|
||||
endif
|
||||
-include $(DEVX_MAK)
|
||||
|
||||
# venv, .env, and activate-scripts are provided by devx.mak
|
||||
# (devx-venv, devx-env, devx-activate-scripts, $(DEVX_VENV)/bin/activate rule)
|
||||
# Aliases for convenience and backward compatibility:
|
||||
.PHONY: venv activate-scripts
|
||||
venv: devx-venv
|
||||
.env: devx-env
|
||||
activate-scripts: devx-activate-scripts
|
||||
|
||||
# Full setup for local development
|
||||
setup: $(VENV)/bin/activate .env activate-scripts install-tools
|
||||
@$(BIN)/pip install -e '.[dev]' 2>/dev/null; \
|
||||
@@ -31,25 +61,13 @@ setup-release: $(VENV)/bin/activate .env
|
||||
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,lint,release" --no-pre-commit
|
||||
|
||||
# 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 -e . --no-deps 2>/dev/null; \
|
||||
export PATH="$$HOME/.local/bin:$$PATH"; \
|
||||
python3 -m devx.tools.install_tools --tool hadolint 2>/dev/null || true; \
|
||||
@if [ -d /opt/venv ]; then ln -sf /opt/venv $(VENV); . $(VENV)/bin/activate && pip install --no-cache-dir -e . 2>/dev/null; \
|
||||
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
|
||||
|
||||
.env:
|
||||
@if [ ! -f .env ]; then cp .env.example .env; echo "Created .env from .env.example — please edit it."; fi
|
||||
|
||||
$(VENV)/bin/activate:
|
||||
@python3 -c "import sys; v=sys.version_info; assert v >= (3, 12), f'Python 3.12+ required, found {v.major}.{v.minor}'; print(f'Python {v.major}.{v.minor}.{v.micro} OK')"
|
||||
$(PYTHON) -m venv $(VENV)
|
||||
$(BIN)/pip install --upgrade pip setuptools wheel
|
||||
|
||||
activate-scripts: $(VENV)/bin/activate
|
||||
@test -f activate.sh || (echo '#!/usr/bin/env bash' > activate.sh && echo 'source "$$(cd "$$(dirname "$${BASH_SOURCE[0]}")" && pwd)/.venv/bin/activate"' >> activate.sh && chmod +x activate.sh)
|
||||
@test -f activate.fish || (echo '#!/usr/bin/env fish' > activate.fish && echo 'set -l script_dir (dirname (status --current-filename))' >> activate.fish && echo 'source "$$script_dir/.venv/bin/activate.fish"' >> activate.fish && chmod +x activate.fish)
|
||||
@test -f activate.zsh || (echo '#!/usr/bin/env zsh' > activate.zsh && echo '0="$${ZERO:-$${0:#$$ZSH_ARGZERO}}"' >> activate.zsh && echo '0="$${$${(M)0:#/*}:-$$PWD/$$0}"' >> activate.zsh && echo 'source "$${0:A:h}/.venv/bin/activate"' >> activate.zsh && chmod +x activate.zsh)
|
||||
|
||||
install-hooks:
|
||||
@cp hooks/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
|
||||
@cp hooks/pre-push .git/hooks/pre-push && chmod +x .git/hooks/pre-push
|
||||
@@ -59,23 +77,13 @@ install-tools: $(VENV)/bin/activate
|
||||
@$(BIN)/pip install -e '.' 2>/dev/null; \
|
||||
$(BIN)/python -m devx.tools.install_tools
|
||||
|
||||
# --- devx.mak integration ----------------------------------------------------
|
||||
# Include shared targets from the devx package itself (workflow-lint,
|
||||
# notify-failure, checkmake, lint targets, quality checks, etc.)
|
||||
# Since devx IS the package, we can include its own devx.mak.
|
||||
DEVX_PYTHON := $(BIN)/python
|
||||
DEVX_VENV := $(VENV)
|
||||
DEVX_BIN := $(BIN)
|
||||
DEVX_LINT_PATHS := src/ tests/
|
||||
DEVX_COV_PKG := src/devx
|
||||
DEVX_TEST_PATHS := tests/
|
||||
|
||||
DEVX_MAK := $(shell $(BIN)/python -c \
|
||||
"from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
|
||||
2>/dev/null)
|
||||
-include $(DEVX_MAK)
|
||||
|
||||
# Aliases — project-specific names map to devx.mak targets
|
||||
.PHONY: lint-ruff lint-format typecheck lint-bandit lint-deps lint
|
||||
.PHONY: workflow-lint workflow-dryrun workflow-dryrun-safe workflow-check
|
||||
.PHONY: notify-failure checkmake check-mutable-globals check-dep-docs
|
||||
.PHONY: check-test-speed check-test-coverage check-docs
|
||||
.PHONY: create-task create-pr push-with-pr git-push rebase pr-rebase
|
||||
.PHONY: lint-all lint-dockerfiles
|
||||
lint-ruff: devx-lint-ruff
|
||||
lint-format: devx-lint-format
|
||||
typecheck: devx-typecheck
|
||||
@@ -97,16 +105,17 @@ create-task: devx-create-task
|
||||
create-pr: devx-create-pr
|
||||
push-with-pr: devx-push-with-pr
|
||||
git-push: devx-push
|
||||
rebase: devx-rebase
|
||||
pr-rebase: devx-pr-rebase
|
||||
|
||||
lint-all: lint workflow-lint lint-dockerfiles
|
||||
@echo "[lint-all] All linting checks passed."
|
||||
|
||||
# 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
|
||||
@command -v hadolint >/dev/null 2>&1 || { echo "hadolint not found" >&2; exit 1; }
|
||||
@find docker -name 'Dockerfile*' -exec hadolint {} +
|
||||
@echo "[lint-dockerfiles] All Dockerfiles passed."
|
||||
|
||||
|
||||
@@ -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.36.2",
|
||||
]
|
||||
|
||||
[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` (for example, `"devx==0.36.2"`) or use a version constraint
|
||||
> (for example, `"devx>=0.36.2,<0.37"`).
|
||||
|
||||
### 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
|
||||
|
||||
@@ -417,7 +420,7 @@ make clean # Remove caches, build artifacts, coverage data
|
||||
| `make lint-deps` | pip-audit dependency vulnerability scan |
|
||||
| `make test-unit` | Unit tests without coverage |
|
||||
| `make pytest-cov` | Unit tests with 100% coverage enforcement |
|
||||
| `make workflow-lint` | actionlint on .gitea/workflows/*.yml |
|
||||
| `make workflow-lint` | actionlint on `.gitea/workflows/*.yml` |
|
||||
| `make workflow-dryrun` | act_runner exec --dryrun on all workflows |
|
||||
| `make workflow-check` | workflow-lint + workflow-dryrun |
|
||||
| `make clean` | Remove caches, build artifacts, coverage data |
|
||||
@@ -431,7 +434,7 @@ devx is a self-contained Python package under `src/devx/`. It never imports
|
||||
from scripts outside the package. All tools are invoked via
|
||||
`python -m devx.ci.*`, `python -m devx.tools.*`, or `python -m devx.molecule.*`.
|
||||
|
||||
```
|
||||
```text
|
||||
src/devx/
|
||||
├── __init__.py # Version (single source of truth, read by setuptools)
|
||||
├── cli.py # Click-based CLI entry point (devx command)
|
||||
|
||||
@@ -11,6 +11,10 @@ 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] \
|
||||
|
||||
@@ -13,7 +13,7 @@ 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 \
|
||||
RUN python3 -m devx.tools.install_tools --tool actionlint --tool vale \
|
||||
&& python3 -m devx.tools.install_checkmake
|
||||
|
||||
# Install hadolint (Dockerfile linter)
|
||||
|
||||
+10
-9
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
|
||||
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
|
||||
[](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/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.36.2",
|
||||
]
|
||||
|
||||
[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.36.2"` or `"devx>=0.36.2,<0.37"`.
|
||||
|
||||
### Optional extras
|
||||
|
||||
@@ -131,7 +131,7 @@ wiki sync details.
|
||||
devx provides a `devx` CLI with three command groups:
|
||||
|
||||
- `devx ci <command>` — CI/CD automation (17 commands)
|
||||
- `devx tools <command>` — Developer tools (7 commands)
|
||||
- `devx tools <command>` — Developer tools (9 commands)
|
||||
- `devx molecule <command>` — Molecule testing (4 commands, optional)
|
||||
|
||||
See [CLI Commands](CLI-Commands) for full command documentation with examples.
|
||||
@@ -158,6 +158,7 @@ for the full configuration reference, PR workflow, and project conventions.
|
||||
## Wiki pages
|
||||
|
||||
- [Home](Home) — This page
|
||||
- [Getting Started](Getting-Started) — Installation, configuration, and quick start guide
|
||||
- [CLI Commands](CLI-Commands) — Full CLI command documentation with examples
|
||||
- [Architecture](Architecture) — Package structure, module descriptions, design principles
|
||||
- [CI/CD Workflow](CI-CD-Workflow) — Pipeline documentation, workflows, and CI scripts
|
||||
|
||||
@@ -1,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"
|
||||
|
||||
+13
-11
@@ -6,7 +6,7 @@ from scripts outside the package.
|
||||
|
||||
## Package structure
|
||||
|
||||
```
|
||||
```text
|
||||
src/devx/
|
||||
├── __init__.py # Version (single source of truth, read by setuptools)
|
||||
├── cli.py # Click-based CLI entry point (devx command)
|
||||
@@ -86,7 +86,7 @@ overridden via environment variables with the `DEVX_` prefix. Provides:
|
||||
|
||||
- `GITEA_API_URL` / `VIKUNJA_API_URL` — API endpoints
|
||||
- `REPO_OWNER` — repository owner (must be set per-project)
|
||||
- `TASK_PREFIX` / `TASK_ID_RE` — task ID prefix and regex (e.g., `DEVX-N`)
|
||||
- `TASK_PREFIX` / `TASK_ID_RE` — task ID prefix and regex (for example, `DEVX-N`)
|
||||
- `VIKUNJA_PROJECT_ID` — Vikunja project for task tracking
|
||||
- `DEFAULT_TIMEOUT`, `DEFAULT_PER_PAGE` — HTTP client defaults
|
||||
- `MAX_RETRIES`, `RETRY_BACKOFF_BASE`, `RETRY_STATUS_CODES` — retry config
|
||||
@@ -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
|
||||
@@ -122,7 +122,9 @@ exponential backoff (2s, 4s, 8s).
|
||||
- Labels (list, create, add to issues)
|
||||
- Issues (create, list)
|
||||
- Pull requests (get commits, merge, create review)
|
||||
- Releases (list)
|
||||
- Releases (list, create idempotent)
|
||||
- Actions (list runs, list jobs, get job logs)
|
||||
- Actions variables (get, set idempotent)
|
||||
- Wiki pages (list, fetch, create, update, delete)
|
||||
|
||||
**`VikunjaClient`** — Vikunja REST API wrapper:
|
||||
@@ -206,7 +208,7 @@ a layered rule system configured in `pyproject.toml` under
|
||||
4. **Default**: user-facing (safe default — any unknown file triggers release)
|
||||
|
||||
Also supports custom tags (orthogonal to release impact) for CI conditional
|
||||
execution (e.g., `ansible` tag to trigger molecule tests).
|
||||
execution (for example, `ansible` tag to trigger molecule tests).
|
||||
|
||||
### `pr_review.py`
|
||||
|
||||
@@ -382,7 +384,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.
|
||||
@@ -432,14 +434,14 @@ v2 failures. Supports loading custom platforms from a JSON file.
|
||||
3. **Tool modules** (`devx.tools.*`) may import from `devx.api_clients`,
|
||||
`devx.config`, `devx.gitea_cli`
|
||||
4. **Cross-module imports** within `devx.ci.*` or `devx.tools.*` are allowed
|
||||
but must be documented (e.g., `release.py` imports from
|
||||
but must be documented (for example, `release.py` imports from
|
||||
`classify_changes.py`)
|
||||
|
||||
## Data flow
|
||||
|
||||
### PR lifecycle
|
||||
|
||||
```
|
||||
```text
|
||||
Developer creates Vikunja task (DEVX-N)
|
||||
│
|
||||
▼
|
||||
@@ -475,7 +477,7 @@ CI workflow (ci.yml) triggers:
|
||||
|
||||
### Post-merge flow
|
||||
|
||||
```
|
||||
```text
|
||||
Push to master (squash-merge commit: "DEVX-N <conventional commit>")
|
||||
│
|
||||
▼
|
||||
@@ -519,7 +521,7 @@ Post-merge workflow (post-merge.yml) triggers:
|
||||
|
||||
### Publish flow
|
||||
|
||||
```
|
||||
```text
|
||||
Tag push (vX.Y.Z) triggers publish workflow (publish.yml):
|
||||
│
|
||||
▼
|
||||
@@ -536,7 +538,7 @@ Tag push (vX.Y.Z) triggers publish workflow (publish.yml):
|
||||
|
||||
### Badge generation flow
|
||||
|
||||
```
|
||||
```text
|
||||
push_badges.py:
|
||||
│
|
||||
├── fetch_latest_master() → git fetch + reset --hard origin/master
|
||||
|
||||
@@ -6,7 +6,7 @@ tag-triggered publishing.
|
||||
|
||||
## Workflow overview
|
||||
|
||||
```
|
||||
```text
|
||||
PR opened/synchronized ──► CI (ci.yml)
|
||||
│ ├── quality
|
||||
│ ├── detect-changes
|
||||
@@ -93,7 +93,7 @@ Depends on `quality`, `detect-changes`, and `pr-review`. The final job in the
|
||||
CI workflow. Runs `python -m devx.ci.auto_merge` with the branch name, PR
|
||||
title, repository, and PR number:
|
||||
|
||||
1. **Read task ID** from branch name (e.g., `DEVX-12-fix-foo` → `DEVX-12`)
|
||||
1. **Read task ID** from branch name (for example, `DEVX-12-fix-foo` → `DEVX-12`)
|
||||
2. **Validate PR title format** — must be `{PREFIX}-N: <vikunja task title>`
|
||||
3. **Validate PR title matches Vikunja task** — fetches the Vikunja task and
|
||||
compares the title
|
||||
@@ -143,7 +143,7 @@ updates.
|
||||
|
||||
### Job dependency graph
|
||||
|
||||
```
|
||||
```text
|
||||
detect-type ──┬── validate-commit-msg (skip if release commit)
|
||||
├── release (skip if release commit)
|
||||
│ │
|
||||
@@ -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`
|
||||
|
||||
@@ -205,7 +205,7 @@ automation job. Runs `python -m devx.ci.release`:
|
||||
8. **Push** — pushes both the commit and tag to master
|
||||
|
||||
The script is idempotent: if there are no new conventional commits since the
|
||||
last tag, it exits without doing anything. If the tag already exists (e.g.,
|
||||
last tag, it exits without doing anything. If the tag already exists (for example,
|
||||
from a partial previous run), it skips tag creation and only pushes.
|
||||
|
||||
**Tag consistency**: Before releasing, the script fetches remote tags and
|
||||
@@ -329,7 +329,7 @@ On failure, the `notify_failure` step creates a Gitea issue.
|
||||
### `auto_merge.py`
|
||||
|
||||
Auto-merge PR when all CI checks pass. Reads task ID from the branch name
|
||||
(e.g., `DEVX-12-fix-foo` → `DEVX-12`). Validates PR title format, checks the
|
||||
(for example, `DEVX-12-fix-foo` → `DEVX-12`). Validates PR title format, checks the
|
||||
Vikunja task exists and the title matches, extracts the conventional commit
|
||||
message from PR commits, and squash-merges with
|
||||
`{PREFIX}-N <conventional commit>` title.
|
||||
|
||||
@@ -127,14 +127,37 @@ Click commands from `cli.py` and checks if each has documentation in
|
||||
|
||||
```bash
|
||||
devx ci doc-coverage
|
||||
devx ci doc-coverage --docs-dir docs/ --fail-on-missing
|
||||
devx ci doc-coverage --docs-dir docs/ --source-dir src/ --fail-on-missing
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--docs-dir <dir>` — path to the docs directory (default: `docs/`)
|
||||
- `--source-dir <dir>` — path to the source directory (default: auto-detect)
|
||||
- `--fail-on-missing` — exit with non-zero status if any documentation is
|
||||
missing
|
||||
|
||||
### `devx ci lint-docs`
|
||||
|
||||
Lint documentation files for structure, broken links, heading hierarchy,
|
||||
duplicate headings, TODO/FIXME markers, and trailing whitespace.
|
||||
|
||||
```bash
|
||||
devx ci lint-docs
|
||||
devx ci lint-docs --root . --fix
|
||||
devx ci lint-docs --no-check-links --no-check-stale
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--root <dir>` — repository root directory (default: `.`)
|
||||
- `--docs-dir <dir>` — docs directory (default: `<root>/docs`)
|
||||
- `--check-links/--no-check-links` — check internal links (default: yes)
|
||||
- `--check-headings/--no-check-headings` — check heading hierarchy (default: yes)
|
||||
- `--check-todo/--no-check-todo` — check for TODO/FIXME markers (default: yes)
|
||||
- `--check-stale/--no-check-stale` — check for stale docs (default: no)
|
||||
- `--check-trailing/--no-check-trailing` — check trailing whitespace (default: yes)
|
||||
- `--check-duplicates/--no-check-duplicates` — check duplicate headings (default: yes)
|
||||
- `--fix` — auto-fix trailing whitespace
|
||||
|
||||
### `devx ci integration-guard`
|
||||
|
||||
Run pytest with cross-runner failure detection. If any
|
||||
@@ -398,6 +421,35 @@ Options:
|
||||
- `--no-pre-commit` — skip pre-commit hook installation
|
||||
- `--no-tea-login` — skip tea CLI login configuration
|
||||
|
||||
### `devx tools rebase`
|
||||
|
||||
Rebase the current branch onto `origin/master` and force-push with
|
||||
`--force-with-lease`. Checks if the branch is behind master first —
|
||||
if up-to-date, exits without doing anything.
|
||||
|
||||
```bash
|
||||
devx tools rebase # rebase + force-push
|
||||
devx tools rebase -- --no-push # rebase locally only
|
||||
```
|
||||
|
||||
Options (pass after `--`):
|
||||
- `--no-push` — rebase locally without pushing
|
||||
|
||||
### `devx tools pr-rebase`
|
||||
|
||||
Rebase a pull request's head branch onto master via the Gitea API
|
||||
(server-side). This triggers a new `pull_request synchronize` event,
|
||||
which starts a new CI run. Useful when you don't have the branch
|
||||
checked out locally.
|
||||
|
||||
```bash
|
||||
devx tools pr-rebase -- --pr 42 # rebase PR #42
|
||||
devx tools pr-rebase # auto-detect PR from current branch
|
||||
```
|
||||
|
||||
Options (pass after `--`):
|
||||
- `--pr <N>` — PR number (auto-detected from current branch if omitted)
|
||||
|
||||
## Molecule Commands
|
||||
|
||||
Molecule commands require the `molecule` extra (`pip install devx[molecule]`).
|
||||
|
||||
@@ -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.36.2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"devx>=0.36.2",
|
||||
]
|
||||
```
|
||||
|
||||
### 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:
|
||||
|
||||
```text
|
||||
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
|
||||
+36
-27
@@ -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]
|
||||
@@ -28,44 +31,44 @@ version = {attr = "devx.__version__"}
|
||||
[project.optional-dependencies]
|
||||
# Test runners (pytest + coverage + parallel execution)
|
||||
ci = [
|
||||
"pytest>=9.1.0",
|
||||
"pytest-cov>=7.1.0",
|
||||
"pytest-xdist>=3.8",
|
||||
"pytest==9.1.1",
|
||||
"pytest-cov==7.1.0",
|
||||
"pytest-xdist==3.8.0",
|
||||
]
|
||||
# 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",
|
||||
]
|
||||
# Release tools (build + publish to PyPI/Gitea registry)
|
||||
release = [
|
||||
"build>=1.5.0",
|
||||
"twine>=6.2.0",
|
||||
"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-core>=2.15,<2.17",
|
||||
"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.15,<2.17",
|
||||
"boto3>=1.34",
|
||||
"docker>=7.0",
|
||||
"jinja2>=3.1",
|
||||
"pyyaml>=6.0",
|
||||
"cryptography>=41.0",
|
||||
"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,release,molecule]",
|
||||
"build>=1.3.0",
|
||||
"twine>=6.2.0",
|
||||
"build==1.5.0",
|
||||
"twine==6.2.0",
|
||||
]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
@@ -97,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"]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -139,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.22.1"
|
||||
__version__ = "0.36.2"
|
||||
|
||||
+173
-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,61 @@ 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
|
||||
|
||||
# -- actions variables (repo-level) --
|
||||
|
||||
def get_repo_variable(self, name: str) -> str | None:
|
||||
"""Read a Gitea Actions repository variable.
|
||||
|
||||
Returns the variable value, or ``None`` if the variable is not set.
|
||||
Raises :class:`APIError` on other HTTP errors.
|
||||
"""
|
||||
try:
|
||||
r = self._request("GET", f"/actions/variables/{name}")
|
||||
return r.json().get("value")
|
||||
except APIError as e:
|
||||
if e.status == 404:
|
||||
return None
|
||||
raise
|
||||
|
||||
def set_repo_variable(self, name: str, value: str) -> None:
|
||||
"""Create or update a Gitea Actions repository variable (idempotent).
|
||||
|
||||
Tries PUT first (update); if the variable doesn't exist (404),
|
||||
creates it via POST. Gitea 1.26.x does not support PATCH for
|
||||
action variables.
|
||||
"""
|
||||
try:
|
||||
self._request("PUT", f"/actions/variables/{name}", json={"value": value})
|
||||
except APIError as e:
|
||||
if e.status != 404:
|
||||
raise
|
||||
self._request("POST", f"/actions/variables/{name}", json={"value": value})
|
||||
|
||||
|
||||
class VikunjaClient:
|
||||
"""Low-level Vikunja REST API client with connection pooling."""
|
||||
@@ -312,47 +413,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 +429,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
|
||||
|
||||
+54
-35
@@ -22,7 +22,6 @@ Usage:
|
||||
|
||||
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 ""
|
||||
|
||||
|
||||
@@ -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(
|
||||
_(
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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" / "grm" / "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
|
||||
|
||||
@@ -151,9 +155,9 @@ def main(
|
||||
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 ``customer-1-vm``). Optionally, items can be objects
|
||||
with ``{"id": "...", "weight": N}`` to provide explicit weights.
|
||||
|
||||
The assigned group for *runner_index* is written to ``$GITHUB_ENV`` as
|
||||
``ASSIGNED_ITEMS`` (space-delimited) for use by subsequent steps.
|
||||
|
||||
Usage::
|
||||
|
||||
echo '["observability", "customer-1-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:
|
||||
|
||||
@@ -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,
|
||||
@@ -53,10 +54,10 @@ def cli(pytest_args: tuple[str, ...]) -> None:
|
||||
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/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
|
||||
@@ -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,587 @@
|
||||
#!/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., ``#`` → ``###``).
|
||||
- **Single H1**: each markdown file should have at most one H1 heading.
|
||||
- **Max heading depth**: headings should not exceed H4 (configurable).
|
||||
- **Max line length**: lines should not exceed 120 characters (configurable).
|
||||
- **Code block language**: fenced code blocks should specify a language.
|
||||
- **Orphan docs**: docs not linked from index.md or mapping.json (warning).
|
||||
- **Mapping completeness**: all docs/*.md should be in mapping.json (warning).
|
||||
- **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
|
||||
|
||||
# Maximum heading depth (H4 by default)
|
||||
MAX_HEADING_DEPTH = 4
|
||||
|
||||
# Maximum line length
|
||||
MAX_LINE_LENGTH = 120
|
||||
|
||||
# Code block without language: ``` followed by optional whitespace only
|
||||
_CODE_BLOCK_NO_LANG_RE = re.compile(r"^```[ \t]*$", re.MULTILINE)
|
||||
|
||||
# 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",
|
||||
".vale",
|
||||
"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
|
||||
|
||||
|
||||
def check_single_h1(root: Path) -> list[str]:
|
||||
"""Check that each markdown file has at most one H1 heading."""
|
||||
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)
|
||||
if md_file.name in DUPLICATE_HEADING_EXCLUDES:
|
||||
continue
|
||||
content = strip_code_blocks(md_file.read_text(encoding="utf-8"))
|
||||
h1_count = len(re.findall(r"^#\s+", content, re.MULTILINE))
|
||||
if h1_count > 1:
|
||||
issues.append(f"{rel_path}: {h1_count} H1 headings — should have at most 1")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def check_max_heading_depth(root: Path) -> list[str]:
|
||||
"""Check that headings don't exceed MAX_HEADING_DEPTH."""
|
||||
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"))
|
||||
for match in re.finditer(r"^(#{1,6})\s+", content, re.MULTILINE):
|
||||
level = len(match.group(1))
|
||||
if level > MAX_HEADING_DEPTH:
|
||||
line_num = content[: match.start()].count("\n") + 1
|
||||
issues.append(f"{rel_path}:{line_num}: heading depth H{level} exceeds max H{MAX_HEADING_DEPTH}")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def check_line_length(root: Path) -> list[str]:
|
||||
"""Check that no lines exceed MAX_LINE_LENGTH characters."""
|
||||
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 len(line) > MAX_LINE_LENGTH:
|
||||
issues.append(f"{rel_path}:{i}: line too long ({len(line)} > {MAX_LINE_LENGTH} chars)")
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def check_code_block_languages(root: Path) -> list[str]:
|
||||
"""Check that fenced code blocks specify a language."""
|
||||
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")
|
||||
in_code_block = False
|
||||
for i, line in enumerate(content.splitlines(), 1):
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("```"):
|
||||
if not in_code_block:
|
||||
# Opening fence — check for language
|
||||
if _CODE_BLOCK_NO_LANG_RE.match(line):
|
||||
issues.append(f"{rel_path}:{i}: code block without language specifier")
|
||||
in_code_block = True
|
||||
else:
|
||||
# Closing fence
|
||||
in_code_block = False
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
def check_orphan_docs(root: Path, docs_dir: Path) -> list[str]:
|
||||
"""Check for docs not linked from index.md or mapping.json (warnings)."""
|
||||
issues: list[str] = []
|
||||
if not docs_dir.is_dir():
|
||||
return issues
|
||||
|
||||
# Collect all referenced files from index.md and mapping.json
|
||||
referenced: set[str] = set()
|
||||
index_file = docs_dir / "index.md"
|
||||
if index_file.exists():
|
||||
content = index_file.read_text(encoding="utf-8")
|
||||
for match in _LINK_RE.finditer(content):
|
||||
url = match.group(2).strip()
|
||||
if not url.startswith(("http://", "https://", "mailto:")):
|
||||
referenced.add(url.split("#")[0])
|
||||
|
||||
mapping_file = docs_dir / "mapping.json"
|
||||
if mapping_file.exists():
|
||||
try:
|
||||
mapping = json.loads(mapping_file.read_text(encoding="utf-8"))
|
||||
if isinstance(mapping, dict):
|
||||
# Add both keys (filenames) and values (wiki page names)
|
||||
for k, v in mapping.items():
|
||||
if isinstance(k, str):
|
||||
referenced.add(k)
|
||||
if isinstance(v, str):
|
||||
referenced.add(v)
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
pass
|
||||
|
||||
# Check each doc file
|
||||
for md_file in sorted(docs_dir.rglob("*.md")):
|
||||
if md_file.name == "index.md":
|
||||
continue
|
||||
rel_path = md_file.relative_to(docs_dir).as_posix()
|
||||
if rel_path not in referenced and md_file.name not in referenced:
|
||||
issues.append(f"docs/{rel_path}: orphan doc — not linked from index.md or mapping.json")
|
||||
|
||||
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("--check-single-h1/--no-check-single-h1", "single_h1", default=True, help="Check single H1 per file.")
|
||||
@click.option("--check-depth/--no-check-depth", "depth", default=True, help="Check max heading depth.")
|
||||
@click.option("--check-line-length/--no-check-line-length", "line_length", default=True, help="Check line length.")
|
||||
@click.option("--check-code-lang/--no-check-code-lang", "code_lang", default=True, help="Check code block languages.")
|
||||
@click.option("--check-orphans/--no-check-orphans", "orphans", default=False, help="Check for orphan docs (warnings).")
|
||||
@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,
|
||||
single_h1: bool,
|
||||
depth: bool,
|
||||
line_length: bool,
|
||||
code_lang: bool,
|
||||
orphans: 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))
|
||||
|
||||
# Single H1
|
||||
if single_h1:
|
||||
click.echo(_("Checking single H1 per file..."))
|
||||
all_issues.extend(check_single_h1(root_path))
|
||||
|
||||
# Max heading depth
|
||||
if depth:
|
||||
click.echo(_("Checking max heading depth..."))
|
||||
all_issues.extend(check_max_heading_depth(root_path))
|
||||
|
||||
# Line length (warnings — badge URLs and tables can exceed 120)
|
||||
if line_length:
|
||||
click.echo(_("Checking line length..."))
|
||||
ll_issues = check_line_length(root_path)
|
||||
for issue in ll_issues[:10]: # Show first 10 only
|
||||
click.echo(f" WARN: {issue}")
|
||||
if len(ll_issues) > 10:
|
||||
click.echo(_(" ... and {n} more", n=len(ll_issues) - 10))
|
||||
click.echo(_(" {n} long lines found (warnings only)", n=len(ll_issues)))
|
||||
|
||||
# Code block languages
|
||||
if code_lang:
|
||||
click.echo(_("Checking code block languages..."))
|
||||
all_issues.extend(check_code_block_languages(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 (warnings)
|
||||
if check_stale:
|
||||
click.echo(_("Checking for stale docs..."))
|
||||
stale = check_stale_docs(root_path)
|
||||
for issue in stale:
|
||||
click.echo(f" WARN: {issue}")
|
||||
click.echo(_(" {n} stale docs found (warnings only)", n=len(stale)))
|
||||
|
||||
# Orphan docs (warnings)
|
||||
if orphans:
|
||||
click.echo(_("Checking for orphan docs..."))
|
||||
orphan_issues = check_orphan_docs(root_path, docs_path)
|
||||
for issue in orphan_issues:
|
||||
click.echo(f" WARN: {issue}")
|
||||
click.echo(_(" {n} orphan docs found (warnings only)", n=len(orphan_issues)))
|
||||
|
||||
# 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()
|
||||
@@ -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:
|
||||
|
||||
+116
-2
@@ -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,12 +540,102 @@ 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."""
|
||||
@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: CI_GITEA_TOKEN is not set."))
|
||||
@@ -533,6 +643,10 @@ def main(pr_number: str, repo: str, dry_run: bool) -> None:
|
||||
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)
|
||||
|
||||
@@ -28,7 +28,7 @@ from pathlib import Path
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
|
||||
from devx.config import GITEA_API_URL
|
||||
from devx.config import GITEA_API_URL, REPO_OWNER
|
||||
from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login
|
||||
from devx.i18n import _
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
+48
-14
@@ -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:
|
||||
@@ -85,26 +87,33 @@ def push_to_badges_branch(badges_dir: str) -> str:
|
||||
|
||||
Returns the commit SHA of the pushed badges branch.
|
||||
"""
|
||||
import shutil
|
||||
|
||||
_run(["git", "config", "user.name", "gitea-actions-bot"]) # nosec B607
|
||||
_run(["git", "config", "user.email", "actions@oblachno.fyi"]) # nosec B607
|
||||
_run(["git", "checkout", "--orphan", "badges"]) # nosec B607
|
||||
_run(["git", "rm", "-rf", "."]) # nosec B607
|
||||
# Remove untracked files/dirs left behind, but preserve .badges/ for copy below
|
||||
_run(["git", "clean", "-fdx", "-e", ".git", "-e", badges_dir]) # nosec B607
|
||||
|
||||
# Copy badge files to root
|
||||
import shutil
|
||||
|
||||
for svg in Path(badges_dir).glob("*.svg"):
|
||||
shutil.copy2(svg, Path.cwd() / svg.name)
|
||||
|
||||
_run(["git", "add", "./*.svg"]) # nosec B607
|
||||
_run(["git", "commit", "--no-verify", "-m", "Update badges [skip ci]"]) # nosec B607
|
||||
# Commit even if no changes (ensures badges branch always exists)
|
||||
result = _run_capture(["git", "diff", "--cached", "--name-only"]) # nosec B607
|
||||
if result.stdout.strip():
|
||||
_run(["git", "commit", "--no-verify", "-m", "Update badges [skip ci]"]) # nosec B607
|
||||
else:
|
||||
click.echo(_("No badge changes — skipping commit"))
|
||||
_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
|
||||
|
||||
|
||||
@@ -133,6 +142,22 @@ def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None)
|
||||
_run(["git", "fetch", "origin", "master"]) # nosec B607
|
||||
_run(["git", "reset", "--hard", "origin/master"]) # nosec B607
|
||||
|
||||
# Verify version badge matches current __version__
|
||||
from devx.tools.generate_badges import detect_package_name, read_version
|
||||
|
||||
pkg = detect_package_name(root)
|
||||
current_version = read_version(root) if pkg else "unknown"
|
||||
version_svg = Path(".badges") / "version.svg"
|
||||
if version_svg.exists():
|
||||
svg_content = version_svg.read_text()
|
||||
if current_version != "unknown" and f"v{current_version}" not in svg_content:
|
||||
click.echo(
|
||||
_(
|
||||
"WARNING: Version badge shows stale version (expected v{version}) — regenerating",
|
||||
version=current_version,
|
||||
)
|
||||
)
|
||||
|
||||
updated_any = False
|
||||
for filename in FILES_WITH_BADGE_URLS:
|
||||
filepath = root / filename
|
||||
@@ -142,11 +167,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 +185,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 +218,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
|
||||
|
||||
+81
-42
@@ -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,10 +242,36 @@ 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)
|
||||
|
||||
|
||||
def update_doc_versions(new_version: str) -> None:
|
||||
"""Update documentation version references to match the new release.
|
||||
|
||||
Runs ``check_doc_versions --fix`` so that README.md and docs/*.md
|
||||
always reference the latest released version.
|
||||
"""
|
||||
import subprocess # nosec B404
|
||||
|
||||
result = subprocess.run( # nosec B603
|
||||
[sys.executable, "-m", "devx.tools.check_doc_versions", "--fix"],
|
||||
check=False,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
click.echo(_("Updated documentation version references to v{version}", version=new_version))
|
||||
else:
|
||||
click.echo(
|
||||
_(
|
||||
"WARNING: check_doc_versions --fix failed (rc={rc}): {err}",
|
||||
rc=result.returncode,
|
||||
err=result.stderr.strip()[:200],
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def commit_release_changes(new_version: str) -> bool:
|
||||
"""Stage version file and changelog, then create a release commit.
|
||||
|
||||
@@ -274,12 +281,12 @@ def commit_release_changes(new_version: str) -> bool:
|
||||
commits are a special case generated by the release script.
|
||||
Returns True if a commit was created, False if there were no staged changes.
|
||||
"""
|
||||
run_cmd(["git", "add", INIT_FILE, CHANGELOG_FILE])
|
||||
run_cmd(["git", "add", INIT_FILE, CHANGELOG_FILE, "README.md", "docs/"])
|
||||
status = run_cmd(["git", "diff", "--cached", "--quiet"], check=False)
|
||||
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 +324,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 +365,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 +373,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 +385,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 +396,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 +639,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(
|
||||
@@ -705,7 +710,8 @@ 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 update doc version references via check_doc_versions --fix"))
|
||||
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
|
||||
@@ -718,6 +724,9 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
|
||||
update_changelog(changelog)
|
||||
click.echo(_("Updated {changelog_file}", changelog_file=CHANGELOG_FILE))
|
||||
|
||||
# Update documentation version references (README, docs/*.md)
|
||||
update_doc_versions(new_version)
|
||||
|
||||
# Verify tests pass BEFORE committing or tagging.
|
||||
# This ensures we never release a version that fails tests.
|
||||
if skip_tests:
|
||||
@@ -731,9 +740,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."))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user