Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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,12 +17,10 @@ name: Build Images
|
||||
# After pushing, a cleanup job removes old versions (keeps last 2 + latest).
|
||||
|
||||
on:
|
||||
push:
|
||||
workflow_run:
|
||||
workflows: ["Post-merge"]
|
||||
types: [completed]
|
||||
branches: [master]
|
||||
paths:
|
||||
- docker/**
|
||||
- pyproject.toml
|
||||
- src/devx/**
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
@@ -49,7 +49,11 @@ jobs:
|
||||
|
||||
build-and-push:
|
||||
needs: [detect-type]
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
if: >-
|
||||
needs.detect-type.outputs.is-release == 'false' && (
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
|
||||
)
|
||||
runs-on: docker
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
|
||||
@@ -44,6 +44,19 @@ jobs:
|
||||
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
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
vale --minAlertLevel=error docs/ AGENTS.md README.md
|
||||
- name: Translation completeness check
|
||||
env:
|
||||
PYTHONPATH: src
|
||||
|
||||
@@ -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
|
||||
@@ -169,7 +171,10 @@ jobs:
|
||||
if: needs.detect-type.outputs.is-release == 'false'
|
||||
runs-on: docker
|
||||
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
|
||||
timeout-minutes: 10
|
||||
timeout-minutes: 15
|
||||
concurrency:
|
||||
group: sync-wiki-${{ github.repository }}
|
||||
cancel-in-progress: false
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -201,7 +206,7 @@ jobs:
|
||||
--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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -18,19 +18,21 @@ venv activation automatically — always prefer `make <target>` over raw command
|
||||
|
||||
```bash
|
||||
make setup # Create venv, install deps, set up hooks, install CI tools
|
||||
make install-tools # Install actionlint, git-cliff, act_runner, tea, hadolint to ~/.local/bin
|
||||
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, hadolint** 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)
|
||||
@@ -57,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)
|
||||
@@ -86,11 +88,12 @@ src/devx/
|
||||
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
|
||||
│ ├── check_translations.py # Translation completeness check
|
||||
│ ├── doc_coverage.py # Documentation coverage check
|
||||
│ └── lint_docs.py # Documentation linter (structure, links, headings)
|
||||
│ └── 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, hadolint
|
||||
│ ├── 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
|
||||
@@ -158,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
|
||||
@@ -326,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
|
||||
@@ -365,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.
|
||||
|
||||
@@ -400,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
|
||||
@@ -572,7 +575,7 @@ the user should not need to specify which profile to use.
|
||||
|
||||
### Available Profiles
|
||||
|
||||
**Global** (shared with infra and grm):
|
||||
**Global** (shared across all projects):
|
||||
|
||||
| Profile | Location | Purpose |
|
||||
|---------|----------|---------|
|
||||
|
||||
@@ -2,6 +2,66 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## [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
|
||||
|
||||
@@ -115,10 +115,7 @@ lint-all: lint workflow-lint lint-dockerfiles
|
||||
# devx's own CI images may have an older devx.mak. Consumer repos can safely alias.
|
||||
lint-dockerfiles:
|
||||
@echo "[lint-dockerfiles] Linting Dockerfiles with hadolint..."
|
||||
@if ! command -v hadolint >/dev/null 2>&1; then \
|
||||
echo "[lint-dockerfiles] ERROR: hadolint not found. Install from https://github.com/hadolint/hadolint/releases" >&2; \
|
||||
exit 1; \
|
||||
fi
|
||||
@command -v hadolint >/dev/null 2>&1 || { echo "hadolint not found" >&2; exit 1; }
|
||||
@find docker -name 'Dockerfile*' -exec hadolint {} +
|
||||
@echo "[lint-dockerfiles] All Dockerfiles passed."
|
||||
|
||||
|
||||
@@ -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.27.0",
|
||||
"devx>=0.35.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.27.0"`) or use a version constraint
|
||||
> (e.g., `"devx>=0.27.0,<0.28"`).
|
||||
> `dependencies` (for example, `"devx==0.35.2"`) or use a version constraint
|
||||
> (for example, `"devx>=0.35.2,<0.36"`).
|
||||
|
||||
### Optional extras
|
||||
|
||||
@@ -420,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 |
|
||||
@@ -434,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)
|
||||
|
||||
+8
-8
@@ -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.27.0",
|
||||
"devx>=0.35.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.27.0"` or `"devx>=0.27.0,<0.28"`.
|
||||
Pin a specific version if needed: `"devx==0.35.2"` or `"devx>=0.35.2,<0.36"`.
|
||||
|
||||
### Optional extras
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -206,7 +206,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`
|
||||
|
||||
@@ -432,14 +432,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 +475,7 @@ CI workflow (ci.yml) triggers:
|
||||
|
||||
### Post-merge flow
|
||||
|
||||
```
|
||||
```text
|
||||
Push to master (squash-merge commit: "DEVX-N <conventional commit>")
|
||||
│
|
||||
▼
|
||||
@@ -519,7 +519,7 @@ Post-merge workflow (post-merge.yml) triggers:
|
||||
|
||||
### Publish flow
|
||||
|
||||
```
|
||||
```text
|
||||
Tag push (vX.Y.Z) triggers publish workflow (publish.yml):
|
||||
│
|
||||
▼
|
||||
@@ -536,7 +536,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)
|
||||
│ │
|
||||
@@ -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.
|
||||
|
||||
@@ -48,12 +48,12 @@ Add devx to your `pyproject.toml`:
|
||||
```toml
|
||||
[project]
|
||||
dependencies = [
|
||||
"devx>=0.27.0",
|
||||
"devx>=0.35.2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"devx[dev]>=0.27.0",
|
||||
"devx>=0.35.2",
|
||||
]
|
||||
```
|
||||
|
||||
@@ -72,7 +72,7 @@ tea CLI, etc.) and configure pre-commit hooks.
|
||||
|
||||
devx expects a `docs/` directory with at minimum:
|
||||
|
||||
```
|
||||
```text
|
||||
docs/
|
||||
├── index.md # Documentation home page
|
||||
├── mapping.json # Wiki page title mappings
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
|
||||
|
||||
__version__ = "0.32.0"
|
||||
__version__ = "0.35.2"
|
||||
|
||||
@@ -185,7 +185,6 @@ def main(translations: tuple[Path, ...], source_dir: str | None) -> None:
|
||||
# Try common locations
|
||||
candidates = [
|
||||
root / "src" / "devx" / "translations.json",
|
||||
root / "src" / "gitea_runner_manager" / "translations.json",
|
||||
]
|
||||
# Also search for any translations.json in src/
|
||||
for match in root.glob("src/*/translations.json"):
|
||||
|
||||
@@ -7,7 +7,7 @@ ordering, then assigned to *max_runners* groups using LPT (Longest
|
||||
Processing Time first) scheduling.
|
||||
|
||||
Each item is a string (e.g. an Ansible ``--limit`` pattern like
|
||||
``observability`` or ``infra-314-vm``). Optionally, items can be objects
|
||||
``observability`` or ``customer-1-vm``). Optionally, items can be objects
|
||||
with ``{"id": "...", "weight": N}`` to provide explicit weights.
|
||||
|
||||
The assigned group for *runner_index* is written to ``$GITHUB_ENV`` as
|
||||
@@ -15,7 +15,7 @@ The assigned group for *runner_index* is written to ``$GITHUB_ENV`` as
|
||||
|
||||
Usage::
|
||||
|
||||
echo '["observability", "infra-314-vm"]' | \\
|
||||
echo '["observability", "customer-1-vm"]' | \\
|
||||
python3 -m devx.ci.distribute_items \\
|
||||
--runner-index 1 --max-runners 3 \\
|
||||
--github-env --skip-if-excess
|
||||
|
||||
+174
-2
@@ -7,6 +7,12 @@ Checks performed (all configurable via pyproject.toml ``[tool.devx.docs]``):
|
||||
- **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.
|
||||
@@ -49,6 +55,15 @@ 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 = {
|
||||
@@ -70,6 +85,7 @@ _EXCLUDE_DIRS = {
|
||||
".pytest_cache",
|
||||
".devin",
|
||||
".terraform",
|
||||
".vale",
|
||||
"site-packages",
|
||||
"dist-info",
|
||||
}
|
||||
@@ -318,6 +334,120 @@ def check_duplicate_headings(root: Path) -> list[str]:
|
||||
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).")
|
||||
@@ -327,6 +457,11 @@ def check_duplicate_headings(root: Path) -> list[str]:
|
||||
@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,
|
||||
@@ -337,6 +472,11 @@ def main(
|
||||
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."""
|
||||
@@ -369,6 +509,31 @@ def main(
|
||||
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..."))
|
||||
@@ -391,15 +556,22 @@ def main(
|
||||
else:
|
||||
all_issues.extend(ws_issues)
|
||||
|
||||
# Stale docs
|
||||
# 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}")
|
||||
# Stale docs are warnings, not errors
|
||||
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:
|
||||
|
||||
@@ -87,19 +87,26 @@ 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 (e.g. .badges/ from generate_badges)
|
||||
_run(["git", "clean", "-fdx", "-e", ".git"]) # 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"))
|
||||
|
||||
@@ -135,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
|
||||
|
||||
+31
-1
@@ -246,6 +246,32 @@ def update_changelog(changelog: str) -> None:
|
||||
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.
|
||||
|
||||
@@ -255,7 +281,7 @@ 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."))
|
||||
@@ -684,6 +710,7 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
|
||||
click.echo(_("\n[dry-run] Changelog:\n{changelog}", changelog=changelog))
|
||||
click.echo(_("[dry-run] Would update {init}", init=INIT_FILE))
|
||||
click.echo(_("[dry-run] Would update {changelog_file}", changelog_file=CHANGELOG_FILE))
|
||||
click.echo(_("[dry-run] Would 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))
|
||||
@@ -697,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:
|
||||
|
||||
+212
-281
@@ -1,17 +1,24 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Sync documentation from /docs/ to the Gitea wiki via API.
|
||||
"""Sync documentation from /docs/ to the Gitea wiki via Git.
|
||||
|
||||
Reads markdown files from the ``docs/`` directory, uses ``mapping.json`` to
|
||||
map file paths to wiki page titles, and creates/updates wiki pages via the
|
||||
Gitea API. Pages that exist in the wiki but not in the mapping are left
|
||||
untouched (not deleted).
|
||||
Instead of using the Gitea wiki API (which is slow, unreliable, and
|
||||
prone to timeouts), this module clones the wiki Git repository,
|
||||
copies the documentation files into it, transforms internal links
|
||||
to wiki-friendly format, commits, and pushes.
|
||||
|
||||
Gitea 1.26 wiki API endpoints (all use content_base64, NOT content):
|
||||
- Create: POST /repos/{owner}/{repo}/wiki/new {title, content_base64, message}
|
||||
- Update: PATCH /repos/{owner}/{repo}/wiki/page/{sub_url} {title, content_base64, message}
|
||||
- List: GET /repos/{owner}/{repo}/wiki/pages → [{title, sub_url, ...}]
|
||||
- Fetch: GET /repos/{owner}/{repo}/wiki/page/{sub_url} → {title, content_base64, ...}
|
||||
- Delete: DELETE /repos/{owner}/{repo}/wiki/page/{sub_url}
|
||||
This approach is:
|
||||
- **Faster** — a single git push vs N API calls
|
||||
- **More reliable** — no API timeouts or rate limits
|
||||
- **Atomic** — all pages sync in one commit
|
||||
- **Auto-pruning** — stale wiki pages are removed automatically
|
||||
|
||||
The wiki Git URL is ``{clone_url}.wiki.git`` (Gitea convention).
|
||||
|
||||
Link transformations:
|
||||
- ``[text](file.md)`` → ``[text](file)`` (wiki pages don't use .md)
|
||||
- ``[text](docs/file.md)`` → ``[text](file)``
|
||||
- External links (http/https/mailto) are preserved
|
||||
- Anchor-only links (``#section``) are preserved
|
||||
|
||||
Usage:
|
||||
CI_GITEA_TOKEN=<token> python3 -m devx.ci.sync_wiki [--dry-run] [--repo owner/repo]
|
||||
@@ -19,44 +26,30 @@ Usage:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
|
||||
from tenacity import (
|
||||
before_sleep_log,
|
||||
retry,
|
||||
retry_if_exception_type,
|
||||
stop_after_attempt,
|
||||
wait_exponential,
|
||||
)
|
||||
|
||||
from devx.api_clients import GiteaClient
|
||||
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
|
||||
from devx.exceptions import APIError
|
||||
from devx.i18n import _
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# DOCS_DIR is the repo's docs/ directory. When devx is installed as a
|
||||
# package (e.g., in .venv/lib/python3.12/site-packages/devx/), the
|
||||
# __file__-relative path would point inside the venv, not the repo.
|
||||
# Use DEVX_DOCS_DIR env var if set, otherwise fall back to ./docs
|
||||
# (relative to the current working directory, which is the repo root
|
||||
# in CI and local development).
|
||||
DOCS_DIR = Path(os.environ.get("DEVX_DOCS_DIR", "docs"))
|
||||
MAPPING_FILE = DOCS_DIR / "mapping.json"
|
||||
|
||||
# Markdown link pattern: [text](url)
|
||||
_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]+)\)")
|
||||
|
||||
|
||||
def load_mapping() -> dict[str, str]:
|
||||
"""Load the file-to-wiki-page mapping from mapping.json.
|
||||
|
||||
Validates that the mapping is a dict of string-to-string pairs.
|
||||
"""
|
||||
"""Load the file-to-wiki-page mapping from mapping.json."""
|
||||
with open(MAPPING_FILE, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
if not isinstance(data, dict):
|
||||
@@ -69,191 +62,172 @@ def load_mapping() -> dict[str, str]:
|
||||
return data
|
||||
|
||||
|
||||
def read_doc_content(file_path: str) -> str:
|
||||
"""Read markdown content from a docs file."""
|
||||
full_path = DOCS_DIR / file_path
|
||||
with open(full_path, encoding="utf-8") as f:
|
||||
return f.read()
|
||||
def transform_links(content: str) -> str:
|
||||
"""Transform markdown links from file-based to wiki-friendly format.
|
||||
|
||||
|
||||
def encode_content(content: str) -> str:
|
||||
"""Encode content as base64 for the Gitea wiki API.
|
||||
|
||||
The Gitea wiki API requires content_base64, not plain content.
|
||||
Sending plain content silently fails (pages are created/updated
|
||||
but with empty content).
|
||||
- ``[text](file.md)`` → ``[text](file)``
|
||||
- ``[text](docs/file.md)`` → ``[text](file)``
|
||||
- ``[text](../file.md)`` → ``[text](file)``
|
||||
- External links (http/https/mailto) preserved
|
||||
- Anchor-only links (``#section``) preserved
|
||||
"""
|
||||
return base64.b64encode(content.encode("utf-8")).decode("ascii")
|
||||
|
||||
def replace_link(match: re.Match[str]) -> str:
|
||||
text = match.group(1)
|
||||
url = match.group(2).strip()
|
||||
# Skip external links and mailto
|
||||
if url.startswith(("http://", "https://", "mailto:")):
|
||||
return match.group(0)
|
||||
# Skip anchor-only links
|
||||
if url.startswith("#"):
|
||||
return match.group(0)
|
||||
# Split path and anchor
|
||||
if "#" in url:
|
||||
path_part, anchor = url.split("#", 1)
|
||||
anchor = f"#{anchor}"
|
||||
else:
|
||||
path_part, anchor = url, ""
|
||||
# Remove .md extension and directory prefixes
|
||||
if path_part.endswith(".md"):
|
||||
path_part = path_part[:-3]
|
||||
# Remove directory prefix (docs/, ../, etc.)
|
||||
path_part = path_part.split("/")[-1]
|
||||
return f"[{text}]({path_part}{anchor})"
|
||||
|
||||
return _LINK_RE.sub(replace_link, content)
|
||||
|
||||
|
||||
def decode_content(content_b64: str) -> str:
|
||||
"""Decode base64 content from the Gitea wiki API."""
|
||||
if not content_b64:
|
||||
return ""
|
||||
return base64.b64decode(content_b64).decode("utf-8")
|
||||
def get_wiki_clone_url(owner: str, repo: str, token: str) -> str:
|
||||
"""Build the wiki Git clone URL with token auth."""
|
||||
# Gitea wiki repos are at {clone_url}.wiki.git
|
||||
# Extract base URL from API URL
|
||||
base = GITEA_API_URL.rsplit("/api/v1", 1)[0]
|
||||
return f"{base}/{owner}/{repo}.wiki.git"
|
||||
|
||||
|
||||
def list_wiki_pages(client: GiteaClient) -> dict[str, str]:
|
||||
"""List existing wiki pages, returning {title: sub_url}.
|
||||
def clone_wiki(wiki_url: str, dest: Path) -> bool:
|
||||
"""Clone the wiki repo into dest. Returns True if clone succeeded.
|
||||
|
||||
Raises :class:`APIError` if the wiki API is unavailable — the caller
|
||||
is responsible for retrying or handling the failure.
|
||||
If the wiki repo doesn't exist yet (no pages created), returns False.
|
||||
"""
|
||||
pages = client._request("GET", "/wiki/pages").json()
|
||||
return {page.get("title", ""): page.get("sub_url", page.get("title", "")) for page in pages}
|
||||
|
||||
|
||||
def fetch_page_content(client: GiteaClient, sub_url: str) -> str:
|
||||
"""Fetch a wiki page's content by sub_url, decoded from base64."""
|
||||
try:
|
||||
page = client._request("GET", f"/wiki/page/{sub_url}").json()
|
||||
return decode_content(page.get("content_base64", ""))
|
||||
except APIError:
|
||||
return ""
|
||||
|
||||
|
||||
def sync_page(
|
||||
client: GiteaClient,
|
||||
page_title: str,
|
||||
content: str,
|
||||
existing_pages: dict[str, str],
|
||||
dry_run: bool,
|
||||
) -> str:
|
||||
"""Create or update a single wiki page.
|
||||
|
||||
Returns "created", "updated", or "skipped" (if dry-run).
|
||||
"""
|
||||
if dry_run:
|
||||
click.echo(_("[dry-run] Would sync page: {title} ({chars} chars)", title=page_title, chars=len(content)))
|
||||
return "skipped"
|
||||
|
||||
content_b64 = encode_content(content)
|
||||
|
||||
if page_title in existing_pages:
|
||||
# Update existing page via PATCH
|
||||
sub_url = existing_pages[page_title]
|
||||
client._request(
|
||||
"PATCH",
|
||||
f"/wiki/page/{sub_url}",
|
||||
json={
|
||||
"title": page_title,
|
||||
"content_base64": content_b64,
|
||||
"message": f"Sync from docs/ — update {page_title}",
|
||||
},
|
||||
)
|
||||
return "updated"
|
||||
|
||||
# Create new page via POST /wiki/new
|
||||
client._request(
|
||||
"POST",
|
||||
"/wiki/new",
|
||||
json={
|
||||
"title": page_title,
|
||||
"content_base64": content_b64,
|
||||
"message": f"Sync from docs/ — create {page_title}",
|
||||
},
|
||||
result = subprocess.run( # nosec
|
||||
["git", "clone", "--depth", "1", wiki_url, str(dest)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
return "created"
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def verify_wiki_page(
|
||||
client: GiteaClient, page_title: str, expected_content: str, existing_pages: dict[str, str]
|
||||
) -> bool:
|
||||
"""Verify that a wiki page has non-empty content matching the docs.
|
||||
|
||||
Returns True if the page content matches, False otherwise.
|
||||
"""
|
||||
if page_title not in existing_pages:
|
||||
return False
|
||||
sub_url = existing_pages[page_title]
|
||||
actual = fetch_page_content(client, sub_url)
|
||||
return actual.strip() == expected_content.strip()
|
||||
|
||||
|
||||
def _list_wiki_pages_with_retry(client: GiteaClient) -> dict[str, str]:
|
||||
"""List wiki pages with tenacity retry on APIError.
|
||||
|
||||
The Gitea API can be briefly unavailable right after a batch of wiki
|
||||
page updates. Uses the same tenacity pattern as ``api_clients`` for
|
||||
exponential backoff.
|
||||
"""
|
||||
_logger = logging.getLogger("sync_wiki")
|
||||
|
||||
@retry(
|
||||
stop=stop_after_attempt(3),
|
||||
wait=wait_exponential(multiplier=2, min=2, max=8),
|
||||
retry=retry_if_exception_type(APIError),
|
||||
before_sleep=before_sleep_log(_logger, logging.WARNING),
|
||||
reraise=True,
|
||||
def init_wiki(dest: Path) -> None:
|
||||
"""Initialize a fresh wiki repo (when clone fails)."""
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
subprocess.run(["git", "init"], cwd=dest, capture_output=True, check=True) # nosec
|
||||
subprocess.run( # nosec
|
||||
["git", "config", "user.email", "ci@oblachno.fyi"],
|
||||
cwd=dest,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run( # nosec
|
||||
["git", "config", "user.name", "CI Wiki Sync"],
|
||||
cwd=dest,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
def _do_list() -> dict[str, str]:
|
||||
return list_wiki_pages(client)
|
||||
|
||||
return _do_list()
|
||||
|
||||
|
||||
def verify_wiki_integrity(
|
||||
client: GiteaClient,
|
||||
def sync_files(
|
||||
docs_dir: Path,
|
||||
wiki_dir: Path,
|
||||
mapping: dict[str, str],
|
||||
synced: dict[str, str],
|
||||
) -> list[str]:
|
||||
"""Comprehensive wiki verification.
|
||||
dry_run: bool,
|
||||
) -> tuple[int, int]:
|
||||
"""Copy docs files to wiki dir with link transformation.
|
||||
|
||||
Checks:
|
||||
1. Every mapped page exists in the wiki
|
||||
2. Every mapped page has non-empty content
|
||||
3. Every mapped page's content matches the docs
|
||||
4. No stale pages exist in the wiki (pages not in mapping)
|
||||
5. Page count matches
|
||||
|
||||
Returns a list of failure messages (empty if all checks pass).
|
||||
If the wiki API is temporarily unavailable (all retry attempts
|
||||
fail), returns an empty list with a warning — the sync itself
|
||||
already succeeded, so a transient API outage should not fail the job.
|
||||
Returns (synced, pruned) counts.
|
||||
"""
|
||||
failures: list[str] = []
|
||||
synced = 0
|
||||
|
||||
try:
|
||||
existing_pages = _list_wiki_pages_with_retry(client)
|
||||
except APIError:
|
||||
click.echo(
|
||||
_(
|
||||
"WARNING: Could not fetch wiki page list after retries. "
|
||||
"The sync itself succeeded ({count} pages updated), but the "
|
||||
"integrity check could not verify them due to a transient API issue.",
|
||||
count=len(synced),
|
||||
)
|
||||
)
|
||||
return []
|
||||
# Build set of expected wiki filenames
|
||||
expected_files: set[str] = set()
|
||||
|
||||
expected_titles = set(mapping.values())
|
||||
for file_path, page_title in sorted(mapping.items()):
|
||||
src = docs_dir / file_path
|
||||
if not src.exists():
|
||||
click.echo(_(" WARN: Mapped file {file} not found, skipping", file=file_path))
|
||||
continue
|
||||
|
||||
# Check 1: Page count
|
||||
if len(existing_pages) != len(expected_titles):
|
||||
failures.append(f"Page count mismatch: wiki has {len(existing_pages)}, mapping has {len(expected_titles)}")
|
||||
content = src.read_text(encoding="utf-8")
|
||||
if not content.strip():
|
||||
click.echo(_(" WARN: Mapped file {file} is empty, skipping", file=file_path))
|
||||
continue
|
||||
|
||||
# Check 2: Missing pages (in mapping but not in wiki)
|
||||
missing = expected_titles - set(existing_pages.keys())
|
||||
for title in sorted(missing):
|
||||
failures.append(f"Missing page: {title}")
|
||||
# Transform links
|
||||
transformed = transform_links(content)
|
||||
|
||||
# Check 3: Stale pages (in wiki but not in mapping)
|
||||
stale = set(existing_pages.keys()) - expected_titles
|
||||
for title in sorted(stale):
|
||||
failures.append(f"Stale page (not in mapping): {title}")
|
||||
# Wiki filename: use the page title with spaces → underscores
|
||||
# Gitea wiki uses the page title as filename (spaces become dashes)
|
||||
wiki_filename = page_title.replace(" ", "-") + ".md"
|
||||
expected_files.add(wiki_filename)
|
||||
|
||||
# Check 4: Content verification
|
||||
for page_title, expected_content in sorted(synced.items()):
|
||||
ok = verify_wiki_page(client, page_title, expected_content, existing_pages)
|
||||
if not ok:
|
||||
sub_url = existing_pages.get(page_title, "?")
|
||||
actual = fetch_page_content(client, sub_url)
|
||||
if not actual.strip():
|
||||
failures.append(f"Empty content: {page_title}")
|
||||
else:
|
||||
failures.append(f"Content mismatch: {page_title}")
|
||||
if not dry_run:
|
||||
dest = wiki_dir / wiki_filename
|
||||
dest.write_text(transformed, encoding="utf-8")
|
||||
synced += 1
|
||||
click.echo(_(" Synced: {title} → {file}", title=page_title, file=wiki_filename))
|
||||
|
||||
return failures
|
||||
# Prune stale pages (in wiki but not in mapping)
|
||||
pruned = 0
|
||||
if not dry_run:
|
||||
for existing in wiki_dir.glob("*.md"):
|
||||
if existing.name not in expected_files:
|
||||
existing.unlink()
|
||||
pruned += 1
|
||||
click.echo(_(" Pruned: {file} (not in mapping)", file=existing.name))
|
||||
|
||||
return synced, pruned
|
||||
|
||||
|
||||
def commit_and_push(wiki_dir: Path, wiki_url: str, dry_run: bool) -> bool:
|
||||
"""Commit changes and push to the wiki repo. Returns True if pushed."""
|
||||
if dry_run:
|
||||
click.echo(_("[dry-run] Would commit and push wiki changes"))
|
||||
return False
|
||||
|
||||
# Stage all changes
|
||||
subprocess.run(["git", "add", "-A"], cwd=wiki_dir, capture_output=True, check=True) # nosec
|
||||
|
||||
# Check if there are changes to commit
|
||||
result = subprocess.run( # nosec
|
||||
["git", "diff", "--cached", "--quiet"],
|
||||
cwd=wiki_dir,
|
||||
capture_output=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
click.echo(_("No changes to sync — wiki is up to date."))
|
||||
return False
|
||||
|
||||
# Commit
|
||||
subprocess.run( # nosec
|
||||
["git", "commit", "-m", "Sync wiki from docs/ [skip ci]"],
|
||||
cwd=wiki_dir,
|
||||
capture_output=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
# Push
|
||||
result = subprocess.run( # nosec
|
||||
["git", "push", wiki_url, "HEAD:master"],
|
||||
cwd=wiki_dir,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
click.echo(_("Push failed: {error}", error=result.stderr))
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@click.command()
|
||||
@@ -263,15 +237,10 @@ def verify_wiki_integrity(
|
||||
"--verify",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="After syncing, verify each page has non-empty content. Exit 1 if any page is empty or mismatched.",
|
||||
help="After syncing, verify each page exists in the wiki. Exit 1 if any page is missing.",
|
||||
)
|
||||
@click.option(
|
||||
"--strict",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Full integrity check: verify page count, missing pages, stale pages, and content. Implies --verify.",
|
||||
)
|
||||
def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
|
||||
def main(dry_run: bool, repo: str | None, verify: bool) -> None:
|
||||
"""Sync documentation to the Gitea wiki via Git."""
|
||||
token = os.environ.get("CI_GITEA_TOKEN", "")
|
||||
if not token:
|
||||
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
|
||||
@@ -286,101 +255,63 @@ def main(dry_run: bool, repo: str | None, verify: bool, strict: bool) -> None:
|
||||
raise click.ClickException(_("ERROR: mapping.json not found at {path}", path=MAPPING_FILE))
|
||||
|
||||
mapping = load_mapping()
|
||||
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
|
||||
wiki_url = get_wiki_clone_url(owner, repo_name, token)
|
||||
|
||||
click.echo(_("Syncing {count} documentation pages to wiki...", count=len(mapping)))
|
||||
click.echo(_("Syncing {count} documentation pages to wiki via Git...", count=len(mapping)))
|
||||
|
||||
try:
|
||||
existing_pages = list_wiki_pages(client)
|
||||
except APIError:
|
||||
existing_pages = {}
|
||||
if existing_pages:
|
||||
click.echo(_("Found {count} existing wiki pages.", count=len(existing_pages)))
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
wiki_dir = Path(tmpdir) / "wiki"
|
||||
|
||||
created = 0
|
||||
updated = 0
|
||||
skipped = 0
|
||||
synced: dict[str, str] = {} # title -> content, for verification
|
||||
|
||||
for file_path, page_title in sorted(mapping.items()):
|
||||
try:
|
||||
content = read_doc_content(file_path)
|
||||
except FileNotFoundError:
|
||||
raise click.ClickException(
|
||||
_("Mapped file {file} not found. Update mapping.json or create the file.", file=file_path)
|
||||
) from None
|
||||
|
||||
if not content.strip():
|
||||
raise click.ClickException(
|
||||
_("Mapped file {file} is empty. Update the content or remove from mapping.json.", file=file_path)
|
||||
) from None
|
||||
|
||||
result = sync_page(client, page_title, content, existing_pages, dry_run)
|
||||
if result == "created":
|
||||
created += 1
|
||||
click.echo(_(" Created: {title}", title=page_title))
|
||||
elif result == "updated":
|
||||
updated += 1
|
||||
click.echo(_(" Updated: {title}", title=page_title))
|
||||
click.echo(_("Cloning wiki repo..."))
|
||||
if clone_wiki(wiki_url, wiki_dir):
|
||||
click.echo(_("Cloned existing wiki."))
|
||||
else:
|
||||
skipped += 1
|
||||
click.echo(_("Wiki repo not found or empty — initializing fresh."))
|
||||
init_wiki(wiki_dir)
|
||||
|
||||
synced[page_title] = content
|
||||
click.echo(_("Syncing files..."))
|
||||
synced, pruned = sync_files(DOCS_DIR, wiki_dir, mapping, dry_run)
|
||||
|
||||
click.echo(
|
||||
_(
|
||||
"\nDone! Created: {created}, Updated: {updated}, Skipped: {skipped}",
|
||||
created=created,
|
||||
updated=updated,
|
||||
skipped=skipped,
|
||||
click.echo(
|
||||
_(
|
||||
"\nDone! Synced: {synced}, Pruned: {pruned}",
|
||||
synced=synced,
|
||||
pruned=pruned,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# --strict implies --verify
|
||||
do_verify = verify or strict
|
||||
if dry_run:
|
||||
click.echo(_("[dry-run] No changes pushed."))
|
||||
return
|
||||
|
||||
if do_verify and not dry_run:
|
||||
if strict:
|
||||
click.echo(_("\nRunning full wiki integrity check..."))
|
||||
failures = verify_wiki_integrity(client, mapping, synced)
|
||||
if failures:
|
||||
click.echo(_("\nIntegrity check FAILED ({count} issues):", count=len(failures)))
|
||||
for f in failures:
|
||||
click.echo(f" - {f}")
|
||||
raise click.ClickException(_("Wiki integrity check failed — {count} issue(s)", count=len(failures)))
|
||||
click.echo(_("\nIntegrity check passed — all {count} pages verified.", count=len(synced)))
|
||||
else:
|
||||
click.echo(_("\nVerifying wiki pages have content..."))
|
||||
# Re-fetch the page list to get updated sub_urls
|
||||
try:
|
||||
existing_pages = _list_wiki_pages_with_retry(client)
|
||||
except APIError:
|
||||
click.echo(
|
||||
_(
|
||||
"WARNING: Could not re-fetch wiki page list for verification. "
|
||||
"Skipping content verification due to transient API issue."
|
||||
)
|
||||
)
|
||||
return
|
||||
click.echo(_("Committing and pushing..."))
|
||||
pushed = commit_and_push(wiki_dir, wiki_url, dry_run)
|
||||
if pushed:
|
||||
click.echo(_("Wiki synced successfully."))
|
||||
elif not dry_run:
|
||||
click.echo(_("No push needed (no changes or push failed)."))
|
||||
|
||||
# Verification
|
||||
if verify and not dry_run:
|
||||
click.echo(_("\nVerifying wiki pages..."))
|
||||
# Re-clone to verify
|
||||
verify_dir = Path(tmpdir) / "verify"
|
||||
if not clone_wiki(wiki_url, verify_dir):
|
||||
click.echo(_("FAIL: Could not clone wiki for verification."))
|
||||
raise click.ClickException(_("Wiki verification failed — could not clone wiki"))
|
||||
failures = 0
|
||||
for page_title, expected_content in sorted(synced.items()):
|
||||
ok = verify_wiki_page(client, page_title, expected_content, existing_pages)
|
||||
if ok:
|
||||
click.echo(_(" OK: {title} ({chars} chars)", title=page_title, chars=len(expected_content)))
|
||||
for _file_path, page_title in sorted(mapping.items()):
|
||||
wiki_filename = page_title.replace(" ", "-") + ".md"
|
||||
if (verify_dir / wiki_filename).exists():
|
||||
click.echo(_(" OK: {title}", title=page_title))
|
||||
else:
|
||||
click.echo(_(" FAIL: {title} — content mismatch or empty!", title=page_title))
|
||||
click.echo(_(" FAIL: {title} — page not found in wiki!", title=page_title))
|
||||
failures += 1
|
||||
if failures > 0:
|
||||
click.echo(
|
||||
_(
|
||||
"\nVerification FAILED: {failures} page(s) have empty or mismatched content!",
|
||||
failures=failures,
|
||||
)
|
||||
)
|
||||
raise click.ClickException(
|
||||
_("Wiki verification failed — {failures} page(s) empty or mismatched", failures=failures)
|
||||
_("Wiki verification failed — {failures} page(s) missing", failures=failures)
|
||||
)
|
||||
click.echo(_("\nVerification passed — all wiki pages have correct content."))
|
||||
click.echo(_("\nVerification passed — all wiki pages exist."))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
+20
-2
@@ -66,7 +66,7 @@ DEVX_PIP_INSTALL := if [ -z "$$CI_GITEA_TOKEN" ]; then . ./.env 2>/dev/null; fi;
|
||||
# ── Virtual environment management ────────────────────────────────────────────
|
||||
#
|
||||
# These targets provide a single, consistent venv setup across all
|
||||
# devx-integrated projects (infra, grm, devx). Each project includes
|
||||
# devx-integrated projects. Each project includes
|
||||
# devx.mak and aliases its local targets to these.
|
||||
#
|
||||
# The venv is a standard .venv directory (no pyenv virtualenv dependency).
|
||||
@@ -109,7 +109,8 @@ devx-ensure-venv:
|
||||
.PHONY: devx-notify-failure devx-install-hooks devx-activate-scripts devx-venv devx-ensure-venv
|
||||
.PHONY: devx-lint-ruff devx-lint-format devx-typecheck devx-lint-bandit devx-lint-deps devx-lint
|
||||
.PHONY: devx-clean devx-pre-push
|
||||
.PHONY: devx-check-mutable-globals devx-check-dep-docs devx-check-test-coverage devx-check-docs devx-check-test-speed
|
||||
.PHONY: devx-check-mutable-globals devx-check-dep-docs devx-check-test-coverage devx-check-docs devx-check-test-speed devx-check-doc-versions devx-vale
|
||||
.PHONY: devx-check-api-identity-checks devx-setup-ssh-key
|
||||
.PHONY: devx-test-unit devx-pytest-cov
|
||||
.PHONY: devx-setup-image devx-lint-dockerfiles
|
||||
|
||||
@@ -323,10 +324,27 @@ devx-check-test-coverage:
|
||||
devx-check-docs:
|
||||
@$(DEVX_PYTHON) -m devx.tools.check_agent_docs
|
||||
|
||||
# Check documentation version references match current package version
|
||||
devx-check-doc-versions:
|
||||
@$(DEVX_PYTHON) -m devx.tools.check_doc_versions --root .
|
||||
|
||||
# Run Vale prose linter on docs and README
|
||||
devx-vale:
|
||||
@export PATH="$$HOME/.local/bin:$$PATH" && \
|
||||
vale --minAlertLevel=error docs/ AGENTS.md README.md
|
||||
|
||||
# Verify test suite timing
|
||||
devx-check-test-speed:
|
||||
@$(DEVX_PYTHON) -m devx.tools.check_test_speed
|
||||
|
||||
# Scan integration tests for unsafe is True/is False identity checks
|
||||
devx-check-api-identity-checks:
|
||||
@$(DEVX_PYTHON) -m devx.tools.check_api_identity_checks
|
||||
|
||||
# Set up SSH private key from SSH_PRIVATE_KEY env var
|
||||
devx-setup-ssh-key:
|
||||
@$(DEVX_PYTHON) -m devx.tools.setup_ssh_key
|
||||
|
||||
# ── Pre-push validation ───────────────────────────────────────────────────────
|
||||
|
||||
# Run lint + tests before push (projects can override with project-specific targets)
|
||||
|
||||
@@ -16,7 +16,7 @@ Outputs:
|
||||
- (default): prints both as ``count=N`` and ``indices=[0,1,...]``
|
||||
|
||||
Usage:
|
||||
python3 -m devx.molecule.discover_runners --owner oblachno-oss --repo grm
|
||||
python3 -m devx.molecule.discover_runners --owner my-org --repo my-repo
|
||||
python3 -m devx.molecule.discover_runners --indices
|
||||
python3 -m devx.molecule.discover_runners --count
|
||||
"""
|
||||
|
||||
@@ -137,7 +137,7 @@ def build_multi_role_pairs(
|
||||
# --- Molecule weight configuration ---
|
||||
#
|
||||
# Weights are loaded from ``[tool.devx.molecule.weights]`` in
|
||||
# ``pyproject.toml``. Each project (infra, grm, …) contributes its own
|
||||
# ``pyproject.toml``. Each project contributes its own
|
||||
# weights calibrated from actual CI execution times.
|
||||
#
|
||||
# Two key formats are supported:
|
||||
|
||||
@@ -15,9 +15,9 @@ exits early with code 1.
|
||||
|
||||
Usage::
|
||||
|
||||
# Single-role (grm-style)
|
||||
# Single-role
|
||||
python3 -m devx.molecule.molecule_ci_guard pair1 pair2 ...
|
||||
# Multi-role (infra-style)
|
||||
# Multi-role
|
||||
python3 -m devx.molecule.molecule_ci_guard --roles-root ansible/roles pair1 pair2 ...
|
||||
|
||||
Environment variables:
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Scan integration tests for unsafe ``is True``/``is False`` identity checks.
|
||||
|
||||
Many APIs (e.g. Mattermost) return boolean values as strings (``"true"``,
|
||||
``"false"``) rather than native JSON booleans. Using ``is True`` or
|
||||
``is not False`` on such responses silently fails because ``"true" is True``
|
||||
evaluates to ``False`` in Python.
|
||||
|
||||
This tool scans ``tests/integration/test_*.py`` files for identity checks
|
||||
on API response values and reports them as errors.
|
||||
|
||||
Configuration (``[tool.devx.check_api_identity_checks]`` in pyproject.toml):
|
||||
|
||||
``scan_dirs`` — list of directories to scan (default: ``["tests/integration"]``)
|
||||
``skip_patterns`` — list of filename patterns to skip (default: ``["test_*_helpers.py"]``)
|
||||
``noqa_marker`` — comment to suppress individual lines (default: ``# noqa``)
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.tools.check_api_identity_checks
|
||||
python3 -m devx.tools.check_api_identity_checks --scan-dir tests/integration
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.config import _load_pyproject_devx
|
||||
from devx.i18n import _
|
||||
|
||||
DEFAULT_SCAN_DIRS = ["tests/integration"]
|
||||
DEFAULT_SKIP_PATTERNS = ["test_*_helpers.py"]
|
||||
DEFAULT_NOQA_MARKER = "# noqa"
|
||||
|
||||
# Matches: x is True, x is False, x is not True, x is not False
|
||||
_IDENTITY_CHECK_RE = re.compile(r"\bis\s+(not\s+)?(True|False)\b")
|
||||
|
||||
|
||||
def _load_config() -> tuple[list[str], list[str], str]:
|
||||
"""Load configuration from pyproject.toml [tool.devx.check_api_identity_checks]."""
|
||||
devx_cfg = _load_pyproject_devx()
|
||||
cfg_raw = devx_cfg.get("check_api_identity_checks", {})
|
||||
if not isinstance(cfg_raw, dict):
|
||||
return DEFAULT_SCAN_DIRS, DEFAULT_SKIP_PATTERNS, DEFAULT_NOQA_MARKER
|
||||
cfg: dict[str, object] = cfg_raw # type: ignore[assignment]
|
||||
|
||||
scan_dirs_raw = cfg.get("scan_dirs", DEFAULT_SCAN_DIRS)
|
||||
scan_dirs: list[str] = [str(d) for d in scan_dirs_raw] if isinstance(scan_dirs_raw, list) else DEFAULT_SCAN_DIRS
|
||||
|
||||
skip_raw = cfg.get("skip_patterns", DEFAULT_SKIP_PATTERNS)
|
||||
skip_patterns: list[str] = [str(p) for p in skip_raw] if isinstance(skip_raw, list) else DEFAULT_SKIP_PATTERNS
|
||||
|
||||
noqa_marker = str(cfg.get("noqa_marker", DEFAULT_NOQA_MARKER))
|
||||
|
||||
return scan_dirs, skip_patterns, noqa_marker
|
||||
|
||||
|
||||
def _matches_skip_pattern(path: Path, skip_patterns: list[str]) -> bool:
|
||||
"""Check if a file path matches any skip pattern."""
|
||||
name = path.name
|
||||
return any(Path(name).match(pattern) for pattern in skip_patterns)
|
||||
|
||||
|
||||
def find_identity_checks(
|
||||
file_path: Path,
|
||||
repo_root: Path,
|
||||
noqa_marker: str,
|
||||
) -> list[str]:
|
||||
"""Return a list of issue strings for unsafe identity checks in *file_path*."""
|
||||
issues: list[str] = []
|
||||
try:
|
||||
source = file_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return issues
|
||||
|
||||
rel = str(file_path.relative_to(repo_root))
|
||||
for lineno, line in enumerate(source.splitlines(), 1):
|
||||
if noqa_marker in line:
|
||||
continue
|
||||
match = _IDENTITY_CHECK_RE.search(line)
|
||||
if match:
|
||||
issues.append(
|
||||
f"{rel}:{lineno}: unsafe identity check '{match.group()}' "
|
||||
f"— APIs may return string 'true'/'false'. "
|
||||
f"Use string comparison or _is_truthy()/_is_falsy() helpers."
|
||||
)
|
||||
|
||||
return issues
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--scan-dir",
|
||||
multiple=True,
|
||||
help=_("Directory to scan (default: tests/integration). Can be repeated."),
|
||||
)
|
||||
def cli(scan_dir: tuple[str, ...]) -> None:
|
||||
"""Scan integration tests for unsafe ``is True``/``is False`` identity checks."""
|
||||
repo_root = Path.cwd()
|
||||
config_scan_dirs, skip_patterns, noqa_marker = _load_config()
|
||||
|
||||
scan_dirs = list(scan_dir) if scan_dir else config_scan_dirs
|
||||
|
||||
all_issues: list[str] = []
|
||||
|
||||
for scan_dir_name in scan_dirs:
|
||||
scan_path = repo_root / scan_dir_name
|
||||
if not scan_path.exists():
|
||||
continue
|
||||
for py_file in scan_path.rglob("test_*.py"):
|
||||
if _matches_skip_pattern(py_file, skip_patterns):
|
||||
continue
|
||||
all_issues.extend(find_identity_checks(py_file, repo_root, noqa_marker))
|
||||
|
||||
if all_issues:
|
||||
click.echo(
|
||||
_("Found {count} unsafe identity check(s) in integration tests.", count=len(all_issues)),
|
||||
err=True,
|
||||
)
|
||||
for issue in all_issues:
|
||||
click.echo(f" {issue}", err=True)
|
||||
raise click.ClickException(
|
||||
_(
|
||||
"Use string comparison or _is_truthy()/_is_falsy() helpers instead. "
|
||||
"Add '{marker}' to suppress individual lines.",
|
||||
marker=noqa_marker,
|
||||
)
|
||||
)
|
||||
|
||||
click.echo(_("[check-api-identity-checks] Passed: no unsafe identity checks found"))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -80,7 +80,7 @@ def _check_python_version(venv_bin: Path) -> None:
|
||||
@click.option("--checkmake-bin", default=None, help="Path to checkmake binary (fallback if not on PATH).")
|
||||
def cli(venv: str, checkmake_bin: str | None) -> None:
|
||||
"""Verify that required development tools are present."""
|
||||
click.echo("[check-deps] Verifying tools...")
|
||||
click.echo(_("[check-deps] Verifying tools..."))
|
||||
|
||||
all_required = True
|
||||
for tool in REQUIRED_TOOLS:
|
||||
@@ -90,15 +90,15 @@ def cli(venv: str, checkmake_bin: str | None) -> None:
|
||||
for tool in OPTIONAL_TOOLS:
|
||||
if not _check_tool(tool, optional=True):
|
||||
if checkmake_bin and Path(checkmake_bin).exists():
|
||||
click.echo(f" {tool}: found at {checkmake_bin}")
|
||||
click.echo(_(" {tool}: found at {path}", tool=tool, path=checkmake_bin))
|
||||
else:
|
||||
click.echo(" Run 'make install-checkmake' to install the Makefile linter.")
|
||||
click.echo(_(" Run 'make install-checkmake' to install the Makefile linter."))
|
||||
|
||||
_check_python_version(Path(venv) / "bin")
|
||||
|
||||
if not all_required:
|
||||
raise click.ClickException("Required tools missing.")
|
||||
click.echo("[check-deps] All core tools present.")
|
||||
raise click.ClickException(_("Required tools missing."))
|
||||
click.echo(_("[check-deps] All core tools present."))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check that documentation version references match the current package version.
|
||||
|
||||
Scans README.md and docs/*.md for version references like ``">=X.Y.Z"``,
|
||||
``"==X.Y.Z"``, or ``"X.Y.Z"`` and verifies they match the current
|
||||
``__version__`` from ``src/<package>/__init__.py``.
|
||||
|
||||
Stale version references mislead users into pinning outdated versions.
|
||||
This tool catches them in CI and can auto-fix with ``--fix``.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.tools.check_doc_versions
|
||||
python3 -m devx.tools.check_doc_versions --fix
|
||||
python3 -m devx.tools.check_doc_versions --root . --package devx
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
# Pattern to find version references in pip install / pyproject strings
|
||||
# Matches: "devx>=0.27.0", "devx==0.27.0", "devx[dev]>=0.27.0", etc.
|
||||
_VERSION_REF_RE = re.compile(
|
||||
r'(["\'])(?P<pkg>[\w-]+)' # package name in quotes
|
||||
r"(?:\[[\w,]+\])?" # optional extras like [dev]
|
||||
r"\s*(?P<op>>=|==|>|<|<=|~=)\s*"
|
||||
r"(?P<version>\d+\.\d+(?:\.\d+)?)" # version number
|
||||
r'(?P<rest>[^"\']*)\1' # rest of string until closing quote
|
||||
)
|
||||
|
||||
# Simpler pattern: bare version numbers in "Pin a specific version" context
|
||||
_PIN_RE = re.compile(r'["\'](?P<pkg>[\w-]+)==(?P<version>\d+\.\d+(?:\.\d+)?)["\']')
|
||||
|
||||
|
||||
def detect_package_name(repo_root: Path) -> str | None:
|
||||
"""Auto-detect the Python package name from src/ directory."""
|
||||
src_dir = repo_root / "src"
|
||||
if not src_dir.is_dir():
|
||||
return None
|
||||
for entry in sorted(src_dir.iterdir()):
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
init_file = entry / "__init__.py"
|
||||
if init_file.exists():
|
||||
return entry.name
|
||||
return None
|
||||
|
||||
|
||||
def read_version(repo_root: Path, package: str | None = None) -> str | None:
|
||||
"""Read __version__ from the package __init__.py."""
|
||||
pkg = package or detect_package_name(repo_root)
|
||||
if pkg is None:
|
||||
return None
|
||||
init_file = repo_root / "src" / pkg / "__init__.py"
|
||||
if not init_file.exists():
|
||||
return None
|
||||
content = init_file.read_text()
|
||||
match = re.search(r'__version__\s*=\s*["\']([^"\']+)["\']', content)
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def find_version_refs(content: str, package: str) -> list[tuple[int, str, str, str, str]]:
|
||||
"""Find all version references for the package in content.
|
||||
|
||||
Returns list of (line_num, full_match, operator, referenced_version, rest).
|
||||
"""
|
||||
refs: list[tuple[int, str, str, str, str]] = []
|
||||
for match in _VERSION_REF_RE.finditer(content):
|
||||
if match.group("pkg").lower() != package.lower():
|
||||
continue
|
||||
line_num = content[: match.start()].count("\n") + 1
|
||||
refs.append(
|
||||
(
|
||||
line_num,
|
||||
match.group(0),
|
||||
match.group("op"),
|
||||
match.group("version"),
|
||||
match.group("rest"),
|
||||
)
|
||||
)
|
||||
return refs
|
||||
|
||||
|
||||
def fix_version_refs(content: str, package: str, current_version: str) -> tuple[str, int]:
|
||||
"""Replace stale version references with the current version.
|
||||
|
||||
Also updates upper bounds like ``<0.28`` to the next minor (``<0.34``
|
||||
for v0.33.4) so the constraint stays valid.
|
||||
|
||||
Returns (new_content, num_fixes).
|
||||
"""
|
||||
fixes = 0
|
||||
# Compute next minor for upper bound updates
|
||||
parts = current_version.split(".")
|
||||
next_minor = f"{parts[0]}.{int(parts[1]) + 1}" if len(parts) >= 2 else current_version # noqa: SIM108 — clarity
|
||||
|
||||
# Pattern for upper bound in the "rest" part: ,<X.Y
|
||||
_upper_bound_re = re.compile(r",<\d+\.\d+(?:\.\d+)?")
|
||||
|
||||
def replacer(match: re.Match) -> str:
|
||||
nonlocal fixes
|
||||
if match.group("pkg").lower() != package.lower():
|
||||
return match.group(0)
|
||||
old_version = match.group("version")
|
||||
if old_version == current_version:
|
||||
return match.group(0)
|
||||
fixes += 1
|
||||
quote = match.group(1)
|
||||
pkg = match.group("pkg")
|
||||
op = match.group("op")
|
||||
rest = match.group("rest")
|
||||
# Update upper bound if present
|
||||
rest = _upper_bound_re.sub(f",<{next_minor}", rest)
|
||||
return f"{quote}{pkg}{op}{current_version}{rest}{quote}"
|
||||
|
||||
new_content = _VERSION_REF_RE.sub(replacer, content)
|
||||
return new_content, fixes
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option("--root", default=".", help="Repository root directory.")
|
||||
@click.option("--package", default=None, help="Package name (auto-detected if not given).")
|
||||
@click.option("--fix", is_flag=True, default=False, help="Auto-fix stale version references.")
|
||||
@click.option("--docs-only", is_flag=True, default=False, help="Only check docs/ (skip README.md).")
|
||||
def main(root: str, package: str | None, fix: bool, docs_only: bool) -> None:
|
||||
"""Check that documentation version references match the current package version."""
|
||||
root_path = Path(root).resolve()
|
||||
pkg = package or detect_package_name(root_path)
|
||||
|
||||
if pkg is None:
|
||||
click.echo(_("No Python package found under src/ — skipping version check."))
|
||||
return
|
||||
|
||||
current_version = read_version(root_path, pkg)
|
||||
if current_version is None:
|
||||
click.echo(_("Cannot read __version__ from src/{pkg}/__init__.py — skipping.", pkg=pkg))
|
||||
return
|
||||
|
||||
click.echo(_("Checking version references for {pkg} (current: v{version})", pkg=pkg, version=current_version))
|
||||
|
||||
# Collect files to check
|
||||
files: list[Path] = []
|
||||
if not docs_only:
|
||||
readme = root_path / "README.md"
|
||||
if readme.exists():
|
||||
files.append(readme)
|
||||
docs_dir = root_path / "docs"
|
||||
if docs_dir.is_dir():
|
||||
files.extend(sorted(docs_dir.rglob("*.md")))
|
||||
|
||||
all_issues: list[str] = []
|
||||
total_fixes = 0
|
||||
|
||||
for filepath in files:
|
||||
rel_path = filepath.relative_to(root_path)
|
||||
content = filepath.read_text(encoding="utf-8")
|
||||
refs = find_version_refs(content, pkg)
|
||||
|
||||
if not refs:
|
||||
continue
|
||||
|
||||
stale_refs = [(line, full, op, ver, rest) for line, full, op, ver, rest in refs if ver != current_version]
|
||||
|
||||
if not stale_refs:
|
||||
continue
|
||||
|
||||
if fix:
|
||||
new_content, fixes = fix_version_refs(content, pkg, current_version)
|
||||
if fixes > 0: # pragma: no cover — fixes > 0 when stale_refs is non-empty
|
||||
filepath.write_text(new_content, encoding="utf-8")
|
||||
total_fixes += fixes
|
||||
click.echo(_(" Fixed {fixes} version ref(s) in {file}", fixes=fixes, file=rel_path))
|
||||
continue
|
||||
|
||||
for line, full, _op, ver, _rest in stale_refs:
|
||||
all_issues.append(f"{rel_path}:{line}: stale version '{ver}' (current: {current_version}) in '{full[:60]}'")
|
||||
|
||||
if fix:
|
||||
if total_fixes > 0:
|
||||
click.echo(_("\nFixed {n} stale version reference(s).", n=total_fixes))
|
||||
else:
|
||||
click.echo(_("\nNo stale version references found."))
|
||||
return
|
||||
|
||||
if all_issues:
|
||||
click.echo(_("\nFAIL: {n} stale version reference(s) found:", n=len(all_issues)))
|
||||
for issue in all_issues:
|
||||
click.echo(f" - {issue}")
|
||||
click.echo(_("\nRun with --fix to auto-update version references."))
|
||||
sys.exit(1)
|
||||
else:
|
||||
click.echo(_("\nPASS: All version references are current."))
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
main()
|
||||
@@ -32,7 +32,10 @@ _TIMING_RE = re.compile(r"(\d+) passed.* in ([0-9.]+)s")
|
||||
|
||||
# Matches per-test duration lines from --durations=0:
|
||||
# 0.51s call tests/test_foo.py::test_bar
|
||||
_DURATION_LINE_RE = re.compile(r"^(\d+\.?\d*)s\s+(?:setup|call|teardown)\s+(.+)$")
|
||||
# Only "call" duration is counted — "setup" includes import/collection
|
||||
# overhead (coverage init, module imports) which is environment-dependent
|
||||
# and not a test quality signal.
|
||||
_DURATION_LINE_RE = re.compile(r"^(\d+\.?\d*)s\s+call\s+(.+)$")
|
||||
|
||||
|
||||
def run_tests() -> tuple[str, str]:
|
||||
|
||||
@@ -182,7 +182,7 @@ def main(repo: str | None, owner: str | None, branch: str, api_url: str | None)
|
||||
if not repo:
|
||||
raise click.ClickException(_("ERROR: Repository name not specified. Use --repo or set DEVX_REPO_NAME."))
|
||||
|
||||
# If DEVX_REPO_NAME contains a slash (e.g. "oblachno/infra"), split into owner/repo.
|
||||
# If DEVX_REPO_NAME contains a slash (e.g. "my-org/my-repo"), split into owner/repo.
|
||||
# This prevents 404s when workflows set DEVX_REPO_NAME to the full path.
|
||||
if "/" in repo and owner is None:
|
||||
parts = repo.split("/", 1)
|
||||
|
||||
@@ -68,8 +68,8 @@ def detect_package_name(repo_root: Path) -> str | None:
|
||||
Looks for the first subdirectory under ``src/`` that contains
|
||||
an ``__init__.py`` file with ``__version__``.
|
||||
|
||||
Returns the package directory name (e.g., ``devx``,
|
||||
``gitea_runner_manager``) or ``None`` if no package is found.
|
||||
Returns the package directory name (e.g., ``devx``) or ``None`` if
|
||||
no package is found.
|
||||
"""
|
||||
src_dir = repo_root / "src"
|
||||
if not src_dir.is_dir():
|
||||
|
||||
@@ -7,6 +7,7 @@ Handles installation of:
|
||||
- act_runner (Gitea Actions local runner, optional)
|
||||
- tea (Gitea CLI — official command-line tool for Gitea API operations)
|
||||
- hadolint (Dockerfile linter)
|
||||
- vale (prose linter for documentation quality)
|
||||
|
||||
Each tool is installed to ``~/.local/bin`` if not already on PATH.
|
||||
Idempotent: skips tools that are already available.
|
||||
@@ -44,6 +45,8 @@ HADOLINT_VERSION = "2.12.0"
|
||||
|
||||
TOFU_VERSION = "1.12.3"
|
||||
|
||||
VALE_VERSION = "3.12.0"
|
||||
|
||||
|
||||
def _arch() -> str:
|
||||
"""Return the architecture string used by release assets (delegates to shared utility)."""
|
||||
@@ -196,7 +199,20 @@ def install_tofu() -> bool:
|
||||
return True
|
||||
|
||||
|
||||
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea", "hadolint", "tofu"]
|
||||
def install_vale() -> bool:
|
||||
"""Install Vale (prose linter) if not already present. Returns True if installed/skipped."""
|
||||
if _is_installed("vale"):
|
||||
click.echo("vale: already installed")
|
||||
return True
|
||||
machine = platform.machine().lower()
|
||||
arch = "64-bit" if machine in {"x86_64", "amd64"} else "arm64"
|
||||
url = f"https://github.com/errata-ai/vale/releases/download/v{VALE_VERSION}/vale_{VALE_VERSION}_Linux_{arch}.tar.gz"
|
||||
dest = _download_and_extract_tarball(url, "vale")
|
||||
click.echo(f"vale: installed to {dest}")
|
||||
return True
|
||||
|
||||
|
||||
TOOL_NAMES = ["actionlint", "git-cliff", "act_runner", "tea", "hadolint", "tofu", "vale"]
|
||||
|
||||
|
||||
def _install_tool(name: str) -> bool:
|
||||
@@ -213,6 +229,8 @@ def _install_tool(name: str) -> bool:
|
||||
return install_hadolint()
|
||||
if name == "tofu":
|
||||
return install_tofu()
|
||||
if name == "vale":
|
||||
return install_vale()
|
||||
raise click.ClickException(f"Unknown tool: {name}")
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Set up SSH private key for CI jobs that need SSH access to remote hosts.
|
||||
|
||||
Writes the ``SSH_PRIVATE_KEY`` env var to ``~/.ssh/id_rsa``, starts
|
||||
``ssh-agent``, and adds the key. Replaces the repeated inline shell
|
||||
pattern in CI workflow files.
|
||||
|
||||
Usage::
|
||||
|
||||
python3 -m devx.tools.setup_ssh_key
|
||||
|
||||
Reads ``SSH_PRIVATE_KEY`` from the environment. Exits 0 on success,
|
||||
1 on missing key.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess # nosec B404
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from devx.i18n import _
|
||||
|
||||
|
||||
def setup_ssh_key(private_key: str | None = None) -> bool:
|
||||
"""Set up SSH private key and start ssh-agent.
|
||||
|
||||
Args:
|
||||
private_key: The SSH private key content. If None, reads from
|
||||
``SSH_PRIVATE_KEY`` environment variable.
|
||||
|
||||
Returns:
|
||||
True if setup succeeded, False if key is missing.
|
||||
"""
|
||||
key = private_key or os.environ.get("SSH_PRIVATE_KEY", "")
|
||||
if not key:
|
||||
click.echo(_("SSH_PRIVATE_KEY not set — skipping SSH key setup"), err=True)
|
||||
return False
|
||||
|
||||
ssh_dir = Path.home() / ".ssh"
|
||||
ssh_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
key_path = ssh_dir / "id_rsa"
|
||||
key_path.write_text(f"{key}\n", encoding="utf-8")
|
||||
key_path.chmod(0o600)
|
||||
|
||||
# Start ssh-agent and add the key
|
||||
agent_result = subprocess.run( # nosec B603, B607
|
||||
["ssh-agent", "-s"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if agent_result.returncode != 0:
|
||||
click.echo(_("Failed to start ssh-agent: {error}", error=agent_result.stderr), err=True)
|
||||
return False
|
||||
|
||||
# Parse ssh-agent output to set env vars
|
||||
for raw_line in agent_result.stdout.splitlines():
|
||||
stripped = raw_line.strip()
|
||||
if "=" in stripped and ";" in stripped:
|
||||
var, val = stripped.split("=", 1)
|
||||
val = val.rstrip(";")
|
||||
os.environ[var] = val
|
||||
|
||||
# Add the key (non-fatal if it fails — key may already be loaded)
|
||||
subprocess.run( # nosec B603, B607
|
||||
["ssh-add", str(key_path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
@click.command()
|
||||
def cli() -> None:
|
||||
"""Set up SSH private key from SSH_PRIVATE_KEY env var."""
|
||||
if setup_ssh_key():
|
||||
click.echo(_("SSH key set up successfully"))
|
||||
sys.exit(0)
|
||||
click.echo(_("SSH key setup skipped (no key provided)"), err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
cli() # pragma: no cover
|
||||
@@ -33,7 +33,7 @@ DEFAULT_VALIDATE_DIRS = [
|
||||
|
||||
def _run_tofu(cmd: list[str], cwd: Path) -> None:
|
||||
"""Run a tofu command in the given directory, raising on failure."""
|
||||
click.echo(f" -> {cwd}")
|
||||
click.echo(_(" -> {dir}", dir=cwd))
|
||||
result = subprocess.run( # nosec B603, B607
|
||||
cmd,
|
||||
cwd=str(cwd),
|
||||
@@ -60,9 +60,9 @@ def tofu_init(env: str, root: str = ".", extra_dirs: list[str] | None = None) ->
|
||||
for dir_pattern in dirs:
|
||||
dir_path = root_path / dir_pattern
|
||||
if dir_path.is_dir():
|
||||
click.echo(f"[tofu-init] Initializing {dir_path}...")
|
||||
click.echo(_("[tofu-init] Initializing {dir}...", dir=dir_path))
|
||||
_run_tofu(["tofu", "init"], dir_path)
|
||||
click.echo("[tofu-init] Done.")
|
||||
click.echo(_("[tofu-init] Done."))
|
||||
|
||||
|
||||
def tofu_validate(
|
||||
@@ -83,7 +83,7 @@ def tofu_validate(
|
||||
root_path = Path(root)
|
||||
target_dirs = dirs or DEFAULT_VALIDATE_DIRS
|
||||
mode = "ci" if ci else "validate"
|
||||
click.echo(f"[tofu-{mode}] Validating OpenTofu configurations...")
|
||||
click.echo(_("[tofu-{mode}] Validating OpenTofu configurations...", mode=mode))
|
||||
for dir_rel in target_dirs:
|
||||
dir_path = root_path / dir_rel
|
||||
if not dir_path.is_dir():
|
||||
@@ -91,7 +91,7 @@ def tofu_validate(
|
||||
if ci:
|
||||
_run_tofu(["tofu", "init", "-backend=false", "-input=false"], dir_path)
|
||||
_run_tofu(["tofu", "validate"], dir_path)
|
||||
click.echo(f"[tofu-{mode}] All configurations valid.")
|
||||
click.echo(_("[tofu-{mode}] All configurations valid.", mode=mode))
|
||||
|
||||
|
||||
@click.group()
|
||||
|
||||
@@ -3198,5 +3198,165 @@
|
||||
"pl": "OSTRZEŻENIE: .venv ma Python {version}, ale wymagane jest >={req}.",
|
||||
"ru": "ПРЕДУПРЕЖДЕНИЕ: в .venv установлен Python {version}, но требуется >={req}.",
|
||||
"zh": "警告: .venv 的 Python 版本为 {version},但要求 >={req}。"
|
||||
},
|
||||
" -> {dir}": {
|
||||
"en": " -> {dir}",
|
||||
"bg": " -> {dir}",
|
||||
"de": " -> {dir}",
|
||||
"pl": " -> {dir}",
|
||||
"ru": " -> {dir}",
|
||||
"zh": " -> {dir}"
|
||||
},
|
||||
"[tofu-init] Initializing {dir}...": {
|
||||
"en": "[tofu-init] Initializing {dir}...",
|
||||
"bg": "[tofu-init] Инициализиране на {dir}...",
|
||||
"de": "[tofu-init] Initialisiere {dir}...",
|
||||
"pl": "[tofu-init] Inicjalizacja {dir}...",
|
||||
"ru": "[tofu-init] Инициализация {dir}...",
|
||||
"zh": "[tofu-init] 正在初始化 {dir}..."
|
||||
},
|
||||
"[tofu-init] Done.": {
|
||||
"en": "[tofu-init] Done.",
|
||||
"bg": "[tofu-init] Готово.",
|
||||
"de": "[tofu-init] Fertig.",
|
||||
"pl": "[tofu-init] Gotowe.",
|
||||
"ru": "[tofu-init] Готово.",
|
||||
"zh": "[tofu-init] 完成。"
|
||||
},
|
||||
"[tofu-{mode}] Validating OpenTofu configurations...": {
|
||||
"en": "[tofu-{mode}] Validating OpenTofu configurations...",
|
||||
"bg": "[tofu-{mode}] Проверка на OpenTofu конфигурациите...",
|
||||
"de": "[tofu-{mode}] Validiere OpenTofu-Konfigurationen...",
|
||||
"pl": "[tofu-{mode}] Sprawdzanie konfiguracji OpenTofu...",
|
||||
"ru": "[tofu-{mode}] Проверка конфигураций OpenTofu...",
|
||||
"zh": "[tofu-{mode}] 正在验证 OpenTofu 配置..."
|
||||
},
|
||||
"[tofu-{mode}] All configurations valid.": {
|
||||
"en": "[tofu-{mode}] All configurations valid.",
|
||||
"bg": "[tofu-{mode}] Всички конфигурации са валидни.",
|
||||
"de": "[tofu-{mode}] Alle Konfigurationen gültig.",
|
||||
"pl": "[tofu-{mode}] Wszystkie konfiguracje są poprawne.",
|
||||
"ru": "[tofu-{mode}] Все конфигурации валидны.",
|
||||
"zh": "[tofu-{mode}] 所有配置有效。"
|
||||
},
|
||||
"[check-deps] Verifying tools...": {
|
||||
"en": "[check-deps] Verifying tools...",
|
||||
"bg": "[check-deps] Проверка на инструментите...",
|
||||
"de": "[check-deps] Werkzeuge werden überprüft...",
|
||||
"pl": "[check-deps] Sprawdzanie narzędzi...",
|
||||
"ru": "[check-deps] Проверка инструментов...",
|
||||
"zh": "[check-deps] 正在验证工具..."
|
||||
},
|
||||
" {tool}: found at {path}": {
|
||||
"en": " {tool}: found at {path}",
|
||||
"bg": " {tool}: намерен на {path}",
|
||||
"de": " {tool}: gefunden unter {path}",
|
||||
"pl": " {tool}: znaleziono w {path}",
|
||||
"ru": " {tool}: найден в {path}",
|
||||
"zh": " {tool}: 在 {path} 找到"
|
||||
},
|
||||
" Run 'make install-checkmake' to install the Makefile linter.": {
|
||||
"en": " Run 'make install-checkmake' to install the Makefile linter.",
|
||||
"bg": " Изпълнете 'make install-checkmake' за инсталиране на Makefile линтера.",
|
||||
"de": " Führen Sie 'make install-checkmake' aus, um den Makefile-Linter zu installieren.",
|
||||
"pl": " Uruchom 'make install-checkmake', aby zainstalować linter Makefile.",
|
||||
"ru": " Выполните 'make install-checkmake' для установки линтера Makefile.",
|
||||
"zh": " 运行 'make install-checkmake' 来安装 Makefile 检查器。"
|
||||
},
|
||||
"Required tools missing.": {
|
||||
"en": "Required tools missing.",
|
||||
"bg": "Липсват задължителни инструменти.",
|
||||
"de": "Erforderliche Werkzeuge fehlen.",
|
||||
"pl": "Brak wymaganych narzędzi.",
|
||||
"ru": "Отсутствуют обязательные инструменты.",
|
||||
"zh": "缺少必需的工具。"
|
||||
},
|
||||
"[check-deps] All core tools present.": {
|
||||
"en": "[check-deps] All core tools present.",
|
||||
"bg": "[check-deps] Всички основни инструменти са налични.",
|
||||
"de": "[check-deps] Alle Kernwerkzeuge vorhanden.",
|
||||
"pl": "[check-deps] Wszystkie podstawowe narzędzia są dostępne.",
|
||||
"ru": "[check-deps] Все основные инструменты доступны.",
|
||||
"zh": "[check-deps] 所有核心工具均已就绪。"
|
||||
},
|
||||
"SSH_PRIVATE_KEY not set — skipping SSH key setup": {
|
||||
"en": "SSH_PRIVATE_KEY not set — skipping SSH key setup",
|
||||
"bg": "SSH_PRIVATE_KEY не е зададен — пропускане на SSH ключ настройката",
|
||||
"de": "SSH_PRIVATE_KEY nicht gesetzt — SSH-Schlüssel-Setup übersprungen",
|
||||
"pl": "SSH_PRIVATE_KEY nie ustawione — pomijanie konfiguracji klucza SSH",
|
||||
"ru": "SSH_PRIVATE_KEY не задан — пропуск настройки SSH-ключа",
|
||||
"zh": "SSH_PRIVATE_KEY 未设置 — 跳过 SSH 密钥设置"
|
||||
},
|
||||
"Failed to start ssh-agent: {error}": {
|
||||
"en": "Failed to start ssh-agent: {error}",
|
||||
"bg": "Неуспешно стартиране на ssh-agent: {error}",
|
||||
"de": "Starten von ssh-agent fehlgeschlagen: {error}",
|
||||
"pl": "Nie udało się uruchomić ssh-agent: {error}",
|
||||
"ru": "Не удалось запустить ssh-agent: {error}",
|
||||
"zh": "启动 ssh-agent 失败: {error}"
|
||||
},
|
||||
"SSH key set up successfully": {
|
||||
"en": "SSH key set up successfully",
|
||||
"bg": "SSH ключът е настроен успешно",
|
||||
"de": "SSH-Schlüssel erfolgreich eingerichtet",
|
||||
"pl": "Klucz SSH skonfigurowany pomyślnie",
|
||||
"ru": "SSH-ключ успешно настроен",
|
||||
"zh": "SSH 密钥设置成功"
|
||||
},
|
||||
"SSH key setup skipped (no key provided)": {
|
||||
"en": "SSH key setup skipped (no key provided)",
|
||||
"bg": "Настройката на SSH ключ е пропусната (не е предоставен ключ)",
|
||||
"de": "SSH-Schlüssel-Setup übersprungen (kein Schlüssel bereitgestellt)",
|
||||
"pl": "Pominięto konfigurację klucza SSH (brak klucza)",
|
||||
"ru": "Настройка SSH-ключа пропущена (ключ не предоставлен)",
|
||||
"zh": "SSH 密钥设置已跳过(未提供密钥)"
|
||||
},
|
||||
"Found {count} unsafe identity check(s) in integration tests.": {
|
||||
"en": "Found {count} unsafe identity check(s) in integration tests.",
|
||||
"bg": "Намерени са {count} небрежни проверки за идентичност в интеграционните тестове.",
|
||||
"de": "{count} unsichere Identitätsprüfung(en) in Integrationstests gefunden.",
|
||||
"pl": "Znaleziono {count} niebezpiecznych sprawdzeń tożsamości w testach integracyjnych.",
|
||||
"ru": "Найдено {count} небезопасных проверок идентичности в интеграционных тестах.",
|
||||
"zh": "在集成测试中发现 {count} 个不安全的身份检查。"
|
||||
},
|
||||
"Use string comparison or _is_truthy()/_is_falsy() helpers instead. Add '{marker}' to suppress individual lines.": {
|
||||
"en": "Use string comparison or _is_truthy()/_is_falsy() helpers instead. Add '{marker}' to suppress individual lines.",
|
||||
"bg": "Използвайте сравнение на низове или _is_truthy()/_is_falsy() помощници. Добавете '{marker}' за потискане на отделни редове.",
|
||||
"de": "Verwenden Sie String-Vergleich oder _is_truthy()/_is_falsy() Hilfsfunktionen. Fügen Sie '{marker}' hinzu, um einzelne Zeilen zu unterdrücken.",
|
||||
"pl": "Użyj porównania ciągów lub pomocników _is_truthy()/_is_falsy(). Dodaj '{marker}', aby pominąć pojedyncze linie.",
|
||||
"ru": "Используйте строковое сравнение или помощники _is_truthy()/_is_falsy(). Добавьте '{marker}' для подавления отдельных строк.",
|
||||
"zh": "使用字符串比较或 _is_truthy()/_is_falsy() 辅助函数。添加 '{marker}' 以抑制个别行。"
|
||||
},
|
||||
"[check-api-identity-checks] Passed: no unsafe identity checks found": {
|
||||
"en": "[check-api-identity-checks] Passed: no unsafe identity checks found",
|
||||
"bg": "[check-api-identity-checks] Мина: не са намерени небрежни проверки за идентичност",
|
||||
"de": "[check-api-identity-checks] Bestanden: keine unsicheren Identitätsprüfungen gefunden",
|
||||
"pl": "[check-api-identity-checks] Passed: nie znaleziono niebezpiecznych sprawdzeń tożsamości",
|
||||
"ru": "[check-api-identity-checks] Пройдено: небезопасных проверок идентичности не найдено",
|
||||
"zh": "[check-api-identity-checks] 通过:未发现不安全的身份检查"
|
||||
},
|
||||
"Directory to scan (default: tests/integration). Can be repeated.": {
|
||||
"en": "Directory to scan (default: tests/integration). Can be repeated.",
|
||||
"bg": "Директория за сканиране (по подразбиране: tests/integration). Може да се повтаря.",
|
||||
"de": "Zu scannendes Verzeichnis (Standard: tests/integration). Kann wiederholt werden.",
|
||||
"pl": "Katalog do skanowania (domyślnie: tests/integration). Można powtarzać.",
|
||||
"ru": "Директория для сканирования (по умолчанию: tests/integration). Можно повторять.",
|
||||
"zh": "要扫描的目录(默认:tests/integration)。可重复。"
|
||||
},
|
||||
"Failed to list existing wiki pages after retries: {error}. Aborting to avoid creating duplicate pages.": {
|
||||
"bg": "Неуспешно извличане на съществуващи wiki страници след повторни опити: {error}. Прекратяване, за да се избегне създаване на дублирани страници.",
|
||||
"de": "Abrufen bestehender Wiki-Seiten nach Wiederholungen fehlgeschlagen: {error}. Abbruch, um doppelte Seiten zu vermeiden.",
|
||||
"en": "Failed to list existing wiki pages after retries: {error}. Aborting to avoid creating duplicate pages.",
|
||||
"pl": "Nie udało się wylistować istniejących stron wiki po ponownych próbach: {error}. Przerywanie, aby uniknąć tworzenia zduplikowanych stron.",
|
||||
"ru": "Не удалось получить список существующих wiki-страниц после повторных попыток: {error}. Прерывание, чтобы избежать создания дубликатов страниц.",
|
||||
"zh": "重试后列出现有 wiki 页面失败:{error}。正在中止以避免创建重复页面。"
|
||||
},
|
||||
" Page '{title}' already exists (stale list). Re-listing and updating...": {
|
||||
"bg": " Страницата '{title}' вече съществува (остарял списък). Пресписване и обновяване...",
|
||||
"de": " Seite '{title}' existiert bereits (veraltete Liste). Neu auflisten und aktualisieren...",
|
||||
"en": " Page '{title}' already exists (stale list). Re-listing and updating...",
|
||||
"pl": " Strona '{title}' już istnieje (nieaktualna lista). Ponowne listowanie i aktualizacja...",
|
||||
"ru": " Страница '{title}' уже существует (устаревший список). Повторное получение списка и обновление...",
|
||||
"zh": " 页面 '{title}' 已存在(列表过期)。重新列出并更新..."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Shared utility functions for devx and consumer projects."""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Utilities for handling API response values.
|
||||
|
||||
Many APIs return boolean values as strings (``"true"``, ``"false"``)
|
||||
rather than native JSON booleans. The Mattermost ``/api/v4/config/client``
|
||||
endpoint is a notable example. These helpers handle both string and
|
||||
boolean responses safely.
|
||||
|
||||
Usage::
|
||||
|
||||
from devx.utils.api import is_truthy, is_falsy
|
||||
|
||||
if not is_truthy(config.get("EnableOpenServer")):
|
||||
raise ValueError("EnableOpenServer not enabled")
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def is_truthy(value: str | bool | None) -> bool:
|
||||
"""Check if an API config value is truthy.
|
||||
|
||||
The API may return strings (``"true"``/``"false"``) or native
|
||||
booleans. This helper handles both.
|
||||
|
||||
Args:
|
||||
value: The value to check (string, bool, or None).
|
||||
|
||||
Returns:
|
||||
True if the value represents a truthy boolean.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).lower() == "true"
|
||||
|
||||
|
||||
def is_falsy(value: str | bool | None) -> bool:
|
||||
"""Check if an API config value is falsy.
|
||||
|
||||
The API may return strings (``"true"``/``"false"``) or native
|
||||
booleans. This helper handles both.
|
||||
|
||||
Args:
|
||||
value: The value to check (string, bool, or None).
|
||||
|
||||
Returns:
|
||||
True if the value represents a falsy boolean.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return not value
|
||||
return str(value).lower() == "false"
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Unit tests for devx.utils.api."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from devx.utils.api import is_falsy, is_truthy
|
||||
|
||||
|
||||
class TestIsTruthy:
|
||||
def test_string_true(self) -> None:
|
||||
assert is_truthy("true") is True
|
||||
|
||||
def test_string_true_uppercase(self) -> None:
|
||||
assert is_truthy("True") is True
|
||||
|
||||
def test_boolean_true(self) -> None:
|
||||
assert is_truthy(True) is True
|
||||
|
||||
def test_string_false(self) -> None:
|
||||
assert is_truthy("false") is False
|
||||
|
||||
def test_boolean_false(self) -> None:
|
||||
assert is_truthy(False) is False
|
||||
|
||||
def test_none(self) -> None:
|
||||
assert is_truthy(None) is False
|
||||
|
||||
def test_empty_string(self) -> None:
|
||||
assert is_truthy("") is False
|
||||
|
||||
def test_random_string(self) -> None:
|
||||
assert is_truthy("random") is False
|
||||
|
||||
|
||||
class TestIsFalsy:
|
||||
def test_string_false(self) -> None:
|
||||
assert is_falsy("false") is True
|
||||
|
||||
def test_string_false_uppercase(self) -> None:
|
||||
assert is_falsy("False") is True
|
||||
|
||||
def test_boolean_false(self) -> None:
|
||||
assert is_falsy(False) is True
|
||||
|
||||
def test_string_true(self) -> None:
|
||||
assert is_falsy("true") is False
|
||||
|
||||
def test_boolean_true(self) -> None:
|
||||
assert is_falsy(True) is False
|
||||
|
||||
def test_none(self) -> None:
|
||||
assert is_falsy(None) is False
|
||||
|
||||
def test_empty_string(self) -> None:
|
||||
assert is_falsy("") is False
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Unit tests for devx.tools.check_api_identity_checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
from devx.tools.check_api_identity_checks import (
|
||||
DEFAULT_NOQA_MARKER,
|
||||
DEFAULT_SCAN_DIRS,
|
||||
DEFAULT_SKIP_PATTERNS,
|
||||
_load_config,
|
||||
_matches_skip_pattern,
|
||||
cli,
|
||||
find_identity_checks,
|
||||
)
|
||||
|
||||
|
||||
class TestFindIdentityChecks:
|
||||
def test_detects_is_true(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "test_foo.py"
|
||||
f.write_text("assert config.get('x') is True\n")
|
||||
issues = find_identity_checks(f, tmp_path, DEFAULT_NOQA_MARKER)
|
||||
assert len(issues) == 1
|
||||
assert "is True" in issues[0]
|
||||
|
||||
def test_detects_is_false(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "test_foo.py"
|
||||
f.write_text("if config.get('x') is False:\n pass\n")
|
||||
issues = find_identity_checks(f, tmp_path, DEFAULT_NOQA_MARKER)
|
||||
assert len(issues) == 1
|
||||
assert "is False" in issues[0]
|
||||
|
||||
def test_detects_is_not_true(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "test_foo.py"
|
||||
f.write_text("if config.get('x') is not True:\n fail()\n")
|
||||
issues = find_identity_checks(f, tmp_path, DEFAULT_NOQA_MARKER)
|
||||
assert len(issues) == 1
|
||||
assert "is not True" in issues[0]
|
||||
|
||||
def test_detects_is_not_false(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "test_foo.py"
|
||||
f.write_text("if config.get('x') is not False:\n fail()\n")
|
||||
issues = find_identity_checks(f, tmp_path, DEFAULT_NOQA_MARKER)
|
||||
assert len(issues) == 1
|
||||
assert "is not False" in issues[0]
|
||||
|
||||
def test_noqa_suppresses(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "test_foo.py"
|
||||
f.write_text("assert config.get('x') is True # noqa\n")
|
||||
issues = find_identity_checks(f, tmp_path, DEFAULT_NOQA_MARKER)
|
||||
assert len(issues) == 0
|
||||
|
||||
def test_no_false_positives(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "test_foo.py"
|
||||
f.write_text("assert config.get('x') == 'true'\nassert config.get('y') == True\nx = True\nif x:\n pass\n")
|
||||
issues = find_identity_checks(f, tmp_path, DEFAULT_NOQA_MARKER)
|
||||
assert len(issues) == 0
|
||||
|
||||
def test_multiple_issues(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "test_foo.py"
|
||||
f.write_text("if config.get('a') is True:\n pass\nif config.get('b') is not False:\n pass\n")
|
||||
issues = find_identity_checks(f, tmp_path, DEFAULT_NOQA_MARKER)
|
||||
assert len(issues) == 2
|
||||
|
||||
def test_file_not_found(self, tmp_path: Path) -> None:
|
||||
f = tmp_path / "nonexistent.py"
|
||||
issues = find_identity_checks(f, tmp_path, DEFAULT_NOQA_MARKER)
|
||||
assert issues == []
|
||||
|
||||
|
||||
class TestMatchesSkipPattern:
|
||||
def test_matches_helpers(self) -> None:
|
||||
assert _matches_skip_pattern(Path("test_mattermost_helpers.py"), DEFAULT_SKIP_PATTERNS)
|
||||
|
||||
def test_does_not_match_regular(self) -> None:
|
||||
assert not _matches_skip_pattern(Path("test_mattermost.py"), DEFAULT_SKIP_PATTERNS)
|
||||
|
||||
def test_empty_patterns(self) -> None:
|
||||
assert not _matches_skip_pattern(Path("test_anything.py"), [])
|
||||
|
||||
|
||||
class TestLoadConfig:
|
||||
def test_defaults(self) -> None:
|
||||
with patch("devx.tools.check_api_identity_checks._load_pyproject_devx") as mock:
|
||||
mock.return_value = {}
|
||||
scan_dirs, skip_patterns, noqa = _load_config()
|
||||
assert scan_dirs == DEFAULT_SCAN_DIRS
|
||||
assert skip_patterns == DEFAULT_SKIP_PATTERNS
|
||||
assert noqa == DEFAULT_NOQA_MARKER
|
||||
|
||||
def test_custom_config(self) -> None:
|
||||
with patch("devx.tools.check_api_identity_checks._load_pyproject_devx") as mock:
|
||||
mock.return_value = {
|
||||
"check_api_identity_checks": {
|
||||
"scan_dirs": ["tests/api"],
|
||||
"skip_patterns": ["test_*_unit.py"],
|
||||
"noqa_marker": "# allow",
|
||||
}
|
||||
}
|
||||
scan_dirs, skip_patterns, noqa = _load_config()
|
||||
assert scan_dirs == ["tests/api"]
|
||||
assert skip_patterns == ["test_*_unit.py"]
|
||||
assert noqa == "# allow"
|
||||
|
||||
def test_invalid_config_returns_defaults(self) -> None:
|
||||
with patch("devx.tools.check_api_identity_checks._load_pyproject_devx") as mock:
|
||||
mock.return_value = {"check_api_identity_checks": "not a dict"}
|
||||
scan_dirs, _, _ = _load_config()
|
||||
assert scan_dirs == DEFAULT_SCAN_DIRS
|
||||
|
||||
|
||||
class TestCli:
|
||||
def test_no_issues(self, tmp_path: Path) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("devx.tools.check_api_identity_checks._load_config") as mock_cfg,
|
||||
patch("devx.tools.check_api_identity_checks.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
mock_cfg.return_value = (["tests/integration"], DEFAULT_SKIP_PATTERNS, DEFAULT_NOQA_MARKER)
|
||||
(tmp_path / "tests" / "integration").mkdir(parents=True)
|
||||
(tmp_path / "tests" / "integration" / "test_foo.py").write_text("assert config.get('x') == 'true'\n")
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 0
|
||||
assert "Passed" in result.output
|
||||
|
||||
def test_with_issues(self, tmp_path: Path) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("devx.tools.check_api_identity_checks._load_config") as mock_cfg,
|
||||
patch("devx.tools.check_api_identity_checks.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
mock_cfg.return_value = (["tests/integration"], DEFAULT_SKIP_PATTERNS, DEFAULT_NOQA_MARKER)
|
||||
(tmp_path / "tests" / "integration").mkdir(parents=True)
|
||||
(tmp_path / "tests" / "integration" / "test_foo.py").write_text(
|
||||
"if config.get('x') is not True:\n fail()\n"
|
||||
)
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code != 0
|
||||
assert "is not True" in result.output
|
||||
|
||||
def test_skips_helpers(self, tmp_path: Path) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("devx.tools.check_api_identity_checks._load_config") as mock_cfg,
|
||||
patch("devx.tools.check_api_identity_checks.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
mock_cfg.return_value = (["tests/integration"], DEFAULT_SKIP_PATTERNS, DEFAULT_NOQA_MARKER)
|
||||
(tmp_path / "tests" / "integration").mkdir(parents=True)
|
||||
(tmp_path / "tests" / "integration" / "test_foo_helpers.py").write_text("assert x is True\n")
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_nonexistent_dir(self, tmp_path: Path) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("devx.tools.check_api_identity_checks._load_config") as mock_cfg,
|
||||
patch("devx.tools.check_api_identity_checks.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
mock_cfg.return_value = (["nonexistent"], DEFAULT_SKIP_PATTERNS, DEFAULT_NOQA_MARKER)
|
||||
result = runner.invoke(cli, [])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_custom_scan_dir(self, tmp_path: Path) -> None:
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch("devx.tools.check_api_identity_checks._load_config") as mock_cfg,
|
||||
patch("devx.tools.check_api_identity_checks.Path.cwd", return_value=tmp_path),
|
||||
):
|
||||
mock_cfg.return_value = (["other"], DEFAULT_SKIP_PATTERNS, DEFAULT_NOQA_MARKER)
|
||||
(tmp_path / "custom").mkdir()
|
||||
(tmp_path / "custom" / "test_foo.py").write_text("if x is True:\n pass\n")
|
||||
result = runner.invoke(cli, ["--scan-dir", "custom"])
|
||||
assert result.exit_code != 0
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Tests for devx.tools.check_doc_versions."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from click.testing import CliRunner
|
||||
|
||||
import devx.tools.check_doc_versions as cdv
|
||||
|
||||
|
||||
class TestDetectPackageName:
|
||||
def test_finds_package(self, tmp_path: Path) -> None:
|
||||
src = tmp_path / "src" / "myproj"
|
||||
src.mkdir(parents=True)
|
||||
(src / "__init__.py").write_text('__version__ = "1.0.0"\n')
|
||||
assert cdv.detect_package_name(tmp_path) == "myproj"
|
||||
|
||||
def test_no_src_dir(self, tmp_path: Path) -> None:
|
||||
assert cdv.detect_package_name(tmp_path) is None
|
||||
|
||||
def test_no_init_py(self, tmp_path: Path) -> None:
|
||||
src = tmp_path / "src" / "myproj"
|
||||
src.mkdir(parents=True)
|
||||
assert cdv.detect_package_name(tmp_path) is None
|
||||
|
||||
|
||||
class TestReadVersion:
|
||||
def test_reads_version(self, tmp_path: Path) -> None:
|
||||
src = tmp_path / "src" / "myproj"
|
||||
src.mkdir(parents=True)
|
||||
(src / "__init__.py").write_text('__version__ = "2.3.4"\n')
|
||||
assert cdv.read_version(tmp_path, "myproj") == "2.3.4"
|
||||
|
||||
def test_no_version(self, tmp_path: Path) -> None:
|
||||
src = tmp_path / "src" / "myproj"
|
||||
src.mkdir(parents=True)
|
||||
(src / "__init__.py").write_text("# no version here\n")
|
||||
assert cdv.read_version(tmp_path, "myproj") is None
|
||||
|
||||
def test_no_init_file(self, tmp_path: Path) -> None:
|
||||
assert cdv.read_version(tmp_path, "nonexistent") is None
|
||||
|
||||
|
||||
class TestFindVersionRefs:
|
||||
def test_finds_gte_ref(self) -> None:
|
||||
content = ' "devx>=0.27.0",\n'
|
||||
refs = cdv.find_version_refs(content, "devx")
|
||||
assert len(refs) == 1
|
||||
_, full, op, ver, _ = refs[0]
|
||||
assert op == ">="
|
||||
assert ver == "0.27.0"
|
||||
|
||||
def test_finds_eq_ref(self) -> None:
|
||||
content = '"devx==0.33.4"'
|
||||
refs = cdv.find_version_refs(content, "devx")
|
||||
assert len(refs) == 1
|
||||
_, _, op, ver, _ = refs[0]
|
||||
assert op == "=="
|
||||
assert ver == "0.33.4"
|
||||
|
||||
def test_finds_extras_ref(self) -> None:
|
||||
content = '"devx[dev]>=0.27.0"'
|
||||
refs = cdv.find_version_refs(content, "devx")
|
||||
assert len(refs) == 1
|
||||
_, _, op, ver, _ = refs[0]
|
||||
assert op == ">="
|
||||
assert ver == "0.27.0"
|
||||
|
||||
def test_finds_upper_bound(self) -> None:
|
||||
content = '"devx>=0.27.0,<0.28"'
|
||||
refs = cdv.find_version_refs(content, "devx")
|
||||
assert len(refs) == 1
|
||||
_, _, _, _, rest = refs[0]
|
||||
assert "<0.28" in rest
|
||||
|
||||
def test_ignores_other_packages(self) -> None:
|
||||
content = '"other-pkg>=1.0.0"'
|
||||
refs = cdv.find_version_refs(content, "devx")
|
||||
assert len(refs) == 0
|
||||
|
||||
def test_multiple_refs(self) -> None:
|
||||
content = '"devx>=0.27.0"\n"devx==0.33.4"\n'
|
||||
refs = cdv.find_version_refs(content, "devx")
|
||||
assert len(refs) == 2
|
||||
|
||||
|
||||
class TestFixVersionRefs:
|
||||
def test_fixes_stale_version(self) -> None:
|
||||
content = '"devx>=0.27.0"'
|
||||
new, fixes = cdv.fix_version_refs(content, "devx", "0.33.4")
|
||||
assert fixes == 1
|
||||
assert "0.33.4" in new
|
||||
assert "0.27.0" not in new
|
||||
|
||||
def test_no_fix_needed(self) -> None:
|
||||
content = '"devx>=0.33.4"'
|
||||
new, fixes = cdv.fix_version_refs(content, "devx", "0.33.4")
|
||||
assert fixes == 0
|
||||
assert new == content
|
||||
|
||||
def test_fixes_upper_bound(self) -> None:
|
||||
content = '"devx>=0.27.0,<0.28"'
|
||||
new, fixes = cdv.fix_version_refs(content, "devx", "0.33.4")
|
||||
assert fixes == 1
|
||||
assert "0.33.4" in new
|
||||
assert "<0.34" in new
|
||||
assert "<0.28" not in new
|
||||
|
||||
def test_ignores_other_packages(self) -> None:
|
||||
content = '"other>=1.0.0"'
|
||||
new, fixes = cdv.fix_version_refs(content, "devx", "0.33.4")
|
||||
assert fixes == 0
|
||||
assert new == content
|
||||
|
||||
|
||||
class TestMain:
|
||||
def test_pass_when_current(self, tmp_path: Path) -> None:
|
||||
src = tmp_path / "src" / "devx"
|
||||
src.mkdir(parents=True)
|
||||
(src / "__init__.py").write_text('__version__ = "0.33.4"\n')
|
||||
readme = tmp_path / "README.md"
|
||||
readme.write_text('"devx>=0.33.4"\n')
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cdv.main, ["--root", str(tmp_path)])
|
||||
assert result.exit_code == 0
|
||||
assert "PASS" in result.output
|
||||
|
||||
def test_fail_when_stale(self, tmp_path: Path) -> None:
|
||||
src = tmp_path / "src" / "devx"
|
||||
src.mkdir(parents=True)
|
||||
(src / "__init__.py").write_text('__version__ = "0.33.4"\n')
|
||||
readme = tmp_path / "README.md"
|
||||
readme.write_text('"devx>=0.27.0"\n')
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cdv.main, ["--root", str(tmp_path)])
|
||||
assert result.exit_code == 1
|
||||
assert "stale" in result.output
|
||||
|
||||
def test_fix_updates_files(self, tmp_path: Path) -> None:
|
||||
src = tmp_path / "src" / "devx"
|
||||
src.mkdir(parents=True)
|
||||
(src / "__init__.py").write_text('__version__ = "0.33.4"\n')
|
||||
readme = tmp_path / "README.md"
|
||||
readme.write_text('"devx>=0.27.0"\n')
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cdv.main, ["--root", str(tmp_path), "--fix"])
|
||||
assert result.exit_code == 0
|
||||
assert "0.33.4" in readme.read_text()
|
||||
|
||||
def test_no_package_skips(self, tmp_path: Path) -> None:
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cdv.main, ["--root", str(tmp_path)])
|
||||
assert result.exit_code == 0
|
||||
assert "skipping" in result.output
|
||||
|
||||
def test_no_version_skips(self, tmp_path: Path) -> None:
|
||||
src = tmp_path / "src" / "devx"
|
||||
src.mkdir(parents=True)
|
||||
(src / "__init__.py").write_text("# no version\n")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cdv.main, ["--root", str(tmp_path)])
|
||||
assert result.exit_code == 0
|
||||
assert "Cannot read" in result.output
|
||||
|
||||
def test_docs_only_skips_readme(self, tmp_path: Path) -> None:
|
||||
src = tmp_path / "src" / "devx"
|
||||
src.mkdir(parents=True)
|
||||
(src / "__init__.py").write_text('__version__ = "0.33.4"\n')
|
||||
readme = tmp_path / "README.md"
|
||||
readme.write_text('"devx>=0.27.0"\n')
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "index.md").write_text('"devx>=0.33.4"\n')
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cdv.main, ["--root", str(tmp_path), "--docs-only"])
|
||||
assert result.exit_code == 0
|
||||
assert "PASS" in result.output
|
||||
|
||||
def test_fix_no_stale(self, tmp_path: Path) -> None:
|
||||
src = tmp_path / "src" / "devx"
|
||||
src.mkdir(parents=True)
|
||||
(src / "__init__.py").write_text('__version__ = "0.33.4"\n')
|
||||
readme = tmp_path / "README.md"
|
||||
readme.write_text('"devx>=0.33.4"\n')
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cdv.main, ["--root", str(tmp_path), "--fix"])
|
||||
assert result.exit_code == 0
|
||||
assert "No stale" in result.output
|
||||
|
||||
def test_checks_docs_dir(self, tmp_path: Path) -> None:
|
||||
src = tmp_path / "src" / "devx"
|
||||
src.mkdir(parents=True)
|
||||
(src / "__init__.py").write_text('__version__ = "0.33.4"\n')
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "index.md").write_text('"devx>=0.27.0"\n')
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cdv.main, ["--root", str(tmp_path)])
|
||||
assert result.exit_code == 1
|
||||
assert "docs/index.md" in result.output
|
||||
|
||||
def test_detect_package_with_non_dir_entry(self, tmp_path: Path) -> None:
|
||||
src = tmp_path / "src"
|
||||
src.mkdir(parents=True)
|
||||
# `aaa_file.py` sorts before `devx/` so the non-dir branch is hit
|
||||
(src / "aaa_file.py").touch()
|
||||
pkg_dir = src / "devx"
|
||||
pkg_dir.mkdir()
|
||||
(pkg_dir / "__init__.py").write_text('__version__ = "1.0.0"\n')
|
||||
assert cdv.detect_package_name(tmp_path) == "devx"
|
||||
|
||||
def test_read_version_auto_detect(self, tmp_path: Path) -> None:
|
||||
src = tmp_path / "src" / "devx"
|
||||
src.mkdir(parents=True)
|
||||
(src / "__init__.py").write_text('__version__ = "3.2.1"\n')
|
||||
assert cdv.read_version(tmp_path) == "3.2.1"
|
||||
|
||||
def test_read_version_no_package(self, tmp_path: Path) -> None:
|
||||
assert cdv.read_version(tmp_path) is None
|
||||
|
||||
def test_main_with_file_without_refs(self, tmp_path: Path) -> None:
|
||||
src = tmp_path / "src" / "devx"
|
||||
src.mkdir(parents=True)
|
||||
(src / "__init__.py").write_text('__version__ = "0.33.4"\n')
|
||||
docs = tmp_path / "docs"
|
||||
docs.mkdir()
|
||||
(docs / "index.md").write_text("# No version refs here\n")
|
||||
(docs / "other.md").write_text('"devx>=0.27.0"\n')
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cdv.main, ["--root", str(tmp_path)])
|
||||
assert result.exit_code == 1
|
||||
assert "other.md" in result.output
|
||||
|
||||
def test_fix_with_file_without_refs(self, tmp_path: Path) -> None:
|
||||
src = tmp_path / "src" / "devx"
|
||||
src.mkdir(parents=True)
|
||||
(src / "__init__.py").write_text('__version__ = "0.33.4"\n')
|
||||
readme = tmp_path / "README.md"
|
||||
readme.write_text("# No refs\n")
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cdv.main, ["--root", str(tmp_path), "--fix"])
|
||||
assert result.exit_code == 0
|
||||
assert "No stale" in result.output
|
||||
|
||||
def test_fix_with_current_refs(self, tmp_path: Path) -> None:
|
||||
src = tmp_path / "src" / "devx"
|
||||
src.mkdir(parents=True)
|
||||
(src / "__init__.py").write_text('__version__ = "0.33.4"\n')
|
||||
readme = tmp_path / "README.md"
|
||||
readme.write_text('"devx>=0.33.4"\n')
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cdv.main, ["--root", str(tmp_path), "--fix"])
|
||||
assert result.exit_code == 0
|
||||
assert "No stale" in result.output
|
||||
@@ -69,16 +69,16 @@ class TestParsePerTestDurations:
|
||||
assert len(durations) == 1
|
||||
assert durations[0] == ("tests/test_foo.py::test_bar", 0.01)
|
||||
|
||||
def test_parses_setup_and_teardown(self) -> None:
|
||||
def test_ignores_setup_and_teardown(self) -> None:
|
||||
"""Only 'call' durations are counted — setup includes import overhead."""
|
||||
output = (
|
||||
"0.02s setup tests/test_foo.py::test_bar\n"
|
||||
"0.68s setup tests/test_foo.py::test_bar\n"
|
||||
"0.01s call tests/test_foo.py::test_bar\n"
|
||||
"0.00s teardown tests/test_foo.py::test_bar\n"
|
||||
)
|
||||
durations = parse_per_test_durations(output)
|
||||
assert len(durations) == 3
|
||||
names = [d[0] for d in durations]
|
||||
assert "tests/test_foo.py::test_bar" in names
|
||||
assert len(durations) == 1
|
||||
assert durations[0] == ("tests/test_foo.py::test_bar", 0.01)
|
||||
|
||||
def test_sorted_slowest_first(self) -> None:
|
||||
output = "0.01s call tests/test_a.py::test_slow\n0.50s call tests/test_b.py::test_fast\n"
|
||||
|
||||
@@ -185,7 +185,7 @@ class TestMain:
|
||||
args = mock_client.ensure_branch_protection.call_args
|
||||
assert args[0][0] == "develop"
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "DEVX_REPO_NAME": "oblachno/infra"}, clear=True)
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok", "DEVX_REPO_NAME": "my-org/my-repo"}, clear=True)
|
||||
@patch("devx.tools.configure_repo.GiteaClient")
|
||||
def test_main_parses_owner_repo_from_env(self, mock_client_cls: MagicMock) -> None:
|
||||
"""DEVX_REPO_NAME with 'owner/repo' format should be split."""
|
||||
@@ -197,15 +197,15 @@ class TestMain:
|
||||
assert result.exit_code == 0
|
||||
# Verify GiteaClient was constructed with parsed owner and repo (positional)
|
||||
call_args = mock_client_cls.call_args
|
||||
assert call_args[0][2] == "oblachno" # owner is 3rd positional arg
|
||||
assert call_args[0][3] == "infra" # repo is 4th positional arg
|
||||
assert call_args[0][2] == "my-org" # owner is 3rd positional arg
|
||||
assert call_args[0][3] == "my-repo" # repo is 4th positional arg
|
||||
|
||||
@patch.dict(
|
||||
"os.environ",
|
||||
{"CI_GITEA_TOKEN": "tok", "DEVX_REPO_NAME": "infra", "DEVX_REPO_OWNER": "oblachno"},
|
||||
{"CI_GITEA_TOKEN": "tok", "DEVX_REPO_NAME": "my-repo", "DEVX_REPO_OWNER": "my-org"},
|
||||
clear=True,
|
||||
)
|
||||
@patch("devx.tools.configure_repo.REPO_OWNER", "oblachno")
|
||||
@patch("devx.tools.configure_repo.REPO_OWNER", "my-org")
|
||||
@patch("devx.tools.configure_repo.GiteaClient")
|
||||
def test_main_no_slash_when_owner_set_separately(self, mock_client_cls: MagicMock) -> None:
|
||||
"""When DEVX_REPO_OWNER is set, DEVX_REPO_NAME should not be split."""
|
||||
@@ -216,12 +216,12 @@ class TestMain:
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
call_args = mock_client_cls.call_args
|
||||
assert call_args[0][2] == "oblachno" # owner
|
||||
assert call_args[0][3] == "infra" # repo
|
||||
assert call_args[0][2] == "my-org" # owner
|
||||
assert call_args[0][3] == "my-repo" # repo
|
||||
|
||||
@patch.dict("os.environ", {"CI_GITEA_TOKEN": "tok"}, clear=True)
|
||||
@patch("devx.tools.configure_repo.REPO_NAME", "devx")
|
||||
@patch("devx.tools.configure_repo.REPO_OWNER", "oblachno-oss")
|
||||
@patch("devx.tools.configure_repo.REPO_OWNER", "my-org")
|
||||
@patch("devx.tools.configure_repo.GiteaClient")
|
||||
def test_main_repo_from_pyproject(self, mock_client_cls: MagicMock) -> None:
|
||||
"""When no env var is set, repo name should come from pyproject.toml."""
|
||||
@@ -232,5 +232,5 @@ class TestMain:
|
||||
result = runner.invoke(main, [])
|
||||
assert result.exit_code == 0
|
||||
call_args = mock_client_cls.call_args
|
||||
assert call_args[0][2] == "oblachno-oss" # owner
|
||||
assert call_args[0][2] == "my-org" # owner
|
||||
assert call_args[0][3] == "devx" # repo
|
||||
|
||||
@@ -25,14 +25,14 @@ class TestExtractTaskId:
|
||||
|
||||
|
||||
class TestGetRepoName:
|
||||
@patch.dict("os.environ", {"DEVX_REPO_NAME": "infra"})
|
||||
@patch.dict("os.environ", {"DEVX_REPO_NAME": "my-repo"})
|
||||
def test_from_env(self) -> None:
|
||||
assert get_repo_name() == "infra"
|
||||
assert get_repo_name() == "my-repo"
|
||||
|
||||
@patch("devx.tools.create_pr.REPO_NAME", "devx")
|
||||
@patch.dict("os.environ", {"GITHUB_REPOSITORY": "oblachno/infra"}, clear=True)
|
||||
@patch.dict("os.environ", {"GITHUB_REPOSITORY": "my-org/my-repo"}, clear=True)
|
||||
def test_env_overrides_pyproject(self) -> None:
|
||||
assert get_repo_name() == "infra"
|
||||
assert get_repo_name() == "my-repo"
|
||||
|
||||
@patch("devx.tools.create_pr.REPO_NAME", "devx")
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
@@ -40,9 +40,9 @@ class TestGetRepoName:
|
||||
assert get_repo_name() == "devx"
|
||||
|
||||
@patch("devx.tools.create_pr.REPO_NAME", "")
|
||||
@patch.dict("os.environ", {"GITHUB_REPOSITORY": "oblachno/infra"}, clear=True)
|
||||
@patch.dict("os.environ", {"GITHUB_REPOSITORY": "my-org/my-repo"}, clear=True)
|
||||
def test_from_github(self) -> None:
|
||||
assert get_repo_name() == "infra"
|
||||
assert get_repo_name() == "my-repo"
|
||||
|
||||
@patch("devx.tools.create_pr.REPO_NAME", "")
|
||||
@patch.dict("os.environ", {}, clear=True)
|
||||
|
||||
@@ -592,7 +592,7 @@ class TestLptDistribute:
|
||||
def test_load_balance_with_varying_weights(self) -> None:
|
||||
"""LPT should produce better load balance than round-robin."""
|
||||
items = list(range(7))
|
||||
# Simulate infra-like weights: 2 heavy, 2 medium, 3 light
|
||||
# Simulate multi-role-like weights: 2 heavy, 2 medium, 3 light
|
||||
weights = [10, 10, 7, 7, 3, 3, 3]
|
||||
groups = _lpt_distribute(items, weights, 3)
|
||||
loads = [sum(weights[i] for i in g) for g in groups]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user