Compare commits

...
132 Commits
Author SHA1 Message Date
devx-ci-bot 05de2b0aa9 release: v0.36.2 [skip ci] 2026-07-07 15:52:35 +00:00
emil 6a463a93d2 DEVX-121: fix: GiteaClient.set_repo_variable uses PUT instead of PATCH
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 22s
Post-merge / configure-repo (push) Successful in 17s
Post-merge / sync-wiki (push) Successful in 31s
Post-merge / release (push) Successful in 38s
Post-merge / publish (push) Successful in 23s
Post-merge / badges (push) Successful in 39s
2026-07-07 15:51:45 +00:00
gitea-actions-bot ad7b52c368 chore: update badge URLs to commit d9423d85 [skip ci] 2026-07-07 12:05:28 +00:00
devx-ci-bot 6b81e1a50a release: v0.36.1 [skip ci] 2026-07-07 12:04:41 +00:00
emil 443dc01b4e DEVX-120: fix: preserve .badges/ dir during git clean in push_badges
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 16s
Post-merge / vikunja (push) Successful in 21s
Post-merge / sync-wiki (push) Successful in 30s
Post-merge / release (push) Successful in 40s
Post-merge / publish (push) Successful in 25s
Post-merge / badges (push) Successful in 41s
2026-07-07 12:03:51 +00:00
devx-ci-bot 1d7bf7118a release: v0.36.0 [skip ci] 2026-07-07 11:58:00 +00:00
emil f98534ebe2 DEVX-119: feat: add GiteaClient repo variable methods and parallelize pytest-cov
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / configure-repo (push) Successful in 17s
Post-merge / vikunja (push) Successful in 23s
Post-merge / sync-wiki (push) Successful in 29s
Post-merge / release (push) Successful in 43s
Post-merge / publish (push) Successful in 23s
Post-merge / badges (push) Failing after 31s
2026-07-07 11:57:04 +00:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> c62b168b25 DEVX-116: chore: update grm package name references
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / vikunja (push) Successful in 22s
Post-merge / release (push) Successful in 23s
Post-merge / publish (push) Has been skipped
Post-merge / sync-wiki (push) Successful in 30s
Post-merge / badges (push) Failing after 30s
Update hardcoded path and docstring examples from
`gitea_runner_manager` to `grm` after the package rename in grm PR #203.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-06 15:51:10 +02:00
devx-ci-bot 40a94df029 release: v0.35.7 [skip ci] 2026-07-06 13:22:27 +00:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> f50c4c1e00 DEVX-118: fix: use Gitea wiki dash-marker filename convention
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / vikunja (push) Successful in 24s
Post-merge / configure-repo (push) Successful in 16s
Post-merge / sync-wiki (push) Successful in 35s
Post-merge / release (push) Successful in 44s
Post-merge / publish (push) Successful in 22s
Post-merge / badges (push) Failing after 30s
Gitea appends a ".-" suffix before ".md" for wiki page titles that
contain dashes, to distinguish literal dashes from space-to-dash
conversions. For example, "Getting-Started" becomes
"Getting-Started.-.md", while "Architecture" becomes "Architecture.md".

Previously the code wrote "Getting-Started.md" which Gitea couldn't
recognize as a valid wiki page, causing verification to fail with
"page not found" for 15 of 21 pages.

Also force-push to handle concurrent CI runs that may have pushed to
the wiki repo between our clone and push.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-06 15:21:22 +02:00
devx-ci-bot bbb264efc9 release: v0.35.6 [skip ci] 2026-07-06 13:00:59 +00:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> c97b249935 DEVX-118: fix: add delay before wiki verification to avoid race condition
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / vikunja (push) Successful in 20s
Post-merge / sync-wiki (push) Successful in 29s
Post-merge / release (push) Successful in 39s
Post-merge / publish (push) Successful in 25s
Post-merge / badges (push) Failing after 38s
Gitea needs a few seconds to process pushed wiki commits before a
re-clone will see them. Add a 5s sleep after a successful push before
verification re-clones the wiki.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-06 15:00:02 +02:00
devx-ci-bot 32b9a53151 release: v0.35.5 [skip ci] 2026-07-06 09:46:57 +00:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> ae68df63f1 DEVX-118: fix: embed token in wiki clone URL for push auth
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 19s
Post-merge / sync-wiki (push) Successful in 24s
Post-merge / configure-repo (push) Successful in 14s
Post-merge / release (push) Successful in 34s
Post-merge / publish (push) Successful in 19s
Post-merge / badges (push) Failing after 29s
The wiki Git push failed with "could not read Username" because the
clone URL didn't include credentials. Use token@host URL format so
both clone and push authenticate properly.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-06 11:46:05 +02:00
devx-ci-bot 6402f31345 release: v0.35.4 [skip ci] 2026-07-06 09:42:49 +00:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> f017fec8f5 DEVX-118: fix: configure git identity before commit in sync_wiki
Post-merge / detect-type (push) Successful in 16s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / configure-repo (push) Successful in 17s
Post-merge / vikunja (push) Successful in 21s
Post-merge / sync-wiki (push) Failing after 24s
Post-merge / release (push) Successful in 37s
Post-merge / publish (push) Successful in 21s
Post-merge / badges (push) Failing after 31s
CI environments may lack git user.email/user.name config, causing
git commit to fail with exit code 128. Set identity explicitly before
committing wiki changes.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-06 11:41:36 +02:00
devx-ci-bot add02273b6 release: v0.35.3 [skip ci] 2026-07-06 09:40:19 +00:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> e489fdb206 DEVX-118: fix: replace --strict with --verify for sync_wiki
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 25s
Post-merge / vikunja (push) Successful in 33s
Post-merge / configure-repo (push) Successful in 28s
Post-merge / sync-wiki (push) Failing after 37s
Post-merge / release (push) Successful in 52s
Post-merge / publish (push) Successful in 29s
Post-merge / badges (push) Failing after 38s
The rewritten sync_wiki.py removed the --strict flag. The new git-based
approach is strict by default; --verify adds post-sync page verification.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-06 11:36:30 +02:00
devx-ci-bot 45a9c7d431 release: v0.35.2 [skip ci] 2026-07-06 08:45:42 +00:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 8e1c7d03a4 DEVX-118: fix: exclude .vale directory from lint_docs scanning
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 21s
Post-merge / vikunja (push) Successful in 23s
Post-merge / sync-wiki (push) Failing after 29s
Post-merge / release (push) Successful in 41s
Post-merge / publish (push) Successful in 39s
Post-merge / badges (push) Failing after 45s
Third-party Vale style packages contain README.md files with code blocks
that don't specify a language, causing false positives in lint_docs.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-06 10:44:25 +02:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 2de3ab4d84 DEVX-118: docs: update AGENTS.md with new tools and make targets
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / release (push) Successful in 16s
Post-merge / publish (push) Has been skipped
Post-merge / vikunja (push) Successful in 17s
Post-merge / sync-wiki (push) Failing after 22s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / badges (push) Failing after 31s
Document check_doc_versions.py, Vale, and new make targets in AGENTS.md.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-06 10:29:47 +02:00
devx-ci-bot fa501adfbc release: v0.35.1 [skip ci] 2026-07-06 08:27:00 +00:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 0a5625b70b DEVX-118: refactor: rewrite sync_wiki.py to use git-based approach
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 21s
Post-merge / configure-repo (push) Successful in 23s
Post-merge / sync-wiki (push) Failing after 28s
Post-merge / release (push) Successful in 42s
Post-merge / publish (push) Successful in 22s
Post-merge / badges (push) Failing after 31s
Replace the unreliable Gitea wiki API with direct Git operations:
- Clone {repo}.wiki.git, copy docs with link transformation, push
- Faster: 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 removed automatically
- Link transformation: [text](file.md) → [text](file) for wiki format
- 36 new tests covering transform_links, clone, sync_files, commit, verify

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-06 10:26:00 +02:00
devx-ci-bot f28ba432ce release: v0.35.0 [skip ci] 2026-07-06 08:16:54 +00:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> fb342e7b9d DEVX-118: feat: enrich lint_docs.py with single H1, max depth, line length, code block lang, orphan checks
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 19s
Post-merge / configure-repo (push) Successful in 16s
Post-merge / release (push) Successful in 40s
Post-merge / sync-wiki (push) Successful in 43s
Post-merge / publish (push) Successful in 28s
Post-merge / badges (push) Failing after 36s
- Add check_single_h1: each markdown file should have at most one H1
- Add check_max_heading_depth: headings should not exceed H4 (configurable)
- Add check_line_length: warn on lines >120 chars (non-blocking — badge URLs)
- Add check_code_block_languages: fenced code blocks must specify a language
- Add check_orphan_docs: warn on docs not linked from index.md or mapping.json
- Fix all code blocks in docs to specify language (text for plain blocks)
- Fix duplicate H1 in .vale/styles/devx/README.md
- Add 18 new tests for full coverage of new checks

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-06 10:15:54 +02:00
devx-ci-bot bb700ab969 release: v0.34.0 [skip ci] 2026-07-06 08:05:29 +00:00
emilandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> bbf0c81c32 DEVX-118: feat: enhance documentation-as-code with badges, version refs, Vale
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Successful in 13s
Post-merge / vikunja (push) Successful in 19s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / release (push) Successful in 45s
Post-merge / sync-wiki (push) Successful in 50s
Post-merge / publish (push) Successful in 32s
Post-merge / badges (push) Failing after 36s
- Fix badge system: clean .badges dir from orphan branch, add version
  verification, make badges job depend on release (avoids stale version
  badge race condition)
- Add check_doc_versions.py: lint tool that verifies docs version
  references match current __version__, with --fix for auto-update
- Integrate check_doc_versions into release process (auto-updates docs
  on every release commit)
- Add Vale prose linter integration: .vale.ini, custom styles for
  terminology and code block language, CI step, make target
- Fix stale version references in docs (0.27.0 → 0.33.4)
- Fix e.g. → for example in docs (Google.Latin Vale rule)
- Add CI steps for check_doc_versions and Vale to quality workflow
- Add make targets: devx-check-doc-versions, devx-vale

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-07-06 10:03:47 +02:00
gitea-actions-bot 3e12cf222f chore: update badge URLs to commit 40fbd801 [skip ci] 2026-07-06 06:18:52 +00:00
devx-ci-bot 951ba7de7a release: v0.33.4 [skip ci] 2026-07-06 06:18:39 +00:00
emil e796b06a91 DEVX-117: refactor: remove project-specific references from devx
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 15s
Post-merge / configure-repo (push) Successful in 19s
Post-merge / release (push) Successful in 45s
Post-merge / sync-wiki (push) Successful in 46s
Post-merge / badges (push) Successful in 46s
Post-merge / publish (push) Successful in 17s
2026-07-06 06:17:52 +00:00
gitea-actions-bot 990f2fa612 chore: update badge URLs to commit 7802ce60 [skip ci] 2026-07-06 04:56:16 +00:00
devx-ci-bot a7f5f47564 release: v0.33.3 [skip ci] 2026-07-06 04:56:04 +00:00
emil d623a64344 DEVX-115: fix: make wiki sync resilient to API timeouts and stale page lists
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 16s
Post-merge / release (push) Successful in 39s
Post-merge / configure-repo (push) Successful in 17s
Post-merge / sync-wiki (push) Successful in 45s
Post-merge / badges (push) Successful in 47s
Post-merge / publish (push) Successful in 18s
2026-07-06 04:55:06 +00:00
gitea-actions-bot 268a4e7988 chore: update badge URLs to commit b07bea6f [skip ci] 2026-07-05 20:47:45 +00:00
emil 7daaf9e4a9 DEVX-114: ci: add testing-and-debugging skill for devx repo
Post-merge / detect-type (push) Successful in 13s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / release (push) Successful in 17s
Post-merge / publish (push) Has been skipped
Post-merge / vikunja (push) Successful in 17s
Post-merge / configure-repo (push) Successful in 20s
Post-merge / sync-wiki (push) Successful in 43s
Post-merge / badges (push) Successful in 54s
2026-07-05 20:46:21 +00:00
gitea-actions-bot b7c9334881 chore: update badge URLs to commit 83595808 [skip ci] 2026-07-05 19:18:21 +00:00
devx-ci-bot 3406639f13 release: v0.33.2 [skip ci] 2026-07-05 19:18:03 +00:00
emil 9f02ccb40d DEVX-113: fix: abort sync_wiki when list_wiki_pages fails
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / vikunja (push) Successful in 22s
Post-merge / configure-repo (push) Successful in 18s
Post-merge / release (push) Successful in 47s
Post-merge / badges (push) Successful in 54s
Post-merge / sync-wiki (push) Successful in 55s
Post-merge / publish (push) Successful in 31s
2026-07-05 19:17:10 +00:00
gitea-actions-bot 5206158603 chore: update badge URLs to commit 66fec9ab [skip ci] 2026-07-05 14:47:30 +00:00
devx-ci-bot 489cc8343a release: v0.33.1 [skip ci] 2026-07-05 14:47:16 +00:00
emil 20ea80135c DEVX-112: fix: build images after post-merge publish, not on push
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / sync-wiki (push) Successful in 28s
Post-merge / release (push) Successful in 32s
Post-merge / vikunja (push) Successful in 13s
Post-merge / badges (push) Successful in 36s
Post-merge / configure-repo (push) Successful in 14s
Post-merge / publish (push) Successful in 20s
2026-07-05 14:46:33 +00:00
gitea-actions-bot 53b1d300aa chore: update badge URLs to commit 546910d3 [skip ci] 2026-07-05 14:13:08 +00:00
devx-ci-bot 5b9e92f324 release: v0.33.0 [skip ci] 2026-07-05 14:12:51 +00:00
emil 2c0118111d DEVX-111: feat: add check_api_identity_checks, setup_ssh_key, and api utils
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 9s
Build Images / detect-type (push) Successful in 42s
Post-merge / vikunja (push) Successful in 17s
Post-merge / release (push) Successful in 52s
Post-merge / configure-repo (push) Successful in 23s
Post-merge / badges (push) Successful in 55s
Post-merge / sync-wiki (push) Successful in 58s
Post-merge / publish (push) Successful in 21s
Build Images / build-and-push (push) Successful in 3m15s
Build Images / cleanup (push) Successful in 3m38s
2026-07-05 14:11:58 +00:00
gitea-actions-bot 333641f862 chore: update badge URLs to commit e8088b8e [skip ci] 2026-07-01 23:37:55 +00:00
devx-ci-bot f21b01dce2 release: v0.32.1 [skip ci] 2026-07-01 23:37:11 +00:00
emil ff80745eea DEVX-110: fix: add missing i18n translations for new tools
Post-merge / detect-type (push) Successful in 15s
Post-merge / validate-commit-msg (push) Successful in 14s
Build Images / detect-type (push) Successful in 56s
Post-merge / release (push) Successful in 1m2s
Post-merge / configure-repo (push) Successful in 28s
Post-merge / sync-wiki (push) Successful in 1m11s
Post-merge / badges (push) Successful in 1m19s
Post-merge / vikunja (push) Successful in 1m15s
Post-merge / publish (push) Successful in 33s
Build Images / build-and-push (push) Successful in 4m22s
Build Images / cleanup (push) Successful in 2m50s
2026-07-01 23:35:56 +00:00
gitea-actions-bot 319807f41c chore: update badge URLs to commit ce9bf024 [skip ci] 2026-07-01 23:01:36 +00:00
devx-ci-bot e652d3bb75 release: v0.32.0 [skip ci] 2026-07-01 23:01:19 +00:00
emil d59de06652 DEVX-110: feat: extract docker-login, tofu-ops, check-deps, install-tofu to Python tools
Build Images / cleanup (push) Successful in 3m20s
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 21s
Build Images / detect-type (push) Successful in 41s
Post-merge / configure-repo (push) Successful in 17s
Post-merge / release (push) Successful in 43s
Post-merge / sync-wiki (push) Successful in 47s
Post-merge / badges (push) Successful in 51s
Post-merge / publish (push) Successful in 22s
Build Images / build-and-push (push) Successful in 3m26s
2026-07-01 23:00:28 +00:00
gitea-actions-bot ae37a8e3e4 chore: update badge URLs to commit de35661c [skip ci] 2026-07-01 22:35:46 +00:00
devx-ci-bot c63e85923a release: v0.31.0 [skip ci] 2026-07-01 22:35:34 +00:00
emil 77c1af8ed3 DEVX-110: feat: centralize venv management in devx.mak
Post-merge / detect-type (push) Successful in 9s
Build Images / detect-type (push) Failing after 13s
Build Images / build-and-push (push) Has been skipped
Post-merge / validate-commit-msg (push) Successful in 10s
Build Images / cleanup (push) Has been skipped
Post-merge / vikunja (push) Successful in 15s
Post-merge / configure-repo (push) Successful in 18s
Post-merge / sync-wiki (push) Successful in 29s
Post-merge / release (push) Successful in 32s
Post-merge / badges (push) Successful in 39s
Post-merge / publish (push) Successful in 17s
2026-07-01 22:34:49 +00:00
gitea-actions-bot a48fb46c52 chore: update badge URLs to commit 1755d7a2 [skip ci] 2026-07-01 20:54:47 +00:00
emil 2392a13afc DEVX-109: docs: add container-level fix verification and verified state modification rules
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / release (push) Successful in 21s
Post-merge / publish (push) Has been skipped
Post-merge / vikunja (push) Successful in 17s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / sync-wiki (push) Successful in 45s
Post-merge / badges (push) Successful in 46s
2026-07-01 20:53:32 +00:00
gitea-actions-bot 32315b1d5d chore: update badge URLs to commit 753a5f9e [skip ci] 2026-07-01 14:05:12 +00:00
devx-ci-bot f70f468630 release: v0.30.0 [skip ci] 2026-07-01 14:04:57 +00:00
emil 85b5ec1485 DEVX-108: feat: add standard label creation to configure_repo
Post-merge / detect-type (push) Successful in 16s
Post-merge / validate-commit-msg (push) Successful in 21s
Post-merge / vikunja (push) Successful in 24s
Post-merge / configure-repo (push) Successful in 17s
Post-merge / sync-wiki (push) Successful in 49s
Post-merge / release (push) Successful in 50s
Post-merge / badges (push) Successful in 58s
Build Images / detect-type (push) Successful in 1m28s
Post-merge / publish (push) Successful in 20s
Build Images / build-and-push (push) Successful in 3m2s
Build Images / cleanup (push) Successful in 3m9s
2026-07-01 14:03:48 +00:00
gitea-actions-bot 091b951adc chore: update badge URLs to commit d23c6b86 [skip ci] 2026-07-01 09:36:05 +00:00
emil 19eb57445d DEVX-103: docs: fix outdated version refs, language list, config key, and missing modules
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / release (push) Successful in 17s
Post-merge / publish (push) Has been skipped
Post-merge / vikunja (push) Successful in 19s
Post-merge / configure-repo (push) Successful in 15s
Post-merge / sync-wiki (push) Successful in 47s
Post-merge / badges (push) Successful in 42s
2026-07-01 09:34:53 +00:00
gitea-actions-bot bdd0e05869 chore: update badge URLs to commit d5dfe563 [skip ci] 2026-07-01 09:29:34 +00:00
devx-ci-bot 27fd99a091 release: v0.29.1 [skip ci] 2026-07-01 09:29:17 +00:00
emil e3fa9b7c95 DEVX-107: fix: strip task ID prefix from commit messages in extract_conventional_msg
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 13s
Build Images / detect-type (push) Successful in 43s
Post-merge / vikunja (push) Successful in 16s
Post-merge / sync-wiki (push) Successful in 33s
Post-merge / release (push) Successful in 37s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / badges (push) Successful in 48s
Post-merge / publish (push) Successful in 20s
Build Images / build-and-push (push) Successful in 3m7s
Build Images / cleanup (push) Successful in 3m18s
2026-07-01 09:28:23 +00:00
gitea-actions-bot ce60356542 chore: update badge URLs to commit 82fb419c [skip ci] 2026-07-01 06:20:37 +00:00
devx-ci-bot 35c72ef595 release: v0.29.0 [skip ci] 2026-07-01 06:20:24 +00:00
emil c0fcaef25f DEVX-106: feat: detect badge commits as automated CI commits
Build Images / build-and-push (push) Successful in 3m1s
Build Images / cleanup (push) Successful in 2m47s
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 21s
Post-merge / configure-repo (push) Successful in 13s
Build Images / detect-type (push) Successful in 40s
Post-merge / sync-wiki (push) Successful in 36s
Post-merge / release (push) Successful in 39s
Post-merge / badges (push) Successful in 45s
Post-merge / publish (push) Successful in 18s
2026-07-01 06:19:34 +00:00
gitea-actions-bot 3dd5b452c0 chore: update badge URLs to commit d5cf4c7f [skip ci] 2026-07-01 01:11:39 +00:00
emil b2515bbf37 DEVX-105: docs: add devx-workflow skill for agent guidance
Post-merge / detect-type (push) Successful in 13s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / release (push) Successful in 18s
Post-merge / vikunja (push) Successful in 17s
Post-merge / publish (push) Has been skipped
Post-merge / configure-repo (push) Successful in 15s
Post-merge / sync-wiki (push) Successful in 55s
Post-merge / badges (push) Successful in 54s
2026-07-01 01:10:23 +00:00
gitea-actions-bot ad2e59980f chore: update badge URLs to commit b041147a [skip ci] 2026-07-01 00:51:32 +00:00
devx-ci-bot 68a01d1bda release: v0.28.0 [skip ci] 2026-07-01 00:51:20 +00:00
emil 621b051793 DEVX-104: feat: auto-rebase in auto-merge, new rebase tools, CLI registration
Build Images / build-and-push (push) Successful in 5m35s
Build Images / cleanup (push) Successful in 3m32s
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / vikunja (push) Successful in 24s
Post-merge / configure-repo (push) Successful in 21s
Post-merge / sync-wiki (push) Successful in 38s
Post-merge / release (push) Successful in 44s
Post-merge / badges (push) Successful in 50s
Post-merge / publish (push) Successful in 20s
Build Images / detect-type (push) Successful in 1m26s
2026-07-01 00:50:23 +00:00
gitea-actions-bot 66554657f2 chore: update badge URLs to commit 44123e77 [skip ci] 2026-06-30 05:34:13 +00:00
devx-ci-bot 587d3a6ca4 release: v0.27.3 [skip ci] 2026-06-30 05:33:54 +00:00
emil 9d75e408ae DEVX-14: fix: retry wiki integrity check on transient API timeout
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 8s
Build Images / detect-type (push) Successful in 42s
Post-merge / sync-wiki (push) Successful in 30s
Post-merge / vikunja (push) Successful in 16s
Post-merge / release (push) Successful in 44s
Post-merge / configure-repo (push) Successful in 17s
Post-merge / badges (push) Successful in 43s
Post-merge / publish (push) Successful in 20s
Build Images / build-and-push (push) Successful in 3m12s
Build Images / cleanup (push) Successful in 2m37s
2026-06-30 05:33:00 +00:00
gitea-actions-bot 412bbea01d chore: update badge URLs to commit 8d35f5dd [skip ci] 2026-06-29 11:30:18 +00:00
devx-ci-bot ce5ce33a12 release: v0.27.2 [skip ci] 2026-06-29 11:30:13 +00:00
emil 0fae419584 DEVX-100: fix: retry release push on non-fast-forward with rebase loop
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 12s
Post-merge / configure-repo (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 23s
Post-merge / release (push) Successful in 29s
Post-merge / badges (push) Successful in 30s
Build Images / detect-type (push) Successful in 1m0s
Post-merge / publish (push) Successful in 16s
Build Images / build-and-push (push) Successful in 3m57s
Build Images / cleanup (push) Successful in 2m56s
2026-06-29 11:29:31 +00:00
gitea-actions-bot 70b011d4a6 chore: update badge URLs to commit 53e15be6 [skip ci] 2026-06-28 17:02:18 +00:00
devx-ci-bot a5c16a92df release: v0.27.1 [skip ci] 2026-06-28 17:02:12 +00:00
emil e6f022ae96 DEVX-99: fix: exclude .devin/.terraform dirs from lint_docs, add duplicate heading excludes
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / vikunja (push) Successful in 12s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / sync-wiki (push) Successful in 19s
Build Images / detect-type (push) Successful in 35s
Post-merge / release (push) Successful in 25s
Post-merge / badges (push) Successful in 28s
Post-merge / publish (push) Successful in 15s
Build Images / build-and-push (push) Successful in 2m51s
Build Images / cleanup (push) Successful in 2m18s
2026-06-28 17:01:37 +00:00
gitea-actions-bot 1a27983750 chore: update badge URLs to commit 8f2186a4 [skip ci] 2026-06-28 16:36:47 +00:00
devx-ci-bot 5d7ed62b34 release: v0.27.0 [skip ci] 2026-06-28 16:36:35 +00:00
emil ee80c27631 DEVX-98: feat: add lint_docs tool, fix doc_coverage/check_translations for any repo
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / vikunja (push) Successful in 13s
Post-merge / configure-repo (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 21s
Post-merge / release (push) Successful in 25s
Build Images / detect-type (push) Successful in 38s
Post-merge / badges (push) Successful in 34s
Post-merge / publish (push) Successful in 33s
Build Images / build-and-push (push) Successful in 3m20s
Build Images / cleanup (push) Successful in 1m57s
2026-06-28 16:35:59 +00:00
gitea-actions-bot 98b1659579 chore: update badge URLs to commit f308b9f8 [skip ci] 2026-06-28 15:02:09 +00:00
devx-ci-bot c12d9abc6d release: v0.26.4 [skip ci] 2026-06-28 15:01:52 +00:00
emil 40a65cb0a6 DEVX-97: fix: wrap all user-facing strings with _() for i18n completeness
Post-merge / vikunja (push) Successful in 13s
Post-merge / detect-type (push) Successful in 12s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 21s
Post-merge / release (push) Successful in 29s
Post-merge / configure-repo (push) Successful in 11s
Post-merge / badges (push) Successful in 34s
Post-merge / publish (push) Successful in 22s
Build Images / detect-type (push) Successful in 48s
Build Images / build-and-push (push) Successful in 4m3s
Build Images / cleanup (push) Successful in 2m6s
2026-06-28 15:01:07 +00:00
gitea-actions-bot 1a89738dd4 chore: update badge URLs to commit 36f474db [skip ci] 2026-06-28 14:56:08 +00:00
devx-ci-bot 9e604ea2c7 release: v0.26.3 [skip ci] 2026-06-28 14:55:48 +00:00
emil 4bb50bed58 DEVX-97: fix: pin all dependencies to exact versions for reproducibility
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 12s
Post-merge / sync-wiki (push) Successful in 24s
Post-merge / vikunja (push) Successful in 15s
Post-merge / release (push) Successful in 32s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / badges (push) Successful in 49s
Post-merge / publish (push) Successful in 24s
Build Images / detect-type (push) Waiting to run
Build Images / build-and-push (push) Blocked by required conditions
Build Images / cleanup (push) Blocked by required conditions
2026-06-28 14:55:03 +00:00
gitea-actions-bot 5bb9dce530 chore: update badge URLs to commit c04f826e [skip ci] 2026-06-28 14:42:36 +00:00
emil 73662a3bf0 DEVX-95: chore: pin all dependency versions to concrete releases
Post-merge / detect-type (push) Successful in 18s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / release (push) Successful in 15s
Post-merge / sync-wiki (push) Successful in 24s
Post-merge / vikunja (push) Successful in 14s
Build Images / detect-type (push) Successful in 45s
Post-merge / publish (push) Has been skipped
Post-merge / configure-repo (push) Successful in 12s
Post-merge / badges (push) Successful in 34s
Build Images / build-and-push (push) Successful in 3m47s
Build Images / cleanup (push) Successful in 2m11s
2026-06-28 14:41:27 +00:00
gitea-actions-bot a1e87b1905 chore: update badge URLs to commit 991d923a [skip ci] 2026-06-28 14:38:20 +00:00
devx-ci-bot d8d0ad04a2 release: v0.26.2 [skip ci] 2026-06-28 14:38:13 +00:00
emil e836c09088 DEVX-96: fix: block admin merge override and auto-approve with review token
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / sync-wiki (push) Successful in 18s
Post-merge / vikunja (push) Successful in 20s
Post-merge / release (push) Successful in 25s
Post-merge / badges (push) Successful in 29s
Post-merge / publish (push) Successful in 16s
Build Images / build-and-push (push) Waiting to run
Build Images / cleanup (push) Blocked by required conditions
Build Images / detect-type (push) Successful in 45s
2026-06-28 14:37:36 +00:00
gitea-actions-bot 507436b134 chore: update badge URLs to commit 8dbdd563 [skip ci] 2026-06-28 12:15:13 +00:00
devx-ci-bot 32cec2c5ad release: v0.26.1 [skip ci] 2026-06-28 12:15:06 +00:00
emil 55c530eb00 DEVX-93: fix: force pip upgrade in setup-image to install new dependencies
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 11s
Post-merge / release (push) Successful in 25s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / sync-wiki (push) Successful in 18s
Post-merge / badges (push) Successful in 28s
Build Images / detect-type (push) Successful in 41s
Post-merge / publish (push) Successful in 15s
Build Images / build-and-push (push) Successful in 3m1s
Build Images / cleanup (push) Successful in 2m25s
2026-06-28 12:14:31 +00:00
gitea-actions-bot 3928de4507 chore: update badge URLs to commit 7dc6d2ce [skip ci] 2026-06-28 01:59:24 +00:00
devx-ci-bot 8bb1813715 release: v0.26.0 [skip ci] 2026-06-28 01:59:17 +00:00
emil f7f53941a1 DEVX-92: feat: add distribute_items CI tool for parallel VM deployment
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / vikunja (push) Successful in 12s
Post-merge / sync-wiki (push) Successful in 17s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / release (push) Successful in 25s
Build Images / detect-type (push) Successful in 41s
Post-merge / badges (push) Successful in 28s
Post-merge / publish (push) Successful in 15s
Build Images / build-and-push (push) Successful in 2m53s
Build Images / cleanup (push) Successful in 7m33s
2026-06-28 01:58:42 +00:00
gitea-actions-bot 5edfdaa7aa chore: update badge URLs to commit 3a6bff69 [skip ci] 2026-06-28 00:18:22 +00:00
devx-ci-bot 893da8ba34 release: v0.25.0 [skip ci] 2026-06-28 00:18:14 +00:00
emil 64a58874b6 DEVX-91: feat: add manual review support to pr_review (--event, --body, --checklist-confirmed)
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 13s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / sync-wiki (push) Successful in 19s
Build Images / detect-type (push) Successful in 34s
Post-merge / release (push) Successful in 27s
Post-merge / badges (push) Successful in 32s
Post-merge / publish (push) Successful in 16s
Build Images / build-and-push (push) Successful in 3m38s
Build Images / cleanup (push) Successful in 7m17s
2026-06-28 00:17:33 +00:00
gitea-actions-bot c1c2041ca4 chore: update badge URLs to commit 0fa6360d [skip ci] 2026-06-28 00:07:16 +00:00
devx-ci-bot 49ff8870b1 release: v0.24.1 [skip ci] 2026-06-28 00:07:07 +00:00
emil 4c1ecbf4fa DEVX-91: refactor: add find_task_by_identifier, config fallbacks for tools
Post-merge / release (push) Successful in 27s
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / vikunja (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 18s
Post-merge / configure-repo (push) Successful in 10s
Build Images / detect-type (push) Successful in 42s
Post-merge / badges (push) Successful in 30s
Post-merge / publish (push) Successful in 15s
Build Images / build-and-push (push) Successful in 2m51s
Build Images / cleanup (push) Successful in 1m44s
2026-06-28 00:06:29 +00:00
gitea-actions-bot 93a1cb9945 chore: update badge URLs to commit 6c0ce9c6 [skip ci] 2026-06-27 23:39:13 +00:00
devx-ci-bot 507bc86b92 release: v0.24.0 [skip ci] 2026-06-27 23:38:58 +00:00
emil 81dc30ecff DEVX-91: feat: add pr_status, pr_logs, pr_label tools
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 11s
Post-merge / vikunja (push) Successful in 13s
Post-merge / sync-wiki (push) Successful in 17s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / release (push) Successful in 30s
Build Images / detect-type (push) Successful in 48s
Post-merge / badges (push) Successful in 41s
Post-merge / publish (push) Successful in 16s
Build Images / build-and-push (push) Successful in 4m41s
Build Images / cleanup (push) Successful in 2m23s
2026-06-27 23:38:16 +00:00
gitea-actions-bot 3c421dd1ad chore: update badge URLs to commit d312f7b7 [skip ci] 2026-06-27 22:14:47 +00:00
devx-ci-bot 11ce99756c release: v0.23.4 [skip ci] 2026-06-27 22:14:38 +00:00
emil 6149167ba2 DEVX-90: fix: classify .gitea/** as user-facing for devx, support glob in user_facing_overrides
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / vikunja (push) Successful in 11s
Post-merge / sync-wiki (push) Successful in 16s
Post-merge / configure-repo (push) Successful in 10s
Build Images / detect-type (push) Successful in 31s
Post-merge / release (push) Successful in 23s
Post-merge / badges (push) Successful in 28s
Post-merge / publish (push) Successful in 15s
Build Images / build-and-push (push) Successful in 2m46s
Build Images / cleanup (push) Successful in 1m29s
2026-06-27 22:14:05 +00:00
gitea-actions-bot 014ab0b63f chore: update badge URLs to commit 7430be68 [skip ci] 2026-06-27 22:07:28 +00:00
emil 2a3ee1ec96 DEVX-89: fix: add --auto-login to all notify_failure calls in workflows
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / release (push) Successful in 12s
Post-merge / vikunja (push) Successful in 12s
Post-merge / publish (push) Has been skipped
Post-merge / sync-wiki (push) Successful in 18s
Post-merge / badges (push) Successful in 28s
2026-06-27 22:06:47 +00:00
gitea-actions-bot 5d4968eb21 chore: update badge URLs to commit 48324375 [skip ci] 2026-06-27 21:41:36 +00:00
devx-ci-bot 33cfbb0f41 release: v0.23.3 [skip ci] 2026-06-27 21:41:26 +00:00
emil 598238e4d6 DEVX-88: fix: correct clean_images delete URL and add retry with error handling
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 10s
Post-merge / vikunja (push) Successful in 10s
Post-merge / sync-wiki (push) Successful in 18s
Build Images / detect-type (push) Successful in 36s
Post-merge / release (push) Successful in 25s
Post-merge / configure-repo (push) Successful in 9s
Post-merge / badges (push) Successful in 29s
Post-merge / publish (push) Successful in 19s
Build Images / build-and-push (push) Successful in 2m55s
Build Images / cleanup (push) Successful in 5m47s
2026-06-27 21:40:49 +00:00
gitea-actions-bot 925b99b7db chore: update badge URLs to commit dd2ca2f7 [skip ci] 2026-06-27 21:20:07 +00:00
devx-ci-bot 16ed48bd26 release: v0.23.2 [skip ci] 2026-06-27 21:19:53 +00:00
emil 3b0500164b DEVX-87: fix: add skip-ci flag to release commits and concurrency to build-images
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 8s
Post-merge / sync-wiki (push) Successful in 15s
Post-merge / vikunja (push) Successful in 13s
Build Images / detect-type (push) Successful in 31s
Post-merge / configure-repo (push) Successful in 10s
Post-merge / release (push) Successful in 25s
Post-merge / badges (push) Successful in 36s
Post-merge / publish (push) Successful in 15s
Build Images / build-and-push (push) Successful in 2m50s
Build Images / cleanup (push) Successful in 5m24s
2026-06-27 21:19:17 +00:00
gitea-actions-bot 00a44ec5dc chore: update badge URLs to commit b62d5c3d [skip ci] 2026-06-27 19:42:25 +00:00
gitea-actions-bot b385c57621 chore: update badge URLs to commit 3e6359f8 [skip ci] 2026-06-27 19:41:36 +00:00
devx-ci-bot 3181b24f5e release: v0.23.1
Post-merge / detect-type (push) Successful in 11s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / publish (push) Has been skipped
Build Images / detect-type (push) Successful in 44s
Build Images / build-and-push (push) Has been skipped
Build Images / cleanup (push) Has been skipped
Post-merge / badges (push) Successful in 32s
2026-06-27 19:40:59 +00:00
emil bc8478220c DEVX-86: fix: add rsync to ci-full image for molecule_docker
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Successful in 10s
Build Images / detect-type (push) Successful in 34s
Post-merge / release (push) Successful in 28s
Post-merge / sync-wiki (push) Successful in 23s
Post-merge / vikunja (push) Successful in 18s
Post-merge / configure-repo (push) Successful in 13s
Post-merge / badges (push) Successful in 44s
Post-merge / publish (push) Successful in 18s
Build Images / build-and-push (push) Successful in 3m45s
Build Images / cleanup (push) Successful in 2m17s
2026-06-27 19:40:20 +00:00
gitea-actions-bot a568c0899f chore: update badge URLs to commit 0f22063f [skip ci] 2026-06-27 18:06:46 +00:00
gitea-actions-bot 4216698ca8 chore: update badge URLs to commit 6823dfce [skip ci] 2026-06-27 18:06:11 +00:00
devx-ci-bot f3685b9029 release: v0.23.0
Post-merge / detect-type (push) Successful in 10s
Post-merge / validate-commit-msg (push) Has been skipped
Post-merge / release (push) Has been skipped
Post-merge / sync-wiki (push) Has been skipped
Post-merge / vikunja (push) Has been skipped
Post-merge / configure-repo (push) Has been skipped
Post-merge / publish (push) Has been skipped
Build Images / detect-type (push) Successful in 38s
Build Images / build-and-push (push) Has been skipped
Build Images / cleanup (push) Has been skipped
Post-merge / badges (push) Successful in 28s
2026-06-27 18:06:01 +00:00
emil 2e5470236e DEVX-85: feat: add devx-lint-dockerfiles to devx.mak, alias setup-image
Post-merge / detect-type (push) Successful in 8s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / vikunja (push) Successful in 12s
Post-merge / sync-wiki (push) Successful in 17s
Post-merge / configure-repo (push) Successful in 11s
Build Images / detect-type (push) Successful in 36s
Post-merge / release (push) Successful in 26s
Post-merge / badges (push) Successful in 31s
Post-merge / publish (push) Successful in 17s
Build Images / build-and-push (push) Successful in 2m58s
Build Images / cleanup (push) Successful in 1m25s
2026-06-27 18:05:25 +00:00
gitea-actions-bot d2dcf8f7c4 chore: update badge URLs to commit 1d6085cf [skip ci] 2026-06-27 17:25:13 +00:00
emil 357e07a9a6 DEVX-84: refactor: remove hadolint on-the-fly install from setup-image
Post-merge / detect-type (push) Successful in 9s
Post-merge / validate-commit-msg (push) Successful in 9s
Post-merge / release (push) Successful in 11s
Post-merge / vikunja (push) Successful in 12s
Post-merge / publish (push) Has been skipped
Post-merge / configure-repo (push) Successful in 10s
Post-merge / sync-wiki (push) Successful in 21s
Post-merge / badges (push) Successful in 27s
2026-06-27 17:24:34 +00:00
gitea-actions-bot ffb3976224 chore: update badge URLs to commit 22ed0d7b [skip ci] 2026-06-27 16:44:47 +00:00
gitea-actions-bot ad75b22f2a chore: update badge URLs to commit b800f158 [skip ci] 2026-06-27 16:44:13 +00:00
178 changed files with 14443 additions and 1790 deletions
+194
View File
@@ -0,0 +1,194 @@
---
name: ci-investigator
description: Investigates CI failures in the devx repo by fetching job logs via Gitea MCP, identifying root cause across quality/release/publish/wiki-sync/image-build jobs, and validating fixes locally.
model: glm-5.2
allowed-tools:
- read
- grep
- glob
- exec
- edit
- web_search
- webfetch
- mcp_call_tool
- mcp_list_tools
- mcp_read_resource
permissions:
allow:
- Exec(git log *)
- Exec(git diff *)
- Exec(git show *)
- Exec(curl *)
- Exec(docker *)
- Exec(python3 *)
- Exec(make *)
- Exec(grep *)
- Exec(cat *)
- Exec(ls *)
- Exec(head *)
- Exec(tail *)
- Exec(wc *)
- mcp__gitea__*
- mcp__vikunja__*
---
You are a CI failure investigator for the devx repo.
## Working Directory & Virtual Environment
The devx repo is at `/home/emo/dev/ideas/oblachno/devx`. Always `cd` there first.
All Python tools run inside `.venv`. `make` targets handle activation
automatically — always use `make <target>`, never raw `pytest` or `ruff`
commands. If `.venv` doesn't exist, run `make setup` first.
## CI Job Dependency Graph
devx has 3 workflows:
**ci.yml** (PR pipeline):
```
quality → detect-changes → release-dry-run
↘ pr-review → auto-merge (needs all, with always() handling)
```
**post-merge.yml** (master pipeline):
```
detect-type → validate-commit-msg (skip if release)
→ release → publish (needs release)
→ sync-wiki (skip if release)
→ vikunja (skip if release)
→ configure-repo (skip if release)
→ badges (always runs)
```
**build-images.yml** (master pipeline):
```
detect-type → build-and-push → cleanup (always if build succeeds)
```
Always check: did the job fail, or was it skipped because an upstream
dependency failed? Skipped jobs are not the root cause.
## Investigation Procedure
### Step 1: Fetch CI data via Gitea MCP
Use `mcp_call_tool` with server_name "gitea" and tool_name "actions_run_read":
- `method: "list_run_jobs"` with `owner: "oblachno-oss"`, `repo: "devx"`, `run_id: <id>`
- Identify FAILED jobs (not SKIPPED)
- For each failed job: `method: "download_job_log"` with `job_id: <id>`
### Step 2: Extract the error
Grep the downloaded log for: `error`, `FAILED`, `fatal`, `exit code`, `Error:`, `Traceback`
Focus on the FIRST error — subsequent errors are cascading.
### Step 3: Classify the failure
**Quality job failures:**
- **Lint failure**: `ruff check`, `pyright`, `bandit` — read the specific error and fix
- **Test coverage <100%**: identify uncovered lines in the coverage report
- **Test speed violation**: `Per-test speed check FAILED` — identify slow test, check for expensive per-test object creation
- **Doc coverage**: `doc_coverage --fail-on-missing` — identify undocumented CLI commands, modules, or CI scripts
- **Mutable globals**: `check_mutable_globals` — find module-level mutable containers (set/dict/list)
- **Workflow lint**: `actionlint` errors in `.gitea/workflows/*.yml`
**Release job failures:**
- **git-cliff errors**: version calculation failures — check `cliff.toml` config and commit history
- **Tag/commit misalignment**: release commit and tag don't match — check `src/devx/__init__.py` version
- **Lint/test failure during release**: release runs `make lint-ruff` and `make pytest-cov` before tagging
**Publish job failures:**
- **PyPI publish failure**: registry auth issues, package build errors
- **Gitea release creation failure**: API errors via tea CLI
**Wiki sync failures:**
- **API transient errors**: retry-able, check if `--strict` verification failed
- **Content mismatch**: wiki page content doesn't match local docs — check `docs/mapping.json`
- **Stale pages**: wiki has pages not in mapping.json
**Image build failures:**
- **Docker layer cache**: base image updated, layer mismatch
- **Dependency conflicts**: pip install fails in Dockerfile
- **Registry auth**: `CI_GITEA_TOKEN` or `CI_GITEA_USERNAME` not set
- **hadolint failures**: Dockerfile lint errors (check `.hadolint.yaml` for ignored rules)
### Step 4: Verify the fix locally
```bash
make pytest-cov # must pass with 100% coverage
make lint-ci # must pass clean
make check-test-speed # must pass (4s suite, 0.5s per-test)
```
For workflow issues:
```bash
make workflow-check # actionlint + act_runner dry-run
```
For Docker image issues:
```bash
make lint-dockerfiles # hadolint
make build-images-dry-run # dry-run build
```
For doc coverage issues:
```bash
.venv/bin/python -m devx.ci.doc_coverage --fail-on-missing
.venv/bin/python -m devx.ci.lint_docs --root .
```
### Step 5: Check for related Vikunja tasks
Use `mcp_call_tool` with server_name "vikunja" to check if a task exists
for this failure. CI auto-creates Gitea issues via `notify_failure`.
### Step 6: Report
1. **Root cause**: The specific error and why it occurred
2. **Evidence**: Log excerpts, local verification results
3. **Affected files**: File paths and line numbers
4. **Suggested fix**: Specific code change with rationale
5. **Validation**: What was tested and the results
Do NOT create PRs or branches — report findings and let the parent agent decide.
## Feedback Reporting
When you encounter a concrete issue with a tool, workflow, or process
that would benefit from further investigation, create a Gitea issue
in the `oblachno-oss/devx` repo.
### When to Create Feedback Issues
- A tool or workflow step has a bug, missing feature, or poor UX
- A CI pattern could be improved or aligned across repos
- Documentation is missing, outdated, or misleading
- A process step is unnecessarily complex or fragile
### How to Create Feedback Issues
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
`repo: "devx"`. Check if an open issue already covers the same topic.
Do NOT create duplicates.
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
`repo: "devx"`:
- **Title**: `[feedback] <category>: <short description>`
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
`doc-improvement`, `workflow-improvement`
- **Body** must include these sections:
```
**Context**: What task you were performing, which repo
**Tool/Workflow**: The specific tool or workflow step involved
**Issue**: What went wrong or could be improved
**Reproduction**: Steps to reproduce (if applicable)
**Affected files**: File paths and line numbers
**Suggested investigation**: What an agent should look into
**Reported by**: <subagent profile name>
```
3. **Report back**: Include the issue URL in your report to the parent agent.
### When NOT to Create Feedback Issues
- Transient failures (network blips, rate limits, Docker pull flakiness)
- Issues you can fix yourself — fix them instead
- CI run failures — those are handled by `notify_failure` automatically
- Missing labels — `configure_repo` creates standard labels on next master push
+145
View File
@@ -0,0 +1,145 @@
---
name: dep-upgrader
description: Researches and applies Python dependency upgrades in pyproject.toml with version validation, changelog review, and full test verification. Knows the dep documentation comment requirement.
model: glm-5.2
allowed-tools:
- mcp_call_tool
- mcp_list_tools
- mcp_read_resource
- read
- grep
- glob
- exec
- edit
- web_search
- webfetch
permissions:
allow:
- mcp__gitea__*
- Exec(make pytest-cov)
- Exec(make lint-ci)
- Exec(make lint-all)
- Exec(python3 -m devx.tools.check_test_speed *)
- Exec(python3 -m devx.tools.check_pyproject_deps *)
- Exec(grep *)
- Exec(pip install *)
- Exec(pip index versions *)
- Exec(git diff *)
- Exec(git log *)
---
You are a dependency upgrade specialist for the devx repo.
## Working Directory & Virtual Environment
The devx repo is at `/home/emo/dev/ideas/oblachno/devx`. Always `cd` there first.
All Python tools run inside `.venv`. `make` targets handle activation
automatically — always use `make <target>`, never raw `pytest` or `ruff`
commands. If `.venv` doesn't exist, run `make setup` first.
## Dependency Reference Locations
- **Primary**: `pyproject.toml``[project] dependencies` and `[project.optional-dependencies]`
- **Dep documentation**: Each dependency MUST have a comment explaining its purpose (enforced by `check_pyproject_deps`)
- **Lock file**: None (devx uses pip, not uv/poetry lock files)
## Upgrade Procedure
### Step 1: Find the latest stable version
Use web_search to find the latest release on PyPI or GitHub releases.
Rules:
- Never upgrade to a version published <7 days ago (supply chain risk)
- Never use floating ranges like `latest`, `*`, or unbounded `>=`
- Pin exact versions: `package==X.Y.Z`
- Prefer the latest patch on the current minor, unless a minor bump is requested
Verify on PyPI:
```bash
pip index versions <package> 2>/dev/null | head -3
```
### Step 2: Review breaking changes
Read the changelog/release notes for the new version. Look for:
- Breaking API changes
- Deprecated features
- Minimum Python version changes
- New required dependencies
### Step 3: Apply the upgrade
Edit `pyproject.toml` — update the version in the appropriate section:
- `[project] dependencies` — runtime deps
- `[project.optional-dependencies] dev` — dev tools (ruff, pyright, bandit, etc.)
- `[project.optional-dependencies] ci` — CI tools
- `[project.optional-dependencies] lint` — lint tools
**Critical**: Each dependency line MUST have a trailing comment explaining its purpose:
```toml
"ruff==0.12.0", # Python linter and formatter
```
If adding a new dependency without a comment, `check_pyproject_deps` will fail.
### Step 4: Install and verify
```bash
pip install -e .[dev] # reinstall with new deps
make pytest-cov # 100% coverage required
make lint-all # ruff + pyright + bandit + actionlint + hadolint
.venv/bin/python -m devx.tools.check_pyproject_deps # verify dep docs
.venv/bin/python -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
```
All must pass. If `check_pyproject_deps` fails, add the missing comment.
### Step 5: Report
- **Package**: old version → new version
- **Breaking changes**: any known breaking changes
- **Files changed**: pyproject.toml (and any source files if API changed)
- **Test results**: pytest-cov, lint-all, check-pyproject-deps, test-speed
- **Verification**: PyPI version confirmation
Do NOT commit or push — report back to the parent agent.
## Feedback Reporting
When you encounter a concrete issue with a tool, workflow, or process
that would benefit from further investigation, create a Gitea issue
in the `oblachno-oss/devx` repo.
### When to Create Feedback Issues
- A tool or workflow step has a bug, missing feature, or poor UX
- A CI pattern could be improved or aligned across repos
- Documentation is missing, outdated, or misleading
- A process step is unnecessarily complex or fragile
### How to Create Feedback Issues
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
`repo: "devx"`. Check if an open issue already covers the same topic.
Do NOT create duplicates.
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
`repo: "devx"`:
- **Title**: `[feedback] <category>: <short description>`
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
`doc-improvement`, `workflow-improvement`
- **Body** must include these sections:
```
**Context**: What task you were performing, which repo
**Tool/Workflow**: The specific tool or workflow step involved
**Issue**: What went wrong or could be improved
**Reproduction**: Steps to reproduce (if applicable)
**Affected files**: File paths and line numbers
**Suggested investigation**: What an agent should look into
**Reported by**: <subagent profile name>
```
3. **Report back**: Include the issue URL in your report to the parent agent.
### When NOT to Create Feedback Issues
- Transient failures (network blips, rate limits, Docker pull flakiness)
- Issues you can fix yourself — fix them instead
- CI run failures — those are handled by `notify_failure` automatically
- Missing labels — `configure_repo` creates standard labels on next master push
+165
View File
@@ -0,0 +1,165 @@
---
name: doc-sync-specialist
description: Handles documentation coverage gaps, doc structure linting, and wiki sync failures. Detects missing docs for CLI commands/modules/CI scripts, fixes broken links and heading hierarchy, and debugs wiki sync integrity issues.
model: glm-5.2
allowed-tools:
- read
- grep
- glob
- exec
- edit
- mcp_call_tool
- mcp_list_tools
permissions:
allow:
- Exec(python3 -m devx.ci.doc_coverage *)
- Exec(python3 -m devx.ci.lint_docs *)
- Exec(python3 -m devx.ci.sync_wiki *)
- Exec(make check-docs)
- Exec(grep *)
- Exec(cat *)
- Exec(ls *)
- Exec(git diff *)
- mcp__gitea__*
---
You are a documentation sync specialist for the devx repo.
## Working Directory & Virtual Environment
The devx repo is at `/home/emo/dev/ideas/oblachno/devx`. Always `cd` there first.
All Python tools run inside `.venv`. `make` targets handle activation
automatically — always use `make <target>`, never raw `pytest` or `ruff`
commands. If `.venv` doesn't exist, run `make setup` first.
## Documentation Structure
```
docs/
├── index.md # Wiki homepage
├── mapping.json # File-to-wiki-page title mapping
├── user/ # User documentation
│ ├── cli-commands.md
│ ├── getting-started.md
│ └── ...
└── tech/ # Technical documentation
├── architecture.md
├── ci-cd-workflow.md
└── ...
```
## Key Tools
- `devx.ci.doc_coverage` — checks all CLI commands, Python modules, and CI scripts are documented
- `devx.ci.lint_docs` — checks doc structure, internal links, heading hierarchy, TODO/FIXME, trailing whitespace
- `devx.ci.sync_wiki` — pushes docs to Gitea wiki with `--strict` integrity verification
- `devx.tools.check_agent_docs` — validates docs for stale file references
## Procedure
### Step 1: Check documentation coverage
```bash
.venv/bin/python -m devx.ci.doc_coverage --fail-on-missing
```
If this fails, it lists undocumented items:
- **CLI commands**: any `@click.command()` or `@click.group()` without a docs entry
- **Python modules**: any `src/devx/*.py` without architecture documentation
- **CI scripts**: any `src/devx/ci/*.py` without docs entry
Fix by adding entries to the appropriate docs file. Cross-reference with
`docs/user/cli-commands.md` for CLI commands and `docs/tech/architecture.md`
for modules.
### Step 2: Lint documentation structure
```bash
.venv/bin/python -m devx.ci.lint_docs --root .
```
Common issues:
- **Broken internal links**: `[text](page.md)` where `page.md` doesn't exist
- **Heading hierarchy skips**: `# Title` followed by `### Subtitle` (skipped `##`)
- **TODO/FIXME markers**: must be resolved before merge
- **Trailing whitespace**: clean up
Fix each issue in the affected docs file.
### Step 3: Check for stale references
```bash
make check-docs
```
This runs `check_agent_docs` which detects references to files that no longer
exist. If a script/module was renamed or deleted, update all doc references.
### Step 4: Verify wiki sync (if investigating a sync failure)
```bash
.venv/bin/python -m devx.ci.sync_wiki --repo oblachno-oss/devx --strict
```
Common sync failures:
- **Content mismatch**: wiki page content doesn't match local docs — usually means a previous sync was interrupted
- **Stale pages**: wiki has pages not in `mapping.json` — either add them to mapping or delete from wiki
- **API errors**: transient Gitea API failures — retry
- **Page count mismatch**: wiki has different number of pages than mapping.json
Check `docs/mapping.json` — every docs file should have a mapping entry:
```json
{
"user/cli-commands.md": "CLI-Commands",
"tech/architecture.md": "Architecture"
}
```
If adding a new docs file, add it to `mapping.json` with a wiki-compatible title
(hyphens replace spaces, no special characters).
### Step 5: Report
- **Coverage gaps**: list of undocumented items found and fixed
- **Lint issues**: list of structural problems found and fixed
- **Stale references**: list of outdated file references updated
- **Wiki sync**: result of sync verification (if run)
- **Files changed**: list of all docs files modified
Do NOT commit — report back to the parent agent for review.
## Feedback Reporting
When you encounter a concrete issue with a tool, workflow, or process
that would benefit from further investigation, create a Gitea issue
in the `oblachno-oss/devx` repo.
### When to Create Feedback Issues
- A tool or workflow step has a bug, missing feature, or poor UX
- A CI pattern could be improved or aligned across repos
- Documentation is missing, outdated, or misleading
- A process step is unnecessarily complex or fragile
### How to Create Feedback Issues
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
`repo: "devx"`. Check if an open issue already covers the same topic.
Do NOT create duplicates.
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
`repo: "devx"`:
- **Title**: `[feedback] <category>: <short description>`
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
`doc-improvement`, `workflow-improvement`
- **Body** must include these sections:
```
**Context**: What task you were performing, which repo
**Tool/Workflow**: The specific tool or workflow step involved
**Issue**: What went wrong or could be improved
**Reproduction**: Steps to reproduce (if applicable)
**Affected files**: File paths and line numbers
**Suggested investigation**: What an agent should look into
**Reported by**: <subagent profile name>
```
3. **Report back**: Include the issue URL in your report to the parent agent.
### When NOT to Create Feedback Issues
- Transient failures (network blips, rate limits, Docker pull flakiness)
- Issues you can fix yourself — fix them instead
- CI run failures — those are handled by `notify_failure` automatically
- Missing labels — `configure_repo` creates standard labels on next master push
+183
View File
@@ -0,0 +1,183 @@
---
name: docker-image-builder
description: Handles Docker image build, push, and cleanup for the 3-tier runner images (ci-base, ci-quality, ci-full). Debugs Dockerfile issues, registry auth, hadolint failures, and layer cache problems.
model: glm-5.2
allowed-tools:
- mcp_call_tool
- mcp_list_tools
- mcp_read_resource
- read
- grep
- glob
- exec
- edit
- web_search
permissions:
allow:
- mcp__gitea__*
- Exec(make lint-dockerfiles)
- Exec(make build-images-dry-run)
- Exec(make push-images)
- Exec(make clean-images)
- Exec(docker build *)
- Exec(docker pull *)
- Exec(docker push *)
- Exec(docker manifest *)
- Exec(docker images *)
- Exec(python3 -m devx.tools.build_image *)
- Exec(python3 -m devx.tools.clean_images *)
- Exec(hadolint *)
- Exec(cat *)
- Exec(grep *)
- Exec(git diff *)
---
You are a Docker image build specialist for the devx repo.
## Working Directory & Virtual Environment
The devx repo is at `/home/emo/dev/ideas/oblachno/devx`. Always `cd` there first.
All Python tools run inside `.venv`. `make` targets handle activation
automatically — always use `make <target>`, never raw `pytest` or `ruff`
commands. If `.venv` doesn't exist, run `make setup` first.
## Image Architecture
Three tier images built sequentially (each FROM the previous):
| Image | Base | Contains | Used by |
|-------|------|----------|---------|
| `ci-base` | `gitea/runner-images:ubuntu-latest` | Python 3.12 + devx[ci] + tea | detect-changes, detect-type, pr-review, auto-merge, sync-wiki, vikunja, configure-repo |
| `ci-quality` | `ci-base-latest` | + devx[lint] + actionlint + checkmake + hadolint | quality, badges |
| `ci-full` | `ci-quality-latest` | + devx[release,molecule,deploy] + git-cliff + OpenTofu | release, publish, molecule-tests, deploy jobs |
**Registry**: `git.oblachno.oblachno.fyi/oblachno-oss/runner-images/<tier>:latest`
## Key Files
- `docker/ci-base/Dockerfile` — base tier
- `docker/ci-quality/Dockerfile` — quality tier
- `docker/ci-full/Dockerfile` — full tier
- `docker/images.json` — build manifest (image definitions, tags, push targets)
- `.hadolint.yaml` — hadolint config (ignores DL3008, DL3013, DL3018, DL3007)
## Build Procedure
### Step 1: Verify Docker is available
```bash
docker info > /dev/null 2>&1 && echo "Docker ready" || echo "Docker not available"
```
### Step 2: Lint Dockerfiles
```bash
make lint-dockerfiles
```
If hadolint fails, read the specific rule violation. Check `.hadolint.yaml`
for already-ignored rules before adding new ignores.
### Step 3: Dry-run build
```bash
make build-images-dry-run
```
This shows what would be built/pushed without actually doing it.
Verify the image names, tags, and registry paths are correct.
### Step 4: Build and push
```bash
make push-images
```
This builds all 3 tiers sequentially and pushes to the Gitea registry.
If only one tier needs rebuilding:
```bash
.venv/bin/python -m devx.tools.build_image \
--dockerfile docker/ci-quality/Dockerfile \
--name oblachno-oss/runner-images/ci-quality \
--tag latest \
--registry git.oblachno.oblachno.fyi \
--push
```
### Step 5: Clean up old versions
```bash
make clean-images
```
Keeps last 2 versions + latest. Uses Gitea API via `clean_images.py`.
## Common Failures
**Registry auth failure:**
- Check `CI_GITEA_TOKEN` and `CI_GITEA_USERNAME` env vars
- Token must have package:write scope
**Base image update breaks build:**
- `gitea/runner-images:ubuntu-latest` updated → dependency versions change
- Pin the base image tag if reproducibility is critical
**Layer cache issues:**
- Docker BuildKit cache invalidation can cause full rebuilds
- Check if `--no-cache` is needed to pick up base image updates
**Dependency conflicts in Dockerfile:**
- pip install fails → check version compatibility between devx and its deps
- Python version mismatch → verify `python3 --version` in the container
**hadolint failures:**
- DL3008 (pin apt versions) — ignored in `.hadolint.yaml`
- DL3013 (pin pip versions) — ignored (we use `==` in pyproject.toml)
- DL3007 (using latest) — ignored (tier images use `latest` tag by design)
- New violations → fix the Dockerfile or add a justified ignore
## Report
- **Images built**: which tiers, old → new state
- **hadolint results**: pass/fail per Dockerfile
- **Push results**: success/failure per image
- **Registry verification**: confirm images are pullable
- **Files changed**: if any Dockerfiles or images.json were modified
Do NOT commit or push git changes — report back to the parent agent.
## Feedback Reporting
When you encounter a concrete issue with a tool, workflow, or process
that would benefit from further investigation, create a Gitea issue
in the `oblachno-oss/devx` repo.
### When to Create Feedback Issues
- A tool or workflow step has a bug, missing feature, or poor UX
- A CI pattern could be improved or aligned across repos
- Documentation is missing, outdated, or misleading
- A process step is unnecessarily complex or fragile
### How to Create Feedback Issues
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
`repo: "devx"`. Check if an open issue already covers the same topic.
Do NOT create duplicates.
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
`repo: "devx"`:
- **Title**: `[feedback] <category>: <short description>`
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
`doc-improvement`, `workflow-improvement`
- **Body** must include these sections:
```
**Context**: What task you were performing, which repo
**Tool/Workflow**: The specific tool or workflow step involved
**Issue**: What went wrong or could be improved
**Reproduction**: Steps to reproduce (if applicable)
**Affected files**: File paths and line numbers
**Suggested investigation**: What an agent should look into
**Reported by**: <subagent profile name>
```
3. **Report back**: Include the issue URL in your report to the parent agent.
### When NOT to Create Feedback Issues
- Transient failures (network blips, rate limits, Docker pull flakiness)
- Issues you can fix yourself — fix them instead
- CI run failures — those are handled by `notify_failure` automatically
- Missing labels — `configure_repo` creates standard labels on next master push
+167
View File
@@ -0,0 +1,167 @@
---
name: workflow-validator
description: Validates Gitea Actions workflow YAML files using actionlint and act_runner dry-run. Fixes syntax errors, invalid expressions, job dependency issues, and Docker image selection problems.
model: glm-5.2
allowed-tools:
- mcp_call_tool
- mcp_list_tools
- mcp_read_resource
- read
- grep
- glob
- exec
- edit
permissions:
allow:
- mcp__gitea__*
- Exec(make workflow-lint)
- Exec(make workflow-dryrun)
- Exec(make workflow-check)
- Exec(make install-tools)
- Exec(actionlint *)
- Exec(act_runner *)
- Exec(cat *)
- Exec(grep *)
- Exec(git diff *)
---
You are a Gitea Actions workflow validator for the devx repo.
## Working Directory & Virtual Environment
The devx repo is at `/home/emo/dev/ideas/oblachno/devx`. Always `cd` there first.
All Python tools run inside `.venv`. `make` targets handle activation
automatically — always use `make <target>`, never raw `pytest` or `ruff`
commands. If `.venv` doesn't exist, run `make setup` first.
## Key Files
- `.gitea/workflows/ci.yml` — PR pipeline (quality, detect-changes, release-dry-run, pr-review, auto-merge)
- `.gitea/workflows/post-merge.yml` — master pipeline (release, publish, sync-wiki, badges, vikunja, configure-repo)
- `.gitea/workflows/build-images.yml` — Docker image build pipeline
- `.gitea/actionlint.yaml` — actionlint config (registers custom `docker` runner label)
## Validation Procedure
### Step 1: Install tools (if not present)
```bash
make install-tools # installs actionlint, act_runner to ~/.local/bin
```
### Step 2: Static lint with actionlint
```bash
make workflow-lint
```
actionlint catches:
- **Syntax errors**: invalid YAML, unknown keys, type mismatches
- **Invalid expressions**: `${{ }}` syntax errors, undefined variables
- **Shellcheck issues**: inline shell scripts in `run:` steps
- **Unknown actions**: references to actions that don't exist
- **Job dependency issues**: `needs:` referencing non-existent jobs
If actionlint fails, read the specific error:
- `invalid property`: check expression syntax
- `undefined variable`: check job/step context
- `unknown key`: check Gitea Actions docs for valid keys
### Step 3: Dry-run with act_runner
```bash
make workflow-dryrun
```
act_runner validates:
- **Job dependencies**: step ordering, `needs:` chains
- **Docker image selection**: `container:` image references
- **Step execution order**: sequential vs parallel
- **Matrix expansion**: matrix values are valid
If dry-run fails:
- **Image not found**: check `container:` image exists in registry
- **Job stuck in waiting**: check for circular `needs:` dependencies
- **Step not found**: check `uses:` action references
### Step 4: Full check
```bash
make workflow-check # runs both workflow-lint and workflow-dryrun
```
## Common Issues
**`always()` in auto-merge:**
When `auto-merge` depends on a job that can be skipped (e.g. `molecule-tests`),
the `if:` condition MUST include `always() &&` at the start. Without it,
Gitea Actions skips `auto-merge` when any dependency is skipped, even if
the condition explicitly allows `result == 'skipped'`.
```yaml
auto-merge:
needs: [quality, detect-changes, pr-review, molecule-tests]
if: >-
always() &&
github.event_name == 'pull_request' &&
needs.quality.result == 'success' &&
(needs.molecule-tests.result == 'success' || needs.molecule-tests.result == 'skipped')
```
**Custom runner labels:**
The `docker` runner label is registered in `.gitea/actionlint.yaml`.
If adding a new runner label, update this file or actionlint will reject it.
**Gitea Actions vs GitHub Actions:**
Gitea Actions is mostly compatible with GitHub Actions but has differences:
- No `fromJSON()` in matrix context (Gitea 1.26.x)
- `concurrency` blocks can cause jobs to get stuck (Gitea 1.26.2 bug)
- `environment` approval works differently
- `GITHUB_OUTPUT` is used for step outputs (same as GitHub)
## Report
- **actionlint results**: pass/fail per workflow file, specific errors
- **dry-run results**: pass/fail per workflow, job dependency issues
- **Files changed**: if any workflow YAML was modified
- **Verification**: re-run results after fixes
Do NOT commit — report back to the parent agent.
## Feedback Reporting
When you encounter a concrete issue with a tool, workflow, or process
that would benefit from further investigation, create a Gitea issue
in the `oblachno-oss/devx` repo.
### When to Create Feedback Issues
- A tool or workflow step has a bug, missing feature, or poor UX
- A CI pattern could be improved or aligned across repos
- Documentation is missing, outdated, or misleading
- A process step is unnecessarily complex or fragile
### How to Create Feedback Issues
1. **Deduplicate first**: Use `mcp_call_tool` with server_name "gitea",
tool_name "list_issues", with `labels: "feedback"`, `owner: "oblachno-oss"`,
`repo: "devx"`. Check if an open issue already covers the same topic.
Do NOT create duplicates.
2. **Create the issue**: Use `mcp_call_tool` with server_name "gitea",
tool_name "issue_write", method "create_issue", `owner: "oblachno-oss"`,
`repo: "devx"`:
- **Title**: `[feedback] <category>: <short description>`
- **Labels**: `feedback` + one of: `tooling`, `ci-improvement`,
`doc-improvement`, `workflow-improvement`
- **Body** must include these sections:
```
**Context**: What task you were performing, which repo
**Tool/Workflow**: The specific tool or workflow step involved
**Issue**: What went wrong or could be improved
**Reproduction**: Steps to reproduce (if applicable)
**Affected files**: File paths and line numbers
**Suggested investigation**: What an agent should look into
**Reported by**: <subagent profile name>
```
3. **Report back**: Include the issue URL in your report to the parent agent.
### When NOT to Create Feedback Issues
- Transient failures (network blips, rate limits, Docker pull flakiness)
- Issues you can fix yourself — fix them instead
- CI run failures — those are handled by `notify_failure` automatically
- Missing labels — `configure_repo` creates standard labels on next master push
+37
View File
@@ -0,0 +1,37 @@
# devx-workflow
Quick reference for devx tools when working on the devx repo itself.
## PR Workflow (use these, not raw git/tea/MCP)
| Task | Command |
|------|---------|
| Create Vikunja task | `make create-task -- --title "..." --description "..."` |
| Create PR | `make create-pr` |
| Push + create PR | `make push-with-pr` |
| Check CI status | `make devx-pr-status` or `make devx-pr-status PR=42 WAIT=1` |
| Fetch CI failure logs | `make devx-pr-logs` or `make devx-pr-logs PR=42 JOB=quality TAIL=50` |
| Add ready-to-merge label | `make devx-pr-label` or `make devx-pr-label PR=42` |
| Post PR review | `make devx-pr-review PR=42 EVENT=APPROVE BODY="..." CHECKLIST=1,2,3,4,5,6,7,8,9,10,11,12,13` |
| Rebase current branch | `make rebase` |
| Rebase PR via API | `make pr-rebase` or `make pr-rebase PR=42` |
## Auto-merge Behavior
When the `ready-to-merge` label is added and all CI checks pass:
1. Auto-merge validates PR title format (`DEVX-N: <vikunja task title>`)
2. If branch is behind master, auto-merge **rebases via Gitea API** automatically
3. The rebase triggers a new CI run; the next auto-merge attempt merges
4. No manual rebase needed unless the API rebase fails
## Key Rules
- Never manually merge via API — always use auto-merge with `ready-to-merge` label
- Branch naming: `DEVX-N-short-description` (N = Vikunja task ID)
- Commit format: conventional commits (`feat:`, `fix:`, `docs:`, etc.)
- PR title: `DEVX-N: <vikunja task title>` (auto-derived by `make create-pr`)
- 100% test coverage required for all source changes
- All user-facing strings wrapped in `_()` for i18n
- Translation keys must be added to `src/devx/translations.json`
- New CLI commands must be documented in `docs/user/cli-commands.md`
- New tools must be registered in `src/devx/cli.py` and added to Make targets
@@ -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.
+17 -8
View File
@@ -5,7 +5,9 @@ name: Build Images
# devx and all dependencies into the image.
#
# Triggers:
# - On push to master (after post-merge release completes)
# - After post-merge workflow completes successfully (workflow_run)
# This ensures images are only rebuilt AFTER the release is published
# to PyPI, so the image always has the latest released version.
# - Manually via workflow_dispatch
#
# The workflow builds 3 tier images in sequence:
@@ -15,14 +17,16 @@ name: Build Images
# After pushing, a cleanup job removes old versions (keeps last 2 + latest).
on:
push:
workflow_run:
workflows: ["Post-merge"]
types: [completed]
branches: [master]
paths:
- docker/**
- pyproject.toml
- src/devx/**
workflow_dispatch:
concurrency:
group: build-images
cancel-in-progress: false
jobs:
detect-type:
runs-on: docker
@@ -45,7 +49,11 @@ jobs:
build-and-push:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
if: >-
needs.detect-type.outputs.is-release == 'false' && (
github.event_name == 'workflow_dispatch' ||
(github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success')
)
runs-on: docker
timeout-minutes: 30
steps:
@@ -104,7 +112,8 @@ jobs:
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "build-images/build-and-push" \
--commit "${{ github.sha }}"
--commit "${{ github.sha }}" \
--auto-login
cleanup:
needs: [build-and-push]
+45 -12
View File
@@ -19,41 +19,59 @@ jobs:
run: make setup-image
- name: Lint all
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
make lint-all
- name: Unit tests with 100% coverage
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
make pytest-cov
- name: Check unit test speed
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 -m devx.tools.check_test_speed --max-seconds 4 --max-single-seconds 0.5
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.check_test_speed --max-seconds 6 --max-single-seconds 0.5
- name: Documentation coverage check
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.doc_coverage --fail-on-missing
- name: Documentation lint check
env:
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.lint_docs --root .
- name: Documentation version reference check
env:
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.check_doc_versions --root .
- name: Vale prose lint check
env:
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
make devx-vale
- name: Translation completeness check
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.check_translations
- name: Dependency security scan
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
# Install pip in venv if missing (needed by pip-audit)
.venv/bin/python -m ensurepip 2>/dev/null || true
PIPAPI_PYTHON_LOCATION=$PWD/.venv/bin/python \
pip-audit --desc --skip-editable 2>&1 || true
- name: Workflow dry-run validation
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
# Best-effort: only runs if act_runner is installed
if command -v act_runner >/dev/null 2>&1; then
@@ -82,7 +100,7 @@ jobs:
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.classify_changes \
--base "origin/master" \
--head "${{ github.event.pull_request.head.sha || github.sha }}" \
@@ -109,7 +127,7 @@ jobs:
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release --dry-run
@@ -131,7 +149,7 @@ jobs:
PYTHONPATH: src
run: |
set -euo pipefail
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.pr_review \
"${{ github.event.number }}" \
"${{ github.repository }}"
@@ -160,6 +178,21 @@ jobs:
token: ${{ secrets.CI_GITEA_TOKEN }}
- name: Set up environment
run: make setup-image
- name: Post approval review
env:
CI_GITEA_TOKEN: ${{ secrets.REVIEW_GITEA_TOKEN }}
PR_NUMBER: ${{ github.event.number }}
REPOSITORY: ${{ github.repository }}
PYTHONPATH: src
run: |
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.pr_review \
"$PR_NUMBER" \
"$REPOSITORY" \
--event APPROVE \
--checklist-confirmed \
--checklist-categories 1,2,3,4,5,6,7,8,9,10,11,12,13 \
--body "Auto-approved: all CI checks passed (quality, pr-review, release-dry-run)."
- name: Squash merge with task ID
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
@@ -171,7 +204,7 @@ jobs:
REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.number }}
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.auto_merge \
"$HEAD_REF" \
"$PR_TITLE" \
+35 -22
View File
@@ -8,7 +8,8 @@ name: Post-merge
# detect-type ──┬── validate-commit-msg (skip if release commit)
# ├── release (skip if release commit)
# │ └── publish (needs release — builds & publishes to PyPI)
# ├── badges (ALWAYS runs — even on release commits)
# ├── badges (needs release — ALWAYS runs, waits for release
# │ so version badge picks up new __version__)
# ├── configure-repo (independent — skip if release commit)
# ├── sync-wiki (skip if release commit — runs for ALL merges)
# └── vikunja (skip if release commit — runs for ALL merges)
@@ -17,9 +18,10 @@ name: Post-merge
# release succeeds. This ensures the wiki and task tracker are updated
# even for infrastructure-only changes (docs, CI config, etc.).
#
# The badges job uses `if: always()` with no is-release condition so it
# runs on every push to master, including release commits. This ensures
# badges (tests, coverage, version, etc.) are always current.
# The badges job uses `if: always()` and needs `release` so it waits for
# the release job to complete (whether it ran or was skipped). This ensures
# the version badge always reflects the latest __version__ on master.
# Badges run on every push to master, including release commits.
#
# When release creates a "release: vX.Y.Z" commit and tag, the publish
# job (which depends on release) builds and publishes the package to the
@@ -51,7 +53,7 @@ jobs:
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.detect_release_commit
validate-commit-msg:
@@ -73,7 +75,7 @@ jobs:
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
git log -1 --format=%B > commit-msg.txt
python3 -m devx.ci.validate_commit_msg commit-msg.txt --branch master
rm -f commit-msg.txt
@@ -107,7 +109,7 @@ jobs:
env:
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.release
- name: Notify on failure
@@ -122,7 +124,8 @@ jobs:
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/release" \
--commit "${{ github.sha }}"
--commit "${{ github.sha }}" \
--auto-login
publish:
needs: [release]
@@ -145,7 +148,7 @@ jobs:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
export PATH="$HOME/.local/bin:$PATH"
python3 -m devx.ci.publish "${{ needs.release.outputs.tag }}" "${{ github.repository }}" --auto-login
- name: Notify on failure
@@ -160,14 +163,18 @@ jobs:
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/publish" \
--commit "${{ github.sha }}"
--commit "${{ github.sha }}" \
--auto-login
sync-wiki:
needs: [detect-type]
if: needs.detect-type.outputs.is-release == 'false'
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-base:latest
timeout-minutes: 10
timeout-minutes: 15
concurrency:
group: sync-wiki-${{ github.repository }}
cancel-in-progress: false
defaults:
run:
shell: bash
@@ -182,8 +189,8 @@ jobs:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
run: |
. .venv/bin/activate
python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --strict
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.sync_wiki --repo "${{ github.repository }}" --verify
- name: Notify on failure
if: failure()
env:
@@ -195,10 +202,11 @@ jobs:
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/sync-wiki" \
--commit "${{ github.sha }}"
--commit "${{ github.sha }}" \
--auto-login
badges:
needs: [detect-type]
needs: [detect-type, release]
if: always()
runs-on: docker
container: git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
@@ -222,7 +230,7 @@ jobs:
env:
PRE_COMMIT_ALLOW_NO_CONFIG: "1"
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.push_badges
- name: Notify on failure
if: failure()
@@ -235,7 +243,8 @@ jobs:
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/badges" \
--commit "${{ github.sha }}"
--commit "${{ github.sha }}" \
--auto-login
vikunja:
needs: [detect-type]
@@ -258,7 +267,7 @@ jobs:
DEVX_VIKUNJA_PROJECT_ID: "8"
PYTHONPATH: src
run: |
. .venv/bin/activate
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.ci.post_merge --git-sha "${{ github.sha }}"
- name: Notify on failure
if: failure()
@@ -271,7 +280,8 @@ jobs:
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/vikunja" \
--commit "${{ github.sha }}"
--commit "${{ github.sha }}" \
--auto-login
configure-repo:
needs: [detect-type]
@@ -290,9 +300,11 @@ jobs:
env:
CI_GITEA_TOKEN: ${{ secrets.CI_GITEA_TOKEN }}
PYTHONPATH: src
DEVX_REPO_NAME: devx
DEVX_REPO_OWNER: oblachno-oss
run: |
. .venv/bin/activate
python3 -m devx.tools.configure_repo --repo devx --owner oblachno-oss
. .venv/bin/activate 2>/dev/null || true
python3 -m devx.tools.configure_repo
- name: Notify on failure
if: failure()
env:
@@ -304,4 +316,5 @@ jobs:
--repo "${{ github.repository }}" \
--run-id "${{ github.run_id }}" \
--workflow "post-merge/configure-repo" \
--commit "${{ github.sha }}"
--commit "${{ github.sha }}" \
--auto-login
+38
View File
@@ -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
+46
View File
@@ -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
+9
View File
@@ -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]\.'
+64
View File
@@ -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
+8
View File
@@ -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]'
+30
View File
@@ -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
+9
View File
@@ -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}'
+9
View File
@@ -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:
- '\.\.\.'
+13
View File
@@ -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'
+12
View File
@@ -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|$)'
+13
View File
@@ -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
+9
View File
@@ -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
+43
View File
@@ -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*$'
+29
View File
@@ -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
+11
View File
@@ -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
+14
View File
@@ -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'
+12
View File
@@ -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\)'
+7
View File
@@ -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)
+7
View File
@@ -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)'
+7
View File
@@ -0,0 +1,7 @@
extends: existence
message: "Use parentheses judiciously."
link: 'https://developers.google.com/style/parentheses'
nonword: true
level: suggestion
tokens:
- '\(.+\)'
+184
View File
@@ -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
+7
View File
@@ -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,}'
+7
View File
@@ -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:
- '"[^"]+"[.,?]'
+7
View File
@@ -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+'
+8
View File
@@ -0,0 +1,8 @@
extends: existence
message: "Use semicolons judiciously."
link: 'https://developers.google.com/style/semicolons'
nonword: true
scope: sentence
level: suggestion
tokens:
- ';'
+11
View File
@@ -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
+10
View File
@@ -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]'
+10
View File
@@ -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'
+8
View File
@@ -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)
+11
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
extends: existence
message: "Avoid using '%s'."
link: 'https://developers.google.com/style/tense'
ignorecase: true
level: warning
tokens:
- will
+80
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
{
"feed": "https://github.com/errata-ai/Google/releases.atom",
"vale_version": ">=1.0.0"
}
View File
@@ -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"
+8
View File
@@ -0,0 +1,8 @@
extends: metric
message: "Try to keep the ColemanLiau 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 FleschKincaid 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"
+8
View File
@@ -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"
+17
View File
@@ -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"
+8
View File
@@ -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"
+4
View File
@@ -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
+6
View File
@@ -0,0 +1,6 @@
extends: existence
message: "Unlabeled code block — add a language tag (```bash, ```yaml, etc.)"
level: warning
scope: raw
raw:
- '(?s)```\n(?!.*```)'
+13
View File
@@ -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'
+3
View File
@@ -0,0 +1,3 @@
# Custom Vale style for devx documentation
Project-specific terminology and style rules
+11
View File
@@ -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
+702
View File
@@ -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
+32
View File
@@ -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
+11
View File
@@ -0,0 +1,11 @@
extends: repetition
message: "'%s' is repeated!"
level: warning
alpha: true
action:
name: edit
params:
- truncate
- " "
tokens:
- '[^\s]+'
+183
View File
@@ -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
+27
View File
@@ -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.
```
+5
View File
@@ -0,0 +1,5 @@
extends: existence
message: "Don't start a sentence with '%s'."
level: error
raw:
- '(?:[;-]\s)so[\s,]|\bSo[\s,]'
+6
View File
@@ -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'
+221
View File
@@ -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
+29
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
{
"feed": "https://github.com/errata-ai/write-good/releases.atom",
"vale_version": ">=1.0.0"
}
+163 -18
View File
@@ -1,22 +1,38 @@
# AGENTS.md — Project Conventions for devx
## Virtual Environment
All Python tools, tests, and scripts run inside a standard `.venv` directory.
Activate it before running any non-`make` command:
```bash
source activate.sh # bash/zsh
source activate.fish # fish
source activate.zsh # zsh
```
If `.venv` doesn't exist, run `make setup` first. The `make` targets handle
venv activation automatically — always prefer `make <target>` over raw commands.
## Build & Test Commands
```bash
make setup # Create venv, install deps, set up hooks, install CI tools
make install-tools # Install actionlint, git-cliff, act_runner to ~/.local/bin
make lint-all # ruff + pyright + bandit + actionlint
make install-tools # Install actionlint, git-cliff, act_runner, tea, hadolint, vale to ~/.local/bin
make lint-all # ruff + pyright + bandit + actionlint + lint-dockerfiles
make pytest-cov # Unit tests with 100% coverage enforcement
make test-unit # Unit tests without coverage
make workflow-lint # Static lint of .gitea/workflows/*.yml (actionlint)
make workflow-dryrun # Dry-run all workflows in Docker (act_runner exec --dryrun)
make workflow-check # workflow-lint + workflow-dryrun
make devx-check-doc-versions # Verify docs version refs match __version__
make devx-vale # Run Vale prose linter on docs and README
make clean # Remove caches, build artifacts, coverage data
```
`make setup` automatically installs all development tools:
- **Python deps** via `python -m devx.tools.setup` (pip install -e .[dev], pre-commit hooks)
- **actionlint, git-cliff, act_runner, tea** via `python -m devx.tools.install_tools` (CI/CD tools to ~/.local/bin)
- **actionlint, git-cliff, act_runner, tea, hadolint, vale** via `python -m devx.tools.install_tools` (CI/CD tools to ~/.local/bin)
- **tea CLI login** via `python -m devx.tools.setup` (configures `tea login` from `.env` `CI_GITEA_TOKEN`)
## Workflow Verification (Before Push)
@@ -43,7 +59,7 @@ devx is a reusable Python package providing development and CI/CD tools for obla
### Package Structure
```
```text
src/devx/
├── __init__.py # Version (single source of truth, read by setuptools)
├── cli.py # Click-based CLI entry point (devx command)
@@ -52,29 +68,32 @@ src/devx/
├── gitea_cli.py # TeaCLI — wrapper around tea CLI with JSON parsing
├── i18n.py # Translation system (gettext-based, translations.json)
├── exceptions.py # Custom exception types
├── translations.json # Translation strings (en, bg)
├── translations.json # Translation strings (en, bg, de, pl, ru, zh)
├── ci/ # CI/CD automation modules (run by workflows)
│ ├── release.py # Automated versioning, tagging, changelog
│ ├── publish.py # Build and publish to Gitea PyPI registry (--skip-build for non-Python repos)
│ ├── auto_merge.py # Squash-merge PRs with task ID validation
│ ├── check_auto_merge_ready.py # Pre-merge validation gate (branch, PR title, Vikunja, behind-master)
│ ├── _shared.py # Shared utilities (get_latest_tag)
│ ├── classify_changes.py # User-facing vs workflow-only change detection
│ ├── classify_changes.py # User-facing vs infrastructure change detection
│ ├── detect_release_commit.py # Detect release commits on master
│ ├── validate_commit_msg.py # Conventional commit validation
│ ├── pr_review.py # Automated PR review
│ ├── pr_review.py # Automated PR review + manual reviews (--event, --body, --checklist-confirmed)
│ ├── post_merge.py # Vikunja task updates after merge
│ ├── sync_wiki.py # Sync documentation to Gitea wiki
│ ├── push_badges.py # Generate and push quality badges (--retries for retry on git push failures)
│ ├── notify_failure.py # Create Gitea issues on CI failures (--auto-login)
│ ├── distribute_files.py # Distribute files across parallel runners (LPT scheduling)
│ ├── distribute_items.py # Distribute generic items (VMs, hosts) across parallel runners (LPT)
│ ├── integration_guard.py # Run pytest with cross-runner fail-fast
│ ├── check_translations.py # Translation completeness check
── doc_coverage.py # Documentation coverage check
── doc_coverage.py # Documentation coverage check
│ └── lint_docs.py # Documentation linter (structure, links, headings, code blocks, orphans)
├── tools/ # Developer tooling modules (run locally or by CI)
│ ├── setup.py # Environment setup (venv, deps, hooks)
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea
│ ├── install_tools.py # Install actionlint, git-cliff, act_runner, tea, hadolint, vale
│ ├── install_checkmake.py # Install checkmake (Makefile linter)
│ ├── check_doc_versions.py # Verify docs version refs match __version__
│ ├── build_image.py # Build and push Docker images to Gitea registry
│ ├── clean_images.py # Clean up old Docker image versions from Gitea registry
│ ├── check_test_speed.py # Measure unit test execution time
@@ -82,14 +101,24 @@ src/devx/
│ ├── check_pyproject_deps.py # Validate pyproject.toml deps have documentation comments
│ ├── check_test_coverage.py # Ensure changed files have corresponding tests (configurable rules)
│ ├── check_agent_docs.py # Validate docs for stale file references (configurable patterns)
│ ├── check_config.py # Validate pyproject.toml [tool.devx] config
│ ├── configure_repo.py # Branch protection and label setup
── generate_badges.py # Badge SVG generation
── generate_badges.py # Badge SVG generation
│ ├── generate_cliff_config.py # Generate git-cliff config (cliff.toml)
│ ├── create_task.py # Create Vikunja tasks
│ ├── create_pr.py # Create PRs with auto-derived title from Vikunja
│ ├── pr_status.py # Check CI status for a PR/commit (--wait polls)
│ ├── pr_logs.py # Fetch logs for failed CI jobs
│ ├── pr_label.py # Add labels to PRs (idempotent)
│ ├── pre_push_check.py # Validate Vikunja task existence before push
│ └── _shared.py # Shared tool utilities
├── opentofu.py # OpenTofu output helpers (get_tofu_output, get_tofu_vm_ip, get_tofu_vm_field)
└── molecule/ # Optional molecule testing helpers (for Ansible projects)
├── discover_runners.py # Dynamic Gitea runner discovery
├── distribute_molecule.py # Distribute molecule scenarios across runners (LPT scheduling, --roles-root for multi-role)
├── molecule_ci_guard.py # Run molecule with cross-runner fail-fast (--roles-root)
├── molecule_all.py # Run all molecule scenarios locally
├── start_docker.py # Ensure Docker daemon is running for molecule tests
└── platforms.py # Supported molecule platforms
```
@@ -132,7 +161,7 @@ git checkout -b DEVX-N-short-description
### 4. Commit (Conventional Commits)
Branch commits use conventional commit format (no `DEVX-N:` prefix):
```
```text
feat: add new feature
fix: resolve bug
docs: update README
@@ -300,14 +329,14 @@ setuptools via `dynamic = ["version"]` in `pyproject.toml`.
### Task ID Resolution
`auto_merge` resolves the task ID solely from the branch name (e.g.
`auto_merge` resolves the task ID solely from the branch name (for example
`DEVX-12-fix-foo``DEVX-12`). Branch names must include the task ID
prefix — there is no `.taskid` file fallback. If a stale `.taskid` file
exists in the repo, a deprecation warning is printed advising its removal.
### Workflow `auto-merge` Job and `always()`
When `auto-merge` depends on a job that can be skipped (e.g.
When `auto-merge` depends on a job that can be skipped (for example
`molecule-tests`), the `if:` condition MUST include `always() &&`
at the start. Without it, Gitea Actions skips `auto-merge` when any
dependency is skipped, even if the condition explicitly allows
@@ -339,7 +368,7 @@ balanced distribution when test items have varying costs:
2. **LPT assignment**: Items are sorted by weight (descending), then
each is assigned to the runner with the least total weight.
This ensures heavy scenarios (e.g. `nextcloud`) are spread across
This ensures heavy scenarios (for example `nextcloud`) are spread across
different runners rather than clustered on one, reducing the
longest-runner time from ~16 min to ~11 min with 6 runners.
@@ -357,7 +386,7 @@ devx uses environment variables with `.env` file fallback for configuration.
| `DEVX_REPO_NAME` | **(none — must be set)** | Repository name (or `owner/repo`) |
| `DEVX_TASK_PREFIX` | `DEVX` | Task ID prefix (GRM, OBL-INFRA, etc.) |
| `DEVX_VIKUNJA_PROJECT_ID` | `6` | Vikunja project ID |
| `DEVX_LANG` | `en` | Language for i18n (en, bg) |
| `DEVX_LANG` | `en` | Language for i18n (en, bg, de, pl, ru, zh) |
| `CI_GITEA_TOKEN` | (from .env) | Gitea API token |
| `VIKUNJA_TOKEN` | (from .env) | Vikunja API token |
@@ -374,7 +403,7 @@ the `[tool.devx]` section in `pyproject.toml`. This allows per-project
customization without environment variables.
**Base config** (`[tool.devx]`):
- `task_prefix` — Task ID prefix (e.g. `"DEVX"`, `"GRM"`, `"OBL-INFRA"`)
- `task_prefix` — Task ID prefix (for example `"DEVX"`, `"GRM"`, `"OBL-INFRA"`)
- `vikunja_project_id` — Vikunja project ID
- `repo_owner` / `repo_name` — Gitea repository coordinates
- `gitea_api_url` / `vikunja_api_url` — API endpoints
@@ -398,13 +427,17 @@ projects.
| `devx-create-pr` | Create a PR with auto-derived title |
| `devx-push` | Push current branch to origin |
| `devx-push-with-pr` | Push and create PR in one step |
| `devx-pr-status` | Check CI status for a PR (`PR=`, `WAIT=`, `TIMEOUT=`) |
| `devx-pr-logs` | Fetch logs for failed CI jobs (`PR=`, `JOB=`, `TAIL=`) |
| `devx-pr-label` | Add a label to a PR (`PR=`, `LABEL=ready-to-merge`) |
| `devx-pr-review` | Post a review on a PR (`PR=`, `EVENT=`, `BODY=`, `CHECKLIST=`) |
| `devx-check-config` | Validate devx configuration |
| `devx-configure-gitea-pypi` | Configure Gitea private PyPI registry |
| `devx-env` | Create .env from .env.example |
| `devx-venv` | Create Python venv with version check |
| `devx-activate-scripts` | Create shell/fish/zsh activate scripts |
| `devx-install-hooks` | Set git hooks path to hooks/ |
| `devx-install-tools` | Install actionlint, git-cliff, act_runner, tea |
| `devx-install-tools` | Install actionlint, git-cliff, act_runner, tea, hadolint |
| `devx-install-checkmake` | Install checkmake (Makefile linter) |
| `devx-checkmake` | Lint Makefiles with checkmake |
| `devx-workflow-lint` | Static lint of Gitea Actions YAML (actionlint) |
@@ -428,6 +461,7 @@ projects.
| `devx-pre-push` | Run lint + tests before push |
| `devx-clean` | Remove caches, build artifacts, coverage data |
| `devx-setup-image` | Link /opt/venv + install project (for pre-built image CI jobs) |
| `devx-lint-dockerfiles` | Lint Dockerfiles with hadolint (fail-fast, parameterized by `DEVX_DOCKERFILE_PATHS`) |
| `devx-build-images` | Build Docker images from manifest (no push) |
| `devx-push-images` | Build and push Docker images to Gitea registry |
| `devx-build-images-dry-run` | Show what would be built/pushed |
@@ -441,6 +475,7 @@ projects.
- `DEVX_COV_PKG` — coverage package (default: `src/devx`)
- `DEVX_TEST_PATHS` — pytest paths (default: `tests/`)
- `DEVX_PR_BASE` — PR base branch (default: `master`)
- `DEVX_DOCKERFILE_PATHS` — directory to search for Dockerfiles (default: `docker`)
- `DEVX_GITEA_REGISTRY` — registry URL (default: `git.oblachno.oblachno.fyi`)
- `DEVX_IMAGE_MANIFEST` — path to JSON manifest (default: `docker/images.json`)
- `DEVX_IMAGE_OWNER` — package owner for cleanup (default: `oblachno-oss`)
@@ -453,7 +488,7 @@ to eliminate the 40-120s setup tax on every CI job:
| Image | Contains | Used by jobs |
|-------|----------|-------------|
| `ci-base-latest` | Python 3.12 + devx[ci] + tea | detect-changes, detect-type, validate-commit-msg, pr-review, auto-merge, sync-wiki, vikunja, configure-repo |
| `ci-quality-latest` | ci-base + devx[lint] + actionlint + checkmake | quality, badges |
| `ci-quality-latest` | ci-base + devx[lint] + actionlint + checkmake + hadolint | quality, badges |
| `ci-full-latest` | ci-quality + devx[release,molecule,deploy] + git-cliff + OpenTofu | release, publish, release-dry-run, molecule-tests, deploy jobs |
**Build process** (in `build-images.yml` workflow):
@@ -503,3 +538,113 @@ create-task: devx-create-task
- Line length: 120 chars
- Secrets are passed via environment variables, never on the command line
- All user-facing strings wrapped in `_()` for i18n
### Container-Level Fix Verification (Mandatory)
**Rule:** Before pushing any fix that modifies container state (CA certs,
config files, installed packages, daemon restarts), reproduce the exact
sequence locally with the actual Docker image. Do not push to CI as the
first test.
This is a hard rule, not a suggestion. CI cycles take 20+ minutes and
ephemeral staging VMs are destroyed after each run, making interactive
debugging impossible. A local reproduction takes 30 seconds and catches
silent failures immediately.
**Procedure:**
1. `docker pull <actual_image>`
2. `docker run -d --name <test> ...` and wait for it to start
3. Run the exact commands from the Ansible task or script
4. Verify the state change took effect
5. Clean up: `docker rm -f <test>`
### Verified State Modification (Mandatory)
Ansible tasks that modify container state with `changed_when: false`
MUST include a post-task verification step that confirms the state
change took effect. `changed_when: false` suppresses both change
detection AND failure visibility — a task can silently do nothing and
report `ok`.
## Subagent Delegation Policy
Custom subagent profiles are defined in `.devin/agents/` (project-specific)
and `~/.config/devin/agents/` (global, shared across repos). The agent MUST
automatically delegate to the appropriate subagent based on the task —
the user should not need to specify which profile to use.
### Available Profiles
**Global** (shared across all projects):
| Profile | Location | Purpose |
|---------|----------|---------|
| `pr-reviewer` | `~/.config/devin/agents/` | 13-category PR checklist + quality gates |
| `release-check` | `~/.config/devin/agents/` | Pre-merge readiness validation |
**devx-specific** (in `.devin/agents/`):
| Profile | Purpose |
|---------|---------|
| `ci-investigator` | Investigate CI failures (quality, release, publish, wiki sync, image build) |
| `dep-upgrader` | Python dependency upgrades in pyproject.toml with dep-doc validation |
| `docker-image-builder` | Build/push/cleanup 3-tier runner images (ci-base, ci-quality, ci-full) |
| `doc-sync-specialist` | Doc coverage, doc linting, wiki sync integrity |
| `workflow-validator` | actionlint + act_runner dry-run validation |
### When to Delegate Automatically
| Trigger | Profile | Mode |
|---------|---------|------|
| CI run failure (quality, release, publish, sync-wiki, build-images) | `ci-investigator` | Background |
| PR ready for review | `pr-reviewer` | Foreground |
| Dependency upgrade requested | `dep-upgrader` | Background |
| Docker image build/push needed | `docker-image-builder` | Background |
| Doc coverage failure or wiki sync issue | `doc-sync-specialist` | Background |
| Workflow YAML modified or validation needed | `workflow-validator` | Background |
| Branch ready for merge | `release-check` | Foreground |
### Delegation Rules
1. **Auto-select the profile.** Do not ask the user which profile to use.
2. **Background by default, foreground when blocking.**
3. **Provide full context in the prompt** — subagents don't inherit conversation history.
4. **One subagent per concern.** Chain: investigate → fix in main session → review.
5. **Don't delegate trivial work** (<30s, <50 lines of context).
6. **Compact after subagent returns.**
7. **Never skip delegation to save time** — it keeps main context small.
## Feedback Issue Handling
Subagents create Gitea issues in the current repo when they encounter
tool, workflow, or process issues that warrant follow-up. These issues
use the `feedback` label plus a category label (`tooling`,
`ci-improvement`, `doc-improvement`, `workflow-improvement`).
Standard labels are created automatically by `configure_repo` (runs in
post-merge on every master push). If a label does not exist yet, the
subagent's issue creation will still succeed — labels can be added
afterwards.
### When a Subagent Reports a Feedback Issue URL
1. **Acknowledge it** in your response to the user — mention the issue URL
2. **Do NOT close or modify** the issue — it is for follow-up work
3. **Do NOT create a PR** to address it unless the user explicitly asks
4. If the user asks to address feedback, spawn a subagent to investigate
the issue and implement a fix
### Creating Feedback Issues Manually
As the parent agent, you can also create feedback issues directly using
the Gitea MCP (`issue_write` with `create_issue` method). Follow the
same format as subagents:
- Title: `[feedback] <category>: <short description>`
- Labels: `feedback` + category label
- Body: include context, tool/workflow, issue, reproduction, affected
files, suggested investigation, and "Reported by: parent agent"
Always deduplicate first via `list_issues` with `labels: "feedback"`.
+251
View File
@@ -2,6 +2,257 @@
All notable changes to this project will be documented in this file.
## [0.36.2] - 2026-07-07
### Bug Fixes
- GiteaClient.set_repo_variable uses PUT instead of PATCH
## [0.36.1] - 2026-07-07
### Bug Fixes
- Preserve .badges/ dir during git clean in push_badges
## [0.36.0] - 2026-07-07
### Features
- Add GiteaClient repo variable methods and parallelize pytest-cov
## [0.35.7] - 2026-07-06
### Bug Fixes
- Use Gitea wiki dash-marker filename convention
## [0.35.6] - 2026-07-06
### Bug Fixes
- Add delay before wiki verification to avoid race condition
## [0.35.5] - 2026-07-06
### Bug Fixes
- Embed token in wiki clone URL for push auth
## [0.35.4] - 2026-07-06
### Bug Fixes
- Configure git identity before commit in sync_wiki
## [0.35.3] - 2026-07-06
### Bug Fixes
- Replace --strict with --verify for sync_wiki
## [0.35.2] - 2026-07-06
### Bug Fixes
- Exclude .vale directory from lint_docs scanning
## [0.35.1] - 2026-07-06
### Refactor
- Rewrite sync_wiki.py to use git-based approach
## [0.35.0] - 2026-07-06
### Features
- Enrich lint_docs.py with single H1, max depth, line length, code block lang, orphan checks
## [0.34.0] - 2026-07-06
### Features
- Enhance documentation-as-code with badges, version refs, Vale
## [0.33.4] - 2026-07-06
### Refactor
- Remove project-specific references from devx
## [0.33.3] - 2026-07-06
### Bug Fixes
- Make wiki sync resilient to API timeouts and stale page lists
## [0.33.2] - 2026-07-05
### Bug Fixes
- Abort sync_wiki when list_wiki_pages fails
## [0.33.1] - 2026-07-05
### Bug Fixes
- Build images after post-merge publish, not on push
## [0.33.0] - 2026-07-05
### Features
- Add check_api_identity_checks, setup_ssh_key, and api utils
## [0.32.1] - 2026-07-01
### Bug Fixes
- Add missing i18n translations for new tools
## [0.32.0] - 2026-07-01
### Features
- Extract docker-login, tofu-ops, check-deps, install-tofu to Python tools
## [0.31.0] - 2026-07-01
### Features
- Centralize venv management in devx.mak
## [0.30.0] - 2026-07-01
### Features
- Add standard label creation to configure_repo
## [0.29.1] - 2026-07-01
### Bug Fixes
- Strip task ID prefix from commit messages in extract_conventional_msg
## [0.29.0] - 2026-07-01
### Features
- Detect badge commits as automated CI commits
## [0.28.0] - 2026-07-01
### Features
- Auto-rebase in auto-merge, new rebase tools, CLI registration
## [0.27.3] - 2026-06-30
### Bug Fixes
- Retry wiki integrity check on transient API timeout
## [0.27.2] - 2026-06-29
### Bug Fixes
- Retry release push on non-fast-forward with rebase loop
## [0.27.1] - 2026-06-28
### Bug Fixes
- Exclude .devin/.terraform dirs from lint_docs, add duplicate heading excludes
## [0.27.0] - 2026-06-28
### Features
- Add lint_docs tool, fix doc_coverage/check_translations for any repo
## [0.26.4] - 2026-06-28
### Bug Fixes
- Wrap all user-facing strings with _() for i18n completeness
## [0.26.3] - 2026-06-28
### Bug Fixes
- Pin all dependencies to exact versions for reproducibility
## [0.26.2] - 2026-06-28
### Bug Fixes
- Block admin merge override and auto-approve with review token
## [0.26.1] - 2026-06-28
### Bug Fixes
- Force pip upgrade in setup-image to install new dependencies
## [0.26.0] - 2026-06-28
### Features
- Add distribute_items CI tool for parallel VM deployment
## [0.25.0] - 2026-06-28
### Features
- Add manual review support to pr_review (--event, --body, --checklist-confirmed)
## [0.24.1] - 2026-06-28
### Refactor
- Add find_task_by_identifier, config fallbacks for tools
## [0.24.0] - 2026-06-27
### Features
- Add pr_status, pr_logs, pr_label tools
## [0.23.4] - 2026-06-27
### Bug Fixes
- Add --auto-login to all notify_failure calls in workflows
- Classify .gitea/** as user-facing for devx, support glob in user_facing_overrides
## [0.23.3] - 2026-06-27
### Bug Fixes
- Correct clean_images delete URL and add retry with error handling
## [0.23.2] - 2026-06-27
### Bug Fixes
- Add skip-ci flag to release commits and concurrency to build-images
## [0.23.1] - 2026-06-27
### Bug Fixes
- Add rsync to ci-full image for molecule_docker
## [0.23.0] - 2026-06-27
### Features
- Add devx-lint-dockerfiles to devx.mak, alias setup-image
### Refactor
- Remove hadolint on-the-fly install from setup-image
## [0.22.1] - 2026-06-27
### Bug Fixes
+45 -36
View File
@@ -6,6 +6,36 @@ BIN := $(VENV)/bin
all: setup
# --- devx.mak integration ----------------------------------------------------
# Include shared targets from the devx package itself (venv management,
# workflow-lint, notify-failure, checkmake, lint targets, quality checks, etc.)
# Since devx IS the package, we can include its own devx.mak.
DEVX_PYTHON := $(BIN)/python
DEVX_VENV := $(VENV)
DEVX_BIN := $(BIN)
DEVX_LINT_PATHS := src/ tests/
DEVX_COV_PKG := src/devx
DEVX_TEST_PATHS := tests/
DEVX_MAK := $(shell $(BIN)/python -c \
"from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
2>/dev/null)
# Fallback: when the venv doesn't exist yet (chicken-and-egg), use the
# source tree copy directly. devx IS the package, so src/devx/make/devx.mak
# is always available in this repo.
ifeq ($(strip $(DEVX_MAK)),)
DEVX_MAK := $(CURDIR)/src/devx/make/devx.mak
endif
-include $(DEVX_MAK)
# venv, .env, and activate-scripts are provided by devx.mak
# (devx-venv, devx-env, devx-activate-scripts, $(DEVX_VENV)/bin/activate rule)
# Aliases for convenience and backward compatibility:
.PHONY: venv activate-scripts
venv: devx-venv
.env: devx-env
activate-scripts: devx-activate-scripts
# Full setup for local development
setup: $(VENV)/bin/activate .env activate-scripts install-tools
@$(BIN)/pip install -e '.[dev]' 2>/dev/null; \
@@ -31,25 +61,13 @@ setup-release: $(VENV)/bin/activate .env
$(BIN)/python -m devx.tools.setup --bin "$(BIN)" --extras "ci,lint,release" --no-pre-commit
# Setup for pre-built image jobs (deps already in image, just link venv + install project)
# Note: Not aliased to devx-setup-image because devx's own CI images may have
# an older devx.mak that doesn't yet define devx-setup-image. Consumer repos
# (grm, infra) can safely alias to devx-setup-image since they install devx from PyPI.
setup-image:
@if [ -d /opt/venv ]; then ln -sf /opt/venv .venv; . .venv/bin/activate && pip install -e . --no-deps 2>/dev/null; \
export PATH="$$HOME/.local/bin:$$PATH"; \
python3 -m devx.tools.install_tools --tool hadolint 2>/dev/null || true; \
@if [ -d /opt/venv ]; then ln -sf /opt/venv $(VENV); . $(VENV)/bin/activate && pip install --no-cache-dir -e . 2>/dev/null; \
else echo "[setup-image] /opt/venv not found — falling back to setup-ci"; $(MAKE) setup-ci; fi
.env:
@if [ ! -f .env ]; then cp .env.example .env; echo "Created .env from .env.example — please edit it."; fi
$(VENV)/bin/activate:
@python3 -c "import sys; v=sys.version_info; assert v >= (3, 12), f'Python 3.12+ required, found {v.major}.{v.minor}'; print(f'Python {v.major}.{v.minor}.{v.micro} OK')"
$(PYTHON) -m venv $(VENV)
$(BIN)/pip install --upgrade pip setuptools wheel
activate-scripts: $(VENV)/bin/activate
@test -f activate.sh || (echo '#!/usr/bin/env bash' > activate.sh && echo 'source "$$(cd "$$(dirname "$${BASH_SOURCE[0]}")" && pwd)/.venv/bin/activate"' >> activate.sh && chmod +x activate.sh)
@test -f activate.fish || (echo '#!/usr/bin/env fish' > activate.fish && echo 'set -l script_dir (dirname (status --current-filename))' >> activate.fish && echo 'source "$$script_dir/.venv/bin/activate.fish"' >> activate.fish && chmod +x activate.fish)
@test -f activate.zsh || (echo '#!/usr/bin/env zsh' > activate.zsh && echo '0="$${ZERO:-$${0:#$$ZSH_ARGZERO}}"' >> activate.zsh && echo '0="$${$${(M)0:#/*}:-$$PWD/$$0}"' >> activate.zsh && echo 'source "$${0:A:h}/.venv/bin/activate"' >> activate.zsh && chmod +x activate.zsh)
install-hooks:
@cp hooks/pre-commit .git/hooks/pre-commit && chmod +x .git/hooks/pre-commit
@cp hooks/pre-push .git/hooks/pre-push && chmod +x .git/hooks/pre-push
@@ -59,23 +77,13 @@ install-tools: $(VENV)/bin/activate
@$(BIN)/pip install -e '.' 2>/dev/null; \
$(BIN)/python -m devx.tools.install_tools
# --- devx.mak integration ----------------------------------------------------
# Include shared targets from the devx package itself (workflow-lint,
# notify-failure, checkmake, lint targets, quality checks, etc.)
# Since devx IS the package, we can include its own devx.mak.
DEVX_PYTHON := $(BIN)/python
DEVX_VENV := $(VENV)
DEVX_BIN := $(BIN)
DEVX_LINT_PATHS := src/ tests/
DEVX_COV_PKG := src/devx
DEVX_TEST_PATHS := tests/
DEVX_MAK := $(shell $(BIN)/python -c \
"from pathlib import Path; import devx; print(Path(devx.__file__).parent / 'make' / 'devx.mak')" \
2>/dev/null)
-include $(DEVX_MAK)
# Aliases — project-specific names map to devx.mak targets
.PHONY: lint-ruff lint-format typecheck lint-bandit lint-deps lint
.PHONY: workflow-lint workflow-dryrun workflow-dryrun-safe workflow-check
.PHONY: notify-failure checkmake check-mutable-globals check-dep-docs
.PHONY: check-test-speed check-test-coverage check-docs
.PHONY: create-task create-pr push-with-pr git-push rebase pr-rebase
.PHONY: lint-all lint-dockerfiles
lint-ruff: devx-lint-ruff
lint-format: devx-lint-format
typecheck: devx-typecheck
@@ -97,16 +105,17 @@ create-task: devx-create-task
create-pr: devx-create-pr
push-with-pr: devx-push-with-pr
git-push: devx-push
rebase: devx-rebase
pr-rebase: devx-pr-rebase
lint-all: lint workflow-lint lint-dockerfiles
@echo "[lint-all] All linting checks passed."
# Note: Not aliased to devx-lint-dockerfiles for the same reason as setup-image —
# devx's own CI images may have an older devx.mak. Consumer repos can safely alias.
lint-dockerfiles:
@echo "[lint-dockerfiles] Linting Dockerfiles with hadolint..."
@if ! command -v hadolint >/dev/null 2>&1; then \
echo "[lint-dockerfiles] ERROR: hadolint not found. Install from https://github.com/hadolint/hadolint/releases" >&2; \
exit 1; \
fi
@command -v hadolint >/dev/null 2>&1 || { echo "hadolint not found" >&2; exit 1; }
@find docker -name 'Dockerfile*' -exec hadolint {} +
@echo "[lint-dockerfiles] All Dockerfiles passed."
+14 -11
View File
@@ -16,12 +16,12 @@ quality badges.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7471d49059023809dc04f376db0006c18498b270/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7471d49059023809dc04f376db0006c18498b270/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7471d49059023809dc04f376db0006c18498b270/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7471d49059023809dc04f376db0006c18498b270/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7471d49059023809dc04f376db0006c18498b270/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7471d49059023809dc04f376db0006c18498b270/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d9423d85825031bee70ad816de71dad2947c8e63/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d9423d85825031bee70ad816de71dad2947c8e63/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d9423d85825031bee70ad816de71dad2947c8e63/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d9423d85825031bee70ad816de71dad2947c8e63/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d9423d85825031bee70ad816de71dad2947c8e63/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d9423d85825031bee70ad816de71dad2947c8e63/python.svg)](https://www.python.org/downloads/)
## Why devx?
@@ -87,7 +87,7 @@ extra index and list devx in your dependencies:
```toml
[project]
dependencies = [
"devx>=0.11.1",
"devx>=0.36.2",
]
[tool.pip]
@@ -101,8 +101,8 @@ pip install -e .
```
> **Note:** If your project requires a specific devx version, pin it in
> `dependencies` (e.g., `"devx==0.11.1"`) or use a version constraint
> (e.g., `"devx>=0.11.1,<0.12"`).
> `dependencies` (for example, `"devx==0.36.2"`) or use a version constraint
> (for example, `"devx>=0.36.2,<0.37"`).
### Optional extras
@@ -158,6 +158,9 @@ python -m devx.ci.check_translations --translations path/to/translations.json
# Documentation coverage check
python -m devx.ci.doc_coverage --fail-on-missing
# Documentation lint (structure, links, headings, TODOs)
python -m devx.ci.lint_docs --root .
# Validate a commit message
python -m devx.ci.validate_commit_msg commit-msg.txt --branch master
@@ -417,7 +420,7 @@ make clean # Remove caches, build artifacts, coverage data
| `make lint-deps` | pip-audit dependency vulnerability scan |
| `make test-unit` | Unit tests without coverage |
| `make pytest-cov` | Unit tests with 100% coverage enforcement |
| `make workflow-lint` | actionlint on .gitea/workflows/*.yml |
| `make workflow-lint` | actionlint on `.gitea/workflows/*.yml` |
| `make workflow-dryrun` | act_runner exec --dryrun on all workflows |
| `make workflow-check` | workflow-lint + workflow-dryrun |
| `make clean` | Remove caches, build artifacts, coverage data |
@@ -431,7 +434,7 @@ devx is a self-contained Python package under `src/devx/`. It never imports
from scripts outside the package. All tools are invoked via
`python -m devx.ci.*`, `python -m devx.tools.*`, or `python -m devx.molecule.*`.
```
```text
src/devx/
├── __init__.py # Version (single source of truth, read by setuptools)
├── cli.py # Click-based CLI entry point (devx command)
+4
View File
@@ -11,6 +11,10 @@ FROM git.oblachno.oblachno.fyi/oblachno-oss/runner-images/ci-quality:latest
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
# Install rsync (required by molecule_docker for file sync between host and test containers)
RUN apt-get update && apt-get install -y --no-install-recommends rsync \
&& rm -rf /var/lib/apt/lists/*
# Install devx[release,molecule,deploy] from local source
COPY . /tmp/devx
RUN pip install --no-cache-dir /tmp/devx[release,molecule,deploy] \
+1 -1
View File
@@ -13,7 +13,7 @@ RUN pip install --no-cache-dir /tmp/devx[lint] \
&& rm -rf /tmp/devx
# Install CI/CD binary tools
RUN python3 -m devx.tools.install_tools --tool actionlint \
RUN python3 -m devx.tools.install_tools --tool actionlint --tool vale \
&& python3 -m devx.tools.install_checkmake
# Install hadolint (Dockerfile linter)
+10 -9
View File
@@ -12,12 +12,12 @@ project to be reusable across all oblachno-oss repositories.
[![CI](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions/workflows/ci.yml/badge.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/src/branch/master/LICENSE)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7471d49059023809dc04f376db0006c18498b270/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7471d49059023809dc04f376db0006c18498b270/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7471d49059023809dc04f376db0006c18498b270/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7471d49059023809dc04f376db0006c18498b270/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7471d49059023809dc04f376db0006c18498b270/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/7471d49059023809dc04f376db0006c18498b270/python.svg)](https://www.python.org/downloads/)
[![Coverage](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d9423d85825031bee70ad816de71dad2947c8e63/coverage.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Tests](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d9423d85825031bee70ad816de71dad2947c8e63/tests.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Docs](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d9423d85825031bee70ad816de71dad2947c8e63/docs.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/wiki)
[![Code Quality](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d9423d85825031bee70ad816de71dad2947c8e63/quality.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/actions)
[![Version](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d9423d85825031bee70ad816de71dad2947c8e63/version.svg)](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/releases)
[![Python](https://git.oblachno.oblachno.fyi/oblachno-oss/devx/raw/commit/d9423d85825031bee70ad816de71dad2947c8e63/python.svg)](https://www.python.org/downloads/)
## Overview
@@ -74,14 +74,14 @@ Add devx to your `pyproject.toml` dependencies and configure the registry:
```toml
[project]
dependencies = [
"devx>=0.11.1",
"devx>=0.36.2",
]
[tool.pip]
extra-index-url = "https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple"
```
Pin a specific version if needed: `"devx==0.11.1"` or `"devx>=0.11.1,<0.12"`.
Pin a specific version if needed: `"devx==0.36.2"` or `"devx>=0.36.2,<0.37"`.
### Optional extras
@@ -131,7 +131,7 @@ wiki sync details.
devx provides a `devx` CLI with three command groups:
- `devx ci <command>` — CI/CD automation (17 commands)
- `devx tools <command>` — Developer tools (7 commands)
- `devx tools <command>` — Developer tools (9 commands)
- `devx molecule <command>` — Molecule testing (4 commands, optional)
See [CLI Commands](CLI-Commands) for full command documentation with examples.
@@ -158,6 +158,7 @@ for the full configuration reference, PR workflow, and project conventions.
## Wiki pages
- [Home](Home) — This page
- [Getting Started](Getting-Started) — Installation, configuration, and quick start guide
- [CLI Commands](CLI-Commands) — Full CLI command documentation with examples
- [Architecture](Architecture) — Package structure, module descriptions, design principles
- [CI/CD Workflow](CI-CD-Workflow) — Pipeline documentation, workflows, and CI scripts
+1
View File
@@ -1,5 +1,6 @@
{
"index.md": "Home",
"user/getting-started.md": "Getting-Started",
"user/cli-commands.md": "CLI-Commands",
"tech/architecture.md": "Architecture",
"tech/ci-cd-workflow.md": "CI-CD-Workflow"
+13 -11
View File
@@ -6,7 +6,7 @@ from scripts outside the package.
## Package structure
```
```text
src/devx/
├── __init__.py # Version (single source of truth, read by setuptools)
├── cli.py # Click-based CLI entry point (devx command)
@@ -86,7 +86,7 @@ overridden via environment variables with the `DEVX_` prefix. Provides:
- `GITEA_API_URL` / `VIKUNJA_API_URL` — API endpoints
- `REPO_OWNER` — repository owner (must be set per-project)
- `TASK_PREFIX` / `TASK_ID_RE` — task ID prefix and regex (e.g., `DEVX-N`)
- `TASK_PREFIX` / `TASK_ID_RE` — task ID prefix and regex (for example, `DEVX-N`)
- `VIKUNJA_PROJECT_ID` — Vikunja project for task tracking
- `DEFAULT_TIMEOUT`, `DEFAULT_PER_PAGE` — HTTP client defaults
- `MAX_RETRIES`, `RETRY_BACKOFF_BASE`, `RETRY_STATUS_CODES` — retry config
@@ -103,7 +103,7 @@ Custom exception hierarchy:
### `i18n.py`
Simple i18n system using a JSON translations file (`translations.json`).
Supports five languages: `en`, `bg`, `de`, `ru`, `zh`. The `_()` function
Supports six languages: `en`, `bg`, `de`, `pl`, `ru`, `zh`. The `_()` function
wraps user-facing strings for translation.
Projects can extend translations by setting `DEVX_TRANSLATIONS_PATH` to a
@@ -122,7 +122,9 @@ exponential backoff (2s, 4s, 8s).
- Labels (list, create, add to issues)
- Issues (create, list)
- Pull requests (get commits, merge, create review)
- Releases (list)
- Releases (list, create idempotent)
- Actions (list runs, list jobs, get job logs)
- Actions variables (get, set idempotent)
- Wiki pages (list, fetch, create, update, delete)
**`VikunjaClient`** — Vikunja REST API wrapper:
@@ -206,7 +208,7 @@ a layered rule system configured in `pyproject.toml` under
4. **Default**: user-facing (safe default — any unknown file triggers release)
Also supports custom tags (orthogonal to release impact) for CI conditional
execution (e.g., `ansible` tag to trigger molecule tests).
execution (for example, `ansible` tag to trigger molecule tests).
### `pr_review.py`
@@ -382,7 +384,7 @@ single-role (4-part) and multi-role (5-part) pair encoding.
Runs all molecule scenarios on all supported OS platforms sequentially.
Intended for local development; CI uses the parallel matrix instead.
### `discover_runners.py`
### `molecule/discover_runners.py`
Discovers available Gitea Actions runners for molecule tests. Same logic as
`devx.ci.discover_runners` but intended for molecule-specific workflows.
@@ -432,14 +434,14 @@ v2 failures. Supports loading custom platforms from a JSON file.
3. **Tool modules** (`devx.tools.*`) may import from `devx.api_clients`,
`devx.config`, `devx.gitea_cli`
4. **Cross-module imports** within `devx.ci.*` or `devx.tools.*` are allowed
but must be documented (e.g., `release.py` imports from
but must be documented (for example, `release.py` imports from
`classify_changes.py`)
## Data flow
### PR lifecycle
```
```text
Developer creates Vikunja task (DEVX-N)
@@ -475,7 +477,7 @@ CI workflow (ci.yml) triggers:
### Post-merge flow
```
```text
Push to master (squash-merge commit: "DEVX-N <conventional commit>")
@@ -519,7 +521,7 @@ Post-merge workflow (post-merge.yml) triggers:
### Publish flow
```
```text
Tag push (vX.Y.Z) triggers publish workflow (publish.yml):
@@ -536,7 +538,7 @@ Tag push (vX.Y.Z) triggers publish workflow (publish.yml):
### Badge generation flow
```
```text
push_badges.py:
├── fetch_latest_master() → git fetch + reset --hard origin/master
+6 -6
View File
@@ -6,7 +6,7 @@ tag-triggered publishing.
## Workflow overview
```
```text
PR opened/synchronized ──► CI (ci.yml)
│ ├── quality
│ ├── detect-changes
@@ -93,7 +93,7 @@ Depends on `quality`, `detect-changes`, and `pr-review`. The final job in the
CI workflow. Runs `python -m devx.ci.auto_merge` with the branch name, PR
title, repository, and PR number:
1. **Read task ID** from branch name (e.g., `DEVX-12-fix-foo``DEVX-12`)
1. **Read task ID** from branch name (for example, `DEVX-12-fix-foo``DEVX-12`)
2. **Validate PR title format** — must be `{PREFIX}-N: <vikunja task title>`
3. **Validate PR title matches Vikunja task** — fetches the Vikunja task and
compares the title
@@ -143,7 +143,7 @@ updates.
### Job dependency graph
```
```text
detect-type ──┬── validate-commit-msg (skip if release commit)
├── release (skip if release commit)
│ │
@@ -166,7 +166,7 @@ When `release` creates a `release: vX.Y.Z` commit, the release commit's
post-merge run still updates badges (the version badge picks up the new
version). Other jobs skip. The tag push triggers `publish.yml`.
### Jobs
### Post-merge jobs
#### `detect-type`
@@ -205,7 +205,7 @@ automation job. Runs `python -m devx.ci.release`:
8. **Push** — pushes both the commit and tag to master
The script is idempotent: if there are no new conventional commits since the
last tag, it exits without doing anything. If the tag already exists (e.g.,
last tag, it exits without doing anything. If the tag already exists (for example,
from a partial previous run), it skips tag creation and only pushes.
**Tag consistency**: Before releasing, the script fetches remote tags and
@@ -329,7 +329,7 @@ On failure, the `notify_failure` step creates a Gitea issue.
### `auto_merge.py`
Auto-merge PR when all CI checks pass. Reads task ID from the branch name
(e.g., `DEVX-12-fix-foo``DEVX-12`). Validates PR title format, checks the
(for example, `DEVX-12-fix-foo``DEVX-12`). Validates PR title format, checks the
Vikunja task exists and the title matches, extracts the conventional commit
message from PR commits, and squash-merges with
`{PREFIX}-N <conventional commit>` title.
+53 -1
View File
@@ -127,14 +127,37 @@ Click commands from `cli.py` and checks if each has documentation in
```bash
devx ci doc-coverage
devx ci doc-coverage --docs-dir docs/ --fail-on-missing
devx ci doc-coverage --docs-dir docs/ --source-dir src/ --fail-on-missing
```
Options:
- `--docs-dir <dir>` — path to the docs directory (default: `docs/`)
- `--source-dir <dir>` — path to the source directory (default: auto-detect)
- `--fail-on-missing` — exit with non-zero status if any documentation is
missing
### `devx ci lint-docs`
Lint documentation files for structure, broken links, heading hierarchy,
duplicate headings, TODO/FIXME markers, and trailing whitespace.
```bash
devx ci lint-docs
devx ci lint-docs --root . --fix
devx ci lint-docs --no-check-links --no-check-stale
```
Options:
- `--root <dir>` — repository root directory (default: `.`)
- `--docs-dir <dir>` — docs directory (default: `<root>/docs`)
- `--check-links/--no-check-links` — check internal links (default: yes)
- `--check-headings/--no-check-headings` — check heading hierarchy (default: yes)
- `--check-todo/--no-check-todo` — check for TODO/FIXME markers (default: yes)
- `--check-stale/--no-check-stale` — check for stale docs (default: no)
- `--check-trailing/--no-check-trailing` — check trailing whitespace (default: yes)
- `--check-duplicates/--no-check-duplicates` — check duplicate headings (default: yes)
- `--fix` — auto-fix trailing whitespace
### `devx ci integration-guard`
Run pytest with cross-runner failure detection. If any
@@ -398,6 +421,35 @@ Options:
- `--no-pre-commit` — skip pre-commit hook installation
- `--no-tea-login` — skip tea CLI login configuration
### `devx tools rebase`
Rebase the current branch onto `origin/master` and force-push with
`--force-with-lease`. Checks if the branch is behind master first —
if up-to-date, exits without doing anything.
```bash
devx tools rebase # rebase + force-push
devx tools rebase -- --no-push # rebase locally only
```
Options (pass after `--`):
- `--no-push` — rebase locally without pushing
### `devx tools pr-rebase`
Rebase a pull request's head branch onto master via the Gitea API
(server-side). This triggers a new `pull_request synchronize` event,
which starts a new CI run. Useful when you don't have the branch
checked out locally.
```bash
devx tools pr-rebase -- --pr 42 # rebase PR #42
devx tools pr-rebase # auto-detect PR from current branch
```
Options (pass after `--`):
- `--pr <N>` — PR number (auto-detected from current branch if omitted)
## Molecule Commands
Molecule commands require the `molecule` extra (`pip install devx[molecule]`).
+161
View File
@@ -0,0 +1,161 @@
# Getting Started with devx
This guide walks you through installing devx, configuring it for your project,
and setting up a complete CI/CD pipeline.
## Prerequisites
- **Python 3.12+**
- **A Gitea instance** with Actions enabled
- **A Gitea API token** with repo, workflow, and organization scopes
- **(Optional) Vikunja API token** for task tracking integration
## Installation
devx is published to the Gitea PyPI registry. Configure pip to use it:
```bash
# Configure Gitea PyPI registry
pip config set global.extra-index-url https://git.oblachno.oblachno.fyi/api/packages/oblachno-oss/pypi/simple
# Install devx
pip install devx
```
Or install from source:
```bash
git clone https://git.oblachno.oblachno.fyi/oblachno-oss/devx.git
cd devx
make setup
```
## Quick Start
### 1. Configure environment variables
Create a `.env` file in your project root:
```bash
CI_GITEA_TOKEN=your_gitea_api_token
VIKUNJA_TOKEN=your_vikunja_api_token # optional
```
### 2. Add devx to your project
Add devx to your `pyproject.toml`:
```toml
[project]
dependencies = [
"devx>=0.36.2",
]
[project.optional-dependencies]
dev = [
"devx>=0.36.2",
]
```
### 3. Set up the Makefile
devx provides a shared Makefile fragment. Add this to your `Makefile`:
```makefile
include devx.mak
```
Run `devx tools setup` to install all development tools (actionlint, git-cliff,
tea CLI, etc.) and configure pre-commit hooks.
### 4. Create the docs structure
devx expects a `docs/` directory with at minimum:
```text
docs/
├── index.md # Documentation home page
├── mapping.json # Wiki page title mappings
├── user/ # User-facing documentation
│ └── cli-commands.md
└── tech/ # Technical documentation
├── architecture.md
└── ci-cd-workflow.md
```
Example `docs/mapping.json`:
```json
{
"index.md": "Home",
"user/cli-commands.md": "CLI-Commands",
"tech/architecture.md": "Architecture",
"tech/ci-cd-workflow.md": "CI-CD-Workflow"
}
```
### 5. Set up CI workflows
Create `.gitea/workflows/ci.yml` and `.gitea/workflows/post-merge.yml` in your
project. See the [CI/CD Workflow guide](../tech/ci-cd-workflow.md) for details.
### 6. Configure release settings
Add a `cliff.toml` for git-cliff-based versioning:
```bash
devx tools generate-cliff-config
```
Add `[tool.devx]` section to `pyproject.toml` for project-specific config:
```toml
[tool.devx]
# Vikunja project ID for task tracking
vikunja_project_id = 6
[tool.devx.classify]
# File patterns that are infrastructure (no release needed)
infrastructure = [
".gitea/**",
"docs/**",
"tests/**",
"AGENTS.md",
"README.md",
"CHANGELOG.md",
]
```
## Available Tools
### CI/CD Automation (`devx.ci.*`)
- `devx.ci.release` — Automated semver versioning and tagging
- `devx.ci.publish` — Package publishing to Gitea PyPI registry
- `devx.ci.auto_merge` — Squash-merge automation with task ID validation
- `devx.ci.pr_review` — Automated PR review with inline comments
- `devx.ci.classify_changes` — User-facing vs workflow-only change detection
- `devx.ci.sync_wiki` — Push docs/ to Gitea wiki
- `devx.ci.doc_coverage` — Documentation coverage checker
- `devx.ci.lint_docs` — Documentation linter (structure, links, headings)
- `devx.ci.check_translations` — i18n translation completeness checker
- `devx.ci.notify_failure` — Create Gitea issues on CI failures
- `devx.ci.distribute_files` — Parallel test file distribution
- `devx.ci.distribute_items` — Parallel item distribution across runners
- `devx.ci.discover_runners` — Dynamic runner discovery via Gitea API
### Development Tools (`devx.tools.*`)
- `devx.tools.setup` — Environment setup (venv, deps, hooks, tools)
- `devx.tools.install_tools` — Install CI/CD tools (actionlint, git-cliff, tea)
- `devx.tools.create_task` — Create Vikunja tasks
- `devx.tools.create_pr` — Create Gitea PRs with task ID in title
- `devx.tools.configure_repo` — Configure branch protection and labels
- `devx.tools.generate_badges` — Generate quality badge SVGs
- `devx.tools.check_test_speed` — Enforce test execution speed limits
## Next Steps
- Read the [CLI Commands reference](cli-commands.md) for all available commands
- Read the [Architecture guide](../tech/architecture.md) to understand internals
- Read the [CI/CD Workflow guide](../tech/ci-cd-workflow.md) for pipeline details
+36 -27
View File
@@ -13,10 +13,13 @@ classifiers = [
"Programming Language :: Python :: 3",
"License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
]
# All dependencies are pinned to exact versions for full reproducibility.
# Update pinned versions in a dedicated PR with verification.
dependencies = [
"requests>=2.34.2",
"python-dotenv>=1.2.2",
"click>=8.4.1",
"requests==2.34.2",
"python-dotenv==1.2.2",
"click==8.4.2",
"tenacity==9.1.4", # retry logic for GiteaClient/VikunjaClient
]
[project.scripts]
@@ -28,44 +31,44 @@ version = {attr = "devx.__version__"}
[project.optional-dependencies]
# Test runners (pytest + coverage + parallel execution)
ci = [
"pytest>=9.1.0",
"pytest-cov>=7.1.0",
"pytest-xdist>=3.8",
"pytest==9.1.1",
"pytest-cov==7.1.0",
"pytest-xdist==3.8.0",
]
# Lint and type-checking tools (quality job, badge generation)
lint = [
"ruff>=0.15.17",
"pyright>=1.1.410",
"bandit>=1.8.2",
"pip-audit>=2.10",
"pre-commit>=4.6.0",
"ruff==0.15.20",
"pyright==1.1.411",
"bandit==1.9.4",
"pip-audit==2.10.1",
"pre-commit==4.6.0",
]
# Release tools (build + publish to PyPI/Gitea registry)
release = [
"build>=1.5.0",
"twine>=6.2.0",
"build==1.5.0",
"twine==6.2.0",
]
# Molecule testing (for projects with Ansible roles)
molecule = [
"molecule>=26.4.0",
"molecule-docker>=2.1.0",
"ansible-lint>=26.4.0",
"ansible-core>=2.15,<2.17",
"molecule==26.4.0",
"molecule-docker==2.1.0",
"ansible-lint==26.4.0",
"ansible-core==2.21.1",
]
# Deploy tools (for infra staging/production deployments)
deploy = [
"ansible-core>=2.15,<2.17",
"boto3>=1.34",
"docker>=7.0",
"jinja2>=3.1",
"pyyaml>=6.0",
"cryptography>=41.0",
"ansible-core==2.21.1",
"boto3==1.43.36",
"docker==7.1.0",
"jinja2==3.1.6",
"pyyaml==6.0.3",
"cryptography==49.0.0",
]
# Full dev environment (local development)
dev = [
"devx[ci,lint,release,molecule]",
"build>=1.3.0",
"twine>=6.2.0",
"build==1.5.0",
"twine==6.2.0",
]
[tool.setuptools.packages.find]
@@ -97,6 +100,8 @@ indent-style = "space"
[tool.pyright]
include = ["src"]
pythonVersion = "3.12"
venvPath = "."
venv = ".venv"
strict = ["src/devx/config.py", "src/devx/exceptions.py", "src/devx/i18n.py", "src/devx/api_clients.py", "src/devx/gitea_cli.py"]
# ---------------------------------------------------------------------------
@@ -139,8 +144,12 @@ infrastructure_overrides = [
]
# User-facing overrides — safety override for broad infrastructure patterns
# (empty — add when an infrastructure pattern is too broad)
user_facing_overrides = []
# devx workflow files (.gitea/**) are reference implementations that
# downstream repos (grm, infra) copy from. Changes to them affect how
# consumer projects run their CI, so they must trigger a release.
user_facing_overrides = [
".gitea/**",
]
# Tag patterns — additional categories for CI conditional execution
# Orthogonal to release impact (user-facing vs infrastructure)
+1 -1
View File
@@ -1,3 +1,3 @@
"""devx — reusable development and CI/CD tools for oblachno-oss projects."""
__version__ = "0.22.1"
__version__ = "0.36.2"
+173 -93
View File
@@ -4,10 +4,16 @@ from __future__ import annotations
import json
import logging
import time
from typing import Any
import requests
from tenacity import (
before_sleep_log,
retry,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
from devx.config import DEFAULT_TIMEOUT, MAX_RETRIES, RETRY_BACKOFF_BASE, RETRY_STATUS_CODES
from devx.exceptions import APIError
@@ -27,14 +33,69 @@ def _parse_error(e: requests.HTTPError) -> tuple[int, str]:
return status, message
def _is_retryable(e: Exception) -> bool:
"""Check if an exception is a transient error worth retrying."""
if isinstance(e, requests.ConnectionError):
return True
if isinstance(e, requests.HTTPError):
status, _ = _parse_error(e)
return status in RETRY_STATUS_CODES
return isinstance(e, requests.Timeout)
class _TransientHTTPError(requests.HTTPError):
"""HTTP error with a retryable status code (wrapped for tenacity)."""
class _RetryableRequestError(Exception):
"""Connection/timeout error wrapped for tenacity retry."""
def _execute_request(
session: requests.Session,
method: str,
url: str,
**kwargs: Any,
) -> requests.Response:
"""Execute a single HTTP request, wrapping transient errors for tenacity.
Non-retryable HTTP errors (4xx except 429) raise :class:`APIError` directly.
Retryable errors (429, 5xx, connection, timeout) raise exceptions that
tenacity will retry.
"""
try:
response = session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
response.raise_for_status()
return response
except requests.HTTPError as e:
status, message = _parse_error(e)
if status in RETRY_STATUS_CODES:
# Wrap in _TransientHTTPError so tenacity retries it
raise _TransientHTTPError(message, response=e.response) from e
raise APIError(status, message) from e
except (requests.ConnectionError, requests.Timeout) as e:
raise _RetryableRequestError(str(e)) from e
# Tenacity retry decorator shared by both clients.
# Retries on transient HTTP errors (429, 5xx) and connection/timeout errors.
_retry_decorator = retry(
stop=stop_after_attempt(MAX_RETRIES),
wait=wait_exponential(multiplier=RETRY_BACKOFF_BASE, min=RETRY_BACKOFF_BASE, max=RETRY_BACKOFF_BASE**MAX_RETRIES),
retry=retry_if_exception_type((_TransientHTTPError, _RetryableRequestError)),
before_sleep=before_sleep_log(logger, logging.WARNING),
reraise=True,
)
def _request_with_retry(
session: requests.Session,
url: str,
method: str,
**kwargs: Any,
) -> requests.Response:
"""Execute an HTTP request with tenacity-managed retry logic.
On exhaustion, the last exception is translated to :class:`APIError`.
"""
try:
return _retry_decorator(_execute_request)(session, method, url, **kwargs)
except _TransientHTTPError as e:
response = getattr(e, "response", None)
status = response.status_code if response is not None else 0
raise APIError(status, str(e)) from e
except _RetryableRequestError as e:
raise APIError(0, str(e)) from e
class GiteaClient:
@@ -56,49 +117,7 @@ class GiteaClient:
return f"{self._base_url}/repos/{self._owner}/{self._repo}{path}"
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
url = self._url(path)
last_exc: Exception | None = None
for attempt in range(MAX_RETRIES):
try:
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
response.raise_for_status()
return response
except requests.HTTPError as e:
status, message = _parse_error(e)
if _is_retryable(e) and attempt < MAX_RETRIES - 1:
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
logger.warning(
"Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)",
status,
method,
path,
wait,
attempt + 1,
MAX_RETRIES,
)
time.sleep(wait)
last_exc = e
continue
raise APIError(status, message) from e
except (requests.ConnectionError, requests.Timeout) as e:
if attempt < MAX_RETRIES - 1:
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
logger.warning(
"Connection error on %s %s, retrying in %ds (attempt %d/%d)",
method,
path,
wait,
attempt + 1,
MAX_RETRIES,
)
time.sleep(wait)
last_exc = e
continue
raise APIError(0, str(e)) from e
# Should not reach here, but just in case
if last_exc: # pragma: no cover
raise APIError(0, str(last_exc)) from last_exc
raise APIError(0, "Max retries exceeded") # pragma: no cover
return _request_with_retry(self._session, self._url(path), method, **kwargs)
# -- repo settings --
@@ -175,6 +194,19 @@ class GiteaClient:
payload = {"Do": "squash", "MergeTitleField": merge_title}
self._request("POST", f"/pulls/{pr_number}/merge", json=payload)
def update_pr_branch(self, pr_number: str | int, style: str = "rebase") -> None:
"""Update PR head branch by merging/rebasing the base branch into it.
Uses the Gitea API ``POST /pulls/{index}/update?style=rebase`` endpoint.
This rebases the PR's head branch onto the latest base branch server-side,
triggering a ``pull_request synchronize`` event that starts a new CI run.
Args:
pr_number: PR number.
style: Update method ``"rebase"`` (default) or ``"merge"``.
"""
self._request("POST", f"/pulls/{pr_number}/update", params={"style": style})
def get_commit_status(self, sha: str) -> list[dict[str, Any]]:
"""Fetch all status check contexts reported for a commit.
@@ -223,6 +255,20 @@ class GiteaClient:
r = self._request("GET", f"/pulls/{pr_number}/files")
return r.json()
def add_pr_label(self, pr_number: str | int, label_names: list[str]) -> None:
"""Attach labels to a PR/issue by name.
Args:
pr_number: PR or issue number.
label_names: List of label names to attach.
"""
self._request("POST", f"/issues/{pr_number}/labels", json={"labels": label_names})
def get_pr_label_names(self, pr_number: str | int) -> list[str]:
"""Return label names currently attached to a PR/issue."""
r = self._request("GET", f"/issues/{pr_number}/labels")
return [label.get("name", "") for label in r.json()]
def get_pr_commits(self, pr_number: str | int) -> list[dict[str, Any]]:
"""Fetch the commits included in a pull request."""
r = self._request("GET", f"/pulls/{pr_number}/commits")
@@ -301,6 +347,61 @@ class GiteaClient:
return existing
return self.create_release(tag=tag, name=name, body=body, draft=draft, prerelease=prerelease)
# -- actions (CI/CD) --
def list_action_runs(self, **params: Any) -> dict[str, Any]:
"""List workflow runs for the repository.
Returns the raw API response dict (includes ``workflow_runs`` and
``total_count`` keys per Gitea API).
"""
r = self._request("GET", "/actions/runs", params=params)
return r.json()
def get_action_run_jobs(self, run_id: str | int) -> list[dict[str, Any]]:
"""List jobs for a specific workflow run."""
r = self._request("GET", f"/actions/runs/{run_id}/jobs")
data = r.json()
return data.get("jobs", [])
def get_action_job_logs(self, job_id: str | int) -> str:
"""Fetch logs for a specific CI job.
Returns the raw log text. Raises APIError if logs are unavailable.
"""
r = self._request("GET", f"/actions/jobs/{job_id}/logs")
return r.text
# -- actions variables (repo-level) --
def get_repo_variable(self, name: str) -> str | None:
"""Read a Gitea Actions repository variable.
Returns the variable value, or ``None`` if the variable is not set.
Raises :class:`APIError` on other HTTP errors.
"""
try:
r = self._request("GET", f"/actions/variables/{name}")
return r.json().get("value")
except APIError as e:
if e.status == 404:
return None
raise
def set_repo_variable(self, name: str, value: str) -> None:
"""Create or update a Gitea Actions repository variable (idempotent).
Tries PUT first (update); if the variable doesn't exist (404),
creates it via POST. Gitea 1.26.x does not support PATCH for
action variables.
"""
try:
self._request("PUT", f"/actions/variables/{name}", json={"value": value})
except APIError as e:
if e.status != 404:
raise
self._request("POST", f"/actions/variables/{name}", json={"value": value})
class VikunjaClient:
"""Low-level Vikunja REST API client with connection pooling."""
@@ -312,47 +413,7 @@ class VikunjaClient:
def _request(self, method: str, path: str, **kwargs: Any) -> requests.Response:
url = f"{self._base_url}{path}"
last_exc: Exception | None = None
for attempt in range(MAX_RETRIES):
try:
response = self._session.request(method, url, timeout=DEFAULT_TIMEOUT, **kwargs)
response.raise_for_status()
return response
except requests.HTTPError as e:
status, message = _parse_error(e)
if _is_retryable(e) and attempt < MAX_RETRIES - 1:
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
logger.warning(
"Transient HTTP %d on %s %s, retrying in %ds (attempt %d/%d)",
status,
method,
path,
wait,
attempt + 1,
MAX_RETRIES,
)
time.sleep(wait)
last_exc = e
continue
raise APIError(status, message) from e
except (requests.ConnectionError, requests.Timeout) as e:
if attempt < MAX_RETRIES - 1:
wait = RETRY_BACKOFF_BASE ** (attempt + 1)
logger.warning(
"Connection error on %s %s, retrying in %ds (attempt %d/%d)",
method,
path,
wait,
attempt + 1,
MAX_RETRIES,
)
time.sleep(wait)
last_exc = e
continue
raise APIError(0, str(e)) from e
if last_exc: # pragma: no cover
raise APIError(0, str(last_exc)) from last_exc
raise APIError(0, "Max retries exceeded") # pragma: no cover
return _request_with_retry(self._session, url, method, **kwargs)
def list_tasks(self, **params: Any) -> list[dict[str, Any]]:
r = self._request("GET", "/tasks", params=params)
@@ -368,6 +429,25 @@ class VikunjaClient:
r = self._request("GET", f"/projects/{project_id}/tasks", params=params)
return r.json()
def find_task_by_identifier(self, project_id: int, identifier: str, per_page: int = 50) -> dict[str, Any] | None:
"""Find a task by its identifier (e.g. ``DEVX-42``) in a project.
Paginates through all tasks in the project. Returns the task dict
or None if not found.
"""
page = 1
while True:
tasks = self.list_project_tasks(project_id, page=page, per_page=per_page)
if not tasks:
break
for t in tasks:
if t.get("identifier") == identifier:
return t
if len(tasks) < per_page:
break
page += 1
return None
def create_task(self, project_id: int, title: str, description: str = "") -> dict[str, Any]:
"""Create a task in a project and return the created task dict.
+100
View File
@@ -2,8 +2,14 @@
from __future__ import annotations
import os
import subprocess # nosec B404
import click
from devx.config import TASK_ID_RE
from devx.i18n import _
def get_latest_tag() -> str:
"""Get the latest git tag, or empty string if none exists."""
@@ -16,3 +22,97 @@ def get_latest_tag() -> str:
if result.returncode != 0:
return ""
return result.stdout.strip()
def run_cmd(
args: list[str],
check: bool = True,
capture: bool = True,
) -> subprocess.CompletedProcess[str]:
"""Run a command and return the completed process.
Args:
args: Command and arguments as a list.
check: If True, raise :class:`click.ClickException` on non-zero exit.
capture: If True, capture stdout/stderr. If False, inherit parent's.
"""
result = subprocess.run( # nosec B603
args,
capture_output=capture,
text=True,
check=False,
)
if check and result.returncode != 0:
raise click.ClickException(
_(
"Command failed ({cmd}): {stderr}",
cmd=" ".join(args),
stderr=result.stderr.strip() if result.stderr else result.stdout.strip(),
)
)
return result
def extract_task_id(text: str) -> str:
"""Extract the ``{PREFIX}-N`` task identifier from *text*.
Returns the matched string (e.g. ``DEVX-42``) or an empty string if
no task ID is found.
"""
match = TASK_ID_RE.search(text)
return match.group(0) if match else ""
def write_github_env(key: str, value: str) -> None:
"""Append a key=value line to the ``$GITHUB_ENV`` file.
Multi-line values use the heredoc syntax required by Gitea Actions.
Raises :class:`click.ClickException` if ``GITHUB_ENV`` is not set.
"""
gh_env = os.environ.get("GITHUB_ENV")
if not gh_env:
raise click.ClickException("GITHUB_ENV environment variable is not set")
with open(gh_env, "a", encoding="utf-8") as f: # noqa: PTH123
if "\n" in value:
delimiter = "EOF"
f.write(f"{key}<<{delimiter}\n{value}\n{delimiter}\n")
else:
f.write(f"{key}={value}\n")
def write_github_output(key: str, value: str) -> None:
"""Append a key=value line to the ``$GITHUB_OUTPUT`` file.
Raises :class:`click.ClickException` if ``GITHUB_OUTPUT`` is not set.
"""
gh_output = os.environ.get("GITHUB_OUTPUT")
if not gh_output:
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
with open(gh_output, "a", encoding="utf-8") as f: # noqa: PTH123
f.write(f"{key}={value}\n")
def lpt_distribute[T](items: list[T], weights: list[int], max_runners: int) -> list[list[T]]:
"""Distribute *items* across *max_runners* using LPT scheduling.
Sorts items by weight (descending), then assigns each to the runner
with the least total weight. This produces a more balanced distribution
than naive round-robin when items have varying costs.
Args:
items: Items to distribute.
weights: Parallel list of integer weights (higher = heavier).
max_runners: Number of runner groups to create.
Returns:
A list of ``max_runners`` lists, each containing the items assigned
to that runner.
"""
groups: list[list[T]] = [[] for _ in range(max_runners)]
loads = [0] * max_runners
indexed = sorted(enumerate(items), key=lambda x: (-weights[x[0]], x[0]))
for orig_idx, item in indexed:
min_runner = min(range(max_runners), key=lambda r: loads[r])
groups[min_runner].append(item)
loads[min_runner] += weights[orig_idx]
return groups
+54 -35
View File
@@ -22,7 +22,6 @@ Usage:
import os
import re
import subprocess # nosec B404
from pathlib import Path
from typing import Any
@@ -30,11 +29,11 @@ import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.api_clients import GiteaClient, VikunjaClient
from devx.ci._shared import extract_task_id as _extract_task_id
from devx.config import (
CONVENTIONAL_RE,
DEFAULT_PER_PAGE,
GITEA_API_URL,
TASK_ID_RE,
TASK_PREFIX,
VIKUNJA_API_URL,
VIKUNJA_PROJECT_ID,
@@ -42,26 +41,15 @@ from devx.config import (
from devx.exceptions import APIError
from devx.i18n import _
# Strip leading task ID prefix (e.g. "DEVX-12: " or "OBL-INFRA-364: ") from commit subjects.
_TASK_ID_PREFIX_RE = re.compile(rf"^{TASK_PREFIX}-\d+:\s*")
TASKID_FILE = ".taskid" # Deprecated, kept for backward-compat warnings
PR_TITLE_RE = re.compile(rf"^{TASK_PREFIX}-\d+:\s+.+")
load_dotenv()
def run_cmd(args: list[str], check: bool = True) -> subprocess.CompletedProcess[str]:
"""Run a command and return the completed process."""
result = subprocess.run(args, capture_output=True, text=True, check=False) # nosec B603
if check and result.returncode != 0:
raise click.ClickException(
_(
"Command failed ({cmd}): {stderr}",
cmd=" ".join(args),
stderr=result.stderr.strip() or result.stdout.strip(),
)
)
return result
def read_taskid(branch: str) -> str:
"""Read task ID from branch name.
@@ -92,9 +80,8 @@ def read_taskid(branch: str) -> str:
def extract_task_id(branch: str) -> str:
"""Extract DEVX-N task identifier from branch name (legacy fallback)."""
match = TASK_ID_RE.search(branch)
return match.group(0) if match else ""
"""Extract task identifier from branch name (delegates to shared utility)."""
return _extract_task_id(branch)
def validate_pr_title(pr_title: str, task_id: str) -> None:
@@ -174,19 +161,33 @@ def validate_pr_title_matches_vikunja(pr_title: str, task_id: str) -> None:
def extract_conventional_msg(commits: list[dict[str, Any]]) -> str:
"""Extract the conventional commit message from PR commits.
Iterates commits in reverse order (newest first) to find the first
message matching the conventional commit format. Falls back to the
newest commit message if none match.
Picks the highest-priority conventional commit message from the PR.
Priority: feat > fix > refactor > docs > chore > other.
Falls back to the newest commit message if none match.
"""
priority = {"feat": 5, "fix": 4, "refactor": 3, "docs": 2, "chore": 1, "ci": 1, "style": 1, "test": 1}
best_msg = ""
best_score = 0
for commit in reversed(commits):
commit_info = commit.get("commit", {})
message = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
if CONVENTIONAL_RE.match(message):
return message
# Fallback: use the newest commit's first line
# Strip any leading task ID prefix (e.g. "OBL-INFRA-364: fix: ...") so
# conventional commit matching works on the remainder.
stripped = _TASK_ID_PREFIX_RE.sub("", message)
m = CONVENTIONAL_RE.match(stripped)
if m:
prefix = m.group(1).split("(")[0].strip() # e.g. "feat" from "feat(scope)"
score = priority.get(prefix, 0)
if score > best_score:
best_score = score
best_msg = stripped
if best_msg:
return best_msg
# Fallback: use the newest commit's first line (strip task ID prefix if present)
if commits:
commit_info = commits[-1].get("commit", {})
return str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
raw = str(commit_info.get("message", "") if isinstance(commit_info, dict) else "").split("\n")[0]
return _TASK_ID_PREFIX_RE.sub("", raw)
return ""
@@ -237,17 +238,35 @@ def main(branch: str, pr_title: str, repo: str, pr_number: str) -> None:
client.merge_pr(pr_num, merge_title)
except APIError as e:
if e.status == 405 and "behind" in e.message.lower():
# Head branch is behind master — do NOT auto-rebase.
# Auto-rebasing creates a feedback loop: the force-push triggers
# a new pull_request synchronize event, which starts a new CI run,
# which runs auto-merge again, which rebases again, etc.
raise click.ClickException(
# Head branch is behind master. Auto-rebase via Gitea API.
# This triggers a new pull_request synchronize event → new CI run.
# The next auto-merge attempt will find the branch up-to-date and
# merge successfully. This is NOT an infinite loop: the rebase
# resolves the "behind" condition, so the next run merges.
# If another PR merges in between, the branch may fall behind
# again, but the process converges as PRs stop merging.
click.echo(
_(
"Branch is behind master. Rebase manually:\n"
" git fetch origin master && git rebase origin/master && git push --force-with-lease\n"
"Then re-add the ready-to-merge label.",
"Branch is behind master. Auto-rebasing via Gitea API...\n"
"A new CI run will start automatically after the rebase.\n"
"The next auto-merge attempt will merge this PR.",
)
) from None
)
try:
client.update_pr_branch(pr_num, style="rebase")
except APIError as rebase_err:
raise click.ClickException(
_(
"Auto-rebase failed with HTTP {status}: {message}\n"
"Rebase manually:\n"
" git fetch origin master && git rebase origin/master && git push --force-with-lease\n"
"Then re-add the ready-to-merge label.",
status=rebase_err.status,
message=rebase_err.message,
)
) from None
# Exit cleanly — the rebase triggers a new CI run that will retry.
return
else:
raise click.ClickException(
_(
+2 -1
View File
@@ -53,6 +53,7 @@ from devx.config import (
VIKUNJA_API_URL,
VIKUNJA_PROJECT_ID,
)
from devx.exceptions import APIError
from devx.i18n import _
load_dotenv()
@@ -108,7 +109,7 @@ def get_pr_title_from_gitea(repo: str, pr_number: int) -> str | None:
try:
pr = client.get_pr(pr_number)
return str(pr.get("title", ""))
except Exception:
except APIError:
return None
+33 -9
View File
@@ -31,11 +31,11 @@ from pathlib import Path
import click
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
REPO_ROOT = Path.cwd()
SUPPORTED_LANGS = ("en", "bg", "de", "ru", "zh", "pl")
# Default translation set: devx package itself
# Default translation set: look for translations.json in the current repo
DEFAULT_TRANS_FILE = REPO_ROOT / "src" / "devx" / "translations.json"
DEFAULT_SRC_DIR = REPO_ROOT / "src" / "devx"
@@ -169,20 +169,44 @@ def print_result(result: TranslationCheckResult) -> None:
"translations",
multiple=True,
type=click.Path(exists=False, path_type=Path),
help="Path to a translations JSON file to check (can be repeated). Defaults to src/devx/translations.json.",
help="Path to a translations JSON file to check (can be repeated). Auto-detects by default.",
)
def main(translations: tuple[Path, ...]) -> None:
@click.option(
"--source-dir",
default=None,
help="Source directory to scan for _() calls (default: auto-detect).",
)
def main(translations: tuple[Path, ...], source_dir: str | None) -> None:
"""Check translation files for gaps, dead keys, and missing languages."""
results: list[TranslationCheckResult] = []
if not translations:
# Default: check the devx package's own translations
results = [
check_translation_set("devx", DEFAULT_SRC_DIR, DEFAULT_TRANS_FILE),
# Auto-detect translations file in the current repo
root = Path.cwd()
# Try common locations
candidates = [
root / "src" / "devx" / "translations.json",
root / "src" / "grm" / "translations.json",
]
# Also search for any translations.json in src/
for match in root.glob("src/*/translations.json"):
candidates.append(match)
found = False
for candidate in candidates:
if candidate.exists():
src_dir = Path(source_dir) if source_dir else candidate.parent
results.append(check_translation_set(candidate.parent.name, src_dir, candidate))
found = True
break
if not found:
# No translations file found — this repo doesn't use i18n
click.echo("PASS: No translations file found — skipping (repo does not use i18n).")
return
else:
results = []
for trans_file in translations:
# Infer source directory as the parent of the translations file
src_dir = trans_file.parent
src_dir = Path(source_dir) if source_dir else trans_file.parent
name = trans_file.parent.name
results.append(check_translation_set(name, src_dir, trans_file))
+20 -26
View File
@@ -138,7 +138,7 @@ from typing import Any
import click
from devx.ci._shared import get_latest_tag
from devx.ci._shared import get_latest_tag, write_github_output
from devx.i18n import _
# ---------------------------------------------------------------------------
@@ -393,14 +393,15 @@ class ChangeClassifier:
tags = self._compute_tags(file_path)
# 1. User-facing overrides (highest priority — safety)
if file_path in self._user_overrides:
return FileClassification(
path=file_path,
is_user_facing=True,
reason="User-facing override (safety override)",
matched_rule="user_facing_overrides",
tags=tags,
)
for pattern in self._user_overrides:
if _matches_glob(file_path, pattern):
return FileClassification(
path=file_path,
is_user_facing=True,
reason=f"User-facing override (matches '{pattern}')",
matched_rule="user_facing_overrides",
tags=tags,
)
# 2. Infrastructure overrides
if file_path in self._infra_overrides:
@@ -583,15 +584,8 @@ def has_user_facing_changes(
# ---------------------------------------------------------------------------
def _write_github_output(key: str, value: str) -> None:
"""Append a key=value line to the $GITHUB_OUTPUT file."""
gh_output = os.environ.get("GITHUB_OUTPUT")
if not gh_output:
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
with open(gh_output, "a") as f: # noqa: PTH123
f.write(f"{key}={value}\n")
# ---------------------------------------------------------------------------
# Classification logic
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
@@ -632,9 +626,9 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
force = True
if force and github_output:
_write_github_output("user-facing-changed", "true")
write_github_output("user-facing-changed", "true")
for tag in available_tags:
_write_github_output(f"{tag}-changed", "true")
write_github_output(f"{tag}-changed", "true")
click.echo("Forced user-facing-changed=true via --force flag.")
return
@@ -642,9 +636,9 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
base = get_latest_tag()
if not base:
if github_output:
_write_github_output("user-facing-changed", "true")
write_github_output("user-facing-changed", "true")
for tag in available_tags:
_write_github_output(f"{tag}-changed", "true")
write_github_output(f"{tag}-changed", "true")
click.echo("No tags found — treating all changes as user-facing.")
return
if quiet:
@@ -656,9 +650,9 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
files = get_changed_files(base, head)
if not files:
if github_output:
_write_github_output("user-facing-changed", "false")
write_github_output("user-facing-changed", "false")
for tag in available_tags:
_write_github_output(f"{tag}-changed", "false")
write_github_output(f"{tag}-changed", "false")
click.echo(f"No changes between {base} and {head}.")
return
if quiet:
@@ -670,9 +664,9 @@ def main(base: str | None, head: str, quiet: bool, check: str, github_output: bo
result = classifier.classify(files)
if github_output:
_write_github_output("user-facing-changed", "true" if result.has_user_facing else "false")
write_github_output("user-facing-changed", "true" if result.has_user_facing else "false")
for tag in available_tags:
_write_github_output(f"{tag}-changed", "true" if result.has_tag(tag) else "false")
write_github_output(f"{tag}-changed", "true" if result.has_tag(tag) else "false")
click.echo(f"User-facing files changed: {result.has_user_facing}")
for tag in available_tags:
click.echo(f"{tag.capitalize()} files changed: {result.has_tag(tag)}")
+24 -13
View File
@@ -1,7 +1,10 @@
#!/usr/bin/env python3
"""Detect whether the latest git commit is a release commit.
"""Detect whether the latest git commit is an automated CI commit.
Release commits have the format ``release: vX.Y.Z``.
Badge commits have the format ``chore: update badge URLs ... [skip ci]``.
Both are generated by CI and should skip post-merge jobs.
This script writes ``is-release=true`` or ``is-release=false`` to
``$GITHUB_OUTPUT`` for use in CI workflow conditionals.
@@ -12,13 +15,16 @@ Usage::
from __future__ import annotations
import os
import re
import subprocess # nosec B404
import click
from devx.ci._shared import write_github_output
from devx.i18n import _
RELEASE_RE = re.compile(r"^release: v\d+\.\d+\.\d+")
BADGE_RE = re.compile(r"^chore: update badge URLs.*\[skip ci\]")
def get_commit_message() -> str:
@@ -39,26 +45,31 @@ def is_release_commit(message: str) -> bool:
return bool(RELEASE_RE.match(message))
def write_github_output(key: str, value: str) -> None:
"""Append a key=value line to the $GITHUB_OUTPUT file."""
gh_output = os.environ.get("GITHUB_OUTPUT")
if not gh_output:
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
with open(gh_output, "a") as f: # noqa: PTH123
f.write(f"{key}={value}\n")
def is_badge_commit(message: str) -> bool:
"""Check if a commit message matches the badge commit format."""
return bool(BADGE_RE.match(message))
def is_automated_commit(message: str) -> bool:
"""Check if a commit is an automated CI commit (release or badge)."""
return is_release_commit(message) or is_badge_commit(message)
@click.command()
def main() -> None:
"""Detect if the latest commit is a release commit and set GITHUB_OUTPUT."""
"""Detect if the latest commit is an automated CI commit and set GITHUB_OUTPUT."""
msg = get_commit_message()
click.echo(f"Commit message: {msg}")
click.echo(_("Commit message: {msg}", msg=msg))
is_release = is_release_commit(msg)
is_automated = is_automated_commit(msg)
write_github_output("is-release", "true" if is_release else "false")
write_github_output("is-automated", "true" if is_automated else "false")
if is_release:
click.echo("Release commit — skipping all post-merge jobs.")
click.echo(_("Release commit — skipping all post-merge jobs."))
elif is_automated:
click.echo(_("Automated CI commit (badge) — skipping post-merge jobs."))
else:
click.echo("Regular merge commit — running all post-merge jobs.")
click.echo(_("Regular merge commit — running all post-merge jobs."))
if __name__ == "__main__": # pragma: no cover
+18 -14
View File
@@ -29,7 +29,8 @@ import os
import click
import requests
from devx.config import GITEA_API_URL
from devx.config import GITEA_API_URL, REPO_NAME, REPO_OWNER
from devx.i18n import _
DEFAULT_MAX_RUNNERS = 3
@@ -55,9 +56,9 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
data = r.json()
total += data.get("total_count", 0)
else:
click.echo(f"Warning: repo-level runners query returned HTTP {r.status_code}", err=True)
click.echo(_("Warning: repo-level runners query returned HTTP {status}", status=r.status_code), err=True)
except (requests.RequestException, ValueError) as e:
click.echo(f"Warning: repo-level runners query failed: {e}", err=True)
click.echo(_("Warning: repo-level runners query failed: {error}", error=e), err=True)
# 2. Organization-level runners
try:
@@ -70,9 +71,9 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
data = r.json()
total += data.get("total_count", 0)
else:
click.echo(f"Warning: org-level runners query returned HTTP {r.status_code}", err=True)
click.echo(_("Warning: org-level runners query returned HTTP {status}", status=r.status_code), err=True)
except (requests.RequestException, ValueError) as e:
click.echo(f"Warning: org-level runners query failed: {e}", err=True)
click.echo(_("Warning: org-level runners query failed: {error}", error=e), err=True)
# 3. Instance-level runners (requires admin scope)
try:
@@ -85,9 +86,12 @@ def query_runners(api_url: str, token: str, owner: str, repo: str) -> int:
data = r.json()
total += data.get("total_count", 0)
elif r.status_code != 403: # 403 is expected without admin scope
click.echo(f"Warning: instance-level runners query returned HTTP {r.status_code}", err=True)
click.echo(
_("Warning: instance-level runners query returned HTTP {status}", status=r.status_code),
err=True,
)
except (requests.RequestException, ValueError) as e:
click.echo(f"Warning: instance-level runners query failed: {e}", err=True)
click.echo(_("Warning: instance-level runners query failed: {error}", error=e), err=True)
return total
@@ -151,9 +155,9 @@ def main(
token = os.environ.get("CI_GITEA_TOKEN", "")
if owner is None:
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss")
owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
if repo is None:
repo = os.environ.get("DEVX_REPO_NAME", "devx")
repo = os.environ.get("DEVX_REPO_NAME", "") or REPO_NAME
count = get_runner_count(GITEA_API_URL, token, owner, repo)
indices = generate_indices(count)
@@ -162,11 +166,11 @@ def main(
gh_output = os.environ.get("GITHUB_OUTPUT")
if not gh_output:
raise click.ClickException("GITHUB_OUTPUT environment variable is not set")
with open(gh_output, "a") as f: # noqa: PTH123
with open(gh_output, "a", encoding="utf-8") as f: # noqa: PTH123
f.write(f"runner-count={count}\n")
f.write(f"runner-indices={json.dumps(indices)}\n")
click.echo(f"Runner count: {count}")
click.echo(f"Runner indices: {indices}")
click.echo(_("Runner count: {count}", count=count))
click.echo(_("Runner indices: {indices}", indices=indices))
return
if output_count:
@@ -178,8 +182,8 @@ def main(
return
# Default: output both as key=value pairs for CI consumption
click.echo(f"count={count}")
click.echo(f"indices={json.dumps(indices)}")
click.echo(_("count={count}", count=count))
click.echo(_("indices={indices}", indices=json.dumps(indices)))
if __name__ == "__main__": # pragma: no cover
+18 -30
View File
@@ -26,6 +26,7 @@ import os
import click
from devx.ci._shared import lpt_distribute, write_github_env
from devx.i18n import _
DEFAULT_MAX_RUNNERS = 3
@@ -54,15 +55,7 @@ def distribute(files: list[str], max_runners: int) -> list[list[str]]:
the runner with the least total weight.
"""
weights = [_file_weight(f) for f in files]
groups: list[list[str]] = [[] for _ in range(max_runners)]
loads = [0] * max_runners
# Sort by weight descending, preserving original order for ties
indexed = sorted(enumerate(files), key=lambda x: (-weights[x[0]], x[0]))
for orig_idx, f in indexed:
min_runner = min(range(max_runners), key=lambda r: loads[r])
groups[min_runner].append(f)
loads[min_runner] += weights[orig_idx]
return groups
return lpt_distribute(files, weights, max_runners)
def files_for_runner(files: list[str], runner_index: int, max_runners: int) -> list[str]:
@@ -75,19 +68,6 @@ def files_for_runner(files: list[str], runner_index: int, max_runners: int) -> l
return groups[runner_index]
def _write_github_env(key: str, value: str) -> None:
gh_env = os.environ.get("GITHUB_ENV")
if not gh_env:
raise click.ClickException("GITHUB_ENV environment variable is not set")
with open(gh_env, "a") as f: # noqa: PTH123
if "\n" in value:
# Multi-line values require the heredoc syntax in $GITHUB_ENV.
delimiter = "EOF"
f.write(f"{key}<<{delimiter}\n{value}\n{delimiter}\n")
else:
f.write(f"{key}={value}\n")
@click.command()
@click.option("--pattern", required=True, help="Glob pattern for files to distribute.")
@click.option(
@@ -122,26 +102,34 @@ def main(pattern: str, runner_index: int | None, max_runners: int, github_env: b
groups = distribute(files, max_runners)
for i, group in enumerate(groups):
labels = " ".join(group) if group else "(none)"
click.echo(f"Runner {i}: {labels}")
click.echo(_("Runner {i}: {labels}", i=i, labels=labels))
return
if skip_if_excess and github_env and runner_index > max_runners:
click.echo(f"Skipping — runner index {runner_index} > max runners {max_runners}")
_write_github_env("ASSIGNED_FILES", "")
_write_github_env("SKIP", "true")
click.echo(
_(
"Skipping — runner index {runner_index} > max runners {max_runners}",
runner_index=runner_index,
max_runners=max_runners,
)
)
write_github_env("ASSIGNED_FILES", "")
write_github_env("SKIP", "true")
return
if runner_index < 1:
raise click.ClickException(f"Runner index {runner_index} is out of range (must be >= 1)")
raise click.ClickException(
_("Runner index {runner_index} is out of range (must be >= 1)", runner_index=runner_index)
)
zero_based = runner_index - 1
assigned = files_for_runner(files, zero_based, max_runners)
encoded = "\n".join(assigned)
if github_env:
_write_github_env("ASSIGNED_FILES", encoded)
_write_github_env("SKIP", "false")
click.echo(f"Assigned {len(assigned)} files to runner {runner_index}")
write_github_env("ASSIGNED_FILES", encoded)
write_github_env("SKIP", "false")
click.echo(_("Assigned {count} files to runner {runner_index}", count=len(assigned), runner_index=runner_index))
return
click.echo(encoded)
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""Distribute a list of items across N parallel runners using LPT scheduling.
Generic item distribution for CI matrix jobs. Items are read from a JSON
array on stdin (or from a file via --items-file), sorted for deterministic
ordering, then assigned to *max_runners* groups using LPT (Longest
Processing Time first) scheduling.
Each item is a string (e.g. an Ansible ``--limit`` pattern like
``observability`` or ``customer-1-vm``). Optionally, items can be objects
with ``{"id": "...", "weight": N}`` to provide explicit weights.
The assigned group for *runner_index* is written to ``$GITHUB_ENV`` as
``ASSIGNED_ITEMS`` (space-delimited) for use by subsequent steps.
Usage::
echo '["observability", "customer-1-vm"]' | \\
python3 -m devx.ci.distribute_items \\
--runner-index 1 --max-runners 3 \\
--github-env --skip-if-excess
# With weights:
echo '[{"id": "observability", "weight": 5}, {"id": "customer-1", "weight": 3}]' | \\
python3 -m devx.ci.distribute_items \\
--runner-index 1 --max-runners 3 --github-env
"""
from __future__ import annotations
import json
import sys
import click
from devx.ci._shared import lpt_distribute, write_github_env
from devx.i18n import _
DEFAULT_MAX_RUNNERS = 3
DEFAULT_WEIGHT = 1
def parse_items(raw: str) -> list[str]:
"""Parse a JSON array into a list of item identifier strings.
Accepts both plain string arrays (``["a", "b"]``) and object arrays
(``[{"id": "a", "weight": 2}]``). Returns just the identifier strings.
"""
data = json.loads(raw)
if not isinstance(data, list):
raise click.ClickException(_("Items input must be a JSON array, got {type}", type=type(data).__name__))
items: list[str] = []
for entry in data:
if isinstance(entry, str):
items.append(entry)
elif isinstance(entry, dict) and "id" in entry:
items.append(str(entry["id"]))
else:
raise click.ClickException(
_("Each item must be a string or an object with 'id', got {type}", type=type(entry).__name__)
)
return items
def parse_weighted_items(raw: str) -> tuple[list[str], list[int]]:
"""Parse a JSON array into (items, weights) lists.
For plain string arrays, all items get ``DEFAULT_WEIGHT``.
For object arrays, the ``weight`` field is used (default: ``DEFAULT_WEIGHT``).
"""
data = json.loads(raw)
if not isinstance(data, list):
raise click.ClickException(_("Items input must be a JSON array, got {type}", type=type(data).__name__))
items: list[str] = []
weights: list[int] = []
for entry in data:
if isinstance(entry, str):
items.append(entry)
weights.append(DEFAULT_WEIGHT)
elif isinstance(entry, dict) and "id" in entry:
items.append(str(entry["id"]))
weights.append(int(entry.get("weight", DEFAULT_WEIGHT)))
else:
raise click.ClickException(
_("Each item must be a string or an object with 'id', got {type}", type=type(entry).__name__)
)
return items, weights
def distribute(items: list[str], weights: list[int], max_runners: int) -> list[list[str]]:
"""Split *items* into *max_runners* balanced groups using LPT scheduling.
Items are sorted by weight (descending), then assigned to the runner
with the least total weight.
"""
return lpt_distribute(items, weights, max_runners)
def items_for_runner(items: list[str], weights: list[int], runner_index: int, max_runners: int) -> list[str]:
"""Return the subset of items assigned to *runner_index* (0-based)."""
groups = distribute(items, weights, max_runners)
if runner_index < 0 or runner_index >= len(groups):
raise click.ClickException(
_("Runner index {index} out of range (0..{max})", index=runner_index, max=max_runners - 1)
)
return groups[runner_index]
@click.command()
@click.option(
"--items-file",
type=click.Path(exists=True, file_okay=True, path_type=None),
default=None,
help="Read items from a JSON file instead of stdin.",
)
@click.option(
"--runner-index",
type=int,
default=None,
help="One-based runner index. If omitted, prints all groups.",
)
@click.option(
"--max-runners",
type=int,
default=DEFAULT_MAX_RUNNERS,
show_default=True,
help="Total number of parallel runners.",
)
@click.option(
"--github-env",
is_flag=True,
default=False,
help="Write ASSIGNED_ITEMS and SKIP to $GITHUB_ENV.",
)
@click.option(
"--skip-if-excess",
is_flag=True,
default=False,
help="With --github-env: write SKIP=true when runner-index exceeds max-runners.",
)
def main(
items_file: str | None,
runner_index: int | None,
max_runners: int,
github_env: bool,
skip_if_excess: bool,
) -> None:
# Read items from file or stdin
if items_file is not None:
with open(items_file, encoding="utf-8") as f: # noqa: PTH123
raw = f.read()
else:
raw = sys.stdin.read()
raw = raw.strip()
if not raw:
raw = "[]"
items, weights = parse_weighted_items(raw)
if runner_index is None:
groups = distribute(items, weights, max_runners)
for i, group in enumerate(groups):
labels = " ".join(group) if group else "(none)"
click.echo(_("Runner {i}: {labels}", i=i, labels=labels))
return
if skip_if_excess and github_env and runner_index > max_runners:
click.echo(
_(
"Skipping — runner index {runner_index} > max runners {max_runners}",
runner_index=runner_index,
max_runners=max_runners,
)
)
write_github_env("ASSIGNED_ITEMS", "")
write_github_env("SKIP", "true")
return
if runner_index < 1:
raise click.ClickException(
_("Runner index {runner_index} is out of range (must be >= 1)", runner_index=runner_index)
)
zero_based = runner_index - 1
assigned = items_for_runner(items, weights, zero_based, max_runners)
encoded = " ".join(assigned)
if github_env:
write_github_env("ASSIGNED_ITEMS", encoded)
write_github_env("SKIP", "false")
click.echo(
_(
"Assigned {count} items to runner {runner_index}: {encoded}",
count=len(assigned),
runner_index=runner_index,
encoded=encoded,
)
)
return
click.echo(encoded)
if __name__ == "__main__": # pragma: no cover
main()
+55 -17
View File
@@ -5,8 +5,12 @@ Parses Click commands from the CLI source code and checks if each command
has corresponding documentation in the wiki/docs. Reports missing
documentation as warnings and exits with non-zero if coverage is below 100%.
By default, checks the current repository's own source and docs directories.
When run from the devx package itself (development mode), it checks devx's
own files. When installed as a package, it checks the consuming repo's files.
Usage:
python3 -m devx.ci.doc_coverage [--docs-dir docs/] [--fail-on-missing]
python3 -m devx.ci.doc_coverage [--docs-dir docs/] [--source-dir src/] [--fail-on-missing]
"""
from __future__ import annotations
@@ -19,11 +23,12 @@ import click
from devx.i18n import _
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
# Default to the current working directory (consuming repo's root)
REPO_ROOT = Path.cwd()
DOCS_DIR = REPO_ROOT / "docs"
CLI_FILE = REPO_ROOT / "src" / "devx" / "cli.py"
# Major modules that should be documented in tech/architecture.md
# These are devx-specific; when checking other repos, use --source-dir
REQUIRED_MODULES = [
"cli.py",
"i18n.py",
@@ -51,11 +56,16 @@ REQUIRED_SCRIPTS = [
]
def extract_cli_commands() -> list[str]:
def extract_cli_commands(source_dir: Path) -> list[str]:
"""Extract command names from the CLI source file."""
if not CLI_FILE.exists():
# Try to find the CLI file in the source directory
cli_file = None
for candidate in source_dir.rglob("cli.py"):
cli_file = candidate
break
if cli_file is None or not cli_file.exists():
return []
content = CLI_FILE.read_text()
content = cli_file.read_text()
commands: list[str] = []
# Find all @<group>.command("name") occurrences in the CLI source
# Matches @cli.command, @ci.command, @tools.command, @molecule.command
@@ -94,15 +104,30 @@ def check_module_documented(module: str, docs_content: str) -> bool:
@click.command()
@click.option("--docs-dir", default=str(DOCS_DIR), help="Path to the docs directory.")
@click.option("--docs-dir", default=None, help="Path to the docs directory (default: ./docs).")
@click.option("--source-dir", default=None, help="Path to the source directory (default: auto-detect from src/).")
@click.option(
"--fail-on-missing",
is_flag=True,
default=False,
help="Exit with non-zero status if any documentation is missing.",
)
def main(docs_dir: str, fail_on_missing: bool) -> None:
docs_path = Path(docs_dir)
def main(docs_dir: str | None, source_dir: str | None, fail_on_missing: bool) -> None:
root = Path.cwd()
docs_path = Path(docs_dir) if docs_dir else root / "docs"
# Auto-detect source directory
if source_dir:
src_path = Path(source_dir)
else:
# Try common source directories
for candidate in [root / "src", root / "scripts"]:
if candidate.exists():
src_path = candidate
break
else:
src_path = root / "src"
cli_commands_file = docs_path / "user" / "cli-commands.md"
architecture_file = docs_path / "tech" / "architecture.md"
ci_cd_file = docs_path / "tech" / "ci-cd-workflow.md"
@@ -112,21 +137,28 @@ def main(docs_dir: str, fail_on_missing: bool) -> None:
# Check CLI commands
click.echo(_("Checking CLI command documentation..."))
commands = extract_cli_commands()
commands = extract_cli_commands(src_path)
total += len(commands)
cli_docs = cli_commands_file.read_text() if cli_commands_file.exists() else ""
for cmd in commands:
if check_command_documented(cmd, cli_docs):
click.echo(_(" OK: devx {cmd}", cmd=cmd))
click.echo(_(" OK: {cmd}", cmd=cmd))
else:
click.echo(_(" MISSING: devx {cmd}", cmd=cmd))
missing.append(f"CLI command: devx {cmd}")
click.echo(_(" MISSING: {cmd}", cmd=cmd))
missing.append(f"CLI command: {cmd}")
# Check modules in architecture.md
# Auto-detect modules from source directory (top-level only, exclude subdirs)
click.echo(_("\nChecking module documentation in architecture.md..."))
total += len(REQUIRED_MODULES)
if src_path.exists():
detected_modules = sorted(
f.name for f in src_path.glob("*.py") if f.name != "__init__.py" and f.name != "cli.py"
)
else:
detected_modules = REQUIRED_MODULES
total += len(detected_modules)
arch_docs = architecture_file.read_text() if architecture_file.exists() else ""
for module in REQUIRED_MODULES:
for module in detected_modules:
if check_module_documented(module, arch_docs):
click.echo(_(" OK: {module}", module=module))
else:
@@ -134,10 +166,16 @@ def main(docs_dir: str, fail_on_missing: bool) -> None:
missing.append(f"Module: {module}")
# Check CI scripts in ci-cd-workflow.md
# Auto-detect CI scripts from ci/ subdirectory
click.echo(_("\nChecking CI script documentation in ci-cd-workflow.md..."))
total += len(REQUIRED_SCRIPTS)
ci_dir = src_path / "ci" if src_path.name != "ci" else src_path
if ci_dir.exists():
detected_scripts = sorted(f.name for f in ci_dir.glob("*.py") if f.name != "__init__.py")
else:
detected_scripts = REQUIRED_SCRIPTS
total += len(detected_scripts)
ci_docs = ci_cd_file.read_text() if ci_cd_file.exists() else ""
for script in REQUIRED_SCRIPTS:
for script in detected_scripts:
if check_module_documented(script, ci_docs):
click.echo(_(" OK: {script}", script=script))
else:
+4 -3
View File
@@ -36,6 +36,7 @@ import time
import click
from devx.config import REPO_NAME, REPO_OWNER
from devx.i18n import _
from devx.molecule.molecule_ci_guard import (
poll_for_other_failures,
@@ -53,10 +54,10 @@ def cli(pytest_args: tuple[str, ...]) -> None:
run_id = int(os.environ.get("RUN_ID", "0"))
job_name = os.environ.get("JOB_NAME", "integration-tests")
current_index = int(os.environ.get("MATRIX_INDEX", "0"))
repository = os.environ.get("GITEA_REPOSITORY", "oblachno-oss/devx")
repository = os.environ.get("GITEA_REPOSITORY", "")
owner, _sep, repo = repository.partition("/")
if not owner or not repo:
owner, repo = "oblachno-oss", "devx"
owner, repo = REPO_OWNER, REPO_NAME
if not all([gitea_url, token, run_id]):
click.echo(_("GITEA_URL/CI_GITEA_TOKEN/RUN_ID not set; running without cross-runner cancellation."))
@@ -85,7 +86,7 @@ def cli(pytest_args: tuple[str, ...]) -> None:
cmd = [sys.executable, "-m", "pytest"]
cmd.extend(pytest_args)
click.echo(f"Running: {' '.join(cmd)}")
click.echo(_("Running: {cmd}", cmd=" ".join(cmd)))
process = subprocess.Popen( # nosec B603
cmd,
+587
View File
@@ -0,0 +1,587 @@
#!/usr/bin/env python3
"""Lint documentation files for structure, links, and quality.
Checks performed (all configurable via pyproject.toml ``[tool.devx.docs]``):
- **Required files**: README.md, AGENTS.md, CHANGELOG.md must exist.
- **Docs structure**: ``docs/index.md`` and ``docs/mapping.json`` must exist.
- **Broken internal links**: relative paths and anchors in markdown files
must resolve to actual files and headings.
- **Heading hierarchy**: no skipping heading levels (e.g., ``#`` → ``###``).
- **Single H1**: each markdown file should have at most one H1 heading.
- **Max heading depth**: headings should not exceed H4 (configurable).
- **Max line length**: lines should not exceed 120 characters (configurable).
- **Code block language**: fenced code blocks should specify a language.
- **Orphan docs**: docs not linked from index.md or mapping.json (warning).
- **Mapping completeness**: all docs/*.md should be in mapping.json (warning).
- **TODO/FIXME**: flags leftover TODO/FIXME markers in documentation.
- **Stale docs**: files not modified in >180 days (warning only).
- **Trailing whitespace**: lines should not end with whitespace.
- **Blank line before headings**: headings should have a blank line before them.
Usage::
python3 -m devx.ci.lint_docs
python3 -m devx.ci.lint_docs --docs-dir docs/ --root .
python3 -m devx.ci.lint_docs --fix # auto-fix trailing whitespace
"""
from __future__ import annotations
import json
import re
import sys
from datetime import datetime, timedelta
from pathlib import Path
import click
from devx.i18n import _
# Heading slug pattern (GitHub-style)
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$", re.MULTILINE)
# Markdown link pattern: [text](url)
_LINK_RE = re.compile(r"\[([^\]]*)\]\(([^)]+)\)")
# Trailing whitespace
_TRAILING_WS_RE = re.compile(r"[ \t]+$")
# Heading without blank line before
_HEADING_NO_BLANK_RE = re.compile(r"([^\n])\n(#{1,6}\s)")
# Files that must exist in every project
REQUIRED_FILES = ["README.md", "AGENTS.md", "CHANGELOG.md"]
# Files that must exist in docs/
REQUIRED_DOC_FILES = ["index.md"]
# Maximum age for docs before they're considered stale (days)
STALE_THRESHOLD_DAYS = 180
# Maximum heading depth (H4 by default)
MAX_HEADING_DEPTH = 4
# Maximum line length
MAX_LINE_LENGTH = 120
# Code block without language: ``` followed by optional whitespace only
_CODE_BLOCK_NO_LANG_RE = re.compile(r"^```[ \t]*$", re.MULTILINE)
# Files excluded from duplicate heading checks (auto-generated or structured
# with repeated subsections under different parent sections)
DUPLICATE_HEADING_EXCLUDES = {
"CHANGELOG.md",
"incident-response-sso.md",
"role-sync-design.md",
}
# TODO/FIXME pattern — matches "TODO:" or "FIXME:" at start of line/after whitespace
# Does NOT match references to the word "TODO" in rules/documentation
_TODO_RE = re.compile(r"(?m)^\s*(?:>>>?\s*)?(TODO|FIXME|HACK|XXX)\s*:", re.IGNORECASE)
# Directories excluded from markdown file scanning
_EXCLUDE_DIRS = {
".venv",
".git",
"node_modules",
"__pycache__",
".pytest_cache",
".devin",
".terraform",
".vale",
"site-packages",
"dist-info",
}
def slugify(text: str) -> str:
"""Convert heading text to a GitHub-style slug."""
slug = text.lower().strip()
slug = re.sub(r"[^\w\s-]", "", slug)
slug = re.sub(r"[\s]+", "-", slug)
return slug
def strip_code_blocks(content: str) -> str:
"""Remove fenced code blocks from markdown content.
Replaces ```...``` blocks with empty lines so heading detection
doesn't pick up # comments inside code blocks.
"""
result: list[str] = []
in_code_block = False
for line in content.splitlines():
if line.strip().startswith("```"):
in_code_block = not in_code_block
result.append("")
continue
if in_code_block:
result.append("")
continue
result.append(line)
return "\n".join(result)
def extract_headings(filepath: Path) -> dict[str, int]:
"""Extract all headings from a markdown file.
Returns a dict mapping slug heading level.
"""
content = strip_code_blocks(filepath.read_text(encoding="utf-8"))
headings: dict[str, int] = {}
for match in _HEADING_RE.finditer(content):
level = len(match.group(1))
text = match.group(2)
slug = slugify(text)
headings[slug] = level
return headings
def extract_links(filepath: Path) -> list[tuple[int, str, str]]:
"""Extract all markdown links from a file.
Returns a list of (line_number, link_text, url) tuples.
Includes anchor-only links (#section) for validation.
Skips external links (http/https) and mailto.
"""
content = filepath.read_text(encoding="utf-8")
links: list[tuple[int, str, str]] = []
for match in _LINK_RE.finditer(content):
url = match.group(2).strip()
# Skip external links and mailto
if url.startswith(("http://", "https://", "mailto:")):
continue
line_num = content[: match.start()].count("\n") + 1
links.append((line_num, match.group(1), url))
return links
def check_required_files(root: Path) -> list[str]:
"""Check that required files exist."""
issues: list[str] = []
for filename in REQUIRED_FILES:
if not (root / filename).exists():
issues.append(f"Missing required file: {filename}")
return issues
def check_docs_structure(root: Path, docs_dir: Path) -> list[str]:
"""Check that docs directory has required structure."""
issues: list[str] = []
if not docs_dir.exists():
issues.append(f"Docs directory not found: {docs_dir}")
return issues
for filename in REQUIRED_DOC_FILES:
if not (docs_dir / filename).exists():
issues.append(f"Missing required doc file: docs/{filename}")
mapping_file = docs_dir / "mapping.json"
if mapping_file.exists():
try:
mapping = json.loads(mapping_file.read_text(encoding="utf-8"))
if not isinstance(mapping, dict):
issues.append("docs/mapping.json must be a JSON object")
elif not mapping:
issues.append("docs/mapping.json is empty")
except json.JSONDecodeError as e:
issues.append(f"docs/mapping.json is invalid JSON: {e}")
return issues
def check_internal_links(root: Path, docs_dir: Path) -> list[str]:
"""Check that all internal links in markdown files resolve."""
issues: list[str] = []
md_files = list(root.rglob("*.md"))
# Exclude .venv, .git, node_modules
md_files = [f for f in md_files if not any(part in _EXCLUDE_DIRS for part in f.parts)]
# Load wiki page names from mapping.json — these are valid link targets
wiki_pages: set[str] = set()
mapping_file = docs_dir / "mapping.json"
if mapping_file.exists():
try:
mapping = json.loads(mapping_file.read_text(encoding="utf-8"))
wiki_pages = set(mapping.values())
except (json.JSONDecodeError, AttributeError):
pass
for md_file in md_files:
rel_path = md_file.relative_to(root)
links = extract_links(md_file)
headings = extract_headings(md_file)
for line_num, _link_text, url in links:
# Split into path and anchor
if "#" in url:
path_part, anchor = url.split("#", 1)
else:
path_part, anchor = url, ""
# Skip wiki page references (no file extension, no /, matches mapping.json values)
if path_part and "." not in path_part and "/" not in path_part:
if path_part in wiki_pages:
continue
# Also skip if it looks like a wiki page name (CamelCase or hyphenated)
# without a file extension — can't verify these locally
if not any(c in path_part for c in "/\\"):
continue
# Resolve relative path
if path_part:
target = (md_file.parent / path_part).resolve()
if not target.exists():
issues.append(f"{rel_path}:{line_num}: broken link '{url}' — file not found: {path_part}")
continue
# Check anchor in target file
if anchor:
target_headings = extract_headings(target)
target_slug = slugify(anchor)
if target_slug not in target_headings:
issues.append(f"{rel_path}:{line_num}: broken anchor '#{anchor}' in {path_part}")
elif anchor:
# Anchor-only link — check in current file
anchor_slug = slugify(anchor)
if anchor_slug not in headings:
issues.append(f"{rel_path}:{line_num}: broken anchor '#{anchor}'")
return issues
def check_heading_hierarchy(root: Path) -> list[str]:
"""Check that headings don't skip levels."""
issues: list[str] = []
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
content = strip_code_blocks(md_file.read_text(encoding="utf-8"))
prev_level = 0
for match in _HEADING_RE.finditer(content):
level = len(match.group(1))
if prev_level > 0 and level > prev_level + 1:
issues.append(f"{rel_path}: heading hierarchy skip — H{prev_level} → H{level}: '{match.group(2)}'")
prev_level = level
return issues
def check_todo_fixme(root: Path) -> list[str]:
"""Check for TODO/FIXME/HACK/XXX markers in documentation.
Only flags actual TODO/FIXME markers (e.g., "TODO: fix this"), not
references to the word "TODO" in rules or documentation about TODOs.
"""
issues: list[str] = []
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
content = md_file.read_text(encoding="utf-8")
for match in _TODO_RE.finditer(content):
line_num = content[: match.start()].count("\n") + 1
line = content.splitlines()[line_num - 1] if line_num <= len(content.splitlines()) else ""
issues.append(f"{rel_path}:{line_num}: TODO/FIXME found: {line.strip()}")
return issues
def check_trailing_whitespace(root: Path) -> list[str]:
"""Check for trailing whitespace in markdown files."""
issues: list[str] = []
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
content = md_file.read_text(encoding="utf-8")
for i, line in enumerate(content.splitlines(), 1):
if _TRAILING_WS_RE.search(line):
issues.append(f"{rel_path}:{i}: trailing whitespace")
return issues
def check_stale_docs(root: Path) -> list[str]:
"""Check for stale documentation (not modified in >180 days)."""
issues: list[str] = []
threshold = datetime.now() - timedelta(days=STALE_THRESHOLD_DAYS)
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
mtime = datetime.fromtimestamp(md_file.stat().st_mtime)
if mtime < threshold:
days_old = (datetime.now() - mtime).days
issues.append(f"{rel_path}: stale doc — not modified in {days_old} days")
return issues
def check_duplicate_headings(root: Path) -> list[str]:
"""Check for duplicate headings within the same file."""
issues: list[str] = []
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
# Skip auto-generated files like CHANGELOG.md
if md_file.name in DUPLICATE_HEADING_EXCLUDES:
continue
content = strip_code_blocks(md_file.read_text(encoding="utf-8"))
seen: dict[str, int] = {}
for match in _HEADING_RE.finditer(content):
text = match.group(2)
slug = slugify(text)
if slug in seen:
issues.append(f"{rel_path}: duplicate heading '{text}'")
seen[slug] = 1
return issues
def check_single_h1(root: Path) -> list[str]:
"""Check that each markdown file has at most one H1 heading."""
issues: list[str] = []
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
if md_file.name in DUPLICATE_HEADING_EXCLUDES:
continue
content = strip_code_blocks(md_file.read_text(encoding="utf-8"))
h1_count = len(re.findall(r"^#\s+", content, re.MULTILINE))
if h1_count > 1:
issues.append(f"{rel_path}: {h1_count} H1 headings — should have at most 1")
return issues
def check_max_heading_depth(root: Path) -> list[str]:
"""Check that headings don't exceed MAX_HEADING_DEPTH."""
issues: list[str] = []
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
content = strip_code_blocks(md_file.read_text(encoding="utf-8"))
for match in re.finditer(r"^(#{1,6})\s+", content, re.MULTILINE):
level = len(match.group(1))
if level > MAX_HEADING_DEPTH:
line_num = content[: match.start()].count("\n") + 1
issues.append(f"{rel_path}:{line_num}: heading depth H{level} exceeds max H{MAX_HEADING_DEPTH}")
return issues
def check_line_length(root: Path) -> list[str]:
"""Check that no lines exceed MAX_LINE_LENGTH characters."""
issues: list[str] = []
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
content = md_file.read_text(encoding="utf-8")
for i, line in enumerate(content.splitlines(), 1):
if len(line) > MAX_LINE_LENGTH:
issues.append(f"{rel_path}:{i}: line too long ({len(line)} > {MAX_LINE_LENGTH} chars)")
return issues
def check_code_block_languages(root: Path) -> list[str]:
"""Check that fenced code blocks specify a language."""
issues: list[str] = []
md_files = [f for f in root.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
rel_path = md_file.relative_to(root)
content = md_file.read_text(encoding="utf-8")
in_code_block = False
for i, line in enumerate(content.splitlines(), 1):
stripped = line.strip()
if stripped.startswith("```"):
if not in_code_block:
# Opening fence — check for language
if _CODE_BLOCK_NO_LANG_RE.match(line):
issues.append(f"{rel_path}:{i}: code block without language specifier")
in_code_block = True
else:
# Closing fence
in_code_block = False
return issues
def check_orphan_docs(root: Path, docs_dir: Path) -> list[str]:
"""Check for docs not linked from index.md or mapping.json (warnings)."""
issues: list[str] = []
if not docs_dir.is_dir():
return issues
# Collect all referenced files from index.md and mapping.json
referenced: set[str] = set()
index_file = docs_dir / "index.md"
if index_file.exists():
content = index_file.read_text(encoding="utf-8")
for match in _LINK_RE.finditer(content):
url = match.group(2).strip()
if not url.startswith(("http://", "https://", "mailto:")):
referenced.add(url.split("#")[0])
mapping_file = docs_dir / "mapping.json"
if mapping_file.exists():
try:
mapping = json.loads(mapping_file.read_text(encoding="utf-8"))
if isinstance(mapping, dict):
# Add both keys (filenames) and values (wiki page names)
for k, v in mapping.items():
if isinstance(k, str):
referenced.add(k)
if isinstance(v, str):
referenced.add(v)
except (json.JSONDecodeError, AttributeError):
pass
# Check each doc file
for md_file in sorted(docs_dir.rglob("*.md")):
if md_file.name == "index.md":
continue
rel_path = md_file.relative_to(docs_dir).as_posix()
if rel_path not in referenced and md_file.name not in referenced:
issues.append(f"docs/{rel_path}: orphan doc — not linked from index.md or mapping.json")
return issues
@click.command()
@click.option("--root", default=".", help="Repository root directory.")
@click.option("--docs-dir", default=None, help="Docs directory (default: <root>/docs).")
@click.option("--check-links/--no-check-links", default=True, help="Check internal links.")
@click.option("--check-headings/--no-check-headings", default=True, help="Check heading hierarchy.")
@click.option("--check-todo/--no-check-todo", default=True, help="Check for TODO/FIXME.")
@click.option("--check-stale/--no-check-stale", default=False, help="Check for stale docs.")
@click.option("--check-trailing/--no-check-trailing", default=True, help="Check trailing whitespace.")
@click.option("--check-duplicates/--no-check-duplicates", default=True, help="Check duplicate headings.")
@click.option("--check-single-h1/--no-check-single-h1", "single_h1", default=True, help="Check single H1 per file.")
@click.option("--check-depth/--no-check-depth", "depth", default=True, help="Check max heading depth.")
@click.option("--check-line-length/--no-check-line-length", "line_length", default=True, help="Check line length.")
@click.option("--check-code-lang/--no-check-code-lang", "code_lang", default=True, help="Check code block languages.")
@click.option("--check-orphans/--no-check-orphans", "orphans", default=False, help="Check for orphan docs (warnings).")
@click.option("--fix", is_flag=True, default=False, help="Auto-fix trailing whitespace.")
def main(
root: str,
docs_dir: str | None,
check_links: bool,
check_headings: bool,
check_todo: bool,
check_stale: bool,
check_trailing: bool,
check_duplicates: bool,
single_h1: bool,
depth: bool,
line_length: bool,
code_lang: bool,
orphans: bool,
fix: bool,
) -> None:
"""Lint documentation files for structure, links, and quality."""
root_path = Path(root).resolve()
docs_path = Path(docs_dir) if docs_dir else root_path / "docs"
click.echo(_("Linting documentation in {root}...", root=str(root_path)))
all_issues: list[str] = []
# Structure checks
click.echo(_("Checking required files..."))
all_issues.extend(check_required_files(root_path))
click.echo(_("Checking docs structure..."))
all_issues.extend(check_docs_structure(root_path, docs_path))
# Link checks
if check_links:
click.echo(_("Checking internal links..."))
all_issues.extend(check_internal_links(root_path, docs_path))
# Heading hierarchy
if check_headings:
click.echo(_("Checking heading hierarchy..."))
all_issues.extend(check_heading_hierarchy(root_path))
# Duplicate headings
if check_duplicates:
click.echo(_("Checking duplicate headings..."))
all_issues.extend(check_duplicate_headings(root_path))
# Single H1
if single_h1:
click.echo(_("Checking single H1 per file..."))
all_issues.extend(check_single_h1(root_path))
# Max heading depth
if depth:
click.echo(_("Checking max heading depth..."))
all_issues.extend(check_max_heading_depth(root_path))
# Line length (warnings — badge URLs and tables can exceed 120)
if line_length:
click.echo(_("Checking line length..."))
ll_issues = check_line_length(root_path)
for issue in ll_issues[:10]: # Show first 10 only
click.echo(f" WARN: {issue}")
if len(ll_issues) > 10:
click.echo(_(" ... and {n} more", n=len(ll_issues) - 10))
click.echo(_(" {n} long lines found (warnings only)", n=len(ll_issues)))
# Code block languages
if code_lang:
click.echo(_("Checking code block languages..."))
all_issues.extend(check_code_block_languages(root_path))
# TODO/FIXME
if check_todo:
click.echo(_("Checking for TODO/FIXME markers..."))
all_issues.extend(check_todo_fixme(root_path))
# Trailing whitespace
if check_trailing:
click.echo(_("Checking trailing whitespace..."))
ws_issues = check_trailing_whitespace(root_path)
if fix and ws_issues:
fixed = 0
md_files = [f for f in root_path.rglob("*.md") if not any(part in _EXCLUDE_DIRS for part in f.parts)]
for md_file in md_files:
content = md_file.read_text(encoding="utf-8")
fixed_content = _TRAILING_WS_RE.sub("", content)
if content != fixed_content:
md_file.write_text(fixed_content, encoding="utf-8")
fixed += 1
click.echo(_(" Auto-fixed trailing whitespace in {n} files", n=fixed))
else:
all_issues.extend(ws_issues)
# Stale docs (warnings)
if check_stale:
click.echo(_("Checking for stale docs..."))
stale = check_stale_docs(root_path)
for issue in stale:
click.echo(f" WARN: {issue}")
click.echo(_(" {n} stale docs found (warnings only)", n=len(stale)))
# Orphan docs (warnings)
if orphans:
click.echo(_("Checking for orphan docs..."))
orphan_issues = check_orphan_docs(root_path, docs_path)
for issue in orphan_issues:
click.echo(f" WARN: {issue}")
click.echo(_(" {n} orphan docs found (warnings only)", n=len(orphan_issues)))
# Report
click.echo(f"\n{'=' * 60}")
if all_issues:
click.echo(_("FAIL: {n} documentation issues found:", n=len(all_issues)))
for issue in all_issues:
click.echo(f" - {issue}")
sys.exit(1)
else:
click.echo(_("PASS: All documentation checks passed!"))
if __name__ == "__main__": # pragma: no cover
main()
+5 -5
View File
@@ -13,7 +13,8 @@ import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.api_clients import VikunjaClient
from devx.config import DEFAULT_PER_PAGE, TASK_ID_RE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
from devx.ci._shared import extract_task_id as _extract_task_id
from devx.config import DEFAULT_PER_PAGE, TASK_PREFIX, VIKUNJA_API_URL, VIKUNJA_PROJECT_ID
from devx.exceptions import APIError
from devx.i18n import _
@@ -47,10 +48,9 @@ def _get_git_commit_sha() -> str:
def extract_task_id(commit_msg: str) -> str:
"""Extract DEVX-N task identifier from the first line of commit message."""
"""Extract task identifier from the first line of commit message (delegates to shared utility)."""
first_line = commit_msg.split("\n")[0]
match = TASK_ID_RE.search(first_line)
return match.group(0) if match else ""
return _extract_task_id(first_line)
def extract_conventional_msg(commit_msg: str) -> str:
@@ -61,7 +61,7 @@ def extract_conventional_msg(commit_msg: str) -> str:
- ``DEVX-N <message>`` (current, space-separated)
"""
first_line = commit_msg.split("\n")[0]
return re.sub(r"^DEVX-\d+[:\s]\s*", "", first_line)
return re.sub(rf"^{TASK_PREFIX}-\d+[:\s]\s*", "", first_line)
def resolve_task_id(client: VikunjaClient, task_id: str) -> int:
+116 -2
View File
@@ -387,14 +387,34 @@ def check_documentation(files: list[dict[str, Any]], result: ReviewResult) -> No
for f in files
)
has_ansible_changes = any(f.get("filename", "").startswith("ansible/") for f in files)
has_tofu_changes = any(f.get("filename", "").startswith("tofu/") for f in files)
has_workflow_changes = any(f.get("filename", "").startswith(".gitea/") for f in files)
# Check for TODO/FIXME in changed docs
todo_issues: list[str] = []
for f in files:
filename = f.get("filename", "")
if filename.endswith(".md") and filename.startswith(("docs/", "README", "AGENTS")):
# Can't check file content from PR API easily, but flag if patch adds TODO
patch = f.get("patch", "")
if patch and re.search(r"^\+.*\b(TODO|FIXME|HACK|XXX)\b", patch, re.IGNORECASE):
todo_issues.append(f"{filename}: new TODO/FIXME added in documentation")
if has_src_changes and not has_doc_changes:
result.add_summary("- Documentation: WARNING — source files changed but no docs updated")
elif has_ansible_changes and not has_doc_changes:
result.add_summary("- Documentation: WARNING — Ansible role changed but no docs updated")
elif has_tofu_changes and not has_doc_changes:
result.add_summary("- Documentation: WARNING — OpenTofu changes but no docs updated")
elif has_workflow_changes and not has_doc_changes:
result.add_summary("- Documentation: INFO — workflow changes (consider updating CI docs if behavior changed)")
else:
result.add_summary("- Documentation: OK")
if todo_issues:
for issue in todo_issues:
result.add_summary(f"- Documentation: WARNING — {issue}")
def check_test_coverage(files: list[dict[str, Any]], result: ReviewResult) -> None:
"""Check that tests are updated for source changes."""
@@ -520,12 +540,102 @@ def post_review(client: GiteaClient, pr_number: str, result: ReviewResult) -> di
return client.create_review(pr_number, event=event, body=body, comments=comments)
def _post_manual_review(
client: GiteaClient,
pr_number: str,
event: str,
body: str | None,
checklist_confirmed: bool,
checklist_categories: str | None,
dry_run: bool,
) -> None:
"""Post a manual review with validation for APPROVE events."""
if not body or len(body) < 50:
raise click.ClickException(_("Review body must be at least 50 characters."))
if event == "APPROVE":
if not checklist_confirmed:
raise click.ClickException(
_("--checklist-confirmed is required for APPROVE events."),
)
cats = [c.strip() for c in (checklist_categories or "").split(",") if c.strip()]
cat_nums: list[int] = []
for c in cats:
try:
cat_nums.append(int(c))
except ValueError:
raise click.ClickException(
_("Invalid checklist category: {cat}. Must be numbers.", cat=c),
) from None
if len(cat_nums) < 8:
raise click.ClickException(
_("--checklist-categories must list at least 8 of 13 categories. Got {count}.", count=len(cat_nums)),
)
click.echo(f"Manual review event: {event}")
click.echo(f"Body: {body[:80]}...")
if checklist_confirmed:
click.echo(f"Checklist confirmed: {checklist_categories}")
if dry_run:
click.echo("\n[dry-run] Review not posted.")
return
try:
review = client.create_review(pr_number, event=event, body=body)
except APIError as e:
if "approve" in e.message.lower() or "422" in str(e.status):
click.echo(_("Note: Self-approval not allowed. Posting COMMENT instead."))
review = client.create_review(pr_number, event="COMMENT", body=body)
else:
raise
review_id = review.get("id", "?")
click.echo(
_(
"\nReview #{review_id} posted on PR #{pr_number} with event '{event}'.",
review_id=review_id,
pr_number=pr_number,
event=event,
)
)
@click.command()
@click.argument("pr_number")
@click.argument("repo")
@click.option("--dry-run", is_flag=True, default=False, help="Print review without posting.")
def main(pr_number: str, repo: str, dry_run: bool) -> None:
"""Run automated PR review and post results to Gitea."""
@click.option(
"--event",
type=click.Choice(["APPROVE", "REQUEST_CHANGES", "COMMENT"], case_sensitive=False),
default=None,
help="Post a manual review with the given event (skips automated checks).",
)
@click.option("--body", default=None, help="Review body text (required with --event).")
@click.option(
"--checklist-confirmed",
is_flag=True,
default=False,
help="Attest that REVIEW_CHECKLIST.md categories were checked (required for APPROVE).",
)
@click.option(
"--checklist-categories",
default=None,
help="Comma-separated checklist category numbers (required for APPROVE, min 8 of 13).",
)
def main(
pr_number: str,
repo: str,
dry_run: bool,
event: str | None,
body: str | None,
checklist_confirmed: bool,
checklist_categories: str | None,
) -> None:
"""Run automated PR review and post results to Gitea.
Without --event: runs automated checks and posts COMMENT/REQUEST_CHANGES.
With --event: posts a manual review (skips automated checks).
"""
token = os.environ.get("CI_GITEA_TOKEN", "")
if not token:
raise click.ClickException(_("ERROR: CI_GITEA_TOKEN is not set."))
@@ -533,6 +643,10 @@ def main(pr_number: str, repo: str, dry_run: bool) -> None:
owner, repo_name = repo.split("/")
client = GiteaClient(GITEA_API_URL, token, owner, repo_name)
if event is not None:
_post_manual_review(client, pr_number, event.upper(), body, checklist_confirmed, checklist_categories, dry_run)
return
result = run_review(client, pr_number)
body = build_review_body(result)
+2 -2
View File
@@ -28,7 +28,7 @@ from pathlib import Path
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.config import GITEA_API_URL
from devx.config import GITEA_API_URL, REPO_OWNER
from devx.gitea_cli import TeaCLI, TeaCLIError, configure_tea_login
from devx.i18n import _
@@ -165,7 +165,7 @@ def _default_gitea_registry_url() -> str:
base = base[: -len("/api/v1")]
elif base.endswith("/api"):
base = base[: -len("/api")]
owner = os.environ.get("DEVX_REPO_OWNER", "oblachno-oss")
owner = os.environ.get("DEVX_REPO_OWNER", "") or REPO_OWNER
return f"{base}/api/packages/{owner}/pypi"
+48 -14
View File
@@ -28,6 +28,8 @@ from typing import Any
import click
from devx.i18n import _
def _repo_root() -> Path:
"""Resolve repo root from GITHUB_WORKSPACE or cwd."""
@@ -68,7 +70,7 @@ def fetch_latest_master(branch: str = "master") -> None:
"""
_run(["git", "fetch", "origin", branch]) # nosec B607
_run(["git", "reset", "--hard", f"origin/{branch}"]) # nosec B607
click.echo(f"Synced to latest origin/{branch}")
click.echo(_("Synced to latest origin/{branch}", branch=branch))
def generate_badges(output_dir: str) -> None:
@@ -76,8 +78,8 @@ def generate_badges(output_dir: str) -> None:
_run([sys.executable, "-m", "devx.tools.generate_badges", "--output-dir", output_dir])
badges = list(Path(output_dir).glob("*.svg"))
if not badges:
raise click.ClickException("No badge SVG files generated")
click.echo(f"Generated {len(badges)} badge files")
raise click.ClickException(_("No badge SVG files generated"))
click.echo(_("Generated {count} badge files", count=len(badges)))
def push_to_badges_branch(badges_dir: str) -> str:
@@ -85,26 +87,33 @@ def push_to_badges_branch(badges_dir: str) -> str:
Returns the commit SHA of the pushed badges branch.
"""
import shutil
_run(["git", "config", "user.name", "gitea-actions-bot"]) # nosec B607
_run(["git", "config", "user.email", "actions@oblachno.fyi"]) # nosec B607
_run(["git", "checkout", "--orphan", "badges"]) # nosec B607
_run(["git", "rm", "-rf", "."]) # nosec B607
# Remove untracked files/dirs left behind, but preserve .badges/ for copy below
_run(["git", "clean", "-fdx", "-e", ".git", "-e", badges_dir]) # nosec B607
# Copy badge files to root
import shutil
for svg in Path(badges_dir).glob("*.svg"):
shutil.copy2(svg, Path.cwd() / svg.name)
_run(["git", "add", "./*.svg"]) # nosec B607
_run(["git", "commit", "--no-verify", "-m", "Update badges [skip ci]"]) # nosec B607
# Commit even if no changes (ensures badges branch always exists)
result = _run_capture(["git", "diff", "--cached", "--name-only"]) # nosec B607
if result.stdout.strip():
_run(["git", "commit", "--no-verify", "-m", "Update badges [skip ci]"]) # nosec B607
else:
click.echo(_("No badge changes — skipping commit"))
_run(["git", "push", "origin", "badges", "--force"]) # nosec B607
click.echo("Badges pushed to badges branch")
click.echo(_("Badges pushed to badges branch"))
# Get the commit SHA of the badges branch
result = _run_capture(["git", "rev-parse", "HEAD"]) # nosec B607
sha = result.stdout.strip()
click.echo(f"Badges commit SHA: {sha}")
click.echo(_("Badges commit SHA: {sha}", sha=sha))
return sha
@@ -133,6 +142,22 @@ def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None)
_run(["git", "fetch", "origin", "master"]) # nosec B607
_run(["git", "reset", "--hard", "origin/master"]) # nosec B607
# Verify version badge matches current __version__
from devx.tools.generate_badges import detect_package_name, read_version
pkg = detect_package_name(root)
current_version = read_version(root) if pkg else "unknown"
version_svg = Path(".badges") / "version.svg"
if version_svg.exists():
svg_content = version_svg.read_text()
if current_version != "unknown" and f"v{current_version}" not in svg_content:
click.echo(
_(
"WARNING: Version badge shows stale version (expected v{version}) — regenerating",
version=current_version,
)
)
updated_any = False
for filename in FILES_WITH_BADGE_URLS:
filepath = root / filename
@@ -142,11 +167,11 @@ def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None)
new_content = update_badge_urls(content, badges_sha)
if new_content != content:
filepath.write_text(new_content)
click.echo(f"Updated badge URLs in {filename}")
click.echo(_("Updated badge URLs in {filename}", filename=filename))
updated_any = True
if not updated_any:
click.echo("No badge URLs found to update — README already up to date")
click.echo(_("No badge URLs found to update — README already up to date"))
return
_run(["git", "add", "README.md", "docs/index.md"]) # nosec B607
@@ -160,7 +185,7 @@ def update_readme_with_badge_sha(badges_sha: str, repo_root: Path | None = None)
]
) # nosec B607
_run(["git", "push", "origin", "master"]) # nosec B607
click.echo(f"Pushed README update with badge SHA {badges_sha[:8]}")
click.echo(_("Pushed README update with badge SHA {sha}", sha=badges_sha[:8]))
@click.command()
@@ -193,13 +218,22 @@ def main(output_dir: str, branch: str, no_readme_update: bool, retries: int) ->
except (subprocess.CalledProcessError, RuntimeError) as exc:
last_error = exc
if attempt < retries:
click.echo(f"Badge push attempt {attempt}/{retries} failed — retrying: {exc}")
click.echo(
_(
"Badge push attempt {attempt}/{retries} failed — retrying: {error}",
attempt=attempt,
retries=retries,
error=exc,
)
)
time.sleep(10)
with contextlib.suppress(subprocess.CalledProcessError):
fetch_latest_master(branch)
else:
click.echo(f"Badge push failed after {retries} attempts: {exc}")
raise click.ClickException(f"Badge push failed after {retries} attempts: {last_error}")
click.echo(_("Badge push failed after {retries} attempts: {error}", retries=retries, error=exc))
raise click.ClickException(
_("Badge push failed after {retries} attempts: {error}", retries=retries, error=last_error)
)
if __name__ == "__main__": # pragma: no cover
+81 -42
View File
@@ -37,13 +37,13 @@ from __future__ import annotations
import os
import re
import subprocess # nosec B404
import sys
import time
import click
from dotenv import load_dotenv # pyright: ignore[reportMissingImports,reportUnknownVariableType]
from devx.ci._shared import get_latest_tag
from devx.ci._shared import get_latest_tag, run_cmd, write_github_output
from devx.ci.classify_changes import has_user_facing_changes # cross-CI import, needs PYTHONPATH=.
from devx.i18n import _
@@ -54,25 +54,6 @@ CHANGELOG_FILE = "CHANGELOG.md"
CLIFF_CONFIG = "cliff.toml"
def run_cmd(args: list[str], check: bool = True, capture: bool = True) -> subprocess.CompletedProcess[str]:
"""Run a command and return the completed process."""
result = subprocess.run( # nosec B603
args,
capture_output=capture,
text=True,
check=False,
)
if check and result.returncode != 0:
raise click.ClickException(
_(
"Command failed ({cmd}): {stderr}",
cmd=" ".join(args),
stderr=result.stderr.strip() if result.stderr else result.stdout.strip(),
)
)
return result
def tag_exists(tag: str) -> bool:
"""Check if a git tag already exists."""
result = run_cmd(["git", "tag", "-l", tag], check=False)
@@ -217,7 +198,7 @@ def has_unreleased_changes(bumped_version: str | None = None) -> bool:
def update_init_version(new_version: str) -> None:
"""Update __version__ in __init__.py."""
with open(INIT_FILE) as f:
with open(INIT_FILE, encoding="utf-8") as f:
content = f.read()
if not re.search(r'^__version__\s*=\s*"[^"]*"', content, flags=re.MULTILINE):
raise click.ClickException(_("Could not find __version__ in {file}", file=INIT_FILE))
@@ -228,7 +209,7 @@ def update_init_version(new_version: str) -> None:
count=1,
flags=re.MULTILINE,
)
with open(INIT_FILE, "w") as f:
with open(INIT_FILE, "w", encoding="utf-8") as f:
f.write(updated)
@@ -245,10 +226,10 @@ def update_changelog(changelog: str) -> None:
changelog = changelog[section_match.start() :]
try:
with open(CHANGELOG_FILE) as f:
with open(CHANGELOG_FILE, encoding="utf-8") as f:
existing = f.read()
except FileNotFoundError:
with open(CHANGELOG_FILE, "w") as f:
with open(CHANGELOG_FILE, "w", encoding="utf-8") as f:
f.write(changelog + "\n")
return
@@ -261,10 +242,36 @@ def update_changelog(changelog: str) -> None:
else:
# No version sections found — append
updated = existing.rstrip() + "\n\n" + changelog + "\n"
with open(CHANGELOG_FILE, "w") as f:
with open(CHANGELOG_FILE, "w", encoding="utf-8") as f:
f.write(updated)
def update_doc_versions(new_version: str) -> None:
"""Update documentation version references to match the new release.
Runs ``check_doc_versions --fix`` so that README.md and docs/*.md
always reference the latest released version.
"""
import subprocess # nosec B404
result = subprocess.run( # nosec B603
[sys.executable, "-m", "devx.tools.check_doc_versions", "--fix"],
check=False,
text=True,
capture_output=True,
)
if result.returncode == 0:
click.echo(_("Updated documentation version references to v{version}", version=new_version))
else:
click.echo(
_(
"WARNING: check_doc_versions --fix failed (rc={rc}): {err}",
rc=result.returncode,
err=result.stderr.strip()[:200],
)
)
def commit_release_changes(new_version: str) -> bool:
"""Stage version file and changelog, then create a release commit.
@@ -274,12 +281,12 @@ def commit_release_changes(new_version: str) -> bool:
commits are a special case generated by the release script.
Returns True if a commit was created, False if there were no staged changes.
"""
run_cmd(["git", "add", INIT_FILE, CHANGELOG_FILE])
run_cmd(["git", "add", INIT_FILE, CHANGELOG_FILE, "README.md", "docs/"])
status = run_cmd(["git", "diff", "--cached", "--quiet"], check=False)
if status.returncode == 0:
click.echo(_("No staged changes — version and changelog already up to date."))
return False
run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version}"])
run_cmd(["git", "commit", "--no-verify", "-m", f"release: v{new_version} [skip ci]"])
return True
@@ -317,18 +324,16 @@ def run_tests() -> None:
click.echo(_("Tests passed."))
def _write_github_output(tag: str) -> None:
def _write_release_tag(tag: str) -> None:
"""Write the release tag to GITHUB_OUTPUT for downstream jobs.
This allows a publish job (needs: release) to read the tag via
``${{ needs.release.outputs.tag }}`` instead of relying on
tag-push event triggering a separate workflow.
"""
github_output = os.environ.get("GITHUB_OUTPUT")
if not github_output:
if not os.environ.get("GITHUB_OUTPUT"):
return
with open(github_output, "a") as f: # noqa: PTH123
f.write(f"tag={tag}\n")
write_github_output("tag", tag)
click.echo(_("Wrote tag {tag} to GITHUB_OUTPUT.", tag=tag))
@@ -360,7 +365,7 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
if not dry_run:
# Ensure the existing tag is pushed
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"], check=False)
_write_github_output(tag)
_write_release_tag(tag)
return False
tag_msg = f"Release v{new_version}\n\n{changelog}"
if dry_run:
@@ -368,7 +373,7 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
return True
run_cmd(["git", "tag", "-a", tag, "-m", tag_msg])
run_cmd(["git", "push", "origin", f"refs/tags/{tag}"])
_write_github_output(tag)
_write_release_tag(tag)
return True
@@ -380,7 +385,7 @@ def create_and_push_tag(new_version: str, changelog: str, dry_run: bool) -> bool
def get_init_version() -> str | None:
"""Read __version__ from the version file."""
try:
with open(INIT_FILE) as f:
with open(INIT_FILE, encoding="utf-8") as f:
content = f.read()
match = re.search(r'^__version__\s*=\s*"([^"]*)"', content, flags=re.MULTILINE)
return match.group(1) if match else None
@@ -391,7 +396,7 @@ def get_init_version() -> str | None:
def get_changelog_versions() -> list[str]:
"""Extract version numbers from CHANGELOG.md headers, in order."""
try:
with open(CHANGELOG_FILE) as f:
with open(CHANGELOG_FILE, encoding="utf-8") as f:
content = f.read()
return re.findall(r"^## \[(\d+\.\d+\.\d+)\]", content, flags=re.MULTILINE)
except FileNotFoundError:
@@ -634,7 +639,7 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
tag=release_tag,
)
)
_write_github_output(release_tag)
_write_release_tag(release_tag)
return
# Tag is missing — recover by creating and pushing it
click.echo(
@@ -705,7 +710,8 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
click.echo(_("\n[dry-run] Changelog:\n{changelog}", changelog=changelog))
click.echo(_("[dry-run] Would update {init}", init=INIT_FILE))
click.echo(_("[dry-run] Would update {changelog_file}", changelog_file=CHANGELOG_FILE))
click.echo(_("[dry-run] Would commit: release: v{version}", version=new_version))
click.echo(_("[dry-run] Would update doc version references via check_doc_versions --fix"))
click.echo(_("[dry-run] Would commit: release: v{version} [skip ci]", version=new_version))
click.echo(_("[dry-run] Would push commit to master"))
click.echo(_("[dry-run] Would create tag: v{version}", version=new_version))
return
@@ -718,6 +724,9 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
update_changelog(changelog)
click.echo(_("Updated {changelog_file}", changelog_file=CHANGELOG_FILE))
# Update documentation version references (README, docs/*.md)
update_doc_versions(new_version)
# Verify tests pass BEFORE committing or tagging.
# This ensures we never release a version that fails tests.
if skip_tests:
@@ -731,9 +740,39 @@ def main(dry_run: bool, skip_tests: bool, verify: bool) -> None:
click.echo(_("Created release commit."))
# Pull --rebase before push to handle the case where master
# advanced between checkout and commit (e.g., another merge).
run_cmd(["git", "pull", "--rebase", "origin", "master"], check=False)
# Use refs/heads/master to avoid ambiguity with a 'master' tag
run_cmd(["git", "push", "origin", "refs/heads/master:refs/heads/master"])
# Retry up to 3 times to handle concurrent pushes.
push_succeeded = False
for attempt in range(3):
rebase = run_cmd(["git", "pull", "--rebase", "origin", "master"], check=False)
if rebase.returncode != 0:
# Rebase failed (likely conflicts). Abort and retry.
click.echo(
_(
"Rebase attempt {n}/3 failed: {err}",
n=attempt + 1,
err=rebase.stderr.strip() if rebase.stderr else rebase.stdout.strip(),
)
)
run_cmd(["git", "rebase", "--abort"], check=False)
# Brief delay before retry to let concurrent pushes settle.
time.sleep(5)
continue
push = run_cmd(["git", "push", "origin", "refs/heads/master:refs/heads/master"], check=False)
if push.returncode == 0:
push_succeeded = True
break
click.echo(
_(
"Push attempt {n}/3 failed: {err}",
n=attempt + 1,
err=push.stderr.strip() if push.stderr else push.stdout.strip(),
)
)
time.sleep(5)
if not push_succeeded:
raise click.ClickException(
_("Failed to push release commit after 3 attempts. Manual intervention required.")
)
click.echo(_("Pushed release commit to master."))
else:
click.echo(_("Skipping commit push — no staged changes."))

Some files were not shown because too many files have changed in this diff Show More